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

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

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

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.jsonfiles[] / 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* / sqlalchemypypdf 為真新增)並 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.pyextract_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_sizeread_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 率偏高時再開子任務。