# case-report 워커 — 보장·청구 분석 프로토타입의 판독·판정

보험 PDF(통합조회·지급내역)와 심평원 진료내역(진료정보분석 DB 스냅샷 또는 심평원 PDF)을 받아
「미청구 보험금·보장 분석 최종본」 결과 JSON 을 만든다. 서버 모듈(`apps/api/src/modules/case-report`)이 하위 프로세스로 부른다.

- 외부 OCR 은 쓰지 않는다. OCR 은 로컬 EasyOCR(CPU)만 쓴다.
- 생성형 AI(Claude API)는 입력 `llm.mode` 가 `intake` 일 때만, 정밀 파서가 없는 20쪽 이하 서류(알 수 없는 문서 · 범용 판독 지급내역 · 증권)에
  가린 쪽 글자를 보낸다(`case_report/llm.py` · `intake.py`). 사람 이름은 `[이름N]` 자리표로 바꿔 보내고 응답을 받은 뒤 되돌린다(`parsers/names.py`, 고객 이름은 `llm.customer_name`). 찾는 자리는 고객 이름 · 칸 값(가족 칸 포함) · 표의 이름 칸 · 줄 끝까지 늘어놓은 이름 · 「○○ 님」 · 「○○ 고객님」 · 주민번호 앞 값이다. 그 밖 자리(문장 안)의 낯선 이름 · 목록 밖 성씨 · 한자 이름은 찾지 못해 그대로 나간다. 결과는 `COVERAGE`(보장 목록) 또는 `STATEMENT`(`reader: LLM`)가 되고,
  `llm.forms_dir` 가 있으면 양식 표본(`<원본 sha256 앞 16자>.json`, 600, 이름은 자리표 상태 그대로)을 남긴다.
- 판정도 이 워커에 있다(`case_report/analyze/` 머리의 `shortcut:` 주석 — 운영 전 서버 TypeScript 로 옮긴다).
- 명세: 부모 저장소 `.claude/notes/case-report-proto/spec.md` 3·4·5 절.

## 실행

```bash
cd insuniverse-server/workers/case-report
PY=$HOME/.venvs/case-report/bin/python               # 로컬 venv(저장소 밖). pandas 없음
$PY -m case_report.cli version --out v.json
$PY -m case_report.cli extract --in extract-input.json --out extraction.json [--password-stdin]
$PY -m case_report.cli revalidate --in revalidate-input.json --out effective.json
$PY -m case_report.cli analyze --in analyze-input.json --out result.json
```

시험(합성 자료만):

```bash
$PY -m pytest -q tests
```

계약 견본 다시 만들기(형상이 바뀌었을 때만, 바뀐 점은 명세 담당에게 보고):

```bash
$PY scripts/gen_contract.py
```

EasyOCR 모델(`~/.EasyOCR/model`)이 없으면 첫 OCR 때 GitHub 에서 내려받는다(내려받기만 하고 자료를 보내지 않는다).
미리 받으려면 `$PY -c "import easyocr; easyocr.Reader(['ko','en'], gpu=False, verbose=False)"`.

## CLI 규약

| 명령 | 입력 `schema` | 출력 `schema` | 출력 `status` |
|---|---|---|---|
| `version` | — | `case-report.version/1` | `OK` · `FAILED` |
| `extract` | `case-report.extract-input/1` | `case-report.extract/1` | `OK` · `PARTIAL` · `FAILED` · `UNSUPPORTED` · `NEEDS_PASSWORD` |
| `revalidate` | `case-report.revalidate-input/1` | `case-report.extract/1` | `OK` · `PARTIAL` · `UNSUPPORTED` · `FAILED`(`EDIT_INVALID`) |
| `analyze` | `case-report.analyze-input/1` | `case-report.result/1` | `OK` · `BLOCKED` · `FAILED` |

- 모든 출력은 최상위에 `schema` · `status` · `error` 를 항상 갖는다. `extract`·`revalidate` 봉투의 최상위 12칸은 어느 상태에서도 있다.
- 종료 코드: `0` 정상 봉투(업무상 `FAILED`·`BLOCKED`·`NEEDS_PASSWORD` 포함) · `2` 입력 오류(`INPUT_INVALID` 봉투, 입력 파일 sha256 이
  `inputs_manifest` 와 다를 때 포함) · `3` 최상위에서 잡은 내부 오류(`INTERNAL` 봉투) · 그 밖·신호는 비정상.
- 비밀번호: 입력 JSON 에 넣지 않는다. `--password-stdin` 을 주면 워커가 시작하자마자 표준입력 한 줄을 읽고 닫는다.
  비밀번호는 PDF 열기 함수에만 넘기고 디스크·출력·캐시·로그 어디에도 남기지 않는다(시험 `test_암호_PDF_…`).
- 표준출력: 진행 줄 `PROGRESS {"stage":"OCR","done":2,"total":11}` 만. `done` 은 다 읽은 쪽 수(0 부터, 결정형), `total` 은 전체 쪽 수. 단계는 `TEXT`(글자 레이어) · `OCR`(글자 인식, 그 쪽을 읽기 전에도 한 줄) · `PARSE`(해석, done=total) · `AI`(AI 읽기 요청 한 번, done=total).
- 표준오류: 명령·상태·오류 코드·예외 종류 이름만(이름·주민번호·전화·금액·병원명 없음).
- 임시 파일: 쪽 이미지는 메모리에서만 만든다(`tmp_dir` 에 아무것도 쓰지 않는다). 워커가 직접 쓰는 곳은 `--out` 과 `cache_dir` 뿐이다.
- OCR 캐시: `<cache_dir>/<파일 sha256>/p<쪽>.<text|ocr>.<dpi>.<엔진 판본>.w<워커 판본>.json`. 임시 파일에 쓰고 `os.replace`.
  캐시는 원본 PDF 와 같은 등급이다(원본 삭제·작업 삭제 때 서버가 함께 지운다).
- 결정성: 판정 데이터는 입력만으로 정해진다. `extract`·`revalidate` 는 `timing` 만 빼고, `analyze` 는 전체가 바이트로 같다(`created_at` = 입력 `now`).

## 판독

| 단계 | 방식 |
|---|---|
| 열기 | `pdfplumber.open(password=…)`. 비밀번호가 없거나 틀리면 `NEEDS_PASSWORD`(`PDF_PASSWORD_REQUIRED` · `PDF_PASSWORD_INCORRECT`) |
| 쪽별 글자 | `read_mode` `auto` 는 쪽마다 텍스트 레이어를 먼저 보고 쓸 글자가 없으면 그 쪽만 OCR. `text`·`ocr` 은 한쪽만 |
| OCR | 쪽을 `ocr_dpi`(200·300)로 메모리 렌더(pdfplumber·pypdfium2, 암호 PDF 도 같은 비밀번호) → EasyOCR `ko,en` CPU. 좌표는 200dpi 픽셀로 맞춰 파서에 넘긴다 |
| 종류 판별 | 약관(쪽 판독 전: `kind_hint:"POLICY"` 또는 앞 30쪽에 「보통약관·특별약관·보험약관·특약약관·주계약약관」이 있고 앞 5쪽에 「통합조회·지급내역」이 없을 때. 20쪽 미만은 증권 제목이 없고 「제N조(」 조항 머리도 있어야 한다) → 심평원 4종(제목) → 통합조회 → 국세청 연말정산(「세액공제증명서류」) → 증권(첫 쪽 앞 10줄의 제목 줄 「보험증권」 등 → `COVERAGE`, AI 읽기로만 읽는다) → 지급내역(「보험금」+「지급·안내문·결정」) → 모름 |
| 약관 | 텍스트 레이어만(`pdftotext`, 한글이 없으면 pdfminer 로 한 번 더). OCR 하지 않는다. 쪽 수 한도는 `limits.max_pages_policy`(워커 기본 2500, 서버 `CASE_REPORT_MAX_PAGES_POLICY` 기본 2500). 글자가 없으면 `POLICY_NO_TEXT` |
| 지급내역 판독기 | 1쪽 위 20%(발행기관 영역)에서 보험사를 먼저 찾는다. 정밀 판독 목록에 있고 필수 표 낱말이 보이면 정밀, 아니면 범용. `reader_hint:"GENERIC"` 으로 강제 가능. 정밀 파서가 예외를 내거나 행을 못 찾으면 범용으로 다시 읽고 경고 `PRECISE_PARSER_FAILED` |

정밀 판독 보험사: 현대해상(`hyundai.period@1`) · 롯데손해보험(`lotte.claim@1`) · 삼성화재(`samsung.paid@1`, 「보험금 지급사실 안내」 — 두 줄 겹친 표를
머리글 x 위치와 사고일자·지급일자 짝으로 복원, 행 합 = 총 지급금액이 막는 검산). 목록은 `version` 의 `precise_insurers` 로 알린다.
보험사를 더하려면 `case_report/parsers/<보험사>.py` 에 `parse(pages, insurer_raw)` 와 `checks(data)` 를 만들고 `parsers/__init__.py` 의 `PRECISE` 에 한 줄 등록한다.

범용 판독(`generic@1`)은 줄마다 날짜·금액·병원명·입원/통원 모양으로 행을 만들고 전부 `confirmed:false` 로 둔다. 문서 범위는 채우지 않는다
(`doc_scope.kind:"UNKNOWN"` · `pages_complete:null` · `contract_scope:"UNKNOWN"`) — 사용자가 판독 확인 화면에서 고쳐야 기간을 덮는다.
담당·연락처·계좌 낱말이 있는 줄은 행으로 만들지 않고, 저장하는 줄 글자는 가림 함수를 거친다.

심평원 PDF(`hira_pdf.py`)는 **실물 칸을 아직 확인하지 못했다**(비밀번호 없음). 명세 4.2 의 예상 머리글 별칭으로 열을 찾고, 결과에 경고
`HIRA_LAYOUT_UNVERIFIED` 를 싣는다. 비밀번호를 얻으면 실물 머리글로 `SPECS` 별칭표를 고친다.

## 판정 옵션(`analyze-input.options`, 없는 키는 기본값)

| 키 | 기본 | 값 |
|---|---|---|
| `hira_source` | `AUTO` | `AUTO`(DB 스냅샷 우선) · `DB` · `PDF` · `NONE` |
| `show_claim_deadline` / `deadline_years` | `true` / `3` | 청구 기한 참고일 표시 — 판정·후보 제외에 쓰지 않는다 |
| `use_nts` | `AUTO` | `AUTO`(분석 건 우선, 없으면 올린 국세청 PDF) · `DB` · `PDF` · `OFF` |
| `estimate_payout` | `true` | 연결된 약관의 자기부담률·공제로 청구 후보에 예상 수령액(급여 본인부담 기준 참고값) |
| `link_window_days` | `0` | 0~7, 지급-진료 연결 허용 일수 |
| `unlinked_block_days` | `30` | 0(끔)~90, 연결 안 된 지급 앞뒤 차단 일수 |
| `pharmacy_follows_prescriber` | `true` | 약국은 같은 날(없으면 전날) 처방 기관 진료건에 붙는다 |
| `generic_unconfirmed_policy` | `REVIEW` | `REVIEW`(확인 전에는 판독 확인 필요) · `USE` |
| `identity_policy` | `BLOCK` | `BLOCK`(통합조회·심평원 PDF 이름이 다르면 중단) · `WARN` |
| `require_complete_pages` | `true` | 끄면 쪽 완결 모름(`null`)도 기간을 덮고 이유 `PAGES_NOT_REQUIRED` 를 남긴다. `false` 는 언제나 덮지 않는다 |
| `require_contract_scope_all` | `true` | 끄면 계약 범위 모름(`UNKNOWN`)도 덮고 이유 `CONTRACT_SCOPE_NOT_REQUIRED` 를 남긴다. `SELECTED` 는 언제나 덮지 않는다 |
| `include_out_of_coverage` | `true` | 실손 보장 기간 밖 진료도 목록에 싣는다 |
| `benchmarks.*` | 명세 4.5 | 점검 기준값(예시 기준) |

## 판정 안전장치(코드 검토 반영)

- 보험사 이름은 판정 전에 약칭 하나로 맞춘다(통합조회 실손·정액·계약, 모든 지급내역, 수기 기록, 직접 확인).
- 지급내역이 「지급 기록 없음」을 말하려면(덮기) 대상기간이 그럴듯해야 하고(끝 ≤ 발행일, 10년 이하) 피보험자 이름을 읽었어야 한다.
- 정액: 날짜가 맞는 같은 종류 지급이 어느 가입 담보 표준명과도 안 맞으면 「판독 확인 필요」(`FIXED_PAYMENT_UNMATCHED`). 진단비 지급은 그 담보에만 붙는다.
- 병원 이름 맞춤은 문자 1~3글자 BM25(`analyze/bm25.py`, 흔한 조각은 IDF 가 낮다)다. 법인 접두·괄호·기호·띄어쓰기를 뺀 이름이 같으면 정확 일치,
  점수 0.8 이상(또는 상병 앞 3자리가 같고 0.7 이상)이면 같은 날·같은 진료 형태·한 병원일 때만 붙인다(`LINKED_NAME_VARIANT`). 0.43 이상은 확인 필요 +
  별칭 제안. 문턱 근거는 `tests/test_bm25.py` 표본 쌍.
- 진료 행 없이 지급 행만 있는 실손 지급은 청구의 사고일·진단코드가 있으면 사고일~청구일(없으면 지급일) 창 안의 같은 상병 진료건에 잇는다 —
  사고일에 같은 상병 진료건(닻)이 있고 닻과 같은 병원이면 연결(`LINKED_BY_DIAGNOSIS`), 아니면 확인 필요(`PAYMENT_DIAGNOSIS_ONLY`).
  그 밖(범용 판독 등)과 보험사를 모르는 문서의 행은 「연결 안 된 지급 기록」에 싣는다(`PAYMENT_ONLY` · `INSURER_UNKNOWN`). 앞뒤 차단(규칙 7)에도 쓴다.
- 통합조회 계약 칸에 확인 항목이 남아 있으면 지금 값이 진료 조건과 맞아도 확정하지 않는다.

## 결과에 더한 칸(명세 예시 밖, 모두 추가만)

- 지급내역 `data.printed_totals[]` — 문서에 인쇄된 소계·결정 합계·지급금액. `revalidate` 가 검산을 다시 계산할 때 쓴다(편집 대상 아님).
- 진료건 항목 `overall_reasons[]` — 보험사가 없는 진료건(`OUT_OF_COVERAGE`·`COVERAGE_UNKNOWN`·통합조회 검산 실패)의 이유.
- 연결 `links[].manual_id` — 수기 지급 기록과 연결됐을 때 그 기록 id.
- `data_scope.pending_documents[].text` — 판독이 끝나지 않은 파일(서버가 대기·판독 중·실패·비밀번호 대기·미지원을 넘긴다)의 상태별 문구.
- `data_scope.contract_periods[]` — 입력 `manual_contracts`(직접 넣은 실손 가입 기간, 명세 12.10)의 행마다 `used`(통합조회를 쓴 분석에서는 `false`)와 `entered`(설계사가 넣은 보험사 글자 그대로 — `insurer` 는 약칭이다).
- 진료건 `episode.diagnoses[].treat_date` — 그 상병 행의 진료일(진단비 첫 진료일 계산에 쓴다).

## 파일

```
case_report/
  cli.py            명령 넷, 종료 코드·봉투·진행 줄
  envelope.py       공통 봉투·schema 이름
  extract.py        판별·판독·파싱·검산 → extract/1 봉투
  revalidate.py     JSON Pointer 편집 적용·값 형식 검사 → 같은 finalize 로 재계산
  finalize.py       종류별 파생값·검산·확인 항목·status·summary(extract 와 revalidate 가 같이 쓴다)
  hira.py           심평원 정규화(DB 스냅샷·PDF 같은 형상)
  read/             pdf.py(열기·비밀번호·쪽별 텍스트/OCR·캐시) · values.py(값 보정)
  parsers/          credit_info · hyundai · lotte · generic_statement · hira_pdf · nts_pdf · policy · common · redact · statement_common
  analyze/          episodes · claims(A1·A2·A7·C1) · fixed(A3·A4·A5) · coverage(B) · guides(C) · nts · policy(약관 연결·예상 수령액) · identity
contract/           계약 견본 12개(합성 값) — 서버·사이트가 형상 기준으로 읽는다
scripts/gen_contract.py
tests/              합성 픽스처 pytest(pdfgen.py 는 합성 PDF·RC4 암호 PDF 를 표준 라이브러리로 쓴다)
```
