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

> 規劃日期：2026-05-30
> 撰寫人：raymond + Claude
> 對應設計：[`design.md`](./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`：

```python
        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 既有 `.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 達成。

### Task 3 — `build_content_blocks` ext 集合加 xlsx / pptx

**檔案**：`container_entrypoint.py`，function `build_content_blocks`（`:259`）

```python
    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 含內容）：

```python
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 一律不動，跑全綠才算過。

跑法：
```bash
cd scripts/evidence/classify/docker
pip install openpyxl python-pptx   # 本地 venv 若未裝
pytest test_container_entrypoint.py -v
```

### Task 5 —（選做）system prompt 提及試算表 / 簡報

**檔案**：`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 要再加。

---

## 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`。
