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

## allowed_resources
```yaml
allowed_resources:
  paths:
    - "server/pdf_extract.py"
    - "server/main.py"
    - "server/newsletter_review.py"
    - "server/requirements.txt"
    - "server/tests/**"
  forbidden_paths:
    - ".github/**"
    - "extension/**"
    - "src/**"
    - "server/migrations/**"
    - "server/newsletter_cross_verify.py"
    - "server/newsletter_validation.py"
  commands: ["pytest","python3","pip","git"]
  merge_policy: "none"
  ttl_hours: 24
```
> ★ base=현 origin/main. 근거: ANU 실측 스파이크(이 대화). 회장 승인 설계. 소식지=텍스트레이어 PDF인데 Opus vision으로 "그림 받아쓰기"→오인식 폭주가 근본원인.

## 배경 (ANU 실측 확정)
- 실제 소식지(DB생명 위드유매거진 19p) = **전부 text_based, OCR 불필요, 신뢰도 1.0**(pdf-inspector `classify_pdf_bytes` 실측). 화려한 매거진이어도 텍스트 레이어 살아있음.
- vision(Opus)이 디자인 글자를 오독 → "말도 안 되는 표현" + codex 오탐 폭주. 텍스트 레이어를 직접 뽑으면 한글·숫자·표 **거의 완벽**(ANU가 pdf-inspector `extract_pages_markdown_bytes`로 실측 확인).
- 단, 기본 마크다운은 **좌우 텍스트박스 경계를 무시하고 합침**(회장 지적). → 좌표·박스 기반 영역인지 필요.

## 의존성
- **`pdf-inspector`** (PyPI, Rust prebuilt wheel, API키 0·자체호스팅·egress 0). `server/requirements.txt`에 추가. (서버 python 환경 설치는 ANU가 배포 시 처리 — `pip install pdf-inspector`.)
- fitz(PyMuPDF)는 이미 사용 중(벡터 드로잉 추출).

## 작업 1 — `server/pdf_extract.py` 신규 (영역인지 추출)
`extract_markdown(file_bytes: bytes) -> dict` (순수 함수, 예외 graceful):
1. **분류**: `pdf_inspector.classify_pdf_bytes(file_bytes)` → `pdf_type`, `pages_needing_ocr`. 반환에 포함.
2. **페이지별 처리**(text 페이지):
   - **L2 박스감지(1순위)**: fitz `page.get_drawings()`로 그려진 사각형 중 **영역급 박스**(폭>80·높이>40, 페이지 상당부분) 추출. 박스가 2개 이상이면 → 각 박스를 영역으로. `pdf_inspector.extract_text_with_positions_bytes`의 TextItem(x·y·width·font·font_size·is_bold)을 **박스에 포함되는지로 배정** → 박스별로 y 내림차순(위→아래) 정렬해 텍스트 재구성. 박스 순서 = (y0 큰 것=위 먼저, 동률이면 x0 작은 것=왼쪽 먼저).
   - **L3 폴백(박스 없거나 1개)**: **XY-cut** — TextItem을 x축 투영해 글자 없는 세로 여백(gutter)으로 컬럼 분할(2/3단 자동), 각 컬럼 y 내림차순. 세로 여백 없으면 단일 컬럼 위→아래.
   - **L4 영역 내 정리**: 각 영역에서 y 근접으로 줄 묶기, `font_size` 상대비/`is_bold`로 제목(#/##) 위계. 표는 pdf_inspector 마크다운의 표 활용 가능(있으면 우선).
3. **needs_ocr 페이지**: 마크다운 대신 마커(`[[OCR_NEEDED page=N]]`)만 남기고, 호출측이 vision 폴백하게.
4. 반환: `{ "markdown": 전체, "pages": [...], "pdf_type": ..., "ocr_pages": [...] }`. 어떤 예외도 삼켜 부분 결과라도 반환(graceful).

## 작업 2 — 업로드 파이프라인 배선 (`main.py`)
- 소식지 업로드 추출에서 **PDF면 우선 `pdf_extract.extract_markdown` 사용**:
  - `pdf_type==text_based`(또는 ocr_pages 없음) → **그 마크다운을 extracted_text/body로 저장**(vision 안 씀).
  - `ocr_pages` 있으면 그 페이지만 기존 vision 경로로 보완(혼합).
- ★ **텍스트레이어 추출은 원문 그 자체(ground truth)이므로 codex 교차검증 불필요** → text_based 추출 시 `run_review_pipeline(..., do_cross_verify=False)` 로 라우팅(오탐 폭주 원인 제거). vision/OCR 페이지가 낀 경우만 codex 유지. (결정론 9규칙은 유지.)
- 기존 parse_mode UI 옵션은 유지하되, **기본 동작이 텍스트레이어 우선**이 되게(vision은 명시 선택 또는 이미지 PDF 폴백).

## 제약
- server/ 만(pdf_extract 신규 + main.py 배선 + newsletter_review do_cross_verify 라우팅 + requirements + 테스트). cross_verify·validation 모듈·migration·src 불변. 기존 업로드/발행/상태기계 회귀 0. published fail-closed 불변.

## 검증
- `pytest server/tests/` 회귀 0. 신규:
  - classify/extract 순수함수 단위테스트(텍스트 PDF→마크다운, 박스 2개→영역 분리, XY-cut 폴백, ocr_page 마커).
  - text_based 업로드 시 do_cross_verify=False 라우팅.
- ★ **L1 실측 필수**: 첨부/제공된 실제 소식지 PDF(예: DB생명 위드유매거진)로 `extract_markdown` 실행 → **페이지1 좌/우 박스가 섞이지 않고 각각 온전히** 추출되는지 확인(회장 지적 해소). 결과 마크다운 일부를 보고서에 첨부.

## 완료 (★ 순서 B)
- server/ 만. **dev6 금지** — dev1(헤르메스, 소식지 파이프라인 원저자). 커밋 → push → **★ push 확인** → finish-task foreground 1회 → .done. ANU 독립검증(실 PDF 추출 육안)·서버환경 pip 설치·머지·배포는 ANU.

## goal_assertions (auto-generated)
- `pytest server/tests/`
