規劃日期: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 不改版。
把現在「字串進、字串出」的分類管道,改成「Anthropic content blocks 進」。mime / 副檔名決定組哪種 block,單一 Claude 呼叫路徑,不重複 retry / JSON 解析 / prompt caching 邏輯。
被排除的方式見 §8。
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 只讀頁數(不解析內容,輕量)classify_with_claude() 簽名改收 blocksdef 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 去除 全部不動process_file_worker() 串接 + 處理 skipdownload_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 分類結果。
不新增任何流程。FR-030 設計本就有「不支援檔型 → 跳過 + 列未處理清單」(FR-030 design.md edge case 段)。圖片 / PDF 超限歸入同一個既有 bucket:
matches: [], primary: null, skipped_reason: "..."_state.json 中即「無任何 match」的檔,review UI 現有「未分類 / 未處理」隊列原樣接住skipped_reason 純為 report / 可選 UI 提示用;UI 不改也能運作_state.json 結構:零變更圖片 / PDF 分類出來的 match 形狀與文字檔完全相同(ao_id / confidence / reasoning):
_state.json 的 files[] / matches[] / placements[] 結構不動files.copy 歸檔)、Step ⑥(寫回 Drive)只認 placements,不在乎檔案是文字還圖片 → 完全沿用skipped_reason(向後相容,舊讀取端忽略即可)這是「流程不變」的關鍵保證 —— 圖片只多一條進得了分類器的路,出來後與文字檔走同一條歸檔路。
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...""...cite specific evidence from the file content (including what is visible in screenshots / images / PDF pages)."system block 內容改變會讓 prompt cache 失效一次(重建快取),之後照常命中,無實質影響。
args.model(設計用 claude-sonnet-4-6)為 vision-capable —— Sonnet 4.x 支援多模態,但實作前 grep container 實際傳的 model id 確認anthropic SDK 版本支援 document(PDF)block;版本太舊則 bump container requirements.txtpypdf(只讀 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 內)build_content_blocks(純函式、不打 Claude):餵 docx / csv / png / jpg / pdf / 超大圖 / 超大 PDF / 超頁 PDF / .heic 各一,斷言回傳 block 類型正確、超限者回對應 skip_reasonclassify_with_claude,Claude client mock 回固定 JSON,驗證 content blocks 組裝正確、skip 檔正確落未處理註:本功能在 standalone Docker container 腳本內(非主專案 app service),測試放
scripts/evidence/classify/對應測試位置,不套用主專案 app service 的 logger patch fixture 慣例。
✅ 本次做:
png / jpg / jpeg / gif / webp + pdf 走 Claude Vision 分類build_content_blocks 重構、新增 pypdf 依賴❌ 本次不做(沿用 FR-030 既有邊界):
_state.json schema 變更、review UI 改版| OCR | Vision(本案採用) | |
|---|---|---|
| 圖裡文字 | ✅(清晰印刷字準) | ✅ |
| 畫面語意(「MFA 設定頁且已啟用」) | ❌ 失去版面 / 狀態 | ✅ 看得懂整個畫面 |
| UI 狀態(勾選框 / toggle / 燈號) | ❌ | ✅ |
| 純畫面 / 圖表 / 拓樸圖證據 | ❌ 幾乎無解 | ✅ |
| 工程成本 | 多 OCR 引擎依賴 | 沿用既有 Claude API |
排除 OCR:分類需要的是語意理解,OCR 先做有損壓縮反而更差,且多一個失敗點。
classify_with_vision() 並按檔型分派 → 兩個 Claude 呼叫點,retry / JSON 解析 / prompt caching 邏輯複製兩份,維護成本高。實驗階段刻意不設成本上限(全圖 / 全頁送,求準確度)。Vision token 用量明顯高於純文字,正式版再談 quota / resize。未來反悔條件:批次成本失控、或要支援大規模租戶時,回頭加 §7 排除的 resize / 頁數壓縮 / quota。
scripts/evidence/classify/docker/container_entrypoint.py(extract_text line 208 / classify_with_claude line 277 / process_file_worker line 312)docs/features/FR-030-2605-auto-evidence-classification/design.mdscripts/evidence/classify/classify_evidence_drive.pydocs/reference/CMMC-Level 1-Evidences/cmmc_l1_aos.json2026-05-29 實作收尾。實作由另一 session(Sonnet)照 implementation-plan.md 執行,12 單元測試通過 + 端對端實測通過。以下為與原計畫的偏差紀錄。
list_jobs 回傳 evidence_folder_id(commit 9da4824e):計畫外的順手補強,供 FE 顯示 Drive 連結。不影響本功能核心,已併入本期 release。test_img_media_type_unknown_raises(unknown ext 應 raise)、test_blocks_pdf_oversize(PDF >32MB skip)。計畫原列 helper + blocks 主分支,實作者補強了邊界覆蓋 → 共 12 測試(計畫預估 10)。02a88ada):實作初版圖片/PDF 分支先 stat().st_size 再 read_bytes() 造成雙讀,後優化為單讀。屬實作細節,不改設計。f7bd6f59 / 4e2fb810):_IMAGE_EXTS 常數定義位置與 docstring 前提對齊,TDD 過程的自我修正。.xlsx / .pptx 支援(兩者 Claude API 無原生 block,需走本地抽文字 openpyxl / python-pptx),討論到抽取預設(xlsx 取值不取公式、pptx 不含備忘稿)即暫停做 summary → 列為 follow-up,本期未實作。未來反悔條件:客戶證據常見 Excel/簡報且 skip 率偏高時再開子任務。