# task-2897 보고서 — 소식지 검토 Workflow Phase 2 (자동검증 + AI 자기검증 + grounding)

**팀**: dev1-team (헤르메스 팀장 / 불칸 백엔드 / 아르고스 테스터)
**작업**: T3(자동검증 9규칙) + T3b(AI 1차 자기검증) + T4(grounding 저장) + 파이프라인 배선
**base**: origin/main `41e2b1e` (Phase 1 머지본: migration 017 + 상태기계)
**worktree**: `projects/insuro/.worktrees/task-2897-dev1` (branch `task/task-2897-dev1`)

---

## S (상황)
Phase 1로 소식지 검토 상태기계(fail-closed)+감사 스키마(migration 017)가 완성됐다. 그러나 AI 추출(보험사명·제목·본문)이 검토대기로 라우팅되지 않고, 자동검증·근거저장이 없었다. review_status DEFAULT가 'published'라 신규 AI 업로드가 review_status를 명시하지 않으면 fail-OPEN 위험이 있었다.

## C (문제)
AI 추출 직후 (1) 결정론 자동검증으로 환각·오귀속을 플래그하고 (2) AI 1차 자기검증으로 필드 오류를 원문 인용 강제하에 의심표시하되 **숫자·보험사명·날짜는 절대 자동수정 금지**, (3) 원문 근거(grounding)를 저장한 뒤 (4) 검토대기(pending_review)로 라우팅해야 한다. migration(017)은 불변(컬럼 추가 금지), premium 경로·기존 발행 로직 회귀 0.

## Q (핵심 질문)
기존 스키마만으로 자동검증·AI검증·grounding을 구현하고, AI가 어떤 경로로도 자동발행·자동수정하지 못하도록 fail-closed를 유지할 수 있는가?

## A (해결)
### 생성/수정 파일 (server/ 만, migration 불변)
- **신규** `server/newsletter_validation.py` (708줄): 결정론 9규칙 + grounding + AI 자기검증(순수/graceful)
- **추가** `server/newsletter_review.py` (+254줄, 기존 transition 함수·전이표·예외 무변경): `advance_to_pending_review`, `run_review_pipeline`
- **추가** `server/main.py` (+117줄, 삭제 0줄): upload-to-drive/parse-premium-file 배선 + review_status='uploaded' 명시 + 하드실패 재추출
- **신규** `server/tests/test_newsletter_validation.py` (71 테스트)

### T3 — 자동검증 9규칙 (플래그만·교정 금지, config 상수, HIGH/MED/INFO)
`validate_extraction(...)` → `validation_flags` jsonb. 각 규칙 순수함수:
1. 보험사명 화이트리스트(`_rule_insurer`): 미등록 HIGH / 공백 MED
2. 숫자 원문 set-diff(`_rule_numbers`): 추출⊄원문=환각 HIGH / 원문핵심⊄추출=누락 MED (1자리 잡음 제외)
3. batch bleed(`_rule_batch_bleed`): 타 origin 혼입 HIGH / 타 보험사명 등장 MED
4. 잘림(`_rule_truncated`): 종결형 아님 MED
5. 표붕괴(`_rule_table_collapse`): 파이프 개수 불일치 MED
6. 준법문구 혼입(`_rule_compliance`): 심의필 등 INFO
7. 중복(`_rule_duplicate`): sha256(title/body) INFO
8. 언어깨짐(`_rule_language`): 한글율<0.30 MED / U+FFFD 다수 HIGH
9. 날짜 대조(`_rule_dates`): 원문불일치 MED / month_key 부재 INFO

★ 하드실패(공백/한글율0/"(텍스트를 추출하지 못했습니다)") → 다른 parse_mode 1회 자동 재추출(auto→markitdown→vision, retry_count<2). newsletter 경로만.

### T3b — AI 1차 자기검증 (회장 260805 승인)
`async def ai_self_verify(..., generate_fn=None)`: 원문 대조 verdict + **원문 인용 span 강제**(인용 못하면 suspect) + 교정 **제안**(적용 아님).
- ★★ 숫자·보험사명·날짜 **자동수정 절대 금지**: 반환 dict는 `{"flags","field_confidence"}` 키만 — **교정된 필드값 키 자체가 코드에 존재하지 않음**. suggestion은 flag의 detail에 텍스트로만 보존, 어디에도 UPDATE 미사용.
- 비대칭: 의심(suspect)만 플래그 추가, verdict='ok'가 다른 규칙 scrutiny 하향 못함.
- graceful: generate_fn 예외/ok=False/JSON 파싱실패/타임아웃 → "AI검증 미완"(ai_verify_incomplete INFO) 플래그, 파이프라인 중단 0.
- AI 발행권한 0: pending_review로만.

### T4 — grounding 저장
grounding 전용 컬럼 부재 → 기존 `field_confidence["_grounding"]`(내부·UI 미노출)에 저장. `build_field_grounding`: 원문 부분문자열 발견=span(offset+quote), page_texts로 span 실패시 page fallback, 고위험 필드 best-effort 후 없으면 none. bbox는 텍스트 파이프라인에 좌표 없어 미지원(주석 명시).

### 파이프라인·fail-closed 배선
`run_review_pipeline`: validate→ai_verify(graceful)→grounding→직접 UPDATE(`*_extracted`만, `*_edited`는 건드리지 않음)→`advance_to_pending_review`(canonical uploaded→extracting→extracted→auto_validated→pending_review). editing/approved=no-op(human 소유 침범 금지), published=미전이. 전체 try/except로 업로드 API 200 유지, review_status는 비-published fail-closed 잔류.
★ upload-to-drive insert 시 newsletter는 `review_status='uploaded'` 명시(context-notes fail-OPEN 방지).

---

## 검증 결과

### 회귀 (clean worktree 대조)
- **전체 스위트: 1168 passed, 1 failed** (base 1097 + 신규 71 = 1168, **회귀 0**)
- 유일 실패 `test_cors_fail_closed_when_ext_origin_unset`는 **코드 무관 확정**: worktree 루트 `.env` 숨기고 재실행 → **PASS**. context-notes 박제된 파일럿 `.env`(INSURO_EXTENSION_ORIGIN) 오염발. task-2897 코드 미변경 파일.
- Phase 1 newsletter_review 테스트 189건 전부 pass(전이표·fail-closed 계약 무손상).

### 신규 테스트 71건 (전부 PASS)
9규칙 각 정상/이상/경계 + AI 자동수정 안 함(반환 dict 교정필드 키 부재) + graceful 3종 + 비대칭 + grounding fallback 구조 + 라우팅(flag 유무 무관 pending_review) + published/editing/approved 침범 안 함 + `*_edited` 미변경.

## L1 스모크테스트 결과
- **서버 재시작**: 성공 — uvicorn `main:app` 실기동, `/openapi.json` **HTTP 200**. 새 배선(newsletter_review/newsletter_validation import) 정상 로드, parse-premium-file·upload-to-drive 라우트 존재(107 routes).
- **API 응답 확인**: 실 프로덕션 함수 end-to-end 실행(환각 소식지: 미등록보험사 '없는화재' + 원문에 없는 숫자 99999):
  - 최종 review_status = **`pending_review`** (발행 차단, fail-closed) ✔
  - 자동검증 플래그 = `insurer_not_whitelisted`(HIGH) · `number_hallucination`(HIGH, 99999) · `number_missing`(MED) · `truncated`(MED) ✔
  - `*_extracted` 저장 / `*_edited` = None 유지 (**AI 자동수정 0**) ✔
  - grounding(insurer) = span (원문 소재 확인) ✔
- **스크린샷**: 해당없음(백엔드 전용, UI 없음 — 검토 UI는 Phase 3 별도).

## 모델 사용 기록
- 불칸(백엔드): sonnet — 로직 구현
- 아르고스(테스터): sonnet — 테스트 작성
- haiku 미사용(로직/테스트 복잡도상 sonnet 적정)

## 미결/후속 (ANU·회장)
- migration 017은 이미 Phase 1 산출물(불변). Supabase rollout은 Phase 1 후속(회장).
- Phase 3(검토 UI: 목록·2-pane 교정·grounding 표시) 별도 위임 대기.
- source_text=현재 추출본 동일(별도 원문 저장소 미구성) → grounding/set-diff는 self-consistency 수준. 원본 저장소(Drive relay vs Supabase storage) 결정은 OPEN(context-notes).
- upload-to-drive에서 프론트가 extracted_text를 pre-fill한 경우 파이프라인 미실행 → 레코드는 'uploaded' 잔류(fail-closed 유지, 미라우팅). 실 AI 업로드는 pre-fill 없음. 완결성 보강은 후속 검토 권장.

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

