# task-3005 — 종합형 실손 증권 E2E 검증 + 회귀 픽스처 봉인

- **레벨**: Lv.2 · **팀**: dev7-team (이참나) · **작업일**: 2026-08-24
- **저장소**: InsuRo (`/home/jay/projects/InsuRo`) · **base**: `a0bf0c9` (task-3004 / PR #249 머지본)
- **워크트리**: `/home/jay/workspace/projects/insuro/.worktrees/task-3005-dev7` · **브랜치**: `task/task-3005-dev7`
- **성격**: 검증 태스크. 프로덕션 로직 **0줄 변경**. 산출물은 회귀 테스트 1파일뿐.

---

## ★ 결론 먼저 — 담보명 '실손' 보존 = **유실됨**

LLM 은 증권 원문의 `종합입원형(질병)실손의료비(갱신형)` 를 `질병입원의료비` 로 **리네임**한다.
3회 재현 측정 전부에서 `coverages[].name` 에 '실손' 문자열은 **0건**이었다.

→ **task-3004 가 추가한 담보명 경로(`coverage_name`)는 현행 프롬프트 하에서 도달 불가능한 코드다.**
   고치지 않았다(명세 금지). 대안 판단은 ANU 영역이므로 그대로 넘긴다.

---

## S — 상황

task-3004(PR #249, main=`a0bf0c9`)가 `is_silson_relevant()` 에 담보명 기반 판정 경로를 넣었고,
ANU 가 `main=false → PR=true` 대조 프로브로 효과를 확인했다.
그러나 그 프로브의 담보명은 **손으로 입력한 문자열**이었다. 실제 경로는 PDF → LLM 파싱 → `coverages[].name` 이다.

## C — 문제

LLM 이 담보명을 정규화·축약해 '실손' 을 지워버리면 그 경로 전체가 무용해진다.
프로덕션 표본에 **종합형 증권이 0건**이라 이 경로는 실데이터로 검증된 적이 없었다.

## Q — 핵심 질문

**LLM 이 담보명의 '실손' 문자열을 보존하는가?**

## A — 해결(측정 결과)

보존하지 않는다. 원인은 프롬프트가 담보명을 **닫힌 어휘로 강제**하기 때문이다.

- `server/policy_analyzer.py` `build_policy_prompt()` 규칙 1:
  *"표준 담보 리스트에 없는 담보는 JSON에 포함하지 마세요."*
- `DEFAULT_COVERAGES["실손"]` = `상해입원의료비 · 질병입원의료비 · 상해외래/처방 · 질병외래/처방 · 4세대실손(급여,상해) · 4세대실손(급여,질병)`
- 2세대 종합형에 대응되는 앞 4개 어휘에는 **'실손' 문자열이 없다.** 뒤 2개(4세대)에만 있다.

즉 담보명 경로는 **4세대 실손 증권에서만** 발동할 수 있고, 그 경우엔 이미 다른 경로로도 잡힌다.

---

## ① E2E 실행 — 실제 파이프라인 경로

### 텍스트 레이어 실측
`fitz(PyMuPDF)` 로 5페이지 각각 `page.get_text().strip()` 측정 → **전 페이지 0자, 총합 0자**.
`policy_extract.detect.detect_pages()` = `['image']*5`, `detect_format()` = **`"image"`**.

### ★ 명세와 실측이 어긋난 지점 (임의 판단하지 않고 보고)

명세 ①은 *"스캔 이미지 PDF 이므로 vision/image 경로를 타야 한다(`doc_format` 이 `image`)"* 라고 했으나,
**주경로에는 `doc_format` 분기가 존재하지 않는다.**

- `server/policy_analyzer.py:208-256` `run_policy_analysis()` 는 `policy_extract` 를 **import 조차 하지 않는다**
  (파일 상단 import 는 `asyncio, json, os, re, datetime` 뿐). `detect_format`/`doc_format` 참조 0건.
- PDF 경로를 그대로 `claude -p --model sonnet --allowedTools Read` 에 넘겨 CLI 의 Read 도구가 직접 읽는다.
  텍스트레이어든 스캔이미지든 **항상 동일 경로**다.
- `doc_format` 분기가 있는 `server/policy_extract/`(vision, 200 DPI PNG 렌더링)는
  `analysis_table` 전용의 **별개 시스템**이다.

**부수 발견**: `silson/analysis_summary.py:175` `_valid_doc_format()` 은 analyze-policy meta 에
`doc_format` 필드가 애초에 없기 때문에 **항상 fallback `"textlayer"`** 를 채운다. 실측과 무관한 상수다.

### ★ 실행 결과 — 실제 함수 호출은 3/3 타임아웃 실패

`run_policy_analysis(PDF, None, "2026-08-24")` 를 코드 그대로 3회 호출:

| 회차 | 결과 |
|---|---|
| Run1 | `Exception('증권 분석 타임아웃 (120초)')` |
| Run2 | `Exception('증권 분석 타임아웃 (120초)')` (120.13s) |
| Run3 | `Exception('증권 분석 타임아웃 (120초)')` (120.10s) |

원인은 `policy_analyzer.py:229` 의 하드코딩 `asyncio.wait_for(..., timeout=120)` 이다.
동일 subprocess 파라미터(같은 프롬프트·모델·PDF)로 **내부 120초 캡만 해제한 진단 실행**의 실소요는
**139초 / 85초 / 111초** 로 120초를 넘나든다.

→ 아래 ②③⑤ 의 내용 분석은 **이 진단 실행 3회(diag1~3) 결과 기반**이며,
   `run_policy_analysis()` 자체의 반환값이 아니다. 구분해 기록한다.

---

## ② ★ 담보명 '실손' 보존 여부 — 원문 대조표

| 증권 원문 (3페이지) | LLM 추출 `name` | `category` | 금액 | '실손' 보존 |
|---|---|---|---|---|
| 종합입원형(질병)실손의료비(갱신형) 5,000만원 | `질병입원의료비` | 실손 | 5000 만원 | **X** |
| (상해 대응분, 원문 발췌 5건 밖) | `상해입원의료비` | 실손 | 5000 만원 | **X** |
| 상해통원형(외래)실손의료비(갱신형) 25만원 | `상해외래/처방` (외래+약제 합산) | 실손 | 30 만원 | **X** |
| 상해통원형(약제)실손의료비(갱신형) 5만원 | ↑ 에 합산, memo 에만 표기 | 실손 | (합산) | **X** |
| 질병통원형(외래)실손의료비(갱신형) 25만원 | `질병외래/처방` (외래+약제 합산) | 실손 | 30 만원 | **X** |
| 질병통원형(약제)실손의료비(갱신형) 5만원 | ↑ 에 합산, memo 에만 표기 | 실손 | (합산) | **X** |

### 3회 재현 교차 확인

| | coverages 총건수 | `name` 에 '실손' 포함 | category 분포 |
|---|---|---|---|
| diag1 | 15 | **0건** | 실손4 · 진단3 · 수술1 · 일당2 · 장애3 · 기타2 |
| diag2 | 16 | **0건** | 실손4 · 진단3 · 수술2 · 일당2 · 장애3 · 기타2 |
| diag3 | 15 | **0건** | 실손4 · 진단3 · 수술1 · 일당2 · 장애3 · 기타2 |

- **금액은 정확히 보존된다** (5000 / 5000 / 25+5=30 / 25+5=30 전부 원문 일치). 유실된 것은 이름뿐이다.
- category='실손' 4건의 **담보명은 3회 모두 완전히 동일**하다. 리네임은 우발적 흔들림이 아니라 **결정적 동작**이다.
- 회차 간 변동은 memo 표기(`[3년갱신/14개월]` vs `[3년/15개월]`, `처방5` vs `약제5`)와
  총건수(diag2 만 `N대 수술비` 1건 추가)뿐이다.

---

## ③ 판정 결과 — `silson_relevance()`

3회 전부 동일:

- `matched` = **True**
- `basis` = **`['category']`**

**명세는 `coverage_name` 이 basis 에 들어가야 정상이라고 했으나, 실측은 `category` 단독이다.**
결과적으로 판정은 옳게 나오지만(미탐 아님), 그것은 담보명 경로가 기여해서가 아니라
`category='실손'` 이 살아남기 때문이다. 임의 판단하지 않고 이 어긋남을 그대로 보고한다.

이는 task-3004 에서 확인된 *"`category` 는 순수 중복"* 이라는 인식도 뒤집는다 —
**이 증권 유형에서 `category` 는 유일하게 작동하는 경로다.**

---

## ④ 회귀 픽스처 봉인

신규 파일 1개: `server/tests/test_silson_jonghap_regression_task3005.py` (136줄). PDF 미사용, PII 0건.

| 테스트 | 봉인 대상 |
|---|---|
| `test_jonghap_e2e_parsed_output_detected_as_silson` | E2E 실측 산출물 → `matched=True` · `'category' in basis` · GEN2 판정 |
| `test_jonghap_raw_document_coverage_names_trigger_coverage_name_path` | 담보명 단독 매칭 → `basis == ['coverage_name']` (**변이 대상**) |
| `test_jonghap_generation_is_gen2_from_contract_date` | 계약일만으로 GEN2 판정 (담보명·가입형태 무관) |

### 픽스처 충실도 — 기계 검증

`JONGHAP_E2E_PARSED` 가 실측 원본(diag1)과 **완전 일치**함을 코드로 대조:

```
meta match: True
coverages match: True
names containing '실손': []
```

※ 1차 작성본에 `상해외래·처방`(가운뎃점)으로 적힌 오차가 있었다. 팀장 브리핑 표기가 그대로 옮겨진 것으로,
실측 원본 `상해외래/처방`(슬래시) 및 memo 실측값으로 교정했다(커밋 `4ba361c`).

### 설계 판단 — 왜 두 종류의 픽스처인가

'실손' 이 유실되므로 **실측 산출물만으로는 담보명 경로를 봉인할 수 없다**.
그래서 두 번째 픽스처는 **증권 원문 담보명**을 쓴다(담보명은 PII 가 아니다).
이 픽스처는 **현재 LLM 산출물이 아니며**, 프롬프트 변경·`custom_coverages` 사용·타 파서 유입으로
원문 문자열이 흘러올 경우를 대비한 defense-in-depth 다. 테스트 주석에 이 한계를 명시했다.

또한 `"coverage_name" not in basis` 같은 **부정 단언은 넣지 않았다** —
프롬프트가 개선되어 '실손' 이 보존되면 그것은 개선이지 회귀이므로, 테스트가 개선을 막아선 안 된다.

### ★ 변이 실증 (mutation kill) — 팀장 직접 재현

`analysis_summary.py:133` 의 `has_coverage_name_match = True` 를 `pass` 로 무력화:

```
1) PRE-mutation   : 3 passed
2) MUTATE line 133
3) POST-mutation  : 1 failed, 2 passed
   FAILED tests/test_silson_jonghap_regression_task3005.py::test_jonghap_raw_document_coverage_names_trigger_coverage_name_path
   assert relevance.matched is True → SilsonRelevance(matched=False, basis=[])
4) RESTORE        : blob=fddbc2d9c8b5fcfc03688357285416e5a564bb9a (base 와 일치) · tree clean
5) POST-restore   : 3 passed
```

테스트 1·3 은 `category` 경로라 변이에도 PASS 를 유지했다 — 예상과 일치하며,
**담보명 경로를 죽였을 때 정확히 담보명 테스트만 죽는다**는 것이 봉인이 겨냥한 지점임을 보여준다.

**금지 파일 원복 실증** (변이는 일시적, 커밋 0):

| 파일 | base `a0bf0c9` blob | 현재 blob | 판정 |
|---|---|---|---|
| `server/silson/analysis_summary.py` | `fddbc2d9c8b5fcfc03688357285416e5a564bb9a` | 동일 | IDENTICAL |
| `server/policy_analyzer.py` | `d907e22a850f34883894f028fc6084d05e070e8b` | 동일 | IDENTICAL |

`git diff --stat a0bf0c9` = **1 file changed, 136 insertions(+)** — 테스트 파일 단독.

---

## ⑤ 세대 판정 확인

- `meta.enrollment_date` (LLM 추출) = **"2012. 10. 29"** — 3회 모두 원문 계약일과 정확히 일치
- `normalize_enrollment_date()` → `2012-10-29`
- `build_silson_summary()` → `generation_id` = **`GEN2_STD1_2009_08`**, `generation_label` = **"2세대 표준화 Ⅰ"**

**2세대 판정 정상.** 계약일만으로 산출되었고, 담보명·가입형태 기반 추정 코드는 추가하지 않았다(계약서 §1-3 준수).
시기 기반 종합형/단독형 분기도 추가하지 않았다.

---

## 수정 파일별 검증 상태

| 파일 | 변경 내용 | grep 검증 | 상태 |
|---|---|---|---|
| /home/jay/workspace/projects/insuro/.worktrees/task-3005-dev7/server/tests/test_silson_jonghap_regression_task3005.py | 종합형 실손 증권 회귀 픽스처 3종 신설 (136줄) | grep "JONGHAP_E2E_PARSED" OK (5건) | verified |
| /home/jay/workspace/projects/insuro/.worktrees/task-3005-dev7/server/tests/test_silson_jonghap_regression_task3005.py | 담보명 경로 봉인 픽스처 (변이 대상) | grep "JONGHAP_RAW_COVERAGE_NAMES" OK (2건) | verified |
| /home/jay/workspace/projects/insuro/.worktrees/task-3005-dev7/server/tests/test_silson_jonghap_regression_task3005.py | 2세대 판정 봉인 | grep "GEN2_STD1_2009_08" OK (3건) | verified |

프로덕션 코드 변경 파일: **없음**(검증 태스크).

---

## L1 스모크테스트

- **서버 재시작**: 해당없음 — 프로덕션 코드 0줄 변경(테스트 파일만 추가). 서버 동작에 영향 없음.
- **API 응답 확인**: `/api/insuro/analyze-policy` 의 실제 처리 함수 `policy_analyzer.run_policy_analysis()` 를
  회장 제공 실증권 PDF 로 **직접 호출**(pytest 아님, 실제 LLM 호출). 결과는 위 ① 참조 —
  **실호출 3/3 타임아웃 실패**, 내부 캡 해제 진단 실행 3/3 성공(139s/85s/111s).
  이것이 이 태스크의 L1 이며, pytest 로는 절대 드러나지 않았을 결함(120초 타임아웃)을 실제로 잡아냈다.
- **스크린샷**: 해당없음 — 백엔드 파싱 경로 검증이라 UI 변경 없음.

---

## 발견 이슈 및 해결

| # | 이슈 | 심각도 | 조치 |
|---|---|---|---|
| 1 | **담보명 '실손' 유실** — LLM 이 표준어휘로 리네임. task-3004 담보명 경로가 현행 프롬프트 하에서 도달 불가 | High | **미수정(명세 금지)** — ANU 판단 이관. 프롬프트 변경은 파급 큼 |
| 2 | **`run_policy_analysis` 120초 타임아웃 부족** — 스캔이미지 5p 실소요 85~139초. 실호출 3/3 실패 | High | **미수정** (`policy_analyzer.py` 금지 경로) — ANU 후속 과제 필요 |
| 3 | 명세 ①의 "vision/image 경로(`doc_format`)" 전제가 주경로에 부재 — `policy_extract` 는 별개 시스템 | Medium | 보고만. 임의 판단 안 함 |
| 4 | `_valid_doc_format()` 이 analyze-policy 경로에서 항상 `"textlayer"` fallback (meta 에 필드 부재) | Low | 보고만 (범위 밖) |
| 5 | 픽스처 담보명 표기 오차(`·` vs `/`) + memo 누락 — 팀장 브리핑 표기가 전파됨 | Medium | **해결**: diag1 원본 대조 후 verbatim 교정, 기계 검증 `coverages match: True` |
| 6 | 명세 기대 basis(`coverage_name`)와 실측(`category`) 불일치 | Medium | 보고만. 판정 자체는 정상(matched=True) |

## 명세와 실측이 어긋난 항목 (ANU 판단 요청)

1. 명세: *"vision/image 경로를 타야 한다(`doc_format` 이 `image`)"* → **주경로에 분기 없음**. `detect_format()='image'` 는 맞으나 주경로가 참조하지 않음.
2. 명세: *"`coverage_name` 이 들어가야 정상"* → **실측 basis 는 `['category']` 단독**.
3. task-3004 기록: *"`category` 는 순수 중복"* → **이 증권 유형에서는 `category` 가 유일 작동 경로**.

---

## trip-wire 5종 실측치

| 항목 | 실측 | 판정 |
|---|---|---|
| Critical7 | 0 | PASS |
| PII net-new | 0 (신규 파일 PII 스캔 0건, PDF 미커밋·미복사) | PASS |
| 회귀 실패 | 0 (baseline 2606 → 2609 passed, +3 = 신규 테스트 수와 정확히 일치) | PASS |
| forbidden_paths 침범 | 0 (`analysis_summary.py`·`policy_analyzer.py` blob 동일성 실증) | PASS |
| nonce = task_id | task-3005 일치 | PASS |

### 회귀 baseline 대조

| | passed | failed | skipped |
|---|---|---|---|
| base `a0bf0c9` (변경 0줄) | 2606 | 1 | 3 |
| 작업 후 | 2609 | 1 | 3 |

실패 1건은 `tests/test_consultation_history_get.py::test_cors_fail_closed_when_ext_origin_unset` —
워크트리 `.env` 존재로 인한 **기존 환경 조건부 실패**이며 base 에서도 동일하게 실패한다. 회귀 아님.

---

## 개인정보 취급 준수

- PDF 는 워크트리 **외부**(`/home/jay/workspace/memory/fixtures/silson/`)에만 존재. 워크트리 내 `*.pdf` 검색 결과 0건.
- workspace repo `.gitignore:52` 의 `memory/fixtures/` 로 무시되며 **untracked** — 커밋 위험 없음.
- 픽스처·보고서·로그에 실명·주민번호·주소·증권번호 **0건**(정규식 스캔 실시).
- 픽스처에 쓴 값: 담보명·상품명·보험사명·가입금액·계약일·보험료 — 모두 PII 아님.
- 중간 산출물은 `/tmp/t3005_diag{1,2,3}.json` 뿐이며 저장소 밖이다.

---

## 셀프 QC 체크리스트

| # | 항목 | 결과 |
|---|---|---|
| 1 | 다른 파일 영향 | 없음 — 신규 테스트 파일 1개 단독, 프로덕션 코드 0줄 |
| 2 | 엣지 케이스 | 기존 48개 테스트가 방어 케이스(list 아님·dict 아님·name 비문자열) 커버. 신규는 실증권 유형 3종 |
| 3 | 작업 지시 일치 | ①~⑤ 전부 수행. 담보명 유실 발견 후에도 ③④⑤ 완주(완료조건에 명시되어 있음) |
| 4 | 에러 처리·보안 | PII 스캔 0건, PDF 미커밋, 금지 파일 blob 동일성 실증 |
| 5 | 테스트 경로 커버 | category 경로·coverage_name 경로·세대판정 3경로. 변이로 이빨 실증 |
| 6 | 발견 이슈 해결 | 이슈 5 해결. 이슈 1·2·3·4·6 은 **명세가 수정을 금지**한 범위(검증 태스크) → 보고 |
| 7 | 아키텍처 원칙 | 로직 변경 0. 기존 테스트 관례(`sys.path.insert` + `from silson...`) 준수 |
| 8 | 인터페이스 변경 문서 | 시그니처 변경 없음 |
| 13 | L1 스모크테스트 | 실제 LLM 파이프라인 직접 호출 완료(위 섹션) |

---

## 모델 사용 기록

| 팀원 | 역할 | 모델 | 담당 |
|---|---|---|---|
| 쿠쿨칸 | 백엔드 | sonnet | E2E 실측 3회, 텍스트레이어·경로 판정, 대조표 |
| 카마소츠 | 테스트/QA | sonnet | 테스트 관례·baseline 조사, 회귀 픽스처 3종, 변이 실증 |
| (사전조사) | 코드 탐색 | sonnet | 파이프라인 구조 매핑 |
| 이참나 | 팀장 | opus | 설계·검토·독립 검증(blob 동일성·변이 재현·픽스처 충실도) |

haiku 미사용 — 실측 판단과 회귀 설계가 포함된 작업이라 sonnet 이상 적용.
아쿠인(UX/UI)·이쉬첼(프론트) 미소집 — UI 변경이 없는 백엔드 검증 태스크.

---

## 완료 조건 대조

| 완료 조건 | 결과 |
|---|---|
| E2E 파싱 실행(경로 명시) | 완료 — 실호출 3/3 타임아웃 + 진단 3/3 성공. 경로 명시 |
| 담보명 '실손' 보존 여부 원문 대조표 | 완료 — **유실됨** |
| `silson_relevance` basis 제시 | 완료 — `matched=True`, `basis=['category']` |
| 회귀 픽스처 추가 + 변이로 봉인 실증 | 완료 — 3종 추가, 변이 kill 팀장 직접 재현 |
| 2세대 판정 확인 | 완료 — `GEN2_STD1_2009_08` |
| PII 유출 0 | 완료 |
| PDF 미커밋 | 완료 |
| 회귀 유지 | 완료 — 2606→2609, 회귀 0 |

**머지 금지 준수**: 태스크 md 가 "PR 생성까지가 범위. 머지는 ANU 가 한다"고 지시 → PR 생성 후 정지. 직접 머지 없음.
