# FR-030.2 — 證據分類支援圖片 / PDF 內容辨識（Claude Vision）

> 規劃日期：2026-05-29
> 撰寫人：raymond + Claude
> 狀態：設計定稿，待 spec review → implementation plan
> arc 關聯：↳ FR-030 子（補強，加多模態能力，**原分類/歸檔流程不變**）

## 一句話描述

讓 FR-030 的證據分類器「看得懂」圖片與 PDF 的內容 —— 把圖片 / PDF 以 Claude Vision（多模態）送進**同一條**分類管道，分類結果與後續歸檔流程完全沿用既有設計，只是讓原本被當 `[Unsupported file type]` 跳過的視覺類證據也能被分類歸檔。

## 為什麼要做

FR-030 的分類器目前只讀「檔名 + 文字內容」：

- 文字檔（`.docx` / `.csv` / `.log` / `.txt` / `.md`）→ 抽文字送 Claude
- 圖片（`.png` / `.jpg` 等）、PDF → 走到 `extract_text()` 的 `else` 分支，變成字串 `[Unsupported file type: .png]`，Claude 收不到任何畫面資料

合規證據裡截圖（設定畫面、console 輸出、後台 dashboard、MFA 設定頁）與 PDF 報告非常常見，這些**畫面狀態本身就是證據**。現況等於這類證據完全分不了類，要 user 手動歸檔。

選擇 **Vision（多模態）而非 OCR**：分類要回答的是「這張圖證明哪條控制項」，不只是「圖裡有哪些字」。OCR 會把版面 / UI 狀態（勾選框、toggle、紅綠燈）的語意壓掉，等於先做一次有損壓縮再餵 LLM，反而比直接給圖差；且分類器本來就在呼叫 Claude API，Vision 是現成能力，不需引進新 OCR 引擎。完整取捨見 §8。

## 核心原則（不可違背）

> **原本 FR-030 的流程設計一個字不動。** 本功能只新增「圖片 / PDF 進得了分類器」這一條路；證據出了分類器之後，跟文字檔走完全相同的歸檔路（Step ④ AO 資料夾建立、`files.copy`、Step ⑥ 寫回 Drive）。`_state.json` 結構不變、review UI 不改版。

---

## 架構決策：方式 A — Content-block builder

把現在「字串進、字串出」的分類管道，改成「Anthropic content blocks 進」。mime / 副檔名決定組哪種 block，**單一 Claude 呼叫路徑**，不重複 retry / JSON 解析 / prompt caching 邏輯。

被排除的方式見 §8。

### 改動點 1 — 新增 `build_content_blocks()` 取代 `extract_text()` 的角色

位置：`scripts/evidence/classify/docker/container_entrypoint.py`

```python
def build_content_blocks(path, mime, filename) -> tuple[list[dict], str | None]:
    """回傳 (content_blocks, skip_reason)。
    skip_reason 非 None → 該檔跳過，不呼叫 Claude，歸入未處理清單。
    文字檔行為與改版前一致（只是包進 text block）。
    """
    ext = path.suffix.lower()

    # 文字檔：沿用既有抽文字邏輯（_extract_text_legacy 即原 extract_text 改名）
    if ext in {".docx", ".csv", ".log", ".txt", ".md"}:
        text = _extract_text_legacy(path)
        return [{
            "type": "text",
            "text": f"Filename: {filename}\nContent preview:\n\"\"\"\n{text}\n\"\"\"",
        }], None

    # 圖片：text(檔名) + image block
    if ext in {".png", ".jpg", ".jpeg", ".gif", ".webp"}:
        if path.stat().st_size > 5 * 1024 * 1024:
            return [], "image exceeds 5MB API limit"
        b64 = base64.standard_b64encode(path.read_bytes()).decode()
        return [
            {"type": "text", "text": f"Filename: {filename}\n(image evidence)"},
            {"type": "image", "source": {
                "type": "base64", "media_type": _img_media_type(ext), "data": b64}},
        ], None

    # PDF：text(檔名) + document block（Claude 原生讀全頁，含文字層 + 圖）
    if ext == ".pdf":
        if path.stat().st_size > 32 * 1024 * 1024:
            return [], "pdf exceeds 32MB API limit"
        if _pdf_page_count(path) > 100:
            return [], "pdf exceeds 100-page API limit"
        b64 = base64.standard_b64encode(path.read_bytes()).decode()
        return [
            {"type": "text", "text": f"Filename: {filename}\n(PDF evidence)"},
            {"type": "document", "source": {
                "type": "base64", "media_type": "application/pdf", "data": b64}},
        ], None

    # 其他（.zip / .mp4 / .heic / .tiff / .bmp …）：跳過
    return [], f"unsupported file type: {ext}"
```

輔助：
- `_extract_text_legacy(path)` = 現有 `extract_text()` 整段搬過來改名（文字檔抽取邏輯零變動）。**`max_chars` 截斷邏輯必須留在此函式內**（現況即在內），因為 `build_content_blocks` 直接把它的輸出包進 text block、不再二次截斷
- `_img_media_type(ext)` = `{".png":"image/png", ".jpg"/".jpeg":"image/jpeg", ".gif":"image/gif", ".webp":"image/webp"}`
- `_pdf_page_count(path)` = 用 `pypdf` 只讀頁數（不解析內容，輕量）

### 改動點 2 — `classify_with_claude()` 簽名改收 blocks

```python
def classify_with_claude(client, model, system_block, content_blocks, retries=3):
    ...
    resp = client.messages.create(
        model=model,
        max_tokens=2048,
        system=[{"type": "text", "text": system_block,
                 "cache_control": {"type": "ephemeral"}}],   # prompt caching 不動
        messages=[{"role": "user", "content": content_blocks}],  # 原本是 user_message 字串
    )
    ...  # retry / JSON 解析 / fence 去除 全部不動
```

### 改動點 3 — `process_file_worker()` 串接 + 處理 skip

```python
download_drive_file(drive, file_id, mime, local_path)
blocks, skip_reason = build_content_blocks(local_path, mime, name)
if skip_reason:
    return {"file_drive_id": file_id, "file_name": name,
            "matches": [], "primary": None, "skipped_reason": skip_reason}
result = classify_with_claude(claude_client, args.model, system_block, blocks)
```

> **實作注意（上述為簡化 pseudocode）**：必須**保留**現有 `process_file_worker` 的 try/finally + tmpfile cleanup（`local_path.unlink()`）+ 既有 exception → `error` 分支（回傳 `matches: [], primary: None, error: str(exc)`）。skip-dict 與 error-dict 形狀一致，下游 `write_outputs` 只讀 `matches`/`primary`，兩者皆自然落入未處理清單。

**回歸保證**：文字檔走的內容跟改版前語意一致（只是字串被包進 `text` block），不影響既有 `.docx` / `.csv` 分類結果。

---

## §2 — 超限 / 不支援 → 落到既有「未處理」清單

不新增任何流程。FR-030 設計本就有「不支援檔型 → 跳過 + 列未處理清單」（FR-030 design.md edge case 段）。圖片 / PDF 超限歸入**同一個既有 bucket**：

- skip 的檔回傳 `matches: [], primary: null, skipped_reason: "..."`
- 在 `_state.json` 中即「無任何 match」的檔，review UI 現有「未分類 / 未處理」隊列原樣接住
- `skipped_reason` 純為 report / 可選 UI 提示用；UI 不改也能運作

## §3 — report / `_state.json` 結構：零變更

圖片 / PDF 分類出來的 match 形狀與文字檔完全相同（`ao_id` / `confidence` / `reasoning`）：

- `_state.json` 的 `files[]` / `matches[]` / `placements[]` 結構不動
- Step ④（建 AO 資料夾、`files.copy` 歸檔）、Step ⑥（寫回 Drive）只認 `placements`，不在乎檔案是文字還圖片 → 完全沿用
- 唯一新增：可選的 `skipped_reason`（向後相容，舊讀取端忽略即可）

這是「流程不變」的關鍵保證 —— 圖片只多一條進得了分類器的路，出來後與文字檔走同一條歸檔路。

## §4 — Prompt 微調（只動 `build_system_block` 幾行字）

現 task 描述寫死「filename + content preview」「cite specific evidence from the file content」。放寬措辭讓 Claude 知道可能收到圖：

- `"Given an evidence file (filename + its content, which may be text, an image, or a PDF document), classify which Assessment Objective(s) the file best satisfies..."`
- reasoning 規則補一句：`"...cite specific evidence from the file content (including what is visible in screenshots / images / PDF pages)."`

system block 內容改變會讓 prompt cache 失效一次（重建快取），之後照常命中，無實質影響。

## §5 — Model / SDK 前置驗證（實作前必查）

- 確認 `args.model`（設計用 `claude-sonnet-4-6`）為 vision-capable —— Sonnet 4.x 支援多模態，但實作前 grep container 實際傳的 model id 確認
- 確認 container 內 `anthropic` SDK 版本支援 `document`（PDF）block；版本太舊則 bump container `requirements.txt`
- **新增依賴**：`pypdf`（只讀 PDF 頁數）；`base64` 用 stdlib，無需新增。plan 階段須定位 container 實際的 requirements 檔（entrypoint 已 import `docx` / `anthropic` / `google*` / `sqlalchemy`，`pypdf` 為真新增）並 bump
- `_img_media_type` 對 `.gif` / `.webp` 的 media type，須在 §5 pre-flight 一併確認 pinned 的 SDK / model 版本確實接受（已在 SDK 版本 gate 內）

## §6 — 測試

- **單元測試 `build_content_blocks`**（純函式、不打 Claude）：餵 docx / csv / png / jpg / pdf / 超大圖 / 超大 PDF / 超頁 PDF / `.heic` 各一，斷言回傳 block 類型正確、超限者回對應 `skip_reason`
- **整合測試**：小張 png + 小份 pdf 走 `classify_with_claude`，Claude client mock 回固定 JSON，驗證 content blocks 組裝正確、skip 檔正確落未處理
- **回歸**：斷言 docx / csv 走的 block 與改版前語意一致（防 regression）

> 註：本功能在 standalone Docker container 腳本內（非主專案 app service），測試放 `scripts/evidence/classify/` 對應測試位置，不套用主專案 app service 的 logger patch fixture 慣例。

## §7 — 範圍邊界

✅ 本次做：
- `png` / `jpg` / `jpeg` / `gif` / `webp` + `pdf` 走 Claude Vision 分類
- 超限 / 不支援 → skip → 既有未處理清單
- prompt 措辭放寬、`build_content_blocks` 重構、新增 `pypdf` 依賴

❌ 本次不做（沿用 FR-030 既有邊界）：
- OCR fallback、圖片 resize / 壓縮補救、PDF 轉圖
- 成本控管 / quota（實驗階段不管成本，全圖 / 全頁送）
- `_state.json` schema 變更、review UI 改版
- 既有文字 / Drive / 歸檔流程任何改動
- 多框架 / 其它 FR-030 未盡項

---

## §8 — 被排除的方案與取捨

### 整體取向：Vision vs OCR

| | OCR | Vision（本案採用） |
|---|---|---|
| 圖裡文字 | ✅（清晰印刷字準） | ✅ |
| 畫面語意（「MFA 設定頁且已啟用」） | ❌ 失去版面 / 狀態 | ✅ 看得懂整個畫面 |
| UI 狀態（勾選框 / toggle / 燈號） | ❌ | ✅ |
| 純畫面 / 圖表 / 拓樸圖證據 | ❌ 幾乎無解 | ✅ |
| 工程成本 | 多 OCR 引擎依賴 | 沿用既有 Claude API |

**排除 OCR**：分類需要的是語意理解，OCR 先做有損壓縮反而更差，且多一個失敗點。

### 整合方式

- **排除 B（平行 vision 函式）**：另寫 `classify_with_vision()` 並按檔型分派 → 兩個 Claude 呼叫點，retry / JSON 解析 / prompt caching 邏輯複製兩份，維護成本高。
- **排除 C（PDF 全轉圖）**：PDF 每頁 render 成 PNG 再送 → 失去 Claude 原生讀 PDF 文字層能力，多 poppler / pdf2image 依賴，被 A 完勝。

### 成本

實驗階段刻意**不設成本上限**（全圖 / 全頁送，求準確度）。Vision token 用量明顯高於純文字，正式版再談 quota / resize。未來反悔條件：批次成本失控、或要支援大規模租戶時，回頭加 §7 排除的 resize / 頁數壓縮 / quota。

### 未來可能反悔的條件

- 若 Claude API 對 PDF / 圖片硬限制改變（頁數 / 大小），§1 的 guard 常數需同步
- 若實測 Vision 對某類純文字 PDF 反而較貴且無語意增益，可加「PDF 有文字層 → 走文字、無文字層（掃描件）→ 走 vision」的混合判斷（目前 YAGNI，不做）

---

## 相關既有資產

- 分類器主程式：`scripts/evidence/classify/docker/container_entrypoint.py`（`extract_text` line 208 / `classify_with_claude` line 277 / `process_file_worker` line 312）
- FR-030 母設計：`docs/features/FR-030-2605-auto-evidence-classification/design.md`
- POC script：`scripts/evidence/classify/classify_evidence_drive.py`
- CMMC L1 AO catalog：`docs/reference/CMMC-Level 1-Evidences/cmmc_l1_aos.json`

---

## §11 — Implementation Reality / Reconciliation

> 2026-05-29 實作收尾。實作由另一 session（Sonnet）照 implementation-plan.md 執行，12 單元測試通過 + 端對端實測通過。以下為與原計畫的偏差紀錄。

1. **加碼 `list_jobs` 回傳 `evidence_folder_id`**（commit `9da4824e`）：計畫外的順手補強，供 FE 顯示 Drive 連結。不影響本功能核心，已併入本期 release。
2. **多補 2 個邊界測試**：`test_img_media_type_unknown_raises`（unknown ext 應 raise）、`test_blocks_pdf_oversize`（PDF >32MB skip）。計畫原列 helper + blocks 主分支，實作者補強了邊界覆蓋 → 共 12 測試（計畫預估 10）。
3. **double-read 修補**（commit `02a88ada`）：實作初版圖片/PDF 分支先 `stat().st_size` 再 `read_bytes()` 造成雙讀，後優化為單讀。屬實作細節，不改設計。
4. **helper 順序 / docstring 修補**（commits `f7bd6f59` / `4e2fb810`）：`_IMAGE_EXTS` 常數定義位置與 docstring 前提對齊，TDD 過程的自我修正。
5. **xlsx / pptx 暫不納入**：收尾階段 user 提出加 `.xlsx` / `.pptx` 支援（兩者 Claude API 無原生 block，需走本地抽文字 `openpyxl` / `python-pptx`），討論到抽取預設（xlsx 取值不取公式、pptx 不含備忘稿）即暫停做 summary → **列為 follow-up，本期未實作**。未來反悔條件：客戶證據常見 Excel/簡報且 skip 率偏高時再開子任務。
