# task-2966 · InsuRo 증권분석 결함 A 수정 (다중사 합산 실패 + Python 예외 원문 노출)

- **팀**: dev1-team (헤르메스/불칸/이리스/아르고스)
- **레벨**: critical (Lv.3 게이트)
- **저장소**: `/home/jay/projects/InsuRo` · worktree `.worktrees/task-2966-dev1` · 브랜치 `task/task-2966-dev1`
- **base**: `d381e570765f8a63a68db588f61ff4137f906855` (origin/main)
- **PR**: [#220](https://github.com/Jeon-Jonghyuk/InsuRo/pull/220) — **머지 HOLD (지시서 준수, 자동 머지 안 함)**

---

## S — 상황

task-2964 프로덕션 스모크에서 증권분석 결함 A가 재현됐다. 사용자 화면에 Python 예외 원문
`unsupported operand type(s) for +: 'int' and 'NoneType'` 이 그대로 노출됐고, 예외로 실패한 증권이
합산 표에서 빠져 **2개사를 올렸는데 표에는 1개사만** 반영됐다. 누적 관측 4회 중 3회 실패로 빈도가 낮지 않으며,
HTTP 는 200 이고 잡 상태도 `completed` 라 상태코드 감시로는 잡히지 않는 **조용한 부분 실패**였다.

## C — 문제

원인 코드가 미확정 상태였다. task-2964 는 검증 전용 작업이라 "AI 추출 필드가 None 인데 None 체크 없이
덧셈" 이라는 **추정**만 남겼고, 어느 필드가 어느 지점에서 새는지는 확인되지 않았다.

## Q — 질문

어느 코드가 None 을 흘리는가, 그리고 그것을 어떻게 막아야 **숫자를 지어내지 않으면서**(금소법)
정상 경로를 건드리지 않는가.

## A — 답변

### 근본 원인 (확정)

`server/policy_analyzer.py` 의 가드가 **키 부재만** 처리했다.

```python
if "total_payment_years" not in meta:   # 키가 있고 값이 null 이면 그대로 통과
    meta["total_payment_years"] = 0
```

LLM 은 값을 모를 때 필드를 생략하는 대신 `"total_payment_years": null` 을 내보내는 경우가 흔하다.
이때 키는 존재하므로 위 가드를 통과하고, `server/main.py` 의 `meta.get("total_payment_years", 0)` 도
(키가 있으므로) 기본값을 적용하지 않아 `None` 이 그대로 흐른다. 최종적으로
`calculate_remaining_payment` 의 `enroll_dt.replace(year=enroll_dt.year + total_payment_years)` 에서 TypeError.
이를 감싼 `except ValueError` 는 TypeError 를 잡지 못해 그대로 전파된다.

같은 함수 안의 `amount` 가드가 `int(x) if x else 0` 으로 **값 레벨**을 보는 것과 대조되는 비대칭이 원인이었다.

### 수정 (4가지)

1. **None 가드** — `calculate_remaining_payment` 가 `total_payment_years is None` 이면 예외 없이
   `{"unknown": True, "unknown_reason": "납입기간 미상", ...}` 반환. 숫자 필드는 전부 `None`.
2. **값 레벨 정규화** — `validate_analysis_result` 가 `None`/빈문자열/정수변환 불가를 `None`(UNKNOWN)으로 정규화.
   **0 대체 금지**: `total_payment_years == 0` 은 코드상 "일시납 → `payment_completed: True`" 를 의미하므로,
   모르는 값을 0 으로 채우면 **"납입 완료"라는 허위 사실 주장**이 되어 금소법 위반이다.
3. **격리** — 개별 증권 실패는 파일 루프 안의 try/except 로 격리되고, 실패 건은 합산 표 재료에서도 제외된다.
4. **예외 원문 은닉** — 사용자에게는 친화 메시지 + `error_code` 만, 예외 상세는 `logger.exception` 으로 서버 로그에만.
   추가로 상태 응답에 `failed_count`/`partial_failure` 를 더해 조용한 부분 실패를 가시화했다.

---

## 수정 파일별 검증 상태

경로는 worktree 절대 경로 기준이다(이 작업은 `/home/jay/workspace` 가 아니라 InsuRo 저장소에서 수행됨).

| 파일 | 변경 내용 | grep 검증 | 상태 |
|------|-----------|-----------|------|
| /home/jay/projects/InsuRo/.worktrees/task-2966-dev1/server/policy_analyzer.py:79 | calculate_remaining_payment None 입력 시 UNKNOWN 반환 | grep "total_payment_years is None" OK | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2966-dev1/server/policy_analyzer.py:271 | validate_analysis_result 값 레벨 None/비정수 정규화 | grep "_tpy" OK | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2966-dev1/server/policy_analyzer.py:309 | CSV 내보내기 UNKNOWN 미상 표기 | grep "미상" OK | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2966-dev1/server/main.py:7057 | 예외 원문 대신 친화 메시지 + logger.exception | grep "POLICY_ANALYSIS_FAILED" OK | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2966-dev1/server/main.py:7053 | 분석 실패 증권은 합산 표 재료에서 제외 (try 블록 내부 유지) | grep "extract_result_for_path" OK | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2966-dev1/server/main.py:7143 | 상태 응답에 failed_count/partial_failure 추가 | grep "failed_count" OK | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2966-dev1/src/pages/PolicyAnalysis.tsx:623 | 납입잔여 unknown 시 미상 배지 | grep "unknown_reason" OK | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2966-dev1/server/tests/test_policy_analyzer.py | None 재현 + 정규화 파라미터화 테스트 | grep "UnknownTotalYears" OK | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2966-dev1/server/tests/test_policy_analysis_table.py | 다중사 2개사 합산 산술 테스트 | grep "MultiCompanySum" OK | verified |
| /home/jay/projects/InsuRo/.worktrees/task-2966-dev1/server/tests/test_policy_analysis_failure_isolation.py | 격리 + 예외 원문 미노출 + 표 제외 테스트 | grep "POLICY_ANALYSIS_FAILED" OK | verified |

planned 항목 **0건**. 추가로 `server/main.py` 의 analyze-policy 라우트 구간(6977~7150행)에
`str(e)` 가 **0건**임을 별도 확인했다(응답 페이로드에 예외 원문 없음).

---

## 테스트 결과

### 결함 재현 → 수정 확인 (before/after 실증)

base worktree(`/tmp/insuro-base-2966`, `d381e57`)와 수정본에 **동일 입력**을 넣어 대조:

```
===== BEFORE (base d381e57) =====
validate -> None
EXCEPTION: TypeError - unsupported operand type(s) for +: 'int' and 'NoneType'

===== AFTER (task-2966) =====
validate -> None
RESULT: {'remaining_years': None, 'remaining_months': None, 'remaining_total_months': None,
         'payment_completed': None, 'unknown': True, 'unknown_reason': '납입기간 미상'}
```

프로덕션에서 관측된 예외 문구가 base 에서 **글자 그대로 재현**됐고, 수정본에서는 사라졌다.

### 다중사 합산 산술 (task-2964 미검증 항목 해소)

삼성화재 사망보험금 5000 + 현대해상 사망보험금 3000 → 합계 **8000** assert 통과.
한쪽이 `None` 이면 합계는 `UNKNOWN` 이 되고 숫자를 지어내지 않음도 함께 검증.
한 회사에만 있는 담보는 다른 회사 열에서 `-`(미보장)로 구분됨.

### 회귀

```
1879 passed, 1 failed, 2 skipped, 173.26s
FAILED tests/test_consultation_history_get.py::test_cors_fail_closed_when_ext_origin_unset
```

실패 1건은 **base 커밋에서도 동일하게 실패**함을 clean worktree(CI parity)에서 독립 확인했다:

```
cd /tmp/insuro-base-2966/server && pytest tests/test_consultation_history_get.py -q
→ 1 failed, 20 passed
```

→ 이번 변경으로 인한 회귀 **0건**. skip 2건은 `INSURO_POLICY_FIXTURE_DIR` 미설정 사유로 무관.

### CI (PR #220)

**11/11 PASS** — ci, diagnostic, e2e-test, guard, ci/guard, qc-check, lock-in-check,
merge-safety-check, hidden-path-audit, cancel-kill-switch, gemini-review-gate.

---

## L1 스모크테스트

- **서버 재시작**: **성공** — worktree 코드로 uvicorn 실기동
  (`python3 -m uvicorn main:app --host 127.0.0.1 --port 8123`). 프로덕션(8001)은 건드리지 않음.
- **API 응답 확인**:
  - `GET /api/status` → **HTTP 200**, 본문 `{"status":"ok"}`
  - `GET /api/insuro/policy-analysis-status/does-not-exist` → **HTTP 401** (라우트 등록 + 인증 게이트 정상 동작)
  - 서버 로그 traceback/error **0건**
  - 추가로 수정된 함수를 **실제 파이썬 프로세스에서 직접 실행**해 before/after 대조(위 §결함 재현) — pytest 가 아닌 실행 증거
  - 실제 라우트 함수 `main.analyze_policy` 를 await 로 구동하는 동적 통합 테스트로 실패 격리 경로 확인
- **스크린샷**: 해당없음 — 백엔드 결함 수정이며, 프론트 변경분(`unknown` 배지)은
  실제 UNKNOWN 응답을 만들려면 프로덕션 AI 분석을 태워야 해서 이번 실행에서 브라우저 캡처는 하지 않았다.
  프론트는 `npm run build` 통과(12.99s)로만 검증했다. **UI 육안 확인은 미수행** — 정직하게 기록한다.
- **프로덕션 무영향**: 프로덕션 서버·DB·Drive 에 대한 쓰기 **0건**. 테스트 흔적 없음.

---

## 게이트 통과 현황 (Lv.3)

| 게이트 | 결과 | 증거 |
|---|---|---|
| G1 Codex 사전 검증 | PASS | `pass: true`, 산출물 `memory/events/task-2966.codex-gate` (worktree) |
| sanitize (외부 AI 호출 전) | PASS | 변경 6파일 전수 PII findings **0** |
| G2 마아트 독립 검증 | **PASS** | V-1~V-7 전항목 PASS, 뮤테이션 검사 실증 포함 |
| G2 Gemini PR 리뷰 | 해당없음 | Gemini sunset(기존 결정) — Codex + 마아트로 대체 |
| G3 머지 게이트 | PR #220 OPEN | CI 11/11 PASS, **머지는 HOLD** |

### 마아트 독립 검증 요지

- base 코드에 신규 테스트의 핵심 입력을 넣으면 **실제로 TypeError 로 실패**함을 실행으로 확인
  → 테스트가 결함을 진짜로 잡는다는 증거(tautology 아님)
- 다중사 합산 테스트가 문자열 비교가 아닌 **진짜 산술**(`int(a)+int(b)==8000`)임을 확인
- 회귀·CORS 실패의 기존성(base 재현)을 독립 재실행으로 확인
- 범위 침범 0 — 6파일 전부 결함 A 범위 내

---

## 3 Step Why

- **1st Why (왜 이 설계가 필요한가) → A**
  AI 추출 필드의 명시적 `null` 이 가드를 통과해 산술에 도달하면 증권 1건이 통째로 실패하고,
  그 실패가 합산 표 누락으로 이어지며, 예외 원문이 사용자에게 노출된다.
  따라서 값 레벨 None 정규화 + None-safe 계산 + 격리 + 메시지 은닉이 **동시에** 필요하다.

- **2nd Why (왜 A가 최선인가) → B**
  대안 세 가지는 각각 치명적 결함이 있다. `None → 0` 대체는 "납입 완료"라는 **허위 사실 주장**(금소법 위반),
  호출부에서 예외만 삼키는 방식은 **결함 은폐**, AI 프롬프트 강화는 **비결정성 의존**(실측 4회 중 3회 실패).
  A 는 "모르는 것을 모른다고 표기"하여 정확성을 지키면서 결정적으로 예외를 차단한다.
  즉 정확성과 안정성을 맞바꾸지 않는 유일한 안이다.

- **3rd Why (왜 B가 대안보다 나은가) → C**
  UNKNOWN 전파는 이 코드베이스에 **이미 확립된 관례**다. `policy_normalizer.py:434` 는
  `any(v is None ...)` 일 때 합계를 `_UNKNOWN_CELL` 로 대체하고, task-2957 의 표는 `UNKNOWN` 배지와
  범례("값 불명(추정하지 않음)")를 이미 렌더한다. 따라서 B 는 새 개념 도입 없이 기존 관례를
  같은 지점에 적용하는 것이며, 표시 계층이 이미 UNKNOWN 을 다룰 줄 안다는 점에서 통합 비용과
  회귀 위험이 가장 낮다. A-B-C 는 "숫자를 지어내지 않는다"는 하나의 원칙으로 일관된다.

---

## 발견 이슈 및 해결

### 해결한 것

1. **"null년 null개월" 노출 버그 (이리스 발견, 수정 완료)**
   `remaining_payment` 의 숫자 필드가 `null` 이 되면서, 개별 증권 카드가 `payment_completed` falsy 분기로 빠져
   `null년 null개월 (null개월)` 을 렌더할 위험이 실재했다. `unknown` 분기를 최우선으로 추가해 "미상" 배지로 처리.
   대구분 표(`CoverageAnalysisTable.tsx`)는 이미 `null → UNKNOWN` 처리가 완비돼 있어 수정 불필요.

2. **CSV 내보내기 `"None년 None개월"` (불칸 발견, 수정 완료)**
   MT-2 로 `total_payment_years` 가 None 이 될 수 있게 되면서 내보내기가 `str(None)` 을 출력하는 경로가
   새로 생겼다. "미상" 표기로 처리.

3. **팀장(제) 판단 오류 — 잘린 grep 을 "0건"으로 읽음 (교정 완료)**
   설계 초기에 "프론트는 `remaining_payment` 를 렌더하지 않는다"고 판단했으나 **틀렸다**.
   `grep ... server/ src/ | head -30` 에서 server/ 매치가 30줄을 채워 `src/` 매치가 잘려 나간 것을
   부재로 오독했다. 이리스의 전수 감사에서 렌더 지점 2곳이 드러났고 그중 1곳에 실제 버그가 있었다.
   교훈은 context-notes.md 에 기록: **부재를 주장하려면 `head` 로 자른 출력을 근거로 삼지 말 것.**

4. **Codex HIGH 수용 — 제 설계 되돌리기 (완료)**
   저는 "합산 표 재료 수집을 분석 성공 여부와 분리"(MT-5)해 실패 증권도 표에 포함시키려 했다.
   Codex 가 HIGH 로 지적했고 수용했다: 사용자에게 "이 증권은 분석에 실패했습니다" 라고 알리면서
   그 증권의 숫자를 합계에 넣는 것은 모순이고, 지시서 문언("**해당 건만 스킵**")과도 어긋난다.
   다중사 합산 복구는 근본 원인 수정만으로 달성되므로 되돌렸다(커밋 `730eed8`).
   아르고스가 테스트도 새 기준(표에서 제외되는지)으로 갱신했다.

5. **taskctl `_run` 기본 cwd 함정 (우회 완료)**
   `taskctl pr-open` 이 `gh` 를 `/home/jay/workspace` 에서 실행해 "1864 uncommitted changes /
   must first push the current branch" 로 실패했다. `TASKCTL_CWD` 환경변수로 worktree 를 지정해 해소.
   `gh pr edit` 은 Projects(classic) deprecation GraphQL 오류로 실패 → `gh api -X PATCH` 로 우회.

### 미해결 — ★ 회장님 판단 요청

**"모르는 값을 0 으로 만든다" 패턴이 3곳 더 남아 있습니다.** Codex 가 두 차례 HIGH 로,
마아트가 이슈 1·2 로 각각 독립 지적한 사항입니다.

| # | 위치 | 현재 동작 | 사용자에게 보이는 결과 |
|---|---|---|---|
| 1 | policy_analyzer.py `validate_analysis_result` | `total_payment_years` **키 자체가 없으면** `0` | "**납입완료**" 로 오표기 |
| 2 | policy_analyzer.py `calculate_remaining_payment` | `enrollment_date` 파싱 불가 시 `0년 0개월` | 남은 납입기간을 0 으로 오표기 |
| 3 | policy_analyzer.py `validate_analysis_result` | `coverages[].amount` None/변환불가 시 `0` | "**가입금액 0만원**" 으로 오표기 (영향 범위 최대) |

**제 판단**: 이번 PR 에서 고치지 않고 **하나의 후속 task 로 묶기를 제안**합니다. 사유는 세 가지입니다.

1. 지시서 원칙이 "Surgical. 정상 경로 무변경, None 가드·격리·메시지만 추가" 로 범위를 명시했습니다.
2. 세 곳 모두 **현재 통과 중인 기존 테스트가 그 동작을 고정**하고 있습니다
   (예: `test_invalid_enrollment_date`, `test_required_meta_fields_defaulted`).
   핫픽스 안에서 통과 중인 테스트를 고쳐 쓰는 것은 회귀가 새는 전형적 경로입니다.
3. **한 곳만 고치면 같은 함수 안에서 UNKNOWN 과 0 이 섞이는 더 나쁜 계약**이 됩니다.
   특히 3번은 담보 표·CSV·확장프로그램 등 표시 계층 전반 검토가 함께 필요합니다.

중간에 1번만 수용할까 검토했다가, 위 3번 사유 때문에 세 건을 묶는 쪽으로 판단을 되돌렸습니다.

**그 밖의 잔존 리스크 (마아트 지적, 기존 코드 그대로 — 회귀 아님)**
- `main.py:7024-7030` Drive 업로드 실패가 **무로그로** 삼켜짐
- `main.py:7094-7106` DB insert 실패가 **무로그로** 삼켜짐 → 분석 이력이 조용히 유실될 수 있음

---

## trip-wire 5종 (실측)

| trip-wire | 실측 | 근거 |
|---|---|---|
| Critical7 | **0** | Codex 게이트 `critical: false`, 마아트 최종 PASS |
| PII net-new | **0** | 변경 6파일 전수 `sanitize_gate.sanitize_text` findings 0 |
| 회귀 실패 | **0** | 1879 passed. 실패 1건은 base 재현 확인된 기존 결함 |
| forbidden_paths 침범 | **0** | 변경 6파일 전부 InsuRo worktree 내부. 다른 팀 디렉토리 접근 0 |
| nonce | **task-2966** | 일치 |

---

## 모델 사용 기록

| 팀원 | 모델 | 비고 |
|---|---|---|
| 조사(원인 특정) | sonnet | 코드 추적·분석 — haiku 부적합 |
| 불칸(백엔드) | sonnet | 로직 구현 |
| 이리스(프론트) | sonnet | 렌더 경로 감사 + 수정 |
| 아르고스(테스터) | sonnet | 테스트 설계·작성 |
| 마아트(독립 검증) | sonnet | 검증/판정 — haiku 금지 대상 |
| 헤르메스(팀장) | opus | 설계·판정·통합 (직접 코딩 0) |

haiku 사용 **0건**.

---

## 머지 판단

- **머지 필요**: Yes — 단 **회장님 승인 후**
- **브랜치**: `task/task-2966-dev1`
- **워크트리 경로**: `/home/jay/projects/InsuRo/.worktrees/task-2966-dev1`
- **PR**: #220 (+687/-12, 6파일, mergeable=MERGEABLE)
- **머지 의견**: 지시서가 **머지 HOLD** 를 명시해 자동 머지하지 않았습니다.
  근본 원인이 실행으로 실증됐고(before/after), CI 11/11·마아트 독립 검증 PASS·회귀 0 입니다.
  다만 위 "회장님 판단 요청" 3건은 이 PR 을 머지하더라도 **여전히 남는** 결함이므로,
  머지 승인과 별개로 후속 task 여부를 결정해 주시기 바랍니다.
- **프로덕션 반영**: PR 머지만으로는 반영되지 않습니다. 서버는 `/home/jay/projects/InsuRo/server` 에서
  기동 중인 별도 프로세스(포트 8001)이므로 **머지 후 서버 재기동이 필요**합니다.

---

## 커밋 이력

```
047a1ab 아르고스: 분석 실패 증권의 합산 표 제외 검증으로 테스트 갱신
730eed8 불칸: MT-5 되돌리기 — 분석 실패 증권은 합산 표에서도 제외(Codex HIGH 수용)
d6aa7a4 아르고스: None 재현·다중사 합산·격리 테스트 추가
b168b2a 이리스: 개별 증권 카드 납입잔여 unknown 처리(null년 null개월 노출 방지)
8081d5e 불칸: 증권분석 상태 응답에 부분 실패 카운트 노출
2ad7ddb 불칸: 합산 표 재료 수집 분리 (→ 730eed8 에서 되돌림)
f6fbc39 불칸: analyze-policy 실패 시 예외 원문 대신 사용자 친화 메시지 반환
72bb899 불칸: 내보내기에서 납입잔여 UNKNOWN 을 '미상' 으로 표기
bdf5f59 불칸: validate_analysis_result 가 명시적 null·비정수를 UNKNOWN 으로 정규화
8470746 불칸: calculate_remaining_payment None 입력 시 UNKNOWN 반환(TypeError 차단)
```

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


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

