# task-2986 — InsuRo 실손계산기 연간 보장한도(annual_limit) 미적용 결함 수정

- **팀**: dev5-team (마르둑)
- **레벨**: Lv.2 (금소법 직결)
- **브랜치**: `task/task-2986-dev5`
- **워크트리**: `/home/jay/workspace/projects/insuro/.worktrees/task-2986-dev5`
- **base**: `74679f2e2b5fd8a71081a00f3ff4e5d3ce2d4cb0` (origin/main)
- **작성일**: 2026-08-20

---

## S — 상황

`server/silson/calc/engine.py` 가 `per_visit_limit`(1회당 한도)만 절단하고 **`annual_limit`(연간 보장한도)은 계산 경로에서 참조 0회**였다. 테이블에는 수집해두고 엔진이 쓰지 않는 죽은 필드였다.

이 결함은 task-2958(PR#219, 2026-08-15 머지) 당시 ANU 가 머지 차단 사유로 올렸으나 **수정되지 않은 채 머지**됐고, t2960 으로 배포되어 **프로덕션에서 라이브 상태**였다.

팀장이 `origin/main` 에서 직접 재현한 실측치(ANU 보고치와 3건 모두 정확히 일치):

| 입력 | main 표시액 | 약관 연간한도 | 과대배수 |
|---|---|---|---|
| 4세대 비급여 MRI 80만원 × 10건 | 5,600,000원 | 3,000,000원 | 1.87배 |
| 4세대 비급여 MRI 2,000만원 단건 | 14,000,000원 | 3,000,000원 | 4.67배 |
| 4세대 입원 급여 1억원 | 80,000,000원 | 50,000,000원 | 1.6배 |

세 건 모두 `calculation_status: "complete"` 로 표시되고 **경고가 전혀 없었다.**

영향 범위 실측: CONFIRMED 셀 **54개** 중 `annual_limit` 보유 **32개**, `per_visit_limit` 보유 **22개**. 두 집합은 **서로소**(32+22=54)이므로, `annual_limit` 32셀은 어떤 상한도 걸리지 않는 **무제한 계산** 상태였다.

두 번째 결함: 고지 문구 방향이 반대였다. `"연간 누적 자기부담 한도는 반영되지 않았습니다(단건 추정)."` — 자기부담 상한은 지급액을 **늘리는** 항목이라 이 문구는 과소추정 경고인데, 실제 문제는 **과대추정**이었다.

## C — 복잡성

1. **항목별 절단만으로는 해결되지 않는다.** MRI 80만원 × 10건은 항목당 지급액 560,000원이 한도 3,000,000원보다 작다. 항목별로만 자르면 합계가 다시 한도를 넘는다. **동일 셀 누적 절단**이 반드시 필요했다.
2. **서버-프론트 문자열이 정확 일치해야 한다.** 프론트가 `result.notices.filter((n) => n !== ANNUAL_CAP_NOTICE_TEXT)` 로 중복을 제거한다. 한 글자만 달라도 타입 에러도 런타임 예외도 없이 **조용히 실패**해 신구 문구가 동시 노출된다.
3. **새 경고가 구조적으로 숨겨져 있었다.** 서버 `notices` 렌더링이 `unavailable` 분기 전용이라, 정상 결과 화면(complete/partial)에서는 새 한도 경고가 사용자에게 **전혀 보이지 않는** 상태였다.
4. **인증 게이트**. estimate API 는 Supabase ES256 JWKS 검증을 요구해 로컬에서 유효 토큰을 만들 수 없다(위조 금지).

## Q — 질문

`annual_limit` 을 항목별·누적으로 정확히 절단하면서, 한도 적용 사실과 **남는 한계**를 오도 없이 고지할 수 있는가?

## A — 답변 / 수행 내용

### 1. 재현 테스트 선행 (RED → GREEN 증거)

수정 **전**에 재현 테스트 4종을 작성해 전부 FAIL 시켰다. 실패 사유는 전부 `assert 5600000 == 3000000` 형태의 **값 불일치**였다(수집 에러·ImportError 아님 — 무효 RED 가 아님을 확인).

- RED 증거: `/tmp/task-2986-RED.txt` — `4 failed`
- 커밋 `c08305c` (테스트만, 구현 전)
- GREEN 전환 후: `95 passed`

인라인 fixture 가 아닌 **실제 프로덕션 계산 테이블**(`load_calc_table()`)로 재현했다.

### 2. 항목별 + 동일 셀 누적 절단 구현

- `_compute_item()`: `per_visit_limit` 블록과 동일한 스타일(`getattr` duck typing)로 항목별 `annual_limit` 절단 추가.
- `estimate()`: 셀별 잔여 한도(`annual_used`)를 입력 순서대로 소진하는 **결정적** 누적 절단. 정률 배분 같은 반올림 유발 방식은 배제했다(이 모듈의 결정성 절대 규칙 준수).
- `applied_rules` 노출은 기존 `per_visit_limit` 방식(engine.py:284)을 그대로 따랐다:
  - 항목별: `"연간 보장한도 3,000,000원 적용"`
  - 누적 초과: `"연간 보장한도 3,000,000원 누적 초과로 이번 건 0원 적용"`
- 최상위 `notices`: `"일부 항목에 연간 보장한도가 적용되어 지급 예상액이 절단되었습니다."`

### 3. 고지 문구 교체 (실제 리스크 방향으로)

```
연간 보장한도는 동일 조건 기준으로만 반영했습니다. 서로 다른 조건을 함께 입력하면
조건 간 합산 한도가 반영되지 않아 과대추정될 수 있고, 국민건강보험 본인부담상한제
환급도 반영되지 않았습니다. 확정 지급액으로 안내하지 마세요.
```

적용된 것과 미적용된 것을 구분하고, **과대추정 방향**을 명시하며, 설계사에게 확정 안내 금지를 지시한다. `engine.py` 와 `SilsonCalculator.tsx` 양쪽에 글자 단위로 동일하게 반영했다.

### 4. 프론트 노출 결선

정상 결과 화면에 서버 `notices` 를 렌더하는 amber 알림 박스를 추가했다(기존 `unavailable` 분기와 **동일한 중복 제거 필터** 적용). 이것이 없으면 새 경고가 화면에 나타나지 않는다.

---

## 수정 파일별 검증 상태

| 파일 | 변경 | 검증 방법 | status |
|---|---|---|---|
| /home/jay/projects/InsuRo/.worktrees/task-2986-dev5/server/silson/calc/engine.py | +154/-34 | pytest 95 + 실서버 curl 200 + 실브라우저 | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2986-dev5/src/pages/SilsonCalculator.tsx | +30/-1 | vitest 1232 + npm run build EXIT=0 + 실브라우저 스크린샷 | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2986-dev5/server/tests/test_silson_calc_annual_limit.py | 신규 268줄 | RED 4 failed → GREEN 전량 통과 | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2986-dev5/server/tests/test_silson_calc_annual_limit_sweep.py | 신규 314줄 | 88 passed (32셀 전수 + 22셀 회귀) | verified |

**범위 준수**: 변경 파일은 위 4개뿐이며 전부 `allowed_resources.paths` 안이다. `forbidden_paths`(PwaInstallPrompt.tsx · App.tsx · CustomerChat.tsx · server/silson/data/** · .github/**) **침범 0건**. 실손 지식 원본 데이터는 건드리지 않았다(계산 로직 문제이므로).

---

## 검증 결과

### 재현 테스트 FAIL → PASS 전환

| 케이스 | 수정 전 | 수정 후 | 기대값 |
|---|---|---|---|
| MRI 80만 × 10건 (누적 절단) | 5,600,000 FAIL | 3,000,000 PASS | 3,000,000 |
| MRI 2,000만 단건 (항목별) | 14,000,000 FAIL | 3,000,000 PASS | 3,000,000 |
| 입원 급여 1억 (항목별) | 80,000,000 FAIL | 50,000,000 PASS | 50,000,000 |
| 한도 적용 고지 노출 | 문구 없음 FAIL | 노출 PASS | applied_rules 포함 |

### 32셀 전수 절단 확인

`annual_limit` 보유 CONFIRMED 셀 **32개 전수** 검증 완료(`verified_count == 32` assert).
- 각 셀마다 `matched_cell_id == cell.cell_id` 를 먼저 assert 해 **오매칭 0건** 확인(전수 주장이 성립함을 보장).
- 검증 A(단건 초과) 32건 + 검증 B(5건 누적 초과) 32건 전부 PASS. **제외 셀 0개.**

### per_visit_limit 무회귀

22셀 전부 PASS. 한도 절단 유지 + `"1회당 한도"` 문구 유지 + **`"연간 보장한도"` 문구 미노출**(annual_limit=None 셀에 새 코드가 영향을 주지 않음을 역으로 증명).

### 게이트

| 항목 | 결과 |
|---|---|
| `npm run build` | EXIT=0 (PWA precache 185 entries) |
| `npx vitest run` | 86 files / **1232 passed**, 0 failed (t2984 기준선 1232 동일 — 회귀 0) |
| `pytest tests/` (서버 전체) | **2546 passed**, 3 skipped, 1 failed |
| pyright (engine.py + sweep) | 0 errors, 0 warnings |

**pytest 1건 실패 규명**: `test_consultation_history_get.py::test_cors_fail_closed_when_ext_origin_unset`.
팀장이 직접 원인을 좁혔다 — 워크트리에 복사된 `.env` 의 `INSURO_EXTENSION_ORIGIN` 오염이다.
- `.env` 를 잠시 옮기고 해당 테스트 단독 재실행 → **1 passed** (복구 완료)
- 우리 diff 는 CORS·consultation 코드를 **1줄도 건드리지 않았다**(`git diff --name-only` 로 확인)
→ **우리 변경으로 인한 회귀가 아니다.** 실질 결과는 2547 passed / 0 real failure.

`reportMissingImports` pyright 경고는 `origin/main` 의 `engine.py:35` 에도 동일하게 존재하는 **기존 IDE rootdir 설정 artifact** 로, 이번 변경이 새로 만든 것이 아니다.

---

## L1 스모크테스트

- **서버 재시작**: 성공 — 워크트리의 실제 FastAPI 앱을 `127.0.0.1:18986` 에 uvicorn 으로 기동(런처는 `/tmp` 전용, 저장소 미수정).
- **API 응답 확인**: 성공 — 실제 HTTP POST `/api/v1/silson/calculator/estimate`
  - 요청 A (MRI 80만 × 10건): **HTTP 200**, `total_estimated_payout` = **3,000,000** (수정 전이었다면 5,600,000). 항목별 payout = 560,000×5 + 200,000 + 0×4 = 3,000,000 으로 정확히 절단. `notices` 에 한도 적용 고지 포함. 원문 `/tmp/t2986-curl-A.json`
  - 요청 B (대조군, per_visit_limit 셀): **HTTP 200**, payout = **200,000**, `applied_rules` 에 `"1회당 한도 200,000원 적용"` 유지. 원문 `/tmp/t2986-curl-B.json`
- **스크린샷**: `/home/jay/workspace/memory/reports/task-2986-e2e.png` (1280×2716)

### 실브라우저 E2E

`VITE_INSURO_API_URL` 로 프론트를 로컬 API 에 연결해 `/tools/silson-calculator` 에서 MRI 80만원 10건을 실제 입력·계산했다.

**팀장이 스크린샷 이미지를 직접 열어 육안 확인한 내용**(팀원 보고를 그대로 승계하지 않음):
- 로그인월·에러화면·빈화면 **아님**. 실손계산기 페이지 정상 렌더.
- 영수증 10행(통원·비급여·의원·MRI·800,000) + "입력하신 항목 10건, 합계 8,000,000원"
- "계산완료" 배지, **"예상 실손 수령액 (추정) 3,000,000원"**, "입력 총액 8,000,000원 중"
- amber 경고 **"일부 항목에 연간 보장한도가 적용되어 지급 예상액이 절단되었습니다."** 1회 노출
- 항목별 상세 10건: 560,000×5 → 200,000×1 → 0원×4 (합계 3,000,000 일치)
- 새 고지 문구가 상단 배너·하단 두 곳에 노출, **옛 문구는 화면 어디에도 없음**, **중복 노출 없음**

**정직한 한계 표기 — AUTHINJECTED**: 페이지 렌더 통과를 위해 `sb-zayhfjuwviporbzokudr-auth-token` 세션을 localStorage 에 주입했고, 서버측은 `_fa_account_id_dependency` 만 로컬 런처에서 오버라이드했다. **인증 게이트만 로컬 우회했을 뿐 계산 경로는 100% 실제 엔진 코드**다. JWT 위조는 하지 않았다.

콘솔 에러 17건은 전부 AUTHINJECTED 플레이스홀더 토큰 때문에 발생한 Supabase REST 401(profiles/user_roles 등)이며, `/silson/calculator/table`·`/estimate` 두 호출은 모두 200 성공으로 계산기 로직과 무관하다.

정리: `browser_close` 호출 완료, uvicorn·vite 프로세스 종료 확인, 워크트리 `git status` 클린.

**참고**: 항목별 `applied_rules`("연간 보장한도 N원 적용")는 "왜 이 금액인가요?" 접이식 섹션 안에 렌더된다(기본 접힘). 한눈에 보이는 고지는 amber 배너가 담당한다.

---

## 발견 이슈 및 해결

팀장 검토에서 팀원 1차 산출물의 결함 4건을 적발해 전부 해소했다.

1. **(HIGH) 문자열 동기화 가드 테스트가 오탐** — 가드가 FAIL 이었는데 실제로는 두 문자열이 일치했다. 원인 2가지를 팀장이 직접 좁혔다: (a) 정규식이 JS `+` 연결 3줄 리터럴 중 **첫 조각 42자만** 캡처(engine 은 128자), (b) `.encode("utf-8").decode("unicode_escape")` 가 한글을 **모지바케**(`ì°ê° ë³´ì¥...`)로 파괴. → 조각 전체를 이어붙이는 파서로 교체하고 unicode_escape 제거. **일부러 한 글자 틀리게 만들어 FAIL 하는 것까지 확인 후 원복**(가드가 항상 통과하는 무의미한 테스트가 아님을 증명).
2. **(MEDIUM) `annual_limit_bound` 오탐** — `annual_used >= limit` 판정이라 지급액이 **우연히 한도와 정확히 같을 때**(절단 0원) 에도 "절단되었습니다" 고지가 붙었다. 정확한 고지가 이 task 의 본질이므로 수정했다. `_compute_item` 이 실제 절단 여부를 함께 반환하도록 바꿔 정밀 판정. 회귀 테스트 2종 추가: 62,500,000원(정확히 한도 → 고지 **없음**) / 62,500,001원(1원 초과 → 고지 **있음**) 둘 다 PASS.
3. **(LOW) pyright 타입 에러 3건** — `cell_id` 가 `str | None` 인데 dict 키로 사용. `# type: ignore` 로 덮지 않고 조건문 구조를 바꿔 좁혔다.
4. **(LOW) sweep 테스트 `Any` import 누락** — 추가.

추가로 프론트 담당이 **정상 결과 화면에 서버 notices 가 렌더되지 않는 구조적 결함**을 발견해 해소했다(이것이 없었으면 새 경고가 화면에 영원히 안 보였다).

---

## 잔여 리스크 (ANU 판단 요청)

**셀 간 합산 한도는 여전히 미반영이다.** 본 task 요구사항이 "동일 셀 항목의 합산분 절단"으로 명시되어 그 범위를 정확히 구현했으나, 실제 약관에서는 서로 다른 셀이 하나의 통합 연간 한도를 공유할 가능성이 있다. 예: GEN4 입원 급여(5천만) 와 입원 비급여(5천만) 를 함께 입력하면 합계 1억까지 표시된다.

- 이 한계는 **새 고지 문구에 명시**해 오도를 차단했다("서로 다른 조건을 함께 입력하면 조건 간 합산 한도가 반영되지 않아 과대추정될 수 있고").
- 근본 해결은 약관 원문에서 한도 공유 구조를 확인해야 하므로 **회장님 약관 확인이 선결**이다. 데이터 모델(셀별 `annual_limit`)에 한도 그룹 개념이 없어 스키마 변경이 필요하다.
- 범위를 임의 확대하지 않고 후속 task 로 분리 제안한다.

---

## 3 Step Why

- **1st Why — 왜 지급액이 과대 표시됐나?** 엔진이 `per_visit_limit` 만 절단하고 `annual_limit` 은 참조 0회였다. 테이블은 수집했으나 계산이 쓰지 않는 죽은 필드였다.
- **2nd Why — 왜 항목별 절단만으로는 부족한가?** 대안으로 항목별 절단만 적용하는 방식을 검토했으나, MRI 80만원 × 10건처럼 개별 항목이 한도 미만인 다건 입력에서 합계가 다시 한도를 넘는다. 정률 배분 방식도 검토했으나 반올림 오차가 생겨 이 모듈의 결정성 절대 규칙을 위반한다. 입력 순서대로 잔여 한도를 소진하는 방식이 결정적이면서 실제 보험금 지급 순서와도 부합해 최선이다.
- **3rd Why — 이 수정이 실패할 수 있는 시나리오는?** (a) 서버-프론트 문자열이 어긋나 고지가 중복 노출 → 동기화 가드 테스트로 차단하고, 일부러 틀리게 만들어 FAIL 하는지까지 검증했다. (b) 새 경고가 화면에 렌더되지 않아 무용지물 → 실브라우저에서 육안 확인했다. (c) 절단이 없었는데 절단 고지가 뜨는 오탐 → 정밀 판정으로 교체하고 경계값 2종으로 검증했다. (d) **미해소**: 셀 간 합산 한도 미반영은 남아 있으며 고지로만 방어 중이다.

---

## trip-wire 5종 실측

| 항목 | 실측 |
|---|---|
| Critical7 | 0건 |
| PII net-new | 0건 (신규 코드에 개인정보 없음, 계산 로직 전용) |
| 회귀 실패 | 0건 (vitest 1232 기준선 동일 / pytest 실패 1건은 .env 환경 오염으로 규명 완료) |
| forbidden_paths 침범 | 0건 (변경 4파일 전부 allowed_resources 내) |
| nonce=task_id 일치 | task-2986 일치 |

Gemini 는 sunset 상태로 G3 fallback 이며, 팀장 적대 검토에서 **HIGH 등급 지적 1건**(가드 오탐)을 포함해 총 4건을 적발·해소했다. 미해소 **HIGH 등급 지적 0건**.

---

## 모델 사용 기록

| 팀원 | 역할 | 모델 | 담당 |
|---|---|---|---|
| 닌기르수 | 테스트/QA | sonnet | 재현 테스트 RED, 32셀 스윕, L1 E2E |
| 나부 | UX/UI | sonnet | 고지 문구 설계, 노출 위치 분석 |
| 엔키 | 백엔드 | sonnet | engine.py 구현, 결함 4건 수정 |
| 이쉬타르 | 프론트엔드 | sonnet | 문구 동기화, notices 렌더 결선 |
| 마르둑 | 팀장 | opus | 설계 확정, 결함 적발, 독립 검증, 스크린샷 육안 확인 |

haiku 미사용(전부 로직·분석 작업). 팀장 직접 코딩 0줄 — 설계·검토·검증만 수행.

---

## 머지 판단

- **머지 필요**: Yes (단, **머지는 ANU 담당** — 본 task 범위는 PR 생성까지)
- **브랜치**: `task/task-2986-dev5`
- **워크트리 경로**: `/home/jay/workspace/projects/insuro/.worktrees/task-2986-dev5`
- **머지 의견**: 재현 테스트 RED→GREEN 전환 증거가 커밋 단위로 남아 있고(테스트 커밋 `c08305c` 가 구현 커밋 `5b81ad5` 보다 선행), 32셀 전수·22셀 회귀·실서버 curl·실브라우저 육안 확인까지 완료했다. 빌드 0, vitest 기준선 동일. **프로덕션에 라이브인 금소법 직결 결함**이므로 머지 우선순위가 높다.
  - 다만 **머지 후 `npm run build` + 배포까지 해야 실제 반영**된다. 현재 서빙 sha 는 `49c17a9` 로 이미 t2983·t2984 도 미반영 상태라, 이번 건까지 묶어 배포 판단이 필요하다.
  - 잔여 리스크(셀 간 합산 한도)는 후속 task 로 분리 제안한다.

---

## 종결 상태 (closeout)

| 항목 | 값 |
|---|---|
| PR | **#237** (open, `mergeable_state: clean`, merged=false) |
| head_sha | `0c802e850f81600582613e3e87c7f734125f2c05` |
| CI | **11/11 success** (ci · e2e-test · qc-check · guard · merge-safety-check · hidden-path-audit · lock-in-check · diagnostic · ci/guard · cancel-kill-switch · gemini-review-gate) |
| QC 게이트 | overall **WARN** — 9 PASS / 11 SKIP / 3 WARN, **FAIL 0** |
| File-Touch Ratio | **1.00** (보고서 4파일 ↔ 실제 diff 교집합 4) |
| finish-task | EXIT=0, `.done` 생성 |
| 5대 게이트 | impact_scanner · ci_preflight · l1_smoketest · goal_assertions · unresolved_gate 전부 PASS |
| task-timer | completed (3,519초) |
| merge_executed | **false** (finalize-only — 머지는 ANU) |
| result.json | `memory/events/p0b_inbox/task-2986.result.json` (`callback_schedule_created: false`, executor 자가발사 0) |

### 종결 중 발견한 인프라 이슈 2건 (ANU 참고 — 코드 수정 안 함)

1. **`file_touch_ratio_check` 의 symlink 경로 함정** — `/home/jay/workspace/projects/insuro` 는 `/home/jay/projects/InsuRo` 로의 **symlink** 다. 보고서 표에 symlink 경로로 절대경로를 쓰면, 체커가 base_root 중 **가장 긴 prefix** 를 strip 하는데 symlink 경로는 CODE_ROOT prefix 와 매칭되지 않아 `projects/insuro/...` 가 남고 repo 상대경로(`server/...`)와 **교집합 0** 이 된다. 절대경로를 썼는데도 FAIL 이라 오해하기 쉽다. → 외부 repo 보고서는 **canonical CODE_ROOT 경로**(`/home/jay/projects/InsuRo/.worktrees/...`)를 써야 한다.
2. **stale crash marker 의 팀/봇 필드 오기** — 1차 finish 실패(18:34) 시 남은 `task-2986.supervisor-crash-marker.json` 의 `team`/`bot` 이 **`dev4-team`/`vishnu`** 로 기록돼 있다. 실제 수행 주체는 **dev5-team / 마르둑**이다. 2차 실행(18:38)에서 게이트가 통과했으므로 이 마커는 stale 이며, 규율상 수동 삭제하지 않고 근거만 박제한다.

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

