송달문서(SCAN파일) 정리하기
[변호사의 로컬llm 세팅기] 27화: 송달문서(SCAN파일) 정리하기
26화에서 기일지정서 한 장으로 구글 드라이브에서 받아, 모델로 읽히고, 캘린더에 일정을 만드는 흐름이 작동하는 것을 봤다. 이번에는 그 흐름을 스크립트로 묶으려 한다.
먼저 무엇을 만들려는지 실제 사무실 풍경으로 그려본다.
법원·검찰·경찰에서 사무실로 송달 온 서류들은 일괄로 스캔되어 구글 드라이브의 한 폴더에 쌓인다. 스캐너가 붙이는 이름은 scan0001.pdf, scan0002.pdf … 식이라, 파일명만으로는 무엇이 누구 사건의 어떤 서류인지 알 수 없다. 지금까지는 이걸 사람이 하나씩 열어보고, 피고인 폴더로 옮기고, 공판기일이 있으면 캘린더에 손으로 입력했다.
내가 만들려는 것은 이 일을 로컬 LLM에 맡기는 것이다. scan0001.pdf 형식으로 쌓인 스캔 파일들을 모델이 읽어 내용을 파악하고, 그에 따라 필요한 일 — 사건 폴더로 분류, 일정 등록, 이력 기록, 통보 — 을 알아서 처리하게 하는 것이다.
한 문장으로 줄이면 이렇다.
스캔 폴더에
scan0001.pdf형식으로 쌓인 서류를 로컬 모델로 읽어, 누구 사건인지 가려 그 사람 폴더로 분류하고, 출석할 일정이 있으면 캘린더에 넣은 후, 처리 결과를 텔레그램으로 알려준다.
이 한 문장을 다시 단계로 펼치면, 다음 순서로 처리된다.
| 단계 | 하는 일 | 쓰는 도구 |
|---|---|---|
| 1. 다운로드 | SCAN 폴더의 PDF를 ATOM에 내려받기 | gog drive download |
| 2. 이미지 변환 | 앞 3쪽을 PNG로 쪼개기 | pdftoppm |
| 3. 추출 | 당사자·출석일정·일시 등을 JSON으로 뽑기 | qwen3-vl:8b-instruct |
| 4. 후처리 | 깨진 JSON 정리(따옴표·키·이름) | sed, jq |
| 5. 폴더 분류 | 당사자로 폴더를 찾거나 만들어 이동 | gog drive ls/mkdir/move |
| 6. 이름 변경 | 피고인\_서류이름\_월일.pdf로 | gog drive rename |
| 7. 캘린더 | 출석 일정이 있으면 등록 | gog calendar create |
| 8. 사건 일지 | 피고인 폴더 독스에 한 줄 누적 | gog docs create/insert |
| 9. 통보 | 텔레그램으로 결과·링크 전송 | curl |
처리 결과가 쌓이는 구글 드라이브 폴더 구조는 이렇게 잡았다.
Ai Test/
├── SCAN/ ← 스캐너가 서류를 떨구는 입력함 (미처리 파일만 남는다)
├── INBOX/ ← 당사자를 못 가려낸 미분류 서류
└── 사건/ ← 피고인 폴더들이 모이는 곳
├── 김OO/
│ ├── 김OO\_공판기일통지서\_0629.pdf
│ └── 김OO\_사건요약 (구글 독스, 이력 누적)
├── 오OO\_이OO/ (공동피고인은 가나다순으로 이름을 잇는다)
└── 한OO/
SCAN폴더에 들어온 서류들은 모델이 해석하여 사건 폴더 내의 피고인 이름 폴더로 들어간다. 피고인이 특정되지 않은, 혹은 분류할 수 없는 파일은 INBOX폴더 안으로 들어간다.
이번 화는 그중 추출 — 위 표의 3·4단계, PDF를 받아 필요한 값을 JSON으로 뽑는 일 — 만 다룬다.
일단은 SCAN 폴더 안에 6종류의 문서를 넣어 테스트해보기로 했다.
- 피고인 작성의 타이핑된 반성문
- 공소장(공동피고인)
- 공판기일지정통지서
- 형사사건 기록
- 불송치이유서
- 판결문
1,2,3으로 먼저 파이프라인을 만들어보고, 4,5,6은 다음날 스캔 된 파일을 가정하여 4,5,6을 돌릴때 1,2,3과 충돌이 없는지 테스트 해보기로 했다. ---
스캔 파일들을 어떻게 분류할 것인가
분류의 출발점은 “이 서류가 누구 것이고, 무엇을 해야 하는가”다. “공판기일통지서인가, 반성문인가, 증거기록인가”를 어떻게 인식하고 분류할 것인가.
- 폴더를 정하려면 → 당사자가 누구인가가 필요하다.
- 일정을 등록할지 정하려면 → 본인이 출석해야 할 일시가 있는가가 필요하다.
OCR모델을 사용하여 문서를 인식하고, 그 내용을 파악하여 각 항목으로 이뤄진 JSON으로 정리한다. 당사자가 누군지 파악할때는, 상대방 이름이랑 헷갈리면 안된다. 동명이인이 있을 경우 어떻게 처리할지, 공동피고인 사건은 어떻게 처리할지 등이 문제다. 기일도 출석일자인지, 발송일인지 등 세심하게 살펴야 할 부분이 있는데 이는 모델에 들어갈 프롬프트에서 정리해보기로 한다.
---
모델은 무엇으로, 몇 쪽을 보는가
22화에서 검증한 qwen3-vl:8b-instruct를 그대로 쓴다. 이미지를 직접 보고 글자를 읽어내는 비전 모델이다.
페이지는 앞 3쪽만 본다. 분류에 필요한 당사자·사건번호·일시는 거의 다 문서 앞머리에 있다. 수백쪽짜리 증거기록이라도 표지 한 장에 피고인과 사건번호가 박혀 있다. 두꺼운 문서를 통째로 모델에 넣으면 느리고 출력도 흔들리니, 앞 3쪽으로 고정한다.
추출 스크립트의 뼈대는 이렇다. PDF를 받아 pdftoppm으로 앞 3쪽을 200 DPI PNG로 변환하고, 그 이미지들을 프롬프트와 함께 모델에 넣어 JSON 한 덩어리를 받는다. 프롬프트는 별도 파일(prompt.txt)로 빼뒀다. 처음 짠 프롬프트는 이랬다.
이 이미지들은 한국어 형사 관련 문서 한 건의 앞부분 페이지다.
모든 페이지를 종합해서 보고, 아래 형식의 JSON만 출력해라. 다른 말은 절대 하지 마라.
{
"당사자": \[],
"출석일정있음": false,
"일시": null,
"장소": null,
"사건번호": null,
"서류이름": "",
"서류요약": ""
}
판단 기준:
- "당사자"는 이 사건에서 수사나 재판을 받는 사람의 이름이다. "피고인",
"피의자", "대상자" 등으로 적힌 사람을 모두 배열에 넣어라. 서류를 보내거나
받는 법원·검찰·경찰·변호사·법무법인의 이름은 당사자가 아니다.
- "출석일정있음"은 본인이 특정 일시에 직접 출석하거나 참석해야 하는 날짜와
시각이 문서에 적혀 있으면 true다. 서류 작성일, 발송일, 구속일, 제출 기한은
출석 일정이 아니다.
- "일시"는 출석일정있음이 true일 때만 "YYYY-MM-DD HH:MM" 형식으로 채워라.
- "서류이름"은 이 문서가 무엇인지 짧게.
- "서류요약"은 이 문서의 핵심을 한 문장으로.
핵심은 두 가지다. 당사자를 직함(피고인·피의자)에 매이지 않고 사람 이름으로 뽑게 한 것, 그리고 출석 일시를 작성일·구속일·제출 기한과 구분하게 한 것이다. 모델에게 종류를 묻지 않고 “당사자가 누구이고 출석 일시가 있는가”라는 사실만 묻는다는 앞의 원칙이 그대로 들어가 있다. 이 프롬프트를 파일에서 읽어 이미지와 함께 모델에 넘긴다.
PROMPT="$(cat "$PROMPT\_FILE")"
RAW="$(ollama run "$MODEL" "$PROMPT" "${PAGES\[@]}" 2>/dev/null)"
첫 줄은 프롬프트를 파일에서 읽어오는 부분이다. cat으로 prompt.txt 내용을 읽고, $( )로 그 결과를 변수 PROMPT에 담는다. 둘째 줄이 모델을 부르는 부분으로, ollama run에 모델 이름·프롬프트·이미지 목록(PAGES, 앞 3쪽 PNG)을 넘겨 응답을 받는다. 2>/dev/null은 진행 표시나 경고 같은 부수 출력을 버려 실제 응답만 RAW에 남기게 한다.
ollama run <모델> "<프롬프트>" <이미지1> <이미지2> <이미지3> 형식이다. 받은 출력을 정리해 JSON으로 파싱하려 했는데, 생각보다 파싱이 어려웠다.
---
출력이 깨끗한데 파싱이 안 된다
첫 시도에서 모델은 정확한 내용을 뱉었다. 공판기일통지서를 넣으니 당사자 김OO, 출석일정 있음, 일시 2026-06-11 15:00, 장소까지 다 맞았다. 출력은 이랬다.
{
"당사자": \["김OO"],
"출석일정있음": true,
"일시": "2026-06-11 15:00",
"장소": "OO지방법원 408호 법정",
"사건번호": "2025노OOOO",
"서류이름": "공판기일통지서",
"서류요약": "OO지방법원이 피고인 김OO에게 2026년 6월 11일 15시에
열릴 공판기일을 통지하는 공판기일통지서."
}
내용은 한 자도 틀리지 않았다. 그런데 이 JSON을 jq로 파싱하려 하는데서 문제가 발생하기 시작했다.
(JSON 파싱 실패 — 위 원본 출력을 확인)
값 안을 자세히 보면 단서가 있다. “서류요약” 값에 줄 바뀜이 있는 것이었다. JSON 문자열 안에서 값이 끝나기 전에 실제 줄바꿈이 들어가면 규격 위반이라 jq가 거부한다. 그래서 파싱이 안 된 것이다.
프롬프트에 “값 안에 줄바꿈을 넣지 마라”를 넣고, 받은 출력의 줄바꿈을 공백으로 바꿔 넘겨봤다.
그래도 깨졌다. 이번엔 다른 자리였다. 혹시 눈에 보이지 않는 출력이 들어있나 싶어, 출력을 cat -A로 — 눈에 안 보이는 문자까지 드러내 보여주는 옵션이다 — 들여다보니, 멀쩡해 보이던 JSON에 ^\[\[K, ^\[\[1G 같은 것이 곳곳에 박혀 있었다. (cat -A는 한글도 M-l M-^^ 같은 바이트 표기로 풀어 보여주는데, 그 사이사이에 이 제어 문자가 섞여 있었다.)
"일시": "2026-06-11 15:00", "M-lM-^^M-%^\[\[K
M-lM-^FM-^L": "...408M-mM-^XM-8 M-kM-2M-^UM-lM- M-^U", ...
^\[\[K가 그 정체다. 이것은 터미널 제어 문자(ANSI escape)다. ollama가 출력하면서 진행 표시(스피너)와 화면 갱신을 위해 이 문자들을 같이 내보냈고, 화면에선 안 보이지만 실제 데이터에는 값 중간중간에 박혀 있었다. 그래서 jq가 “문자열 안에 이상한 게 있다”며 거부한 것이다. 줄바꿈이 문제가 아니었다.
이건 받은 출력에서 제거하면 된다. ESC 문자로 시작하는 시퀀스를 sed로 걷어낸다.
sed 's/\\x1b\\\[\[0-9;?]\*\[a-zA-Z]//g'
sed는 텍스트에서 특정 패턴을 찾아 바꾸거나 지우는 도구다. 기본 형태는 s/A/B/g로, “A에 해당하는 것을 모두 B로 바꿔라”는 뜻이다. 여기서는 바꿀 자리(B)를 비워, A에 걸리는 것을 전부 지운다. A 자리에 쓴 \x1b\[[0-9;?]*[a-zA-Z]가 지울 대상인데, \x1b가 ESC 문자고 그 뒤 [로 시작해 영문자로 끝나는 제어 시퀀스를 가리킨다. 즉 ESC로 시작하는 제어 문자를 찾아 통째로 지우는 명령이다. 이걸 넣으니 출력이 깨끗해졌다. 그런데도 파싱은 또 실패했다.
---
반복되는 두개의 따옴표
이번 깨짐은 모양이 분명했다. 전체 출력은 이랬다.
{"당사자": \["김OO"], "출석일정있음": true, "일시": "2026-06-11 15:00",
"장소": "OO지방법원 408호 법정", "사건번호": "2025노OOOO", "서류이름": "
"공판기일통지서", "서류요약": "공판기일 지정 통지"}
"서류이름": " 다음에 또 "공판기일통지서"가 와서, 따옴표가 ""로 하나 더 붙었다. 모델이 값을 쓸 때 빈 따옴표를 먼저 열고, 그 뒤에 값을 또 쓴 것이다. 이 따옴표 하나 때문에 JSON 구조가 깨진다.
처음엔 14화에서 겪은 비결정론 — 같은 입력에도 모델이 매번 조금씩 다르게 답하는 — 으로 여겼다. 그런데 같은 파일을 세 번 돌리니, 세 번 다 글자 하나까지 똑같이 깨졌다. 완전히 결정론적인 깨짐이었다. 그리고 깨지는 자리가 항상 서류이름·요약 같은 자유 텍스트 값 근처였다.
공통된 것이 서류이름이 공란이었기에 두 가지를 시도했다. 프롬프트의 형식 예시에서 빈 값("")을 없애고 강제로 채우게 한 것, 그리고 서류이름을 객관식(보기 중 택일)으로 만든 것. 둘 다 “빈 자리라서 모델이 헷갈린다”는 가설에서 나온 수정이었다. 그러나 객관식으로 바꿔도 똑같이 깨졌다. 빈 자리가 원인이 아니었다.
결정론적으로 같은 자리에서 깨진다는 것은, 프롬프트로는 이 버릇을 못 이긴다는 뜻이다. 결국 모델을 구조적으로 바꿔야 한다는 뜻인데, 이는 내가 당장 할 수 있는 일이 아니라고 판단해 그냥 후처리를 하기로 했다.
깨짐이 결정론적이라 항상 같은 모양으로 깨지니, 그 모양을 정리하는 후처리 한 줄을 넣을 수 있다. 정상 출력에는 빈 문자열("")이 나오지 않게 프롬프트를 짰으므로, 출력에 따옴표가 연속으로 두 개 나오면 그건 전부 깨짐이다. 위치를 가리지 않고, 연속된 따옴표를 하나로 합친다.
sed -E 's/"\[\[:space:]]\*"/"/g'
따옴표 두 개가 (사이에 공백이 끼어도) 붙어 나오면 하나로 만든다. ""든 " "든 — 줄바꿈을 공백으로 바꾸는 과정에서 두 따옴표 사이에 공백이 끼는 경우도 있었다 — 모두 잡힌다.
이 한 줄을 넣으니 세 번 다 통과했다. 모델 출력을 그대로 믿지 않고, 깨진 패턴을 알고 고쳐 쓰는 것이다. 비결정론이었으면 이 방법도 안 통했겠지만, 결정론적이라 패턴이 고정돼 있어 잡을 수 있었다.
---
키 이름에 공백이 끼고, 이름에 직함이 붙는다
파싱은 통과했지만, 통과한 JSON에 두 가지가 더 보였다.
하나는 키 이름이었다. "장 소"처럼 키 글자 사이에 공백이 끼었다. 모델이 “장소” 두 글자 사이에서 줄을 바꾼 것이 공백이 된 것이다. 파싱은 되지만, 나중에 스크립트에서 .장소로 값을 꺼낼 때 키가 장 소라 못 찾는다.
다른 하나는 당사자 이름이었다. 반성문에서 당사자가 "피고인 한 종 O수"로 나왔다. “피고인”이라는 직함이 이름에 딸려 왔고, 글자 사이 공백도 있었다(이름을 띄어 쓴 양식이라 그렇다). 이대로 폴더명을 만들면 피고인 한 O 수 폴더가 된다.
이 둘도 프롬프트로 막기보다 후처리로 잡았다. 이번엔 sed가 아니라 jq로 정리했다. JSON을 다루는 도구이니 키 이름을 바꾸고 값을 다듬는 데 적합하다. 정리식은 별도 파일(clean.jq)로 뒀다.
with\_entries(.key |= (gsub("\\\\s+"; "")))
| if has("당사자") then
.\["당사자"] |= (map(
gsub("피고인|피의자|대상자|피해자|참고인"; "")
| gsub("\\\\s+"; "")
))
else . end
이 정리식은 두 가지 일을 한다.
첫 줄 with_entries(.key |= (gsub("\\s+"; "")))는 모든 키 이름에서 공백을 지운다. with_entries가 JSON의 각 항목을 하나씩 훑고, .key |= ...가 그 키 이름을 바꾼다. gsub("\\s+"; "")는 공백(\s)이 하나 이상 이어진 부분을 빈 문자열로 바꾸라는 뜻이라, 장 소 같은 키가 장소로 정리된다.
나머지는 당사자 이름을 다듬는 부분이다. if has("당사자") then ... else . end로 “당사자” 키가 있을 때만 처리하고, 없으면 그대로 둔다(.). .["당사자"] |= (map(...))는 당사자 배열의 각 이름에 차례로 손을 댄다는 뜻이다. 한글 키는 .당사자로 바로 못 쓰고 .["당사자"]처럼 따옴표로 감싸야 한다. map 안에서 두 가지를 한다.
이렇게 고치니 정리가 됐다.
---
세 종류 문서로 확인
이제 추출 흐름이 섰다. PDF를 받아 앞 3쪽을 모델에 넣고, ESC 제거 → 따옴표 겹침 보정 → jq로 키·이름 정리. 스캔 폴더에 든 세 종류 문서로 돌렸다.
// 공판기일통지서
{"당사자": \["김OO"], "출석일정있음": true, "일시": "2026-06-11 15:00",
"장소": "OO지방법원 408호 법정", "사건번호": "2025노OOOO",
"서류이름": "공판기일통지서", "서류요약": "공판기일 지정 통지"}
// 공소장 (공동피고인)
{"당사자": \["오OO", "이OO"], "출석일정있음": false, ...
"서류이름": "공소장"}
// 반성문
{"당사자": \["한OO"], "출석일정있음": false, ...
"서류이름": "반성문"}
세 가지가 다 확인됐다. 공판기일통지서는 당사자·일시·장소를 정확히 뽑았다. 공소장은 공동피고인 두 사람을 모두 배열에 넣었고, 출석일정은 false로 뒀다 — 공소장에는 구속일·체포일 같은 날짜가 있는데, 그것을 출석 일정으로 오해하지 않았다. 반성문은 당사자 한 사람을 뽑고 출석일정은 false였다.
---
오늘의 정리
오늘은 여러 종류의 문서를 한 파이프라인으로 다루기 위한 추출 단계를 세웠다.
- 분기 기준을 종류가 아니라 사실로 둔다. 폴더는 당사자 유무로, 캘린더는 출석 일시 유무로 가른다. 서류 종류를 나열하지 않으므로 새로운 서류가 들어와도 코드를 고칠 필요가 없다.
- 모델 출력의 깨짐은 후처리로 잡는다. ESC 제어문자, 따옴표 겹침, 키 공백, 이름의 직함 — 프롬프트로는 막히지 않던 것들이 결정론적으로 같은 모양으로 깨졌고,
sed와jq로 그 모양을 정리해 통과시켰다.
추출이 단단해졌으니, 다음은 이 JSON을 받아 실제로 폴더를 나누고 파일을 옮기는 단계다. 다음 화에서 만든다.