For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 讓 FR-030 證據分類器的 Docker container 能用 Claude Vision 辨識圖片(png/jpg/gif/webp)與 PDF 內容並分類,原分類 / 歸檔流程一個字不動。
Architecture: 方式 A — 把 container_entrypoint.py 的分類管道從「字串進」改成「Anthropic content blocks 進」。新增純函式 build_content_blocks(path, mime, filename),依副檔名組 text / image / document block;classify_with_claude 改收 blocks;process_file_worker 串接並把超限 / 不支援檔 skip 進既有未處理清單。文字檔走的路徑語意不變(只是字串包進 text block)。
Tech Stack: Python 3.11、anthropic>=0.77(image + document block)、pypdf(新增,只讀 PDF 頁數)、stdlib base64、pytest。
改動檔(全部在 container 目錄內,不碰主專案 app/domain/infra):
scripts/evidence/classify/docker/container_entrypoint.pyscripts/evidence/classify/docker/requirements.txt(加 pypdf)scripts/evidence/classify/docker/test_container_entrypoint.py(新測試,目前無)Spec: docs/features/FR-030.2-2605-evidence-classify-image-vision/design.md
| 檔案 | 職責 | 本計畫動作 |
|---|---|---|
container_entrypoint.py |
分類 container 主程式 | 改 3 個函式 + 加 3 個 helper + 改 prompt 措辭 |
requirements.txt |
container 依賴 | 加 pypdf>=4 |
test_container_entrypoint.py |
純函式單元測試 | 新建 |
不動:_state.json / _report-original.json 寫出邏輯(write_outputs)、Drive 歸檔、review UI、主專案任何檔。
plan 寫好到開工常有時間差,先 verify 假設仍成立再動手。
Files: 無(只讀 / 跑命令)
Run:
cd /Users/chouraymond/Projects/Billows/Audit-Manager/compliance-manager-be
grep -n "def extract_text\|def classify_with_claude\|def process_file_worker\|def build_system_block\|DEFAULT_MODEL\|MAX_TEXT_CHARS" scripts/evidence/classify/docker/container_entrypoint.pyExpected: extract_text(~208) / classify_with_claude(~277, 簽名含 filename, content) / process_file_worker(~312) / build_system_block(~233) / DEFAULT_MODEL = "claude-sonnet-4-6" / MAX_TEXT_CHARS = 8000 皆在。若行號漂移以實際為準。
Run:
grep -n "anthropic" scripts/evidence/classify/docker/requirements.txt
python3 -c "import anthropic; print(anthropic.__version__)"Expected: anthropic>=0.77;claude-sonnet-4-6 為多模態 model(支援 image + PDF document block)。若實際傳入的 model(--model 或 DEFAULT_MODEL)非 sonnet-4.x,停下回報 user。
Run:
cd scripts/evidence/classify/docker && python3 -c "import container_entrypoint as c; print('ok', hasattr(c,'extract_text'))"Expected: ok True,無 traceback、不會啟動 main(確認有 if __name__ == "__main__" guard)。若 import 觸發 main 或缺依賴,先解決再繼續。
Run:
grep -n "skipped_reason\|\[\"matches\"\]\|\.get(\"matches\"\|\[\"primary\"\]\|\.get(\"error\"" scripts/evidence/classify/docker/container_entrypoint.pyExpected: 確認下游只讀 matches / primary / error,未讀其它 per-file key → 新增 skipped_reason 向後相容。
pypdf 依賴 + 兩個 helper(_img_media_type / _pdf_page_count)Files:
scripts/evidence/classify/docker/requirements.txtscripts/evidence/classify/docker/container_entrypoint.py(加 import base64、from pypdf import PdfReader、兩個 helper)scripts/evidence/classify/docker/test_container_entrypoint.py在 requirements.txt 末加一行:
pypdf>=4
建 test_container_entrypoint.py:
import sys
from pathlib import Path
HERE = Path(__file__).resolve().parent
sys.path.insert(0, str(HERE))
import container_entrypoint as c
def test_img_media_type():
assert c._img_media_type(".png") == "image/png"
assert c._img_media_type(".jpg") == "image/jpeg"
assert c._img_media_type(".jpeg") == "image/jpeg"
assert c._img_media_type(".gif") == "image/gif"
assert c._img_media_type(".webp") == "image/webp"
def test_pdf_page_count(tmp_path):
# 用 pypdf 寫一個 2 頁 PDF
from pypdf import PdfWriter
w = PdfWriter()
w.add_blank_page(width=72, height=72)
w.add_blank_page(width=72, height=72)
p = tmp_path / "two.pdf"
with open(p, "wb") as f:
w.write(f)
assert c._pdf_page_count(p) == 2Run: cd scripts/evidence/classify/docker && python3 -m pytest test_container_entrypoint.py -v Expected: FAIL — AttributeError: module has no attribute '_img_media_type'(且需先 pip install pypdf)
在 container_entrypoint.py 加 import base64(與其它 stdlib import 並列)、from pypdf import PdfReader(與外部 deps 並列),並在 extract_text 附近加:
_IMG_MEDIA = {
".png": "image/png",
".jpg": "image/jpeg",
".jpeg": "image/jpeg",
".gif": "image/gif",
".webp": "image/webp",
}
def _img_media_type(ext: str) -> str:
return _IMG_MEDIA[ext.lower()]
def _pdf_page_count(path: Path) -> int:
return len(PdfReader(str(path)).pages)Run: cd scripts/evidence/classify/docker && python3 -m pytest test_container_entrypoint.py -v Expected: PASS(2 passed)
git add scripts/evidence/classify/docker/requirements.txt \
scripts/evidence/classify/docker/container_entrypoint.py \
scripts/evidence/classify/docker/test_container_entrypoint.py
git commit -m "feat(evidence-classify): 加 pypdf 依賴與 img/pdf helper"extract_text 改名為 _extract_text_legacy(行為零變更)Files:
scripts/evidence/classify/docker/container_entrypoint.py文字抽取邏輯整段保留(含
MAX_TEXT_CHARS截斷),只改函式名,並更新呼叫端。build_content_blocks(Task 3)會包它的輸出。
實際簽名帶 type hint(一行):def extract_text(path: Path, max_chars: int = MAX_TEXT_CHARS) -> str: → 改名為 def _extract_text_legacy(path: Path, max_chars: int = MAX_TEXT_CHARS) -> str:(函式體一字不改,MAX_TEXT_CHARS 截斷留在內)。把 process_file_worker 內 content = extract_text(local_path)(~line 330)暫時改為 content = _extract_text_legacy(local_path)(Task 4 會再改成走 build_content_blocks)。
Run: cd scripts/evidence/classify/docker && python3 -c "import importlib, container_entrypoint as c; importlib.reload(c); print(hasattr(c,'_extract_text_legacy'), not hasattr(c,'extract_text'))" Expected: True True
Run: python3 -m pytest test_container_entrypoint.py -v Expected: PASS
git add scripts/evidence/classify/docker/container_entrypoint.py
git commit -m "refactor(evidence-classify): extract_text 改名 _extract_text_legacy"build_content_blocks — 四分支(text / image / pdf / unsupported)Files:
scripts/evidence/classify/docker/container_entrypoint.pyscripts/evidence/classify/docker/test_container_entrypoint.py加到 test_container_entrypoint.py:
import base64
def _make_png(tmp_path, name="a.png", size_bytes=100):
# 最小合法 PNG header + padding
png = (b"\x89PNG\r\n\x1a\n" + b"\x00" * max(0, size_bytes - 8))
p = tmp_path / name
p.write_bytes(png)
return p
def test_blocks_text_file(tmp_path):
p = tmp_path / "note.txt"
p.write_text("hello evidence")
blocks, skip = c.build_content_blocks(p, "text/plain", "note.txt")
assert skip is None
assert len(blocks) == 1
assert blocks[0]["type"] == "text"
assert "note.txt" in blocks[0]["text"]
assert "hello evidence" in blocks[0]["text"]
def test_blocks_image(tmp_path):
p = _make_png(tmp_path)
blocks, skip = c.build_content_blocks(p, "image/png", "a.png")
assert skip is None
assert blocks[0]["type"] == "text" and "a.png" in blocks[0]["text"]
assert blocks[1]["type"] == "image"
assert blocks[1]["source"]["media_type"] == "image/png"
assert base64.standard_b64decode(blocks[1]["source"]["data"]) # 可解碼
def test_blocks_image_oversize(tmp_path):
p = _make_png(tmp_path, name="big.png", size_bytes=5 * 1024 * 1024 + 1)
blocks, skip = c.build_content_blocks(p, "image/png", "big.png")
assert blocks == []
assert "5MB" in skip
def test_blocks_pdf(tmp_path):
from pypdf import PdfWriter
w = PdfWriter(); w.add_blank_page(width=72, height=72)
p = tmp_path / "doc.pdf"
with open(p, "wb") as f:
w.write(f)
blocks, skip = c.build_content_blocks(p, "application/pdf", "doc.pdf")
assert skip is None
assert blocks[0]["type"] == "text" and "doc.pdf" in blocks[0]["text"]
assert blocks[1]["type"] == "document"
assert blocks[1]["source"]["media_type"] == "application/pdf"
def test_blocks_pdf_too_many_pages(tmp_path, monkeypatch):
from pypdf import PdfWriter
w = PdfWriter(); w.add_blank_page(width=72, height=72)
p = tmp_path / "many.pdf"
with open(p, "wb") as f:
w.write(f)
monkeypatch.setattr(c, "_pdf_page_count", lambda _p: 101)
blocks, skip = c.build_content_blocks(p, "application/pdf", "many.pdf")
assert blocks == []
assert "100" in skip
def test_blocks_unsupported(tmp_path):
p = tmp_path / "movie.mp4"
p.write_bytes(b"\x00\x00")
blocks, skip = c.build_content_blocks(p, "video/mp4", "movie.mp4")
assert blocks == []
assert "unsupported" in skip.lower()Run: python3 -m pytest test_container_entrypoint.py -k blocks -v Expected: FAIL — module has no attribute 'build_content_blocks'
在 _extract_text_legacy 後加:
_IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".gif", ".webp"}
_MAX_IMAGE_BYTES = 5 * 1024 * 1024
_MAX_PDF_BYTES = 32 * 1024 * 1024
_MAX_PDF_PAGES = 100
def build_content_blocks(path: Path, mime: str, filename: str):
"""回傳 (content_blocks, skip_reason)。skip_reason 非 None → 跳過該檔。"""
ext = path.suffix.lower()
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
if ext in _IMAGE_EXTS:
if path.stat().st_size > _MAX_IMAGE_BYTES:
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
if ext == ".pdf":
if path.stat().st_size > _MAX_PDF_BYTES:
return [], "pdf exceeds 32MB API limit"
if _pdf_page_count(path) > _MAX_PDF_PAGES:
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
return [], f"unsupported file type: {ext}"Run: python3 -m pytest test_container_entrypoint.py -k blocks -v Expected: PASS(6 passed)
git add scripts/evidence/classify/docker/container_entrypoint.py \
scripts/evidence/classify/docker/test_container_entrypoint.py
git commit -m "feat(evidence-classify): build_content_blocks 支援圖片/PDF + 超限 skip"classify_with_claude 改收 content_blocks + process_file_worker 串接Files:
scripts/evidence/classify/docker/container_entrypoint.py(classify_with_claude ~277、process_file_worker ~312)scripts/evidence/classify/docker/test_container_entrypoint.py加到測試:
class _FakeContent:
def __init__(self, text): self.text = text
class _FakeResp:
def __init__(self, text): self.content = [_FakeContent(text)]
class _FakeMessages:
def __init__(self, outer): self.outer = outer
def create(self, **kwargs):
self.outer.last_kwargs = kwargs
return _FakeResp('{"matches": [{"ao_id": "AC.L1-3.1.1[a]", '
'"confidence": 0.9, "reasoning": "x"}], '
'"primary": "AC.L1-3.1.1[a]"}')
class _FakeClient:
def __init__(self): self.messages = _FakeMessages(self); self.last_kwargs = None
def test_classify_passes_blocks_through():
client = _FakeClient()
blocks = [{"type": "text", "text": "Filename: a.txt"}]
out = c.classify_with_claude(client, "claude-sonnet-4-6", "SYS", blocks)
# content blocks 原樣傳給 messages.create
assert client.last_kwargs["messages"][0]["content"] is blocks
# system block 仍帶 prompt caching
assert client.last_kwargs["system"][0]["cache_control"] == {"type": "ephemeral"}
assert out["primary"] == "AC.L1-3.1.1[a]"Run: python3 -m pytest test_container_entrypoint.py -k classify -v Expected: FAIL —目前 classify_with_claude 簽名是 (client, model, system_block, filename, content, retries=3),呼叫缺參數會 TypeError。
實際簽名橫跨兩行且帶 type hint,把:
def classify_with_claude(client, model: str, system_block: str,
filename: str, content: str, retries: int = 3) -> dict:
user_message = (
f"Filename: {filename}\n"
f"Content preview:\n\"\"\"\n{content}\n\"\"\""
)
...
messages=[{"role": "user", "content": user_message}],改為:
def classify_with_claude(client, model: str, system_block: str,
content_blocks, retries: int = 3) -> dict:
...
messages=[{"role": "user", "content": content_blocks}],(移除 user_message 組裝;retry / fence 去除 / json.loads / cache_control system block 全部不動。)
在 try: 內把(此時已是 Task 2 改名後的狀態):
download_drive_file(drive, file_id, mime, local_path)
content = _extract_text_legacy(local_path)
result = classify_with_claude(claude_client, args.model, system_block, name, content)改為:
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)保留外層 try/finally(local_path.unlink() cleanup)與既有 except Exception → {error: str(exc), matches: [], primary: None} 分支不變。注意:return 在 try 內,finally 仍會跑 cleanup(正確)。
Run: python3 -m pytest test_container_entrypoint.py -v Expected: PASS(全部)
git add scripts/evidence/classify/docker/container_entrypoint.py \
scripts/evidence/classify/docker/test_container_entrypoint.py
git commit -m "feat(evidence-classify): classify_with_claude 收 content_blocks + worker 串接 skip"build_system_block prompt 措辭放寬Files:
scripts/evidence/classify/docker/container_entrypoint.py(build_system_block ~233)scripts/evidence/classify/docker/test_container_entrypoint.pydef test_system_block_mentions_image_and_pdf():
catalog = {"domains": [{"code": "AC", "name": "Access Control", "controls": [
{"id": "AC.L1-3.1.1", "name": "x", "statement": "s",
"assessment_objectives": [{"letter": "a", "text": "t"}]}]}]}
sb = c.build_system_block(catalog)
assert "image" in sb and "PDF" in sb
assert "screenshots" in sb or "PDF pages" in sbRun: python3 -m pytest test_container_entrypoint.py -k system_block -v Expected: FAIL — 現 prompt 只提 "filename + content preview",無 image/PDF。
把 Task 段:
"Given an evidence file (filename + content preview), classify which",
"Assessment Objective(s) the file best satisfies as audit evidence. A single",改為:
"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 as audit evidence. A single",把 reasoning 規則:
"- The reasoning must cite specific evidence from the file content.",改為:
"- The reasoning must cite specific evidence from the file content "
"(including what is visible in screenshots / images / PDF pages).",Run: python3 -m pytest test_container_entrypoint.py -v Expected: PASS(全部)
git add scripts/evidence/classify/docker/container_entrypoint.py \
scripts/evidence/classify/docker/test_container_entrypoint.py
git commit -m "feat(evidence-classify): prompt 放寬讓 Claude 知道可能收到圖/PDF"Files: 無新增(驗證 + 文件)
Run: cd scripts/evidence/classify/docker && python3 -m pytest test_container_entrypoint.py -v Expected: 全 PASS(helper 2 + blocks 6 + classify 1 + system_block 1)
Dockerfile 已 COPY requirements.txt → pip install -r requirements.txt,新增 pypdf 會自動被裝,無需改 Dockerfile。確認測試檔不會被 COPY 進 image(image 只 COPY container_entrypoint.py + jedi_helpers/,測試檔留在 repo 即可)。
Run(可選,需 docker):
docker build -t cmmc-classifier:fr0302-test -f scripts/evidence/classify/docker/Dockerfile scripts/evidence/classify/docker && echo BUILD_OKExpected: BUILD_OK(pypdf 裝成功)
需真實 Drive + Claude API key,由 user 在備好證據(含 1 張截圖 + 1 份 PDF)的測試專案觸發一次分類,確認:截圖 / PDF 出現在 report 且有 match、超大檔列未處理。此步交 user 驗收,不在自動測試內。
依 CLAUDE.md「收尾必須等 user 下命令才做」:fix code commit 完停下,給 user 一句話 status + 手測 checklist。changelog(
docs/changelog/YYYY-MM-DD-feat-evidence-classify-image-vision.md)/ design.md §11 reconciliation 等收尾文件,等 user 明確說「收尾」再 batch 寫。
-am(此 repo working tree 有其它未提交 doc 變更,會被誤掃)。docker build 才生效。scripts/evidence/classify/docker/。write_outputs / _state.json 結構 / Drive 歸檔 / review UI 一律不碰。