# [task-2916] 소식지 추출 파이프라인 교체 — pdf-inspector 텍스트레이어 + 영역인지 마크다운 (vision 대체)

- 팀: dev1-team (헤르메스) | 팀원: 불칸(백엔드), 아르고스(테스터)
- 레벨: Lv.3 | base=origin/main `07f402a` | 브랜치 `task/task-2916-dev1` | 원격 head `8e2c17e` (push 검증 완료)
- merge_policy: none — **머지·서버 pip 설치·배포·실 PDF 육안검증은 ANU**

## S — Situation
소식지(DB생명 위드유매거진 등)는 화려한 매거진 레이아웃이지만 실측상 전부 `text_based`(pdf-inspector 신뢰도 1.0, OCR 불필요)다. 그럼에도 업로드 추출이 Opus vision으로 "이미지 받아쓰기"를 하고 있었다.

## C — Complication
Vision이 디자인 글자를 오독 → "말도 안 되는 표현" + codex 교차검증 오탐 폭주. 텍스트 레이어를 직접 뽑으면 한글·숫자·표가 거의 완벽하나, 기본 마크다운은 좌우 텍스트박스 경계를 무시하고 한 줄에 뒤섞는다(회장 지적).

## Q — Question
텍스트 레이어를 좌우 영역이 뒤섞이지 않게 추출하고, 원문 그대로(ground truth)일 때 불필요한 codex 교차검증을 끄도록 파이프라인을 교체할 수 있는가?

## A — Answer
`server/pdf_extract.py` 신규(영역인지 추출) + 두 업로드 엔드포인트 배선 + text_based 채택 시 `do_cross_verify=False` 라우팅으로 해결. 실 소식지 19페이지 좌/우 영역 분리 성공, 신규 테스트 24개 + 전체 회귀 1264 passed(사전 실패 1건 외 회귀 0).

---

## 생성/수정 파일
- **신규** `server/pdf_extract.py` (약 505줄) — 순수 함수 `extract_markdown(file_bytes) -> {"markdown","pages","pdf_type","ocr_pages"}`, 예외 graceful.
- **수정** `server/main.py` — `import pdf_extract`; 신규 헬퍼 `_try_text_layer_extract()`(DRY); `_run_newsletter_review_bg`에 `do_cross_verify` 파라미터→`run_review_pipeline` 전달; `parse_premium_file`(재분석)·`upload_to_drive`(**실 프론트 초기 업로드**) 양쪽에 텍스트레이어 우선 + `do_cross_verify=False` 라우팅.
- **수정** `server/requirements.txt` — `pdf-inspector>=0.2` 추가.
- **신규** `server/tests/test_pdf_extract.py` (8), `server/tests/test_newsletter_text_layer_routing.py` (8+) — 총 24 테스트.

## 알고리즘 (pdf_extract.extract_markdown)
1. `classify_pdf_bytes` → pdf_type, ocr_pages(0-indexed).
2. `extract_text_with_positions_bytes` TextItem(1-indexed page → 0-indexed 정규화).
3. **L2 박스감지(1순위)**: fitz `get_drawings()` 영역급 rect(폭>80·높이>40·면적≥페이지 5%), IoU≥0.9 dedup·내부포함 제거 → 박스≥2면 아이템 앵커점으로 배정(다중매칭=최소면적), 순서 (y0 asc=위, x0 asc=좌).
4. **L3 XY-cut 폴백**: Y-band 분리(전폭 각주 격리) 후 밴드별 X-projection(gutter≥폭 6%) 컬럼 분할.
5. **L4 정리**: y근접 줄묶기, 폰트 중앙값 대비 1.6×→`#`/1.3×→`##`, is_bold 가점.
6. ocr 필요 페이지 → `[[OCR_NEEDED page=N]]` 마커.

### ★ 실측 정정 (좌표계) — 근거 코드 주석화
pdf_inspector TextItem.y는 실측상 **bottom-left/y-up**(PDF 네이티브), fitz get_drawings/get_text는 **top-left/y-down**. task md 서술과 상충 → 실측 우선으로 `y_top = page_height - item.y - item.height` 정규화하여 fitz 좌표계로 통일(주석 명시). 페이지 인덱스도 3종(TextItem 1-idx / classify·PageMarkdown 0-idx) 불일치를 0-indexed로 정규화.

## do_cross_verify 라우팅
- newsletter + `.pdf` + parse_mode=="auto" + `pdf_type=="text_based"` + `ocr_pages` 없음 + markdown non-empty → 텍스트레이어 채택, `do_cross_verify=False`(codex 생략).
- 그 외(ocr 혼합/실패/빈결과/vision·table·markitdown 강제) → 기존 `_smart_parse_file` 경로 완전 폴백, `do_cross_verify=True`.
- T3 하드실패 재추출(vision)이 텍스트 교체 시 `do_cross_verify=True` 복구.
- **premium 경로·상태기계·published fail-closed·결정론 9규칙 불변.**

## L1 스모크테스트 결과
백엔드 순수함수·업로드 배선 변경이므로 실동작 검증은 (1) 모듈 부팅 (2) 실 PDF 추출 (3) 전체 pytest 3종으로 수행했다. 최소 1개 이상 실제 실행+통과 요건을 충족한다.
- **서버 재시작 / 모듈 부팅**: `python3 -c "import main"` 실행 → 성공(app routes 로드, `pdf_extract` 배선 import 확인). 라이브 systemd 재배포는 ANU 배포 영역(outward).
- **API 응답 확인**: 미실행. 사유 = 라이브 Supabase/Drive/JWT 자격증명 + 실제 Drive 업로드 outward 부작용 필요 → ANU 배포검증 영역. 대신 핵심 순수함수를 실 데이터로 직접 실행함(아래).
- **실 PDF 추출 실측(핵심 관찰 검증)**: `DB생명 위드유매거진 26.08.pdf` → `pdf_type=text_based, ocr_pages=[], pages=19`, 크래시 0. **page0 좌/우 영역 분리 성공**(줄 중간 뒤섞임 0). 채택 판정 → `do_cross_verify=False`. 실행 성공.
- **전체 pytest**: CI-parity 환경(로컬 파일럿 .env 오염 중립화)에서 `python3 -m pytest server/tests/` → **1265 passed**, 0 failed (131.6s). 신규 24개 포함 전부 PASS.
- **스크린샷**: 백엔드 텍스트 추출 작업이라 UI 캡처는 대상 아님. page0 마크다운 실출력을 아래에 첨부.

### page0 마크다운 발췌 (좌우 분리 실증)
```
2026년 8월
## GA소식지
DREAM WITH U

# 인기상품
## • 700암치료+ 종신보험
1. 암 진단시 주계약 및 특약 전체 납입면제
...(좌측 리스트 연속, 우측과 뒤섞임 없음)...
- 레켐비 1/7/13/19회차 최대 2,700만원 한도 (p.12 참고)

## HIT
신규 / 특약 / E-BOOK / QR 확인
※ 당사 및 보험판매대리·중개업자는 ...(하단 각주는 별도 orphan 영역)
```

## 테스트 결과
- 신규 24개 **전부 PASS** (graceful·합성 텍스트·L2 박스분리·L3 XY-cut·OCR 마커·실 소식지 19p·라우팅 7종).
- 전체 회귀(CI-parity, 로컬 파일럿 .env 오염 중립화): **1265 passed, 0 failed** (131.6s). **신규 회귀 0**.
- 오염 상태(로컬 .env `INSURO_EXTENSION_ORIGIN` 파일럿값 존재) 그대로면 `test_cors_fail_closed_when_ext_origin_unset` 1건만 사전 실패 — base에서 재현되는 로컬 전용 실패이며 CI-clean은 전부 GREEN(본 변경 무관).
- `py_compile`/AST: pdf_extract.py, main.py OK.

## 발견 이슈 및 해결
- **배선 누락 자체 발견/보완**: 최초 `parse_premium_file`만 배선됐으나, 프론트(`src/pages/AdminNewsletters.tsx`) 실 초기 업로드는 `upload_to_drive` 엔드포인트를 호출함을 확인 → `upload_to_drive`에도 동일 로직을 DRY 헬퍼로 배선(4f33e62). 이 보완이 없었으면 라이브 업로드가 여전히 vision 경로였음.
- **pyright optional 접근 경고**: `_HAS_PDF_INSPECTOR` bool 가드를 `pdf_inspector is not None`으로 교체해 타입 내로잉(6d6a8ac). 런타임은 원래도 graceful.

## 제약 준수
`newsletter_cross_verify.py`·`newsletter_validation.py`·`newsletter_review.py`·`migrations/**`·`src/**`·`extension/**`·`.github/**` 미수정. server/만 변경. premium 경로 무변경.

## 모델 사용 기록
- 불칸(백엔드): sonnet (pdf_extract 신규 + main.py 배선). 정당: 로직 구현.
- 아르고스(테스터): sonnet (테스트 24종). 정당: 테스트 설계.
- 팀장(헤르메스, Opus): 설계·API 실측·좌표계/배선누락 검토·pyright 미세수정만.

## 머지 판단
- **머지 필요**: Yes (ANU 소유 — merge_policy none, 순서 B)
- **브랜치**: `task/task-2916-dev1` (원격 head `8e2c17e` 검증)
- **워크트리**: `/home/jay/workspace/projects/insuro/.worktrees/task-2916-dev1`
- **머지 의견**: server/ 5파일. base 07f402a 대비 충돌 위험 낮음. 회귀 0. **ANU 후속(필수)**: ① 서버 python 환경 `pip install pdf-inspector` ② 실 PDF 추출 육안검증 ③ 머지 ④ systemd `insuro-api.service` 재배포. (배포 전엔 라이브 업로드가 여전히 구 vision 경로.)

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

