# task-2938 모듈 계약서 (팀장 헤르메스 설계 · 병렬 구현용 단일 계약)

> ★★★ 최상위 원칙: **약관은 법조문이다. 키워드/이름 매칭으로 grouping 금지.**
> grouping 판정은 **오직 판별축(axes)** 으로만 한다. 축은 **담보 조항 전문 정독**에서 나온다.
> 담보명(name)은 어떤 매핑 결정에도 입력으로 쓰지 않는다. (mapper 는 name 필드를 아예 받지 않는다)

## 패키지 위치
`/home/jay/projects/InsuRo/.worktrees/task-2938-dev1/server/policy_grouping/`

기존 server 모듈 import 관례를 따른다 (server 디렉토리가 sys.path 루트 → `from anu_provider import ...`).
패키지 내부는 상대 import (`from .axes import ...`).

## 파일 소유권 (★ 다른 파일 절대 수정 금지 — 병렬 충돌 방지)
| 파일 | 소유 |
|---|---|
| `axes.py`, `clause_splitter.py` | 불칸-1 |
| `taxonomy.py`, `mapper.py` | 불칸-2 |
| `knowledge.py`, `hitl.py`, `supabase/migrations/20260812130000_coverage_grouping_knowledge.sql` | 불칸-3 |
| `reading.py`, `prompts.py` | 불칸-4 |
| `pipeline.py`, `cli.py`, `__init__.py` | 불칸-4 (2차 웨이브) |
| `server/tests/test_policy_grouping_*.py`, `server/tests/fixtures/policy_grouping/**` | 아르고스 |

---

## 1. axes.py — 판별축 스키마 (모든 모듈의 공통 계약)

```python
Confidence = float  # 0.0~1.0

@dataclass(frozen=True)
class Provenance:
    page: int            # 약관 PDF 페이지(1-based)
    clause_ref: str      # 예 "제1조(보험금의 지급사유)"
    quote: str           # 근거 조항 원문 발췌(<=400자). 빈 문자열 금지(축이 UNKNOWN 이면 "" 허용)

Trigger = Literal[
    "DIAGNOSIS_ONLY",         # 진단확정만으로 지급
    "SPECIFIC_TREATMENT",     # 특정치료를 받으면 단일 가입금액(치료행위 구분 없이 하나로)
    "PER_ITEM",               # 수술/항암약물/방사선/중환자실 등 항목별로 각각 지급
    "ANNUAL_CAP_INTEGRATED",  # 항목별이되 연간한도 내에서 계속 지급(통합)
    "TREATMENT_COUNT_COND",   # 치료 N회 이상 등 횟수 조건 충족 시 지급
    "HORMONE_INCLUSIVE",      # 호르몬치료까지 포함, 치료받기만 하면 지급
    "SINGLE_MODALITY",        # 단일 치료방식 전용(중입자/양성자/세기조절/표적/면역/호르몬 등)
    "UNKNOWN",
]
BenefitScope = Literal[
    "ALL",                          # 급여/비급여 무관 전체
    "SELF_PAY_FULL_PLUS_NONBENEFIT",# 급여 중 본인전액부담 + 비급여 (하이클래스/비급여통합 계열)
    "NONBENEFIT_ONLY",              # 비급여만
    "BENEFIT_ONLY",                 # 급여만
    "UNKNOWN",
]
Independence = Literal["STANDALONE", "SUBORDINATE_DETAIL", "UNKNOWN"]
# SUBORDINATE_DETAIL = 다른 특약의 세부보장이라 단독가입 불가(예 삼성 치료비지원Ⅱ)
PayoutForm = Literal[
    "LUMP_SUM_ONCE",       # 최초 1회한 일괄
    "LUMP_SUM_ANNUAL",     # 연 1회한 일괄(재진단/연간 반복)
    "MONTHLY_INSTALLMENT", # 매월 분할 지급  ★ 트리거가 진단이어도 이건 치료비
    "PER_EVENT",           # 치료 1회당/수술 1회당
    "UNKNOWN",
]
TargetScope = Literal["INTEGRATED","BY_CANCER_CLASS","SMALL_AMOUNT","MAJOR_N","HIGH_COST","UNKNOWN"]
AmountForm = Literal["FIXED","PROPORTIONAL","UNKNOWN"]  # 정액 vs 비례(1억한도 1천만원단위 등)

@dataclass
class Axis:            # 축 1개 = 값 + 근거
    value: str
    provenance: Provenance | None = None

@dataclass
class CoverageAxes:
    trigger: Axis
    benefit_scope: Axis
    independence: Axis
    payout_form: Axis
    target_scope: Axis
    amount_form: Axis
    detail_count: int | None = None     # "총 N개 세부보장" (제1조 보장범위에서 파싱)
    hospital_condition: str | None = None  # "종합병원"|"상급종합병원"|None
    modality: str | None = None         # SINGLE_MODALITY 일 때 치료방식명
    renewal: bool = False               # [갱신형]
    notes: str = ""

    def axis_signature(self) -> str:
        """학습 룰 키. 6축 + hospital_condition + modality 를 정규화한 결정적 문자열."""
```
- `axis_signature()` 는 **학습 룰 재사용의 키**다. 같은 시그니처 = 같은 실질 담보구조 → 학습된 그룹 자동 적용.
- 시그니처에 **담보명/회사명/상품명을 절대 포함하지 않는다**(이름 매칭 금지 원칙).
- `to_dict()/from_dict()` 왕복 직렬화 필수(fixture·DB 저장용).

## 2. clause_splitter.py — 담보 단위 조항 분할

`split_clauses(pdf_path: str, *, max_pages: int | None = None) -> SplitResult`
- pymupdf 텍스트레이어 사용(OCR 불요). 페이지 텍스트 → **담보(특별약관) 경계**로 분할.
- 경계 정규식은 **복수 형식 지원 필수**(task-2937 recall 34.8% 실패의 직접 원인 = `N.` 미지원):
  - 삼성형 `26-1-23. 담보명 특별약관` / `27-1-25.`
  - 메리츠형 `5-28.` `N-M.`
  - 단독형 `26. 암진단비 특별약관` (숫자+`.`) ← **반드시 지원**
  - 접두 `[갱신형]`, 공백/줄바꿈 삽입 허용
- 각 담보 레코드: `cov_id, code(조항번호), name, start_page, end_page, clause_text(담보 전문), detail_count`
- `clause_text` = **해당 담보 시작 경계부터 다음 담보 경계 직전까지 전문**. 발췌·요약 금지.
- `detail_count`: 제1조(보장범위) 의 "총 N개 세부보장" 류 표현 파싱. 없으면 None.
- **목차(index) 페이지 오탐 방지**: 경계 후보 중 뒤따르는 본문에 `제1조` 또는 `제 1 조` 가 N자 이내에 없으면 목차로 간주해 제외. 목차 페이지 범위 스킵 로직 포함.
- 성능: 1474p 약관에서 60초 이내. 페이지 텍스트 캐시 허용.

## 3. taxonomy.py + mapper.py

### taxonomy.py — 회장 taxonomy 그룹 정의(단일 진실)
그룹 ID (fine 입도):
- 진단비: `DX_BASIC`(암진단비+유사암) `DX_INTEGRATED`(통합암진단비) `DX_REDIAGNOSIS`(신재진단) `DX_N_TIMES`(N번받는)
- 치료비(급여무관): `TX_SPECIFIC_FIXED`(특정치료비 정액) `TX_SPECIFIC_PROPORTIONAL`(특정치료비 비례) `TX_MAJOR_PER_ITEM`(암주요치료비 항목별) `TX_DIRECT_HORMONE`(암직접치료비) `TX_INTEGRATED_ANNUAL`(암통합치료비 연간한도)
- 비급여전용: `NB_HIGHCLASS`(하이클래스) `NB_INTEGRATED`(비급여통합치료비) `NB_ONLY`(비급여만)
- 기타 실증 그룹: `TX_MODALITY_SPECIFIC`(세분형) `TX_RECOVERY_CONDITIONAL`(회복지원금 조건형) `TX_SUPPORT_SUBORDINATE`(치료비지원 종속) `TX_ADJUNCT_DRUG`(항암 부작용약제·항구토제)
- `UNRESOLVED` (매핑 불가 → HITL 질문 대상)

각 그룹: `id, label_ko, coarse_bucket, description`.
`coarse_bucket` (보장분석 표시용 굵은 묶음): `진단비` / `치료비` / `치료비(비급여전용)` / `세분형치료비`.
`fine_to_coarse(group_id) -> coarse_bucket` 제공. **저장은 fine, 보장분석 집계는 coarse.**

### mapper.py — 판별축 → 그룹 + confidence
`map_axes(axes: CoverageAxes) -> MappingResult(group_id, confidence, reasons: list[str], alternatives: list[tuple[group_id, float]])`
- ★ 입력은 `CoverageAxes` 뿐. **담보명 문자열을 인자로 받지 않는다.**
- 결정 순서(회장 taxonomy 유래, 반드시 이 우선순위):
  1. `payout_form == MONTHLY_INSTALLMENT` → 트리거가 진단이어도 **치료비 계열**(매월계속암치료비 규칙).
  2. `benefit_scope == SELF_PAY_FULL_PLUS_NONBENEFIT` → `PER_ITEM`이면 `NB_HIGHCLASS`, `ANNUAL_CAP_INTEGRATED`면 `NB_INTEGRATED`. `NONBENEFIT_ONLY` → `NB_ONLY`.
  3. `trigger == SINGLE_MODALITY` → `TX_MODALITY_SPECIFIC`.
  4. `trigger == TREATMENT_COUNT_COND` → `TX_RECOVERY_CONDITIONAL`.
  5. `trigger == DIAGNOSIS_ONLY`:
     - `independence == SUBORDINATE_DETAIL` → `TX_SUPPORT_SUBORDINATE`
     - `payout_form == LUMP_SUM_ANNUAL` + 재진단 성격 → `DX_REDIAGNOSIS` / `DX_N_TIMES`
     - `target_scope == BY_CANCER_CLASS` → `DX_INTEGRATED`
     - else `DX_BASIC`
  6. `trigger == HORMONE_INCLUSIVE` → `TX_DIRECT_HORMONE`
  7. `trigger == ANNUAL_CAP_INTEGRATED` → `TX_INTEGRATED_ANNUAL`
  8. `trigger == PER_ITEM` → `TX_MAJOR_PER_ITEM`
  9. `trigger == SPECIFIC_TREATMENT` → `amount_form == PROPORTIONAL` ? `TX_SPECIFIC_PROPORTIONAL` : `TX_SPECIFIC_FIXED`
  10. 그 외 → `UNRESOLVED`
- confidence: 기여 축 중 **UNKNOWN 축 개수/증거(provenance) 유무**로 감산. 규칙:
  - 결정에 쓰인 축이 전부 non-UNKNOWN + provenance 有 → 0.9 기준
  - 결정에 쓰인 축 중 UNKNOWN 1개당 -0.25, provenance 없는 축 1개당 -0.1
  - `UNRESOLVED` → 0.0
  - 결과 clamp [0,1]
- `HITL_THRESHOLD = 0.75` 를 taxonomy.py 에 상수로 둔다. confidence < 임계 또는 group == UNRESOLVED → 질문 대상.
- `alternatives`: 차순위 후보 최대 2개(설계사 선택지 제시용).

## 4. knowledge.py + hitl.py + migration

### knowledge.py — 지식DB 어댑터 + 학습
추상 `KnowledgeStore` 프로토콜 + `InMemoryKnowledgeStore`(테스트/오프라인) + `SupabaseKnowledgeStore`(운영, 기존 `sb_helpers.py` 관례 재사용. 없으면 지연 import + 미설정 시 명확한 예외).
메서드:
- `upsert_product(company, product_name, product_code, terms_version, sha256) -> product_id`
- `upsert_clause(product_id, code, name, detail_count, page_start, page_end, excerpt) -> clause_id`
- `get_mapping(clause_id) -> GroupMap | None` — **같은 상품 재분석 시 자동 재사용**(확정건이면 재질문 금지)
- `save_mapping(clause_id, axes, group_id, confidence, status, decided_by=None) -> map_id` (status: `proposed`|`confirmed`)
- `find_rule(axis_signature) -> GroupingRule | None` — **유사 담보 자동 매핑**(다른 상품/회사라도 축 조합 동일하면 학습 룰 적용)
- `learn_rule(axis_signature, group_id, decided_by, source_clause_id) -> None` — 설계사 확정 시 호출. 같은 시그니처 재확정 시 `hit_count` 증가·최신 결정 우선.
- 룰 충돌(같은 시그니처에 다른 그룹 확정) → 기존 룰 무효화 + 최신 결정 채택 + `conflict_count` 기록.

### hitl.py — 학습형 human-in-the-loop
- `build_question(clause, axes, mapping) -> AdvisorQuestion` : 약관 **실질 요약**(축 값 + 근거 인용 quote) + 후보 그룹 2~3개 + "왜 애매한지" 사유. **담보명만 나열하는 질문 금지**.
- `enqueue_questions(...) -> list[AdvisorQuestion]`
- `apply_decision(store, question_id|clause_id, group_id, decided_by) -> DecisionResult`
  → ① `save_mapping(status='confirmed')` ② `learn_rule(axis_signature, ...)` ③ 결과 반환.
- `resolve(store, clause, axes)` 우선순위: **① 확정 매핑 재사용 → ② 학습 룰(axis_signature) 자동 적용 → ③ mapper 자동판정(conf>=임계) → ④ 질문 큐**. 이 4단계가 "한 번 배운 건 다시 안 묻는다"의 구현이다.

### migration SQL (`20260812130000_coverage_grouping_knowledge.sql`)
4테이블 (task 지시서 5번 그대로):
- `coverage_product` (id, company, product_name, product_code, terms_version, terms_sha256 unique, effective_date, created_at)
- `coverage_clause` (id, product_id FK, code, name, detail_count, page_start, page_end, excerpt, created_at, unique(product_id, code))
- `coverage_group_map` (id, clause_id FK, axes jsonb, group_id, confidence numeric, status text check in ('proposed','confirmed'), decided_by, decided_at, created_at)
- `grouping_rule` (id, axis_signature text unique, group_id, decided_by, source_clause_id, hit_count int default 0, conflict_count int default 0, active bool default true, created_at, updated_at)
- 인덱스: clause(product_id), group_map(clause_id), group_map(status), rule(axis_signature).
- 기존 마이그레이션 파일 스타일(주석 헤더·idempotent `if not exists`)을 따를 것. RLS 는 기존 파일 관례 확인 후 동일 수준으로.

## 5. reading.py + prompts.py — 아누 정독 (★ 무학습 경로)

- `prompts.py`: 조항 전문 → 판별축 JSON 을 뽑는 **정독 프롬프트**. 요구:
  - "당신은 약관을 법조문처럼 정독한다. 담보명은 근거가 아니다. 조항 문언만 근거로 삼는다" 명시
  - 각 축마다 **근거 조항 인용(quote) + page + clause_ref** 을 강제
  - 판단 불가 축은 반드시 `UNKNOWN` (추측 금지)
  - 출력은 **JSON only** (스키마 명시)
- `reading.py`:
  - `ReadingClient` Protocol: `async def read(clause_text: str, *, page_hint: int, model_tier: str) -> CoverageAxes`
  - `AnuReadingClient` — `from anu_provider import AnuAIRequest, generate` 를 **그대로 사용**(무학습 경로). `feature_key="policy_grouping_axis_read"`, 기본 `model_tier="sonnet"`, 긴 조항은 `opus` 허용.
  - JSON 파싱 강건화: 코드펜스/앞뒤 잡문 제거 후 첫 `{`~마지막 `}` 추출. 파싱 실패 시 전 축 UNKNOWN + notes 에 사유(예외 던지지 말 것).
  - `StaticReadingClient(fixtures: dict[cov_id, axes_dict])` — 테스트/골든셋 재현용.
  - 긴 조항 절단: 12000자 초과 시 **제1조~지급사유 구간 우선** 보존하고 뒤를 자름(자른 사실을 notes 기록).
  - ★ 절대 금지: 담보명을 프롬프트의 판단 근거로 제시하거나, 이름으로 그룹을 선지정하는 힌트 주입.

## 6. pipeline.py + cli.py (2차 웨이브)
- `analyze_product(pdf_path, *, store, reader, company, product_name, ...) -> AnalysisResult`
  흐름: split → (담보별) store 재사용 확인 → 미확정만 reader 정독 → mapper 매핑 → hitl.resolve → 저장.
  결과: `mapped: list[MappedCoverage]`, `questions: list[AdvisorQuestion]`, `stats`.
- 대상 담보 필터: MVP 는 암 담보군. 필터는 **분할·정독 대상 선정에만** 쓰고 **그룹 판정에는 쓰지 않는다**(이름 매칭 금지 유지). 필터 근거를 stats 에 남긴다.
- `cli.py`: `python -m policy_grouping.cli split|read|analyze|fixture` — 실제 PDF 로 L1 스모크 및 fixture 생성용.

## 7. 검증 (아르고스)
- **골든셋**: `/home/jay/workspace/memory/project_insuro_grouping_norm_samsung_260812.md` 표의 삼성 매핑을 정답으로 → 축 fixture 입력 시 mapper 결과 일치.
- **학습 재사용 테스트**: 애매 담보 → 질문 큐 생성 확인 → `apply_decision` → ① 같은 clause 재분석 시 질문 0건(확정 재사용) ② **다른 상품의 동일 axis_signature 담보도 질문 0건**(룰 학습 전이).
- **이름 비의존 테스트(원칙 회귀 가드)**: 담보명을 무작위 문자열로 바꿔도 매핑 결과 동일해야 한다.
- pytest + `python3 -m py_compile`. `server/policy_analyzer.py` 수정 금지(read-only).
