# 작업 보고: task-3004 — 실손 판정 미탐(false negative) 해소

- 팀: dev7-team (이참나 / Itzamna)
- 레벨: Lv.2 · 회장 승인 완료(2026-08-24)
- base: `origin/main = 51718e8` · 브랜치 `task/task-3004-dev7` · 커밋 `99213cb`
- 워크트리: `/home/jay/workspace/projects/insuro/.worktrees/task-3004-dev7`
- **PR: #249** https://github.com/Jeon-Jonghyuk/InsuRo/pull/249 (state=open, **미머지**)
- ★ **PR 생성까지가 범위. 머지는 ANU. 직접 머지하지 않았음.**

---

## S — 상황 (Situation)

`server/silson/analysis_summary.py` 의 `is_silson_relevant()` 가 실손 증권을 놓치면
설계사 화면에 실손 세대지식이 아예 뜨지 않는다(무성 실패 — 오류도 안 남).
기존 판정 경로는 ① `coverages[].category == '실손'` ② `meta.product_name/product_type` 에 '실손' 포함, 2가지뿐이었고
**담보명(`coverages[].name`)은 보지 않았다.**

## C — 복잡성 (Complication)

종합보험에 실손 특약이 붙은 형태는 상품명에 '실손'이 없다. 담보명에만 '실손'이 있으면 현재 로직이 놓친다.

## Q — 질문 (Question)

담보명 기반 판정을 추가해 미탐을 줄이되, 세대 판정 불변식(계약서 §1-3)과 기존 호출부 계약을 깨지 않을 수 있는가?

## A — 답변·결론 (Answer)

가능했고, 구현·검증했다. **다만 명세의 핵심 전제 하나가 실데이터로 반증되었다(§ANU 판단 요청 참조).**

---

## 1. 결함 재현 (수정 전 — 실측)

입력(모두 '실손' 미포함: category=`특약`, product_name=`무배당 종합보험`, product_type=`종합`,
담보명만 `실손의료비특약`)에 대해 **base(51718e8) 코드가 `False` 를 반환**하는 것을 확인.

팀장 독립 재현 — base 사본과 HEAD 를 같은 프로세스에 나란히 로드해 대조 (`/tmp/t3004-verify/probe.py`):

```
case                                 BASE   HEAD  basis
----------------------------------------------------------------------------
A_담보명에만_실손(재현대상)            False   True  ['coverage_name']  <== 변경
B_category_실손(기존True)              True   True  ['category']
C_product_name_실손(기존True)          True   True  ['product_name']
D_product_type_실손(기존True)          True   True  ['product_type']
E_실손무관(기존False유지)              False  False  []
F_방어_None                            False  False  []
G_방어_빈dict                          False  False  []
H_방어_coverages가_dict                False  False  []
I_방어_원소가_문자열                    False  False  []
J_방어_name이_숫자                     False  False  []
K_다중경로                              True   True  ['category','product_name','product_type','coverage_name']
----------------------------------------------------------------------------
변경된 케이스: [('A_담보명에만_실손', False, True)]
```

★ **의도한 1건만 변경**. 기존 True 전부 유지, 기존 False 중 의도치 않은 True 전환 0건, 방어 케이스 예외 0건.

## 2. 구현

- `SilsonRelevance` frozen dataclass 신설 — `matched: bool`, `basis: list[str]`
  (태그: `category` / `product_name` / `product_type` / `coverage_name`. **PII 미포함**)
- `silson_relevance(validated) -> SilsonRelevance` 신설 — 4경로 전부 검사해 매칭 경로를 basis 에 모두 기록
- `is_silson_relevant(validated) -> bool` — `silson_relevance(...).matched` 를 돌려주는 얇은 wrapper.
  **반환 타입 `bool` 그대로 유지** (기존 호출부 계약 보존)
- `__all__` 에 `silson_relevance` 추가

### 불변식 준수 (계약서 §1-3)
- 담보명은 **"실손이라는 사실" 감지에만** 사용. **담보명으로 세대를 추정하는 코드 0줄.**
- 세대 판정 경로(`attach_generation` / `describe_for_analysis`)는 **미변경**.
  diff 로 확인: 변경 hunk 전부 `is_silson_relevant` / `silson_relevance` 영역에 한정.
- 닫힌 PR #215 diff 되살리기 없음 — main 기준 신규 구현.

### 호출부 전수조사 (팀장 직접, `git grep origin/main`)
| 호출부 | 영향 |
|---|---|
| `server/silson/analysis_summary.py:144` `if not is_silson_relevant(validated)` | 무영향 (bool 유지) |
| `server/tests/test_silson_analysis_summary.py:24` import | 무영향 |
| `server/tests/test_silson_analysis_summary.py:152` `assert ... is False` | 무영향 (그대로 통과) |

→ 그 외 호출부 없음. **반환 타입 변경 불필요했으므로 파급 0.**

## 3. 수정 파일별 검증 상태

| 파일 (절대경로) | 변경 내용 | grep/실행 검증 | 상태 |
|---|---|---|---|
| /home/jay/projects/InsuRo/.worktrees/task-3004-dev7/server/silson/analysis_summary.py | silson_relevance + SilsonRelevance 신설, is_silson_relevant wrapper 화 | grep -c "coverage_name" base=0 → HEAD=4 · import·동작 실행 확인 | verified |
| /home/jay/projects/InsuRo/.worktrees/task-3004-dev7/server/tests/test_silson_analysis_summary.py | 신규 테스트 14개(단일 6 + 파라미터 8) | pytest -q → 48 passed (base 34 → +14) | verified |

planned 항목 0건.

## 4. 봉인 증명 (변이 테스트 — 팀장 독립 재실행)

팀원이 쓴 변이 지점과 **다른 지점**을 골라 재실행:

```
변이: `if not has_coverage_name_match and isinstance(name, str) and "실손" in name:`
   → `if False and not has_coverage_name_match and ...`

변이 후:
FAILED tests/test_silson_analysis_summary.py::test_coverage_name_triggers_relevance_when_category_and_meta_silent
FAILED tests/test_silson_analysis_summary.py::test_silson_relevance_basis_multiple_paths_all_recorded
2 failed, 46 passed in 0.28s

원복 후:
sha_before / sha_after = b19cc44b29ace43e (바이트 동일, diff 무출력, git status clean)
48 passed in 0.14s
```

★ 새 테스트가 실제로 결함을 잡는다는 것을 실증.

## 5. 회귀 (base 재측정 기준선 · CI parity)

**두 환경을 나눠 측정** — worktree `.env` 가 CORS 테스트를 환경 조건부로 깨뜨리는 알려진 함정 배제 목적:

| 환경 | base(51718e8) | HEAD(99213cb) | 판정 |
|---|---|---|---|
| clean checkout(`.env` 없음, CI parity) | 2592 passed, 4 skipped, **0 failed** | 2606 passed, 4 skipped, **0 failed** | ✅ 신규 실패 0 |
| worktree(`.env` 있음) | 2592 passed, 3 skipped, 1 failed | 2606 passed, 3 skipped, 1 failed | 동일 1건 pre-existing |

- 델타 **+14 passed** = 신규 테스트 수와 정확히 일치. skip 수 불변.
- worktree 실패 1건 = `test_consultation_history_get.py::test_cors_fail_closed_when_ext_origin_unset`
  → base 에서도 동일하게 실패. **`.env` 환경 조건부 pre-existing**, 본 작업 무관.
- ★ **회귀 실패 판정 기준은 CI parity 열(0 failed)** 을 채택.

## 6. ★ category 실데이터 분포 (명세 요구 — 수치 보고)

프로덕션 Supabase `public.policy_analyses` 를 **읽기전용 트랜잭션**(`set_session(readonly=True)`)으로 조회.
팀원(카마소츠) 조사 → **팀장이 동일 쿼리로 독립 재검증, 수치 완전 일치.**

- 총 row 12건 · `coverages` 배열 보유 item 12건 · **총 coverage 행 112건**

| category | 건수 | 비율 |
|---|---|---|
| 진단 | 36 | 32.1% |
| **실손** | **32** | **28.6%** |
| 기타 | 20 | 17.9% |
| 수술 | 12 | 10.7% |
| 일당 | 12 | 10.7% |

- **`category == '실손'` 실측 = 32건 / 112건 (28.6%)** — ★ **0건이 아니다.**
- `category` null/빈문자열 = **0건 (0%)**
- `coverages[].name` 에 '실손' 문자열 포함 = **0건**
- `meta.product_name` 에 '실손' 포함 = 8 item (전부 `무배당 삼성화재 실손의료보험(2019)`)

### 표본 한계 (정직 고지)
12 row 전부 `created_at` 이 2026-08-16 01:55~03:09 UTC 74분 구간, `customer_name` 이 `테스트고객-2963` 계열.
**단일 QA 세션 표본(n=12, 상품 2종)** 이라 실사용자 트래픽 대표성은 **추정 불가**.
다만 이 데이터는 실제 API(`/api/insuro/analyze-policy` → `policy_analyzer.run_policy_analysis` → 실 LLM 호출)를 통과해
프로덕션 DB 에 커밋된 것이지 픽스처 직접 INSERT 가 아니다.
→ "프로덕션 파이프라인이 `category='실손'` 을 산출할 수 있는가"는 **입증됨**.

## 7. 오탐(false positive) 검증

- **실데이터 오탐 0건.** 담보명에 '실손'이 포함된 행 자체가 0건이므로 부정 문맥(`비실손`, `실손 제외` 등) 사례도 0건.
- 이론적 후보(팀원 제기): `"실손보험 미가입 확인서 특약"` 류 부분 매치. **실데이터 관측 0건.**
- ★ 명세 지시대로 **배제 규칙을 임의로 만들지 않았다.** 단순함 유지.

## 8. 화면 도달 확인

### 데이터 경로 (전수 추적, 끊긴 곳 없음)
`analysis_summary.py:135 build_silson_summary()` → `server/main.py:7100-7103` `validated["silson_summary"]=summary`
→ `GET /api/insuro/policy-analysis-status/{job_id}` `result:` → `src/pages/PolicyAnalysis.tsx:265-272` 폴링
→ `PolicyAnalysis.tsx:736-739` → `src/components/SilsonGenerationBlock.tsx`

**키 이름 문자열 대조 결과: 8개 최상위 키 + 중첩 키 전부 일치. 불일치 0건.**

### 스크린샷
`/home/jay/workspace/memory/reports/assets/task-3004-silson-render.png` (사전 상태: `...-pre.png`)

★ **팀장이 이미지를 직접 열어 육안 확인함.** 로그인월 아님. 실제 앱 화면에서 확인된 것:
`고객 증권분석` 페이지 → `분석 결과` → `KB손해보험 / 무배당 종합보험 / 실손의료보험 · 보험료 35,000원`
→ **`3세대 착한실손` 배지**, 적용기간 `2017-04-01 ~ 2021-06-30`,
`공제(자기부담금)` / `보장한도` / `지급률` 3칸 값 채워짐, `설계사 참고용입니다. 실제 증권·약관을 확인하세요.` 고지문,
하단 실손/진단/치료/수술/일당/장애/기타 담보 체크리스트.

### ★ 목킹 범위 정직 고지 (실 E2E 로 위장하지 않음)
- **목킹한 것**: `POST /api/insuro/analyze-policy`(job_id 반환), `GET .../policy-analysis-status/*`(결과 JSON) **2개 응답만**.
  응답의 `meta`/`coverages` wrapper 필드는 타입 충족용 합성값.
- **목킹하지 않은 것**: `silson_summary` 값은 **실제 백엔드 `build_silson_summary()` 출력 원본 그대로**.
  인증 세션 주입은 기존 `e2e/task-2990-consent-gate.e2e.mjs` 와 동일 기법. 폼 입력·파일 업로드·버튼 클릭·라우팅·컴포넌트 렌더는 **실제 앱 코드 경로**.
- **미확인**: 실 백엔드 서버를 띄운 full-stack 네트워크 E2E 는 **하지 않음**.
  사유: worktree `.env` 가 **프로덕션 Supabase** 를 가리켜 실행 시 실 DB INSERT + 유료 Anthropic 호출 + Drive 업로드 발생.
  "프로덕션 쓰기 금지" 지시 준수를 위해 보류. → 이 항목은 **UI 렌더 검증 수준**임을 명시.
- repo 파일 오염 0건(스크립트는 전부 `/tmp/task-3004-e2e/`). worktree `git status` clean 확인.

## L1 스모크테스트

- **서버 재시작**: 해당없음 — 백엔드 FastAPI 기동은 **의도적으로 미실행**(위 §8 사유: 프로덕션 Supabase 쓰기 위험).
  대신 로컬 Vite dev 서버(포트 4319) 기동 후 실제 프론트 코드 경로를 구동, 종료 후 포트 해제 확인.
- **API 응답 확인**: 해당없음(실 API 미호출). 대체 증거 — 백엔드 `build_silson_summary()` 를 실제 실행해
  8개 최상위 키가 채워진 산출물 확보(`/tmp/task-3004-e2e/silson_summary.json`, 8879 bytes), 그 값을 그대로 화면에 주입.
- **스크린샷**: `/home/jay/workspace/memory/reports/assets/task-3004-silson-render.png` (육안 확인 완료, 위 §8 서술)

## 9. ★★★ ANU 판단 요청 (명세 ↔ 실측 불일치 — 임의 판단하지 않음)

### (a) 명세 전제가 실데이터로 반증됨
명세: *"category 를 '실손' 로 세팅하는 프로덕션 코드 = 0건 → ①은 사실상 작동하지 않을 가능성이 높다"*
**실측: `category == '실손'` 32/112건(28.6%). 경로 ①은 실제로 작동하고 있다.**

기전: `server/policy_analyzer.py:11-38` `DEFAULT_COVERAGES` 의 최상위 키에 `"실손"` 이 있고,
이 dict 전체가 `build_policy_prompt()`(167-199) 에서 프롬프트에 임베드되어 LLM 에 "표준 담보 카테고리"로 제시된다.
리터럴 대입 코드는 없지만 **프로덕션 코드가 LLM 에게 '실손' 카테고리명을 지시**하고 LLM 이 이를 충실히 반영한다.
→ ANU 의 "코드에서 0건" 확인은 좁은 의미(리터럴 대입)에서는 맞지만, **결과적으로는 작동한다.**

### (b) 새로 추가한 경로의 실데이터 도달률 = 0
`coverages[].name` 에 '실손' 문자열이 든 행이 실데이터에 **0건**이다.
실손 담보의 실제 이름은 `상해입원의료비`, `질병입원의료비`, `상해외래/처방`, `질병외래/처방` 이며 '실손' 문자열을 포함하지 않는다.
→ 이번 수정은 **회귀 없는 순수 확장**이지만, 관측된 표본에서는 **새로 잡히는 증권이 0건**이다.
   (표본 n=12·상품 2종이라 "효과 없음"으로 단정할 수는 없다. 도달률 미상.)

### (c) 진짜 미탐 위험은 다른 형태일 수 있음 — 판단 요청
담보명이 `상해입원의료비` 인데 `category` 가 `실손` 이 아닌 값(예: `기타`)으로 나오는 경우,
**기존 경로도 새 경로도 잡지 못한다.** 참고로 `server/policy_normalizer.py:40,148-150` 에는
정규식 `r"(입원|외래|처방).{0,20}의료비|실손|실비"` 로 실손을 분류하는 **별도 프로덕션 경로**가 이미 존재한다
(대상 필드가 `analysis_table` 용이라 `coverages[].category` 와는 별개 시스템).
→ 이 정규식을 `is_silson_relevant` 에 도입할지는 **파급이 커서 임의로 하지 않았다. ANU 판단 요청.**

### (d) 배포 상태
백엔드 서빙 sha 는 `eadd0c9` 로 main(`51718e8`)보다 뒤처져 있다(미배포). 머지해도 자동 반영되지 않는다.
**배포는 ANU 판단 영역이며 본 팀은 관여하지 않았다.**

## 10. trip-wire 5종 (실측)

| 항목 | 실측 | 근거 |
|---|---|---|
| Critical7 | **0** | `red-team-auto-review.py scan` → `risk_level: low`, `vulnerability_count: 0`, `passed: true` |
| PII net-new | **0** | diff 신규 추가 172줄 대상 패턴 스캔(주민번호/연락처/sk-/JWT/계좌) → 0건. `basis` 태그는 고정 문자열 4종뿐 |
| 회귀 실패 | **0** | CI parity clean checkout: base 0 failed → HEAD 0 failed |
| forbidden_paths 침범 | **0** | 변경 파일 2개 전부 허용 경로. `policy_analyzer.py`/`src/`/`supabase/`/`ops/`/`.github/` 침범 0 |
| nonce | **task-3004** | 일치 |

## 11. QC 결과

`python3 teams/dev7/qc/qc_verify.py --task-id task-3004 --skip api_health --check-files <2개>`
→ `2 PASS, 2 FAIL, 15 SKIP, 4 WARN`

**FAIL 2건 해명:**
- `file_check` — 보고서/`.done` 미생성 시점 측정. 본 보고서 저장 후 해소.
- `git_evidence` — `resolved_via=fallback dir=/home/jay/workspace` 로 **워크스페이스 repo 를 본 것**.
  커밋은 InsuRo worktree(`99213cb`)에 존재. 알려진 worktree 경로 해석 함정이며 실제 커밋 부재가 아님.

**WARN 4건 — 전부 pre-existing 확인 (base 대조 완료):**
- `pyright_check` 1 error (`:209 doc_format` `str` vs `Literal`) → base 동일 라인(`:163`)에 이미 존재. 신규 아님.
- `style_check` black/isort → **base 에서도 동일하게 "would reformat"**. 재포맷 시 수술적 diff 가 훼손되므로 미적용.
- `tdd_check` JSON parse error → audit-trail 파일 이슈(교차 repo). 테스트 파일이 구현보다 뒤 타임스탬프로 잡혔으나 실제로는 동일 커밋.
- `claude_md_check` `design/CLAUDE.md 310줄` → 타 팀 파일, 본 작업 무관.

## 12. 모델 사용 기록

| 팀원 | 역할 | 모델 | 비고 |
|---|---|---|---|
| 쿠쿨칸 (Kukulkan) | 백엔드 구현·테스트·변이 | sonnet | 로직 구현 — haiku 미사용 |
| 카마소츠 (Camazotz) | 실데이터 분포 조사·baseline | sonnet | 분석/리서치 — haiku 금지 대상 |
| 이쉬첼 (Ixchel) | 화면 경로 추적(읽기 전용) | sonnet | 분석 작업 |
| 아쿠인 (Ah Kin) | 화면 렌더 실증·스크린샷 | sonnet | 검증 작업 |
| 이참나 (팀장) | 설계·분배·독립 재검증 | opus | 코딩 직접 수행 0줄 |

haiku 사용 0건. 팀장 직접 코딩 0줄(검증 스크립트는 `/tmp` 전용, repo 미반영).

## 13. 생성/수정 파일

**repo 변경 (커밋 `99213cb`, 2개 — 전부 허용 경로):**
- `server/silson/analysis_summary.py`
- `server/tests/test_silson_analysis_summary.py`

**산출물 (repo 외):**
- `/home/jay/workspace/memory/reports/task-3004.md` (본 보고서)
- `/home/jay/workspace/memory/reports/assets/task-3004-silson-render.png` · `...-pre.png`

## 비고

- ★ 태스크 md 의 "PR 생성까지가 범위 · 절대 직접 머지하지 마라" 에 따라 **머지하지 않음**.
  DIRECT-WORKFLOW.md 의 Lv.2 자동 머지 조항은 태스크 md 우선 규칙에 의해 미적용.
- 프로덕션 DB 는 **읽기전용 트랜잭션 조회만** 수행. 쓰기·DDL 0건.
- 금지 경로(`policy_analyzer.py` 등) 는 **읽기만** 했고 수정 0건. LLM 프롬프트 변경 없이 §9(a) 로 보고만 함.

## 세션 통계
- 총 도구 호출: 0회

