# Phase 1D — Markdown Conversion Pilot 평가

> Plan: `~/.claude/plans/stratified-mingling-otter.md`
> Script: `scripts/phase1d_pilot.py` (subcommands: select / enqueue / report / eval_template)

## 목적

30건 stratified sample 로 marker-pdf 의 **failure mode 종류** 발견. 통계적 대표성이 아니라 **진단 도구**. 결과로 다음 분기점 판정:
- Phase 2 풀 backfill 진입 가능?
- SKIP rule 확장 필요?
- Marker 튜닝 / 대안 (kordoc / OCR 전처리 hybrid) 우선?

## Sample 구성

`pilot_1d_sample.csv` — 30 rows × 15 columns. 시드 `20260502` 고정.

### sample_source 분리

| sample_source | n | 의미 |
|---|---|---|
| `existing_success` | 5 | 기존에 변환 성공한 PDFs. forced anchor (doc 4809 `Note_240805_용접교육 필기`) + calibration 4. **pilot 후 같은 문서 재변환 결과와 비교해 개선 여부 판정 anchor**. |
| `controlled_backfill` | 25 | pending 262건 中 4축 stratified 로 신규 변환. 분포: handwritten 3 / scan_likely 2~3 / mixed 5 / born_digital 12 / large 2 |

### 4 축 stratification

| Axis | Buckets |
|---|---|
| `doc_type` | study_note / Academic_Paper / Reference / Note / Manual / Standard / Specification / NULL |
| `file_size_band` | S (<1MB) / M (1~10MB) / L (>10MB) |
| `text_density_band` | scan-likely (<5 chars/KB) / mixed (5~50) / born-digital (>50) |
| `handwritten_hint` | hi (title/path 매칭: 필기/노트/handwritten/scan/스캔) / lo |

보조 컬럼: `script_mix` (Hangul/CJK/Latin 비율 라벨), `page_count_estimate` (existing_success 만 채워짐), `forced_include_reason`.

## Rubric (사용자 평가, 1~5 점)

각 sample 1건 당 **MarkdownDoc viewer + PDF 원본 토글** 비교하면서 5축 점수 + boolean + notes:

| 축 | 정의 | 1점 | 5점 |
|---|---|---|---|
| **text_accuracy** | OCR/추출 정확도 | 알아보기 어려움, ghost text 다발 | 원본과 거의 동일, OCR 오타 1~2건 |
| **structure** | heading/list/table 구조 보존 | 구조 완전 유실, 한 덩어리 텍스트 | 원본의 heading 계층 + table row 그대로 |
| **noise_rate** | 의미 없는 반복/garbage 토큰 | 본문 30%+ 가 noise | noise 거의 없음 |
| **multi_script** | 한중일/특수문자 혼합 정확도 | 잘못된 스크립트로 mojibake | 원본 스크립트 그대로 보존 |
| **completeness** | 본문 누락 | 페이지 절반 이상 빠짐 | 누락 없음 |

`overall_pass` (true/false) — "이 markdown 으로 검색/참고에 쓸 만한가" 직관 판단. rubric 점수 합계와 별도로 보존.

`notes` — 자유서술. 특히 알려진 failure pattern (예: `TO STAND 12/4` 반복, 한중일 mojibake) 재현 시 명시.

## 평가 워크플로우

### 0. Pre-eval

`evals/markdown/pilot_1d_eval.csv` 가 비어 있다면 (또는 새 라운드면) 스켈레톤 생성:

```bash
ssh hyungi@100.111.160.84 \
  "docker compose -f ~/Documents/code/hyungi_Document_Server/docker-compose.yml \
   exec fastapi python /app/scripts/phase1d_pilot.py eval_template \
   --in /tmp/phase1d_pilot.json \
   --csv /app/evals/markdown/pilot_1d_eval.csv"
```

### 1. 한 건씩 평가

브라우저에서 `https://document.hyungi.net/documents/<doc_id>` 열기:
1. 기본 표시 (Markdown 또는 PDF iframe — `canShowMarkdown` 따라) 확인
2. PDF 원본 토글 클릭해서 PDF 와 비교
3. 5축 점수 매기기 (1~5)
4. `overall_pass` true/false 결정
5. notes 에 발견된 failure pattern 기록 (있으면)
6. 결과를 `evals/markdown/pilot_1d_eval.csv` 에 입력

10건씩 3 세션 분할 권장 (총 ~2.5h 사람 시간).

### 2. 의사결정 매트릭스

평가 끝난 30건의 분포로:

| 결과 패턴 | 다음 액션 |
|---|---|
| overall_pass ≥ 25/30 (83%+) 전 영역 | Phase 2 풀 backfill 본 plan 작성. SKIP rule 확장 불필요. |
| overall_pass 20~24 + 특정 영역 (예: 필기) 만 fail | SKIP_DOC_TYPES / source_kind heuristic 으로 약점 영역 제외 → 나머지 풀 backfill |
| overall_pass < 20 또는 systemic 결함 (multi_script 전반 fail 등) | Marker 설정 튜닝 또는 대안 (kordoc vs marker 비교, OCR 전처리 추가) — Phase 1B 재설계 |
| backfill 자체 실패율 > 10% (failed/timeout) | marker-service 안정화 우선. 1D 평가 보류. |

### 3. anchor 비교

`existing_anchor` (doc 4809) 의 평가 결과는 다음 라운드 (Marker 튜닝 또는 대안 도입 후) 같은 문서 재변환 결과와 1:1 비교. 점수 개선 여부가 튜닝 효과의 가장 깨끗한 신호.

### 4. Marker 자가 metrics 와 cross-check

`md_extraction_quality.metrics` (markdown_heading_count / markdown_table_row_count / text_length_ratio 등) 는 Marker 자가 진단. 사람 평가와 비교:
- Marker 가 "tables=237" 인데 사람 평가 structure=1 → 자가 진단 false positive
- text_length_ratio < 1 인데 사람 평가 completeness=5 → ratio 가 좋은 proxy 아닐 수 있음

이런 mismatch 가 `md_extraction_quality.score` 정의의 출발점 (현재 score 항상 null).

## 파일

| 파일 | 역할 | 갱신 시점 |
|---|---|---|
| `pilot_1d_sample.csv` | 30건 sample 정의 (선정 결과). 시드 `20260502` 재현 가능. | select 결과 commit (1회) |
| `pilot_1d_eval.csv` | 사용자 평가 결과 (rubric 점수 + overall_pass + notes) | 사용자 평가 종료 시 commit |
| `README.md` | 본 가이드 | 초기 commit |

## 실행 환경

GPU 서버 fastapi 컨테이너 안에서 실행 — DB / NAS NFS / md_extraction_quality JSONB 접근 필요:

```bash
ssh hyungi@100.111.160.84
cd ~/Documents/code/hyungi_Document_Server
docker compose exec fastapi python /app/scripts/phase1d_pilot.py select \
  --csv /app/evals/markdown/pilot_1d_sample.csv
```

**enqueue 의 `--yes` 또는 `--no-dry-run` 류 실행은 별도 사용자 승인 + 야간 단발 sweep 윈도우 (23:00~03:00 KST) 안에서만**. 30건 backfill = marker-service BATCH_SIZE=1 × 평균 5분/건 ≈ 2.5h.

---

# Phase 2 — Full Backfill (legacy pending PDFs)

> Plan: `~/.claude/plans/iridescent-gathering-clover.md`
> Script: `scripts/phase2_backfill.py` (subcommands: inventory / select-canary / enqueue / nightly-enqueue / post-report)

## 목적

1D pilot 결과 = engineering go signal. legacy pending PDF (1D 후 잔여 ~237건) 을 marker_worker 로 변환해 `md_status='success'` 누적. **신규 업로드 우선권 보존, 야간 저부하 sweep, DB state 기반 idempotent checkpoint**.

진행 로드맵: **2-A dry-run inventory → 2-B canary 40건 → 2-C nightly sweep ~4-5 nights → 2-D post-report**.

## 파일

| 파일 | 역할 | 갱신 시점 |
|---|---|---|
| `phase2_inventory.csv` | pending PDFs dry-run inventory + skip forecast | 2-A 종료 (commit, 1회) |
| `phase2_canary_sample.csv` | stratified 40건 canary sample (시드 `20260503`) | 2-B(a) (commit) |
| `phase2_canary_result.md` | canary 결과 요약 + 1D 비교 + GO/HALT 결정 근거 | 2-B 종료 (commit) |
| `phase2_nightly_log.tsv` | 야간 sweep 한 줄/일 (date / enqueued / active_queue_at_start / active_queue_oldest_age_min / pending_pool_remaining / abort_reason / marker_ready) | append 매 sweep, 주 1회 commit |
| `phase2_post_report.csv` | Phase 2 sweep 처리된 doc 별 final state + quality | 2-D (commit) |
| `phase2_post_report.md` | 처리 분포 + 1D baseline 비교 + skip/failed/outlier 목록 | 2-D (commit) |

## Subcommand 사용법

### inventory (read-only, dry-run)
```bash
docker exec hyungi_document_server-fastapi-1 python /app/scripts/phase2_backfill.py inventory \
  --output /app/evals/markdown/phase2_inventory.csv
```
- pending PDFs 전체에 대해 doc_id / file_size / text_density / doc_type / forecast_skip_reason 적재.
- forecast_skip_reason ∈ {unsupported_extension / doctype_skip / handwritten_hint / over_max_pages_estimated / none}. 'none' = 변환 시도 후보.
- handwritten_hint = title/path 에 `필기|손글씨|handwritten|handwriting` 매칭 (marker_worker 의 7d0fca2 룰 미러).
- over_max_pages_estimated = file_size > 25MB proxy. 실 page_count 는 marker_worker 가 PyMuPDF 로 결정.

### select-canary (재현성 시드)
```bash
docker exec hyungi_document_server-fastapi-1 python /app/scripts/phase2_backfill.py select-canary \
  --inventory /app/evals/markdown/phase2_inventory.csv \
  --output /app/evals/markdown/phase2_canary_sample.csv \
  --seed 20260503
```
- 40건 buckets: large 6 / scan_likely 2 / study_note 10 / Academic_Paper 8 / Reference 6 / {Standard,Manual,Specification} 4 / {Note,Report,Memo,NULL} 4
- inventory 의 `forecast_skip_reason='none'` 만 선정 후보.
- 시드 고정 → 재실행 시 동일 sample.

### 경로 정책 (2-B canary vs 2-C nightly)

`/app/scripts/` 와 `/app/evals/` 는 parent repo (`~/Documents/code/hyungi_Document_Server`, branch=main) 의 read-only bind-mount. `docker cp ... :/app/scripts/...` 는 read-only 위반으로 실패. `docker compose --build` 은 검색 실험 soft lock 위반. → 단계별로 다른 경로 사용:

| 단계 | script 경로 | sample CSV 경로 | 메커니즘 |
|---|---|---|---|
| **2-B canary (pre-merge)** | `/app/logs/phase2_backfill.py` | `/app/logs/phase2_canary_sample.csv` | `docker cp` worktree → /app/logs (rw bind-mount) |
| **2-C nightly (post-merge canonical)** | `/app/scripts/phase2_backfill.py` | `/app/evals/markdown/phase2_*` | feat/phase2-backfill main 머지 + parent `git pull` 후 bind-mount 자동 활성 |

**2-B 임시 sync** (canary 실행 직전 1회):
```bash
docker cp ~/Documents/code/hyungi_Document_Server_phase2/scripts/phase2_backfill.py \
  hyungi_document_server-fastapi-1:/app/logs/phase2_backfill.py
docker cp ~/Documents/code/hyungi_Document_Server_phase2/evals/markdown/phase2_canary_sample.csv \
  hyungi_document_server-fastapi-1:/app/logs/phase2_canary_sample.csv
```

**2-C 진입 시점** (canary GO 결정 후):
```bash
cd ~/Documents/code/hyungi_Document_Server_phase2 && git push origin feat/phase2-backfill
cd ~/Documents/code/hyungi_Document_Server && git fetch origin \
  && git merge --ff-only origin/feat/phase2-backfill && git push origin main
```

이유: 미검증 코드를 main 에 미리 박지 않음 / canary 결과 따라 worktree 에서 hot-fix 가능 / nightly cron 은 canonical path 사용 (script 자체).

**추가 (2026-05-03)**: nightly cron 의 `--log-tsv` 와 post-report 출력은 `/app/logs/` 사용 (위 표의 canonical path 가 아님). `/app/evals/markdown/` 는 fastapi 컨테이너에 **bind-mount 되어 있지 않아** 컨테이너 writable layer 에 쓰면 컨테이너 재기동 시 유실. `/app/logs/` 는 rw bind-mount → host `~/Documents/code/hyungi_Document_Server/logs/` 에 영구 저장. 주 1회 commit 시 `cp ~/Documents/code/hyungi_Document_Server/logs/phase2_nightly_log.tsv evals/markdown/` 로 복사 후 git add.


### enqueue (one-shot, 사용자 승인 게이트)
```bash
# dry-run (default) — 2-B 단계 = /app/logs 임시 경로 (위 §"경로 정책" 참조)
docker exec hyungi_document_server-fastapi-1 python /app/logs/phase2_backfill.py enqueue \
  --csv /app/logs/phase2_canary_sample.csv

# actual (사용자 승인 후)
docker exec hyungi_document_server-fastapi-1 python /app/logs/phase2_backfill.py enqueue \
  --csv /app/logs/phase2_canary_sample.csv --no-dry-run
```
- marker-service `/ready` 사전 검증.
- `enqueue_stage` idempotent — 중복 호출 안전.

### nightly-enqueue (cron / manual)
```bash
docker exec hyungi_document_server-fastapi-1 python /app/scripts/phase2_backfill.py nightly-enqueue \
  --limit 50 --max-active-queue 5 \
  --log-tsv /app/logs/phase2_nightly_log.tsv  # /app/evals/ 미 bind-mount, /app/logs/ rw 사용
```
- 가드 순서: disable flag (`/tmp/phase2_disable`) → marker /ready → active_queue ≤ threshold → DB pool 비어있지 않음 → enqueue.
- 매 sweep log_tsv 한 줄. abort_reason ∈ {disable_flag / marker_unhealthy / active_queue_threshold / pool_empty / empty}.
- pool_empty = Phase 2 자연 완료 신호 (cron 제거 hard gate trigger).

### post-report
```bash
docker exec hyungi_document_server-fastapi-1 python /app/scripts/phase2_backfill.py post-report \
  --output-csv /app/logs/phase2_post_report.csv \
  --output-md /app/logs/phase2_post_report.md \
  --phase2-start 2026-05-03T00:00:00Z
```
- `--phase2-start` ISO timestamp 이후 `md_generated_at` 만 집계 (Phase 2 코드 push 시점 권장).
- 1D baseline (success 92% / elapsed_p50 34s / text_length_ratio_p50 1.15) 와 비교.
- outlier 후보: elapsed_ms > 300s, text_length_ratio < 0.5 또는 > 10, 신규 warning 종류.

## 의사결정 게이트

### 2-B canary GO/HALT
- success ≥ 36/40 (90%) AND failed ≤ 2 AND skipped ≤ 6 → **2-C 진입 GO**
- 위 미충족 → HALT, 사용자 보고 후 재검토

### 2-C nightly abort
- 1 night 안에 failed > 5 → script 가 disable flag 자동 생성
- marker-service `/ready` 실패 → 그 sweep 건너뜀, 다음 sweep 재시도
- active_queue_oldest_age_min > 60 (stuck 임계) → log 에 [warn], 사용자 morning check 으로 판단

### 2-D 종료 hard gate (Phase 2 closed 선언 직전)
- (cron 모드) `crontab -l | grep phase2_backfill` 결과 비어 있어야 함
- `~/.phase2_disable` 파일 정리 됨
- pending PDF (`md_status='pending'`, `file_format='pdf'`) ≤ 5
- processing_queue markdown active = 0

## 1D 와의 차이

| 항목 | 1D | Phase 2 |
|---|---|---|
| 목적 | failure mode 진단 | 풀 변환 |
| 대상 | 30건 stratified | 237건 잔여 |
| sample_source | existing_success + controlled_backfill | controlled_backfill only |
| 처리 모드 | one-shot cron (1회) | nightly cron (~4-5 nights) |
| 평가 | 사용자 5축 rubric | marker 자가 metrics + 1D baseline 비교 |
| anchor 보존 | doc 4809 forced_include | (재처리 안 함) |
| handwritten | over-sample (3건) | marker_worker 자동 skip 신뢰 |

