# task-3024 — 신규설계 비교에 가입제안서·약관 PDF 업로드 배선

- **팀**: dev3-team (다그다) · **레벨**: Lv.3 · **작성**: 2026-08-26
- **저장소**: `Jeon-Jonghyuk/InsuRo` · **브랜치**: `task/task-3024-dev3` · **PR**: #259 (draft, OPEN)
- **base**: `14e9596` · **HEAD**: `cedcee1`
- **머지**: ANU 판단 대기 (자동 머지 금지)

---

## S — 상황
회장님이 엿새간 **네 번** 지시한 건이다(2026-08-20 / 08-21 / 08-22 / 08-26).
`/new-design-comparison` 화면은 텍스트박스 두 개에 JSON 을 손으로 붙여넣는 화면이었다.
비교 엔진은 완성돼 있는데 **입력 손잡이가 없었다.**

## C — 문제
직전 세션이 **서버 OOM 으로 사망**(10:34 global oom-kill, 봇 4팀 전멸). PR #259 는 열려 있었으나
main 대비 **diff 0줄**이었다.

## Q — 질문
설계사가 가입제안서 PDF 를 올리는 것만으로 담보 파싱 → 확인·수정 → 비교까지 갈 수 있는가?

## A — 답
**갈 수 있다. 실 PDF 로 전 과정 실측 통과했다.** 다만 **공식 비교표는 현재 0행**으로 나온다 —
이는 이번 배선 결함이 아니라 **지식DB 담보 등록률**(메리츠 14/299) 때문이며, 엔진이 이를
`CoverageNotice` 로 정직하게 고지한다. 상세는 §발견 1.

---

## ★ 세션 사망 복구 — 실제로 일어난 일
직전 세션 산출물이 **커밋되지 않은 채 워크트리에 남아 있었다**(`proposal_upload_v1.py` 231줄 +
`proposal_mapping.py` 205줄). PR diff 가 0이었던 이유는 **작업을 안 해서가 아니라 커밋을 안 해서**였다.
착수 즉시 이를 커밋(`59dc3a1`)해 복구했다. → **"진척 0"이라는 명세 전제는 부분 반증**이다.

---

## 1. 샘플 PDF 확보 — 확보됨 (진행 조건 충족)
```
/home/jay/workspace/memory/samples/proposal_meritz_725046310202601305.pdf   (830KB, 실 가입제안서)
/home/jay/workspace/memory/samples/terms_meritz_the건강한_5_10_5_2601.pdf    (14MB, 실 약관)
```
명세는 "샘플이 없으면 즉시 ANU 보고" 였으나 **이미 확보돼 있었다.** 목업 0건.

## 2. 실 PDF 파싱 실측 (검증 ①)
```
insurer            = 메리츠
product_name       = (무) 메리츠 The건강한 5.10.5 보장보험2601 (해약환급금미지급형…)
rows               = 35        (leaf 32 / child 2 / aggregate 1)
total_premium      = 136,411 원
premium_reconciled = True
aggregate 제외 보험료 합 = 136,411   ← 문서 합계와 정확히 일치
mapped_coverage_count = 34 / source_row_count = 35
warnings           = 2건 (amount_reference_marker · amount_missing;premium_missing)
```
**업로드 → 큐 → 폴링 → completed 종단 통과**(FastAPI TestClient, 실제 응답값):
`POST /api/insuro/proposal/upload` → `{"job_id":…, "status":"queued"}` → `status=completed`,
`NewDesignIn(**result["new_design"])` pydantic 검증 34건 통과.

→ **aggregate 행 제외 규칙이 실측으로 검증**됨. 포함 시 172,101원(26% 과대 계상).

## 3. 매핑표 (검증 ③) — `models.py` 직접 읽고 확인

`ProposalExtract` → 응답/비교입력
| ProposalExtract | 도착지 | 비고 |
|---|---|---|
| `insurer` | `NewDesignCoverageIn.premiums_won` 의 **키**(전 담보 공통) + 응답 `insurer` | None → 변환 실패(ok=False), 임의 기본값 금지 |
| `product_name` | 응답 `product_name` | 비교입력에는 미포함 |
| `rows[]` | `new_design.coverages[]` | aggregate 제외 |
| `total_premium` | 응답 `total_premium` | 검산용, 비교입력 미사용 |
| `premium_reconciled` | 응답 `premium_reconciled` | 검산용 |
| `warnings[]` | 응답 `warnings[]` | 파서 원문 그대로 |

`ProposalRow` → `NewDesignCoverageIn`
| ProposalRow | NewDesignCoverageIn | 변환 |
|---|---|---|
| `name` | `name` | 그대로(트리문자 제거 완료) |
| `amount` (원) | `target_amount_manwon` (float\|None) | `amount / 10_000`, None → None |
| `premium` (원) | `premiums_won[insurer]` (dict[str,int]) | None → `{}` (**0으로 채우지 않음**) |
| `row_kind=="aggregate"` | — | **제외** + `excluded_aggregate_rows` 기록 |
| `raw`/`parse_state`/`row_kind` | — | `row_infos[]` 감사용 |
| `parent_index` | — | 매핑 미사용 |

매핑이 채우지 않아 pydantic 기본값이 들어가는 필드: `clause_id`/`clause_text`/`axes`/
`baseline_amount_manwon` = `None`, `has_waiting_or_reduction`/`is_renewable` = `False`,
`amount_form`/`payout_form` = `"UNKNOWN"`.

**docstring 주장 검증**: `premiums_won: dict[str,int]` 등 대부분 실제 모델과 일치.
**틀린 것 1건 정정** — 근거를 "계약서 §5"로 인용했으나 §5는 선형환산 어댑터이고 정의는 **§2**다(`cedcee1`).

## 4. 실패 표면화 (검증 ④) — 실측, "성공"으로 보인 케이스 0건
| 입력 | HTTP | 잡 상태 | 사용자에게 보이는 것 |
|---|---|---|---|
| 빈 파일 0바이트 | 400 | — | ★ 서버 도달 전 **클라이언트 거부**: "파일을 받을 수 없습니다 / 빈 파일입니다" |
| PNG 매직 + `.pdf` 위장 | 400 | — | "파싱 실패" 배지 + "PDF 파일만 업로드할 수 있습니다." + destructive 토스트 |
| 텍스트 바이트 + `.pdf` | 400 | — | 동일 |
| 표 없는 **진짜** PDF | 200 | `failed` | "파싱 실패" 배지 + 빨간 배너 + 토스트. 비교 실행 **비활성 유지** |

**성공 문구를 기본값으로 두지 않음** — 서버가 `completed` 를 줘도 표/담보가 비면 `failed` 로 전이시킨다.
값 미상은 `"모름"`으로 표시하고 **0으로 지어내지 않는다**(스크린샷 06 최하단 행에서 육안 확인).

## 5. 봉인 테스트 (검증 ⑥) — 변이가 no-op 아님을 먼저 증명 후 수행
| 변이 | no-op 아님 증명 | 변이 전 | 변이 후 | 원복 후 |
|---|---|---|---|---|
| A 서버: 담보0건·매핑실패를 COMPLETED 로 위장 | 두 가드 모두 RAISED→RETURNED 확인 | 19 passed | **3 failed / 16 passed** | 19 passed |
| A2 서버: 담보0건 가드만 단독 무력화 | 담보0건만 RAISED→RETURNED | 19 passed | **1 failed / 18 passed** | 19 passed |
| B 프론트: `ok=false`·`premium_reconciled=false` 무시 | fatalReason→null, mismatch→false 확인 | 64 passed | **7 failed / 57 passed** | 64 passed |

★ **정직한 관찰 2건 (봉인 두께가 얇은 지점)**
- A2("insurer 는 정상인데 담보만 0건")는 **단위 테스트 1건만** 잡는다. 종단 테스트는 못 잡는다
  (표 없는 PDF 는 insurer 부터 None 이라 다른 경로로 먼저 실패).
- 변이 B 를 **페이지 레벨 테스트(14건)는 못 잡는다.** 컴포넌트/순수함수 레벨에서만 봉인돼 있다.

## 6. 회귀 (검증 ⑤) — **팀장이 clean worktree 에서 직접 재측정**
| 항목 | base `14e9596` | task `cedcee1` | 델타 |
|---|---|---|---|
| pytest | 2812 passed / 4 skipped / **0 failed** | **2832 passed / 3 skipped / 0 failed** | **+20 passed** |
| vitest | 95 files / 1392 tests / **0 failed** | **99 files / 1478 tests / 0 failed** | **+86 tests** |
| tsc --noEmit | — | **EXIT 0** | — |

★ **`.env` 오염 함정 — 대조실험으로 규명**
작업 워크트리에는 미추적 `.env`(3,101B)가 있고 base 에는 없다. 팀원 보고가 엇갈려
(`push-utils.test.ts:582` 실패 여부) **동일 커밋에서 `.env` 만 넣었다 뺐다** 하며 검증했다:
```
base, .env 없음  → 35 passed / 0 failed
base, .env 복사  → 34 passed / 1 failed   ← push-utils.test.ts:582
base, .env 삭제  → 35 passed / 0 failed
```
→ 그 실패는 **환경 산물이지 회귀가 아니다.** pytest 의 `test_cors_fail_closed_when_ext_origin_unset`
도 동일 원인. **clean worktree 실측 기준 회귀 0.**

## 7. 실 브라우저 E2E (검증 ⑦)
`npx vite dev` + 로컬 FastAPI + Playwright Chromium. 스크린샷 6장:
`/home/jay/workspace/memory/reports/screenshots/task-3024/`

★ **팀장이 이미지를 직접 눈으로 확인함**(로그인월 위장 여부 검증):
- `01-first-screen-pdf-upload.png` — **로그인월 아님.** 실제 기능 화면. 1.증권 2.가입제안서 3.약관(선택)
  4.비교실행 4단계. **textarea 렌더 0개**, JSON 은 최하단 접힌 "개발자용: JSON 직접 입력" 아코디언에만 존재
- `06-real-pdf-parsed-with-warnings.png` — 담보 34행 **편집표**, 합계 136,411원, 경고 배너 3종 동시 노출
  ("파싱 경고 2건" / "합계 행이라 제외한 항목 1건" / "정상적으로 읽지 못한 원본 행 2건"),
  값 미상 행은 **"모름"** 표기 + amber 하이라이트 + 상태 배지
- `03`/`04`/`05` — 파싱 실패·비PDF 거부·빈 파일 거부 배너

★ **E2E 한계 정직 고지**: 세션은 **합성 세션**이다(Supabase ES256/JWKS 라 유효 토큰 발급 불가 →
localStorage 평문 JSON 주입으로 AuthGuard 통과). 로컬 서버는 **`verify_jwt` 만 스텁**했고
업로드 스트리밍·매직바이트·pdfplumber 파싱·매핑·큐·폴링은 **전부 실제 코드 경로**다.
**프로덕션 빌드(`npm run build`)는 OOM 우려로 실행하지 않았다.**

---

## ★ 발견 1 (ANU 판단 필요) — 공식 비교표가 0행으로 나온다
팀장이 실 PDF → 매핑 → **비교 엔진 실호출**로 종단 확인한 결과:
```
mapped coverages   = 34 (ok=True)
official_row_count = 0        ← 공식 비교표 0행
excluded_row_count = 35       ← 전량 unknown_report 로 감
confirmed_only     = True
CoverageNotice: "메리츠화재 담보는 현재 14건만 지식DB에 등록되어 있으며(전체 299건 중 일부),
                 미등록 담보는 '확인 불가'로 표시됩니다 — 해당 상품에 다른 담보가 없다는 뜻이 아닙니다."
```
**원인은 이번 배선이 아니다.** 공식 비교는 담보 동치성이 **CONFIRMED** 된 건만 포함하는데,
지식DB 담보 등록률이 **14/299** 라 제안서 담보 대부분이 미등록이다.
부차적으로 제안서에는 `amount_form`/`payout_form` 이 없어(`UNKNOWN`) `linear_scale` 도 막힌다
(실측: `baseline`/`target` 을 채워도 form 이 UNKNOWN 이면 `applied=False`, `premium_won=None`).

**엔진은 조용히 실패하지 않고 `CoverageNotice` 로 정직하게 고지한다** — 설계대로 동작 중이다.
해소하려면 (a) 지식DB 담보 시딩(=약관 지식 등록 경로 확대) 또는 (b) form 추정(**추측 금지 위반**)
또는 (c) 엔진 변경(**봉인 영역**) 뿐이라 **임의 처리하지 않았다.** 후속 태스크 대상이다.

## ★ 발견 2 — 매핑 실패의 구체 사유가 사용자에게 도달하지 않는다
`ai_queue.submit` 이 예외 시 `job.error` 를 **공용 고정문구**("처리 중 오류가 발생했습니다.")로만 채운다.
"insurer 미확인" 같은 구체 사유는 **서버 로그에만** 남는다. `ai_queue.py` 는 타 라우터와 공유하는
공용 인프라라 이번 범위에서 수정하지 않았다.

## ★ 발견 3 — 고객명 입력이 강제된다
`/api/insuro/analyze-policy` 가 `customer_name` 을 필수로 요구해(`main.py:7154`) 증권 업로드에
입력란을 뒀다. 마찰 제거하려면 백엔드 완화 필요(범위 밖).

## ★ 발견 4 — 상수 2벌
`POLICY_ACCEPTED_*`/HEIC/50MB 를 `existingAnalysisUpload.ts` 에 복제했다. `PolicyAnalysis.tsx` 에서
import 하면 보장분석 lazy 청크가 통째로 딸려오고, 공용 모듈 추출은 `PolicyAnalysis.tsx` 수정(범위 밖)이
필요해 복제 + 출처 주석으로 처리했다. **값 변경 시 두 곳을 함께 고쳐야 한다.**

---

## L1 스모크테스트
- **서버 재시작**: 성공 — 로컬 FastAPI(:18324) 기동, `verify_jwt` 만 스텁, 업로드/파싱/큐/폴링 실경로
- **API 응답 확인**: 성공 — `POST /api/insuro/proposal/upload` → `{"job_id":…,"status":"queued"}` →
  `GET /api/insuro/proposal/status/{job_id}` → `status=completed`, `insurer=메리츠`,
  `total_premium=136411`, `premium_reconciled=True`, coverages 34건.
  실패 경로 실측: 빈 파일/비PDF → **HTTP 400**, 표 없는 PDF → `status=failed`
- **스크린샷**: `/home/jay/workspace/memory/reports/screenshots/task-3024/` 6장 (01~06, 팀장 육안 확인 완료)

## trip-wire 5종 (실측)
| 항목 | 실측 |
|---|---|
| Critical7 | **0** (red-team scan `risk_level=low`, `vulnerability_count=0`, 라우터·매핑 양쪽) |
| PII net-new | **0** (스크린샷 06 은 증권번호 노출 파일명을 `sample_proposal.pdf` 로 교체 후 재촬영, 고객명 공란) |
| 회귀 실패 | **0** (clean worktree: pytest 2832/0 failed, vitest 1478/0 failed) |
| forbidden_paths 침범 | **0** (`git diff --name-only 14e9596..HEAD` 로 확인) |
| nonce = task_id | **일치** (task-3024) |

## 범위 준수
변경 파일 **14개 전부 `allowed_resources` 내부**. `server/main.py` 는 **정확히 +2줄**(import 1 + include_router 1).
미변경 확인: `policy_terms_v1.py`(약관은 기존 경로 **호출만**) · `policy_grouping/**` · `silson/**` ·
`PolicyAnalysis.tsx` · `generateOptions.ts` · `extension/**` · `.github/workflows/**`.
메인 저장소 `/home/jay/projects/InsuRo` 는 `main`/`14e9596` 그대로 — **체크아웃 점유 0**(cron 보호).

## 커밋 (8건)
```
59dc3a1 루: 이전 세션 미커밋분 복구 — proposal_upload_v1 + proposal_mapping
3a84d16 루: main.py 라우터 등록 (import 1줄 + include_router 1줄)
2e96278 루: 업로드 청크 스풀링(메모리 적재 금지)·담보0건 정직 FAILED·태스크 GC 방지
63c1f65 루: proposal upload/status 종단 테스트 19건 (실 PDF TestClient 폴링)
1e75dcd 브리짓: 제안서/증권 업로드 결과 변환·실패판정 순수 헬퍼 2종
f3a9301 브리짓: 업로드 필드 + 파싱결과 편집표 컴포넌트 3종
3e8b3d0 브리짓: 화면 입력 경로를 JSON textarea → PDF 업로드로 교체
cedcee1 루: proposal_mapping docstring 계약서 인용 정정(§5→§2)
```

## DoD 판정
| 조건 | 판정 |
|---|---|
| 가입제안서 PDF 올리면 담보가 파싱되어 화면에 보임 | ✅ 실 PDF 34담보, 스크린샷 06 |
| 확인·수정 가능 | ✅ 편집표(담보명/가입금액/보험료) + 담보 추가/삭제 |
| 비교가 실행됨 | ✅ 실행됨. ★ 단 공식 비교표는 지식DB 등록률 때문에 0행 (발견 1) |
| 파싱 실패·불일치가 화면에 명확히 드러남 | ✅ 배너 5종, "성공 위장" 0건 |
| JSON 직접 입력 없이 전 과정 가능 | ✅ textarea 렌더 0개, 개발자용 폴백만 접혀 있음 |

**회장님 지적("왜 JSON 으로 직접 입력만 받게 되어있지?")은 해소됐다.**
다만 **발견 1** 때문에 설계사가 지금 이 화면을 쓰면 담보는 다 보이되 공식 비교표는 "확인 불가"로
나온다. 이는 정직한 표시이지 조용한 실패가 아니나, **회장님이 기대하는 최종 그림에는 지식DB 시딩이
선행돼야 한다** — ANU 판단 요청.

## 수정 파일별 검증 상태
| 파일 | 변경 | 검증 상태 |
|---|---|---|
| server/main.py | 라우터 등록 +2줄 | PASS — grep 확인, diff 정확히 2줄 |
| server/routes/proposal_upload_v1.py | 업로드/폴링 라우터 | PASS — 실 PDF TestClient 종단 19건 |
| server/policy_extract/proposal_mapping.py | 변환 순수함수 | PASS — 매핑 계약 테스트, docstring 인용 정정 |
| server/tests/test_proposal_upload_v1_task3024.py | 종단 테스트 19건 | PASS — 19 passed |
| src/pages/NewDesignComparison.tsx | JSON→PDF 업로드 교체 | PASS — textarea 렌더 0개, E2E 스크린샷 |
| src/components/policy/proposalUpload.ts | 제안서 변환·실패판정 | PASS — 봉인 변이 7 failed 확인 |
| src/components/policy/existingAnalysisUpload.ts | 증권 변환·사전검증 | PASS — vitest |
| src/components/policy/PdfUploadField.tsx | 업로드 필드 | PASS — vitest |
| src/components/policy/ProposalReviewTable.tsx | 편집표+실패배너 5종 | PASS — E2E 스크린샷 06 |
| src/components/policy/ExistingAnalysisReviewTable.tsx | 증권 편집표 | PASS — vitest |
| src/components/policy/__tests__/*.test.ts(x) 3종 | 테스트 | PASS — 86 passed |
| src/pages/__tests__/NewDesignComparison.uploadFlow.test.tsx | 플로우 테스트 | PASS — 14건 포함 |

### 1st/2nd/3rd Why — 공식 비교표 0행
- **1st Why**: 공식 비교표가 0행이다 → 담보 34건이 전부 `unknown_report` 로 갔다
- **2nd Why**: 왜 unknown 인가 → 공식 비교는 `confirmed_only=True`, 즉 담보 동치성 CONFIRMED 건만 포함한다
- **3rd Why**: 왜 CONFIRMED 가 안 되나 → 지식DB에 메리츠 담보가 **14/299 건만 등록**돼 있다.
  부차적으로 제안서에는 `amount_form`/`payout_form` 이 없어 `linear_scale` 도 `applied=False` 가 된다.
- **조치**: 지식DB 시딩이 선행돼야 한다(후속 태스크). form 추정=추측 금지 위반, 엔진 변경=봉인 영역이라 미수행.

## ★ scope-guard FAIL — 오탐(false positive) 근거 박제 · **자가해소 안 함**
```
VIOLATION: src/pages/__tests__/NewDesignComparison.uploadFlow.test.tsx — "paths 미포함"
snapshot_sha256: 15ea2e251be277f7a61a69e2bc150492c5eadb4ad62acfacbb30a7266ad44965
```
**capabilities 스냅샷에는 해당 파일을 여는 패턴이 실재한다** — `src/**/__tests__/**` (스냅샷 9번째 항목).
즉 범위 이탈이 아니라 **매처 결함**이다.

**근본 원인 (`scripts/task-scope-guard.sh:87` `glob_match()` 검사 순서 결함)**
```python
if pattern.endswith("/**"):          # ← 이 분기가 먼저 걸린다
    prefix = pattern[:-3]            # "src/**/__tests__"
    return path == prefix or path.startswith(prefix + "/")   # ** 를 리터럴 취급 → False
if "**" in pattern:                  # ← 여기까지 도달하지 못함
    fnmatch(path, pattern.replace("**","*"))                 # 도달했다면 True
```
실증:
```
fnmatch('src/pages/__tests__/NewDesignComparison.uploadFlow.test.tsx', 'src/**/__tests__/**')  → True
'**'→'*' 치환 후 ('src/*/__tests__/*')                                                          → True
그러나 endswith('/**') 선분기가 literal startswith('src/**/__tests__/') 로 판정            → False
```
→ **중간에 `**` 가 있으면서 `/**` 로 끝나는 모든 패턴이 구조적으로 깨진다.**
`src/components/policy/**` 는 중간 `**` 가 없어 정상 통과했다(같은 커밋의 `__tests__` 3파일은 미검출).

**조치**: 부여 권한이 ANU 이므로 **가드·task 파일·스냅샷 어느 것도 수정하지 않았다**(self-bypass 금지).
ANU 판단 요청 — override 또는 `task-scope-guard.sh` 검사 순서 수정(별도 태스크).

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

