FR-030.3 — 實作計劃:證據分類支援 .xlsx / .pptx

規劃日期:2026-05-30 撰寫人:raymond + Claude 對應設計:design.md 狀態:待 user approve → 開工 arc 關聯:↳ FR-030 子(sibling of FR-030.2)


0. 開工前的 reality verify(已完成 2026-05-30)

開工前已 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)。
  • Google Sheets 走 .csv 早已支援,不受本期影響。

1. User 已確認的設計決策(2026-05-30)

# 決策 結論
1 xlsx 含公式儲存格取值 取計算後的值load_workbook(..., data_only=True)
2 pptx 講者備忘稿 不含:只抽 slide 文字框 + 表格
3 超大檔上限 只靠 MAX_TEXT_CHARS=8000 截斷,不另設列數/頁數上限

2. 範圍(沿用 design.md「範圍邊界」)

✅ 做:.xlsx / .pptx → 本地抽文字 → text block → 分類;加依賴;擴充 2 個 function;加單元測試。 ❌ 不做:.xls / .ppt(舊二進位);xlsx 內嵌圖表/圖片視覺辨識;_state.json schema / review UI / vision 路徑任何改動。


3. 實作步驟

Task 1 — 加 container 依賴

檔案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)。
  • Dockerfile 不需改動——pip install -r requirements.txt 自動涵蓋。

Task 2 — _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)

實作細節:

  • import 採 function 內 lazy import(mirror 既有 .docxfrom docx import Document 在 top-level;但 xlsx/pptx 改 lazy 可讓未裝套件的本地環境跑既有 test 不炸——與 docx top-level import 不同處需在 PR 說明)。
    • 決定:跟既有 docxpypdf 一致走 top-level importcontainer_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 達成。

Task 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",其餘不動。)

Task 4 — 單元測試

檔案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

Task 5 —(選做)system prompt 提及試算表 / 簡報

檔案container_entrypoint.pybuild_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 要再加。


4. 驗收條件(三層,per memory 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 簡報也一併能分類

5. 不在本期 scope(避免 scope creep)

  • .xls / .ppt 舊二進位格式(需 LibreOffice headless 轉換,另開 FR)
  • xlsx 內嵌圖表 / pptx 投影片圖片的視覺辨識
  • 抽取上限參數化(列數/頁數 cap)—— 已由決策 3 排除
  • 成本控管、_state.json schema、review UI 改版

6. 收尾(等 user 明確下令才做,per memory feedback_wait_for_user_command_to_close

fix code + test 寫完 commit 後停下,給 user 一句話 status + 手測 checklist。等 user verify pass + 明確說「收尾」才執行:

  • changelog:docs/changelog/2026-05-30-feat-evidence-classify-xlsx-pptx.md(type=feat,modules: [evidence-classifier])
  • design.md 補 §「實作後對齊」段(若有偏差)
  • 對話紀錄歸檔(若 user 要)

commit 規劃(各自獨立、顯式 git add,不用 -am):

  • container 依賴 + 抽取邏輯 + 測試 → 1 個 feat commit(檔案集中在 scripts/evidence/classify/docker/
  • 計劃 / changelog → 收尾 commit

7. 改動檔案總覽

檔案 改動 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