規劃日期:2026-05-30 撰寫人:raymond + Claude 對應設計:
design.md狀態:待 user approve → 開工 arc 關聯:↳ FR-030 子(sibling of FR-030.2)
開工前已 read 既有 code,確認設計假設與現況一致:
| 假設 | 現況 | 狀態 |
|---|---|---|
_extract_text_legacy 有 .docx / .csv,.log,.txt,.md / else 三分支 |
container_entrypoint.py:229-247 完全吻合 |
✅ |
build_content_blocks text 分支 ext 集合 {".docx",".csv",".log",".txt",".md"} |
:259 吻合 |
✅ |
container 依賴在 requirements.txt、Dockerfile pip install -r |
Dockerfile:24-25 吻合 |
✅ |
測試沿用 test_container_entrypoint.py(動態建檔 + 斷言) |
既有 pdf/image/text case pattern 可直接 mirror | ✅ |
額外發現(寫進計劃,非設計新增):GOOGLE_NATIVE_EXPORT(:69-72)已把 Google 簡報 export 成 .pptx、Google 試算表 export 成 .csv。所以:
.pptx 後,Google Slides 原生簡報也會一併能分類(先前 download 成 .pptx 後落在 unsupported → skip)。.csv 早已支援,不受本期影響。| # | 決策 | 結論 |
|---|---|---|
| 1 | xlsx 含公式儲存格取值 | 取計算後的值:load_workbook(..., data_only=True) |
| 2 | pptx 講者備忘稿 | 不含:只抽 slide 文字框 + 表格 |
| 3 | 超大檔上限 | 只靠 MAX_TEXT_CHARS=8000 截斷,不另設列數/頁數上限 |
✅ 做:.xlsx / .pptx → 本地抽文字 → text block → 分類;加依賴;擴充 2 個 function;加單元測試。 ❌ 不做:.xls / .ppt(舊二進位);xlsx 內嵌圖表/圖片視覺辨識;_state.json schema / review UI / vision 路徑任何改動。
檔案:scripts/evidence/classify/docker/requirements.txt
加兩行:
openpyxl>=3.1
python-pptx>=0.6
說明:
openpyxl 純 Python,無編譯依賴。python-pptx 連帶拉 lxml(Dockerfile 已裝 libxml2-dev/libxslt1-dev)+ Pillow(PyPI 預編 wheel,本期只讀文字不解圖,無需額外 OS dep;zlib1g-dev 已在 Dockerfile)。pip install -r requirements.txt 自動涵蓋。_extract_text_legacy 加 xlsx / pptx 分支檔案:container_entrypoint.py,function _extract_text_legacy(:229)
在 .docx 分支之後、.csv/.log/.txt/.md 分支之前(或之後皆可,順序不影響)插入兩個 elif:
elif ext == ".xlsx":
from openpyxl import load_workbook
wb = load_workbook(str(path), read_only=True, data_only=True)
parts = []
for ws in wb.worksheets:
parts.append(f"# Sheet: {ws.title}")
for row in ws.iter_rows(values_only=True):
cells = [str(c) for c in row if c is not None]
if cells:
parts.append(" | ".join(cells))
wb.close()
text_out = "\n".join(parts)
elif ext == ".pptx":
from pptx import Presentation
prs = Presentation(str(path))
parts = []
for i, slide in enumerate(prs.slides, 1):
parts.append(f"# Slide {i}")
for shape in slide.shapes:
if shape.has_text_frame and shape.text_frame.text.strip():
parts.append(shape.text_frame.text)
if shape.has_table:
for r in shape.table.rows:
parts.append(" | ".join(c.text for c in r.cells))
text_out = "\n".join(parts)實作細節:
.docx 用 from docx import Document 在 top-level;但 xlsx/pptx 改 lazy 可讓未裝套件的本地環境跑既有 test 不炸——與 docx top-level import 不同處需在 PR 說明)。
docx、pypdf 一致走 top-level import(container_entrypoint.py:49-54 區塊加 from openpyxl import load_workbook / from pptx import Presentation),保持風格統一。lazy import 留作備案(若本地 venv 不想裝這兩套)。wb.close():read_only=True 模式持有檔案 handle,明確 close 避免 Windows/容器 file lock(docx 不需要,xlsx read_only 需要)。try/except Exception 外層會接住任何解析失敗 → [Failed to extract: ...],壞檔不會讓整個 worker 死。if len(text_out) > max_chars 截斷邏輯(:245),決策 3 達成。build_content_blocks ext 集合加 xlsx / pptx檔案:container_entrypoint.py,function build_content_blocks(:259)
if ext in {".docx", ".xlsx", ".pptx", ".csv", ".log", ".txt", ".md"}:
text = _extract_text_legacy(path)
return [{
"type": "text",
"text": f"Filename: {filename}\nContent preview:\n\"\"\"\n{text}\n\"\"\"",
}], None(只在 set 內加 ".xlsx", ".pptx",其餘不動。)
檔案:scripts/evidence/classify/docker/test_container_entrypoint.py
加兩個 case,mirror 既有 test_blocks_text_file / test_blocks_pdf(動態建小檔 → 斷言 text block 含內容):
def test_blocks_xlsx(tmp_path):
from openpyxl import Workbook
wb = Workbook()
ws = wb.active
ws.title = "Assets"
ws.append(["Host", "Owner"])
ws.append(["web01", "alice"])
p = tmp_path / "assets.xlsx"
wb.save(p)
blocks, skip = c.build_content_blocks(
p, "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", "assets.xlsx")
assert skip is None
assert len(blocks) == 1 and blocks[0]["type"] == "text"
assert "assets.xlsx" in blocks[0]["text"]
assert "Sheet: Assets" in blocks[0]["text"]
assert "web01 | alice" in blocks[0]["text"]
def test_blocks_pptx(tmp_path):
from pptx import Presentation
from pptx.util import Inches
prs = Presentation()
slide = prs.slides.add_slide(prs.slide_layouts[5]) # Title Only
slide.shapes.title.text = "Security Awareness Training"
p = tmp_path / "training.pptx"
prs.save(p)
blocks, skip = c.build_content_blocks(
p, "application/vnd.openxmlformats-officedocument.presentationml.presentation",
"training.pptx")
assert skip is None
assert len(blocks) == 1 and blocks[0]["type"] == "text"
assert "training.pptx" in blocks[0]["text"]
assert "Slide 1" in blocks[0]["text"]
assert "Security Awareness Training" in blocks[0]["text"]回歸保證:既有 docx/csv/image/pdf/unsupported case 一律不動,跑全綠才算過。
跑法:
cd scripts/evidence/classify/docker
pip install openpyxl python-pptx # 本地 venv 若未裝
pytest test_container_entrypoint.py -v檔案:container_entrypoint.py,build_system_block(:319 附近 Task 描述)
現有描述:"...evidence file (filename + its content, which may be text, an image, or a PDF document)..."。
xlsx/pptx 抽完都是 text block,技術上已被 "text" 涵蓋,可不改。若想讓 Claude 更清楚證據可能來自表格/簡報,可把 "which may be text" 微調為 "which may be text (including spreadsheet rows and slide text), an image, or a PDF document"。
決定:列為 optional,預設不改(避免動到 test_system_block_mentions_image_and_pdf 的斷言基線、保持 prompt 穩定)。若 user 要再加。
feedback_surface_user_facing_terminal_state)| 層級 | 條件 |
|---|---|
| BE 寫對 | pytest test_container_entrypoint.py 全綠(含新 2 case + 既有回歸) |
| Container 可跑 | docker build 成功(openpyxl/python-pptx 裝得起來);對含 .xlsx/.pptx 的測試 folder 跑 service-classify,log 顯示該兩類檔 -> <ao_id> 而非 skip |
| Demo-ready | review UI 看到 .xlsx/.pptx 證據出現在分類結果(沿用既有 UI,無需改 FE);Google Slides 簡報也一併能分類 |
.xls / .ppt 舊二進位格式(需 LibreOffice headless 轉換,另開 FR)_state.json schema、review UI 改版feedback_wait_for_user_command_to_close)fix code + test 寫完 commit 後停下,給 user 一句話 status + 手測 checklist。等 user verify pass + 明確說「收尾」才執行:
docs/changelog/2026-05-30-feat-evidence-classify-xlsx-pptx.md(type=feat,modules: [evidence-classifier])commit 規劃(各自獨立、顯式 git add,不用 -am):
scripts/evidence/classify/docker/)| 檔案 | 改動 | Task |
|---|---|---|
scripts/evidence/classify/docker/requirements.txt |
+2 行依賴 | 1 |
scripts/evidence/classify/docker/container_entrypoint.py |
top import +2 行;_extract_text_legacy +2 分支;build_content_blocks ext set +2 副檔名 |
2,3 |
scripts/evidence/classify/docker/test_container_entrypoint.py |
+2 test case | 4 |
container_entrypoint.py build_system_block |
(選做,預設不改) | 5 |
風險評估:低。改動集中在自包含 container script,不碰 DDD app 層、不碰 DB、不碰 API、不碰 FE。最大風險是 container build 時 python-pptx/Pillow 在 slim image 裝不起來——已評估 Pillow 走預編 wheel + zlib 已備,風險低;萬一失敗的 fallback 是 Dockerfile 補 libjpeg-dev。