---
title: "證據自動分類 2.0 — 設計文件 (FR-107)"
brand: "Guidant AI · **FR-107** 證據自動分類 2.0"
eyebrow: "FR-107 · 設計文件 · 2026-09-17 · 🟢 16/31 Done，3 張派出中（T-3.4／T-4.1／T-5.3b）"
h1: "分類線搬回系統儲存、分完搬進任務、框架不寫死"
lede: "本文把 [討論稿](./discussion.html) 依決策者 2026-09-16 的裁示（D1–D12）落成可拆卡的規格：資料表到欄位、port 到 Python 簽名、容器工作目錄契約、prompt 組裝、歸檔演算法、六棒 25 張子任務卡與端到端驗收劇本。**D11 是本文與討論稿最大的差異：本案新增功能全部進既有套件 `jedi-evidence-classification`，主專案只留宿主 adapter 與前端。**"
chips: [
  {text: "🟢 16/31 Done，3 張派出中", kind: ok},
  {text: "母卡 CM-1843／子卡 CM-1844～1871、1877～1878", kind: accent},
  {text: "D1–D13 已裁（2026-09-16／17）", kind: ok},
  {text: "六棒 25 張子任務卡＋1 張 mockup 卡", kind: accent},
  {text: "動兩支套件：jedi-evidence-classification／jedi-file-upload", kind: crit},
  {text: "討論稿：discussion.md", kind: plain}
]
footer: "FR-107 · 證據自動分類 2.0 — 設計文件 · 2026-09-17 · 討論稿 discussion.md（v1）· 決策者裁示日 2026-09-16／17 · 沿革見 handoff/LOG 與 git log"
notion:
  parent: {id: CM-1843, title: "FR-107 證據自動分類 2.0（母卡，六棒 25 張子任務卡）", url: "https://app.notion.com/p/FR-107-2-0-22-3dd346da4cd08161b91ef582075062d0"}
  children:
    - {id: CM-1844, title: "FR-107.1 暫存批次＋安全地基", url: "https://app.notion.com/p/FR-107-1-upload_files-RLS-API-4-3dd346da4cd081e9b6dbe8d5b670c6a2"}
    - {id: CM-1845, title: "FR-107.2 儲存 port＋容器改取檔", url: "https://app.notion.com/p/FR-107-2-port-Drive-3-3dd346da4cd0810f8a56e2e4ccf686fa"}
    - {id: CM-1846, title: "FR-107.3 分類跑在批次上", url: "https://app.notion.com/p/FR-107-3-4-3dd346da4cd081c8a8afffc24ae85a01"}
    - {id: CM-1847, title: "FR-107.4 歸檔進任務", url: "https://app.notion.com/p/FR-107-4-job_evidences-3-3dd346da4cd0817793bcf8d00048d4ce"}
    - {id: CM-1848, title: "FR-107.5 框架去硬編碼", url: "https://app.notion.com/p/FR-107-5-catalog-AI-prompt-CMMC-4-3dd346da4cd081e29849ce168532bdda"}
    - {id: CM-1849, title: "FR-107.6 舊線退場＋收口", url: "https://app.notion.com/p/FR-107-6-Drive-e2e-SPEC-4-3dd346da4cd081e39df8f237677e01e6"}
    - {id: CM-1850, title: "T-1.1 upload_files 補租戶／擁有者／RLS", url: "https://app.notion.com/p/FR-107-1-T-1-1-RLS-jedi-file-upload-3dd346da4cd08182ac33dc609ea72f7f"}
    - {id: CM-1851, title: "T-1.2 批次兩張表＋狀態機骨架", url: "https://app.notion.com/p/FR-107-1-T-1-2-entity-repo-jedi-evidence-classification-3dd346da4cd0816ebb6af1f980c7c2d8"}
    - {id: CM-1852, title: "T-1.3 批次 API＋IEvidenceStorage port＋存檔 adapter", url: "https://app.notion.com/p/FR-107-1-T-1-3-API-3dd346da4cd0816ebfa7ce1059b64833"}
    - {id: CM-1853, title: "T-1.4 FE 上傳到批次頁", url: "https://app.notion.com/p/FR-107-1-T-1-4-3dd346da4cd0811d9327c6cd0b15cccb"}
    - {id: CM-1878, title: "T-1.4m 自動分類證據兩頁 HTML mockup", url: "https://app.notion.com/p/FR-107-1-T-1-4m-HTML-mockup-T-1-4-3de346da4cd081ef872bd5ca8e676799"}
    - {id: CM-1854, title: "T-2.1 三支 port 定義＋舊 Drive port 標 deprecated", url: "https://app.notion.com/p/FR-107-2-T-2-1-Drive-3dd346da4cd081beb4a4f0d7db11377b"}
    - {id: CM-1855, title: "T-2.2 宿主三支 adapter＋DI wiring", url: "https://app.notion.com/p/FR-107-2-T-2-2-DI-3dd346da4cd0810f949ed3760fb34da3"}
    - {id: CM-1856, title: "T-2.3 容器搬套件 repo＋改讀目錄＋改名", url: "https://app.notion.com/p/FR-107-2-T-2-3-repo-Drive-evidence-classifier-3dd346da4cd08199a94dc4219d7f9f70"}
    - {id: CM-1857, title: "T-3.1 runs 表改掛批次", url: "https://app.notion.com/p/FR-107-3-T-3-1-uid-3dd346da4cd081ac9475e6bf5b9430c9"}
    - {id: CM-1858, title: "T-3.2 批次版觸發＋狀態機＋心跳", url: "https://app.notion.com/p/FR-107-3-T-3-2-3dd346da4cd08181ac85e6d5085faaf8"}
    - {id: CM-1859, title: "T-3.3 run 新定址 route＋重複提示", url: "https://app.notion.com/p/FR-107-3-T-3-3-API-3dd346da4cd081c085b9e5266ad1c2d9"}
    - {id: CM-1860, title: "T-3.4 FE 審閱頁換資料來源＋批次列表", url: "https://app.notion.com/p/FR-107-3-T-3-4-3dd346da4cd08113ad63e3c434346ed7"}
    - {id: CM-1861, title: "T-4.1 job_evidences 新 source＋冪等索引＋attach", url: "https://app.notion.com/p/FR-107-4-T-4-1-AI-3dd346da4cd081b5b0d8ff15e83fc22b"}
    - {id: CM-1862, title: "T-4.2 歸檔 API＋清理 API", url: "https://app.notion.com/p/FR-107-4-T-4-2-API-3dd346da4cd08145b7e0ee83f658e2c1"}
    - {id: CM-1863, title: "T-4.3 FE 歸檔對話框＋任務證據標籤", url: "https://app.notion.com/p/FR-107-4-T-4-3-AI-3dd346da4cd081319bb1fa57fa257a4d"}
    - {id: CM-1864, title: "T-5.1 classification_profiles 表＋CRUD＋解析鏈", url: "https://app.notion.com/p/FR-107-5-T-5-1-AI-API-3dd346da4cd08129b63eeb80c7de21e3"}
    - {id: CM-1865, title: "T-5.2 catalog 補 part_id＋靜態 JSON 退場", url: "https://app.notion.com/p/FR-107-5-T-5-2-CMMC-3dd346da4cd08184b53ec0b010f2ff2e"}
    - {id: CM-1866, title: "T-5.3a prompt 三段組裝", url: "https://app.notion.com/p/FR-107-5-T-5-3-AI-3dd346da4cd08123b2cbcb89991d10ac"}
    - {id: CM-1877, title: "T-5.3b 分類容器改多供應商 AI client（Anthropic＋OpenAI）", url: "https://app.notion.com/p/FR-107-5-T-5-3b-AI-client-Anthropic-OpenAI-3dd346da4cd081c699affcf6eb8d4344"}
    - {id: CM-1867, title: "T-5.4 FE AI 分類設定分頁", url: "https://app.notion.com/p/FR-107-5-T-5-4-AI-3dd346da4cd0817291b2fcc26733ff24"}
    - {id: CM-1879, title: "T-5.5 三個 AI 功能統一金鑰來源（D13）", url: "https://app.notion.com/p/FR-107-5-T-5-5-AI-ROOT-env-install-sh-3de346da4cd0816e98a7df52286ac393"}
    - {id: CM-1868, title: "T-6.1 舊入口隱藏＋舊 route 標 legacy", url: "https://app.notion.com/p/FR-107-6-T-6-1-Drive-legacy-3dd346da4cd081ceb9e7e85d6365c904"}
    - {id: CM-1869, title: "T-6.2 兩支套件發版＋pin 還原", url: "https://app.notion.com/p/FR-107-6-T-6-2-image-3dd346da4cd08128809aca7f4dc501e5"}
    - {id: CM-1870, title: "T-6.3 e2e 全鏈＋SPEC", url: "https://app.notion.com/p/FR-107-6-T-6-3-3dd346da4cd081d39779ea387c06a2f5"}
    - {id: CM-1871, title: "T-6.4 分類容器 image 進出貨包", url: "https://app.notion.com/p/FR-107-6-T-6-4-image-image-3dd346da4cd081d0a1a7e8678c238b47"}
---

> 狀態：**🟢 16/31 張 Done，3 張派出中（T-3.4／T-4.1／T-5.3b）**｜建立日期：2026-09-16｜決策者裁示日：2026-09-16／17
> Notion：母卡 [CM-1843](https://app.notion.com/p/FR-107-2-0-22-3dd346da4cd08161b91ef582075062d0)｜子需求 CM-1844～1849｜子任務 CM-1850～1871、1877～1879
> 討論稿（含現況盤點原文）：[`discussion.html`](./discussion.html)｜前作：FR-030（Drive 版自動分類）／FR-031（分類結果報表）
>
> 🔴 **這份是設計決策，不是現況。** 內文的座標與數字停在 2026-09-16 定稿當時，之後實作改動的結果不回頭改寫這裡——現況一律看 FINAL-SPEC.md（收口時產出）。

## 變更紀錄 {#changelog nav="變更紀錄"}

| 日期 | 變更 | 對應 |
|------|------|------|
| 2026-09-16 | 初版設計定案：討論稿 D1–D10 依決策者裁示落地，新增 D11（全進既有套件）；六棒拆成 21 張子任務卡 | FR-107 母案 |
| 2026-09-16 | 寫作時發現的六項待裁全數裁定；分類容器 image 進出貨包加 T-6.4，六棒改 22 張子任務卡 | FR-107 母案 |
| 2026-09-16 | Notion 母子卡樹開卡完成（29 張：母卡 CM-1843、子需求 CM-1844～1849、子任務 CM-1850～1871），卡號回填 front matter、概述表與拆分表 | CM-1843 |
| 2026-09-16 | 新增 D12（多供應商 AI）：金鑰存 `AI_PROVIDER_CONFIG`、可用模型動態算、容器抽 `LlmClient`；T-5.3 拆成 T-5.3a／T-5.3b，六棒改 23 張子任務卡 | CM-1843 |
| 2026-09-16 | T-1.1／T-1.2／T-1.3 驗收通過；T-1.4 與 T-2.1～T-2.3 派出中；交接 STATE／LOG 雙檔建立 | CM-1850／1851／1852 |
| 2026-09-17 | T-1.4 UI 整頁驗收不合格（決策者：需流程引導＋歷史清單＋新建與上傳分開＋改名「自動分類證據」＋檔案可預覽＋大量檔案不失控，且未依規範先出 mockup）；新增 T-1.4m（先出 HTML mockup 過目再重做），六棒 23 張子任務卡外加 1 張 mockup 卡；.2 三張（T-2.1～T-2.3）首腦實查建議通過 | CM-1853／CM-1878 |
| 2026-09-17 | 新增 D13（原廠 AI 金鑰）：AI 小幫手／AI Dashboard／證據分類三個功能統一金鑰解析（租戶設定 → ROOT 原廠鑰 → 環境變數），install.sh 裝機寫入 ROOT；裁示 4 修正為「profile 守門放寬到租戶管理員」、金鑰設定頁鎖 ROOT 平台管理員；新增 T-5.5，六棒改 25 張子任務卡 | CM-1843／CM-1879 |
| 2026-09-17 | 交接：16/31 張子任務卡驗收通過（含 T-1.4 依 mockup 重刻、T-5.2／T-5.4／T-5.5 全數過關），3 張派出中（T-3.4／T-4.1／T-5.3b） | fr107-STATE.md |

---

## 分工概述（30 秒版） {#overview nav="概述"}

整案一句話：**把「證據自動分類」從 Google Drive 搬回系統自己的儲存空間，分完的檔直接搬進任務變成正式證據，而且不管哪個合規框架都能用——新功能全長在套件 `jedi-evidence-classification` 裡，主專案只負責「接線」與前端。**

先解釋幾個詞（白話）：

- **儲存後端**：系統把上傳檔案實際放在哪裡——主機硬碟、物件儲存（MinIO／SeaweedFS，類似自架雲端硬碟）、或客戶內網的遠端 agent。設定切一下就換，程式不用改。
- **暫存批次**：使用者一次上傳的一堆證據檔，還沒分類、還沒掛任何任務，先放在一個「籃子」裡。
- **AO**（Assessment Objective，評估目標）：一條控制項底下的細分檢查點。分類就是把每份檔對到它證明的那幾個檢查點。
- **任務**：稽核輪次裡每個檢查點對應的工作項；證據最終要掛在任務上，稽核員才看得到。
- **port**：套件開出來的「插座」——套件只說「我需要有人幫我拿檔／找任務」，怎麼拿由主專案（宿主）插上去的 adapter 決定。
- **宿主 adapter**：主專案為每個 port 寫的實作，本案落在 `core/plugins/evidence_classification.py`。

| 棒 | 做什麼（一句話） | 產出 | 跨 repo／動哪個套件 | 完成怎麼判定（決策者親手可做） |
|----|----------------|------|---------------------|------------------------------|
| **.1 暫存批次＋安全地基**<br>[CM-1844](https://app.notion.com/p/FR-107-1-upload_files-RLS-API-4-3dd346da4cd081e9b6dbe8d5b670c6a2) | 建「批次」「批次檔」兩張表；`upload_files` 補租戶／擁有者／RLS；做建批次、上傳到批次、列批次、刪檔四支 API；未設儲存後端時拒絕上傳（412）而不是偷寫 `/tmp` | 兩支套件各一支 migration ＋ 批次 API ＋ 前端「上傳到批次」頁 | **套件 jedi-evidence-classification**（批次表＋API）、**套件 jedi-file-upload**（`upload_files` 三欄）、BE（migration 落地＋宿主存檔 adapter）、FE | 兩個不同租戶的帳號各上傳一批，A 帳號打 B 的批次 API 拿到 403；DEV 查 `upload_files` 新列都有 `tenant_id`；DEV 清掉 STORAGE_CONFIG 再上傳，畫面看到明確錯誤（不是成功） |
| **.2 儲存 port＋容器改取檔**<br>[CM-1845](https://app.notion.com/p/FR-107-2-port-Drive-3-3dd346da4cd0810f8a56e2e4ccf686fa) | 套件開「取檔／放檔」與「找任務／掛證據」兩個 port；主專案接到現有儲存後端；容器改成只讀掛進來的目錄，不再連 Drive 與資料庫；容器 image 搬進套件 repo | 套件兩支 port ＋ 主專案兩支 adapter ＋ 容器 image 改版（改名 `evidence-classifier`） | **套件 jedi-evidence-classification**（port＋容器 image）、BE（adapter） | 一台**沒有 Drive 憑證、沒有資料庫密碼**的容器跑一次分類能拿到檔、能回結果；儲存後端從 local 切到 seaweedfs 再跑一次，程式零改動；`docker inspect` 看新容器環境變數只剩 AI 金鑰 |
| **.3 分類跑在批次上**<br>[CM-1846](https://app.notion.com/p/FR-107-3-4-3dd346da4cd081c8a8afffc24ae85a01) | 觸發分類改吃「批次」不吃「AP 的 Drive 資料夾」；結果掛批次；批次狀態機落地（分類中不可加檔）；job 狀態 DB 化最小版（心跳＋啟動掃逾時） | 觸發／查詢／狀態 API ＋ 前端審閱頁改資料來源 | **套件 jedi-evidence-classification**、BE（migration 落地）、FE | 上傳一批 → 按分類 → 分類中再上傳被擋（有訊息）→ 分完在審閱頁看到每檔對到的檢查點，能加減、標不適用、儲存；分類中把 BE 砍掉重啟，30 分鐘後批次自動變「失敗」可重跑 |
| **.4 歸檔進任務**<br>[CM-1847](https://app.notion.com/p/FR-107-4-job_evidences-3-3dd346da4cd0817793bcf8d00048d4ce) | 確認後把檔**搬**進對應任務（同一筆檔案紀錄改綁任務，不複製實體檔）；一檔多檢查點＝每個任務各一筆證據紀錄；沒對到任務的留在批次標「無對應任務」；搬完問要不要清掉剩餘檔（實刪） | 歸檔 API ＋ 清理 API ＋ `job_evidences` 新來源值 ＋ 前端歸檔對話框 | **套件 jedi-evidence-classification**（歸檔／清理 API）、BE（`job_evidences` migration＋宿主掛證據 adapter）、FE | 歸檔後到「我的任務」證據頁看得到那份檔；DEV 查 `job_evidences` 有 `source='AI_CLASSIFIED'` 的列、`upload_files` 沒多出實體副本；同一批連按兩次歸檔不會產生重複證據；清掉剩餘檔後儲存後端上實體檔不見、批次檔表那列標 `removed` |
| **.5 框架去硬編碼**<br>[CM-1848](https://app.notion.com/p/FR-107-5-catalog-AI-prompt-CMMC-4-3dd346da4cd081e29849ce168532bdda) | 目標集、檢查點代號、領域全走 catalog 結構；新表 `classification_profiles` 存 AI 角色與指引與供應商／模型預設；前端框架版本管理頁多「AI 分類設定」分頁（含供應商金鑰區）；容器 prompt 改三段組裝、容器抽 `LlmClient` 支援 Anthropic／OpenAI 多供應商；套件內 CMMC 靜態 JSON 退場；三個 AI 功能統一金鑰解析＋原廠鑰裝機寫入（D13） | migration ＋ profile CRUD API ＋ 前端分頁 ＋ 容器 prompt 改版 ＋ 多供應商 `LlmClient` ＋ 報表改讀快照 ＋ 統一金鑰解析器 | **套件 jedi-evidence-classification**（表＋CRUD＋prompt 組裝＋`llm_models.py` 碼表＋`LlmClient`＋報表）、BE（catalog 輸出補欄＋宿主 profile 脈絡 adapter＋統一金鑰解析器＋installer 寫入）、FE | 不建任何 profile 直接跑分類要能跑（通用設定生效）；建一個框架版本的 profile 後，job 目錄的 `prompt.json` 開頭出現那段 persona；把套件的 `cmmc_l1_aos.json` 改名再跑一次分類與兩份報表，全部正常；切到 OpenAI 跑一次同批證據，兩份報表可產出且可比對（D12）；租戶無設定時 AI 小幫手／Dashboard／分類三功能都能 fallback ROOT 原廠鑰跑通（D13） |
| **.6 舊線退場＋收口**<br>[CM-1849](https://app.notion.com/p/FR-107-6-Drive-e2e-SPEC-4-3dd346da4cd081e39df8f237677e01e6) | Drive 版分類前端入口隱藏、後端 route 保留（D3）；套件發版、pin 還原；e2e 回歸；SPEC；出貨基線重產回報；分類容器 image 進出貨包（T-6.4） | 退場 commit ＋ 兩支套件發版 ＋ e2e ＋ SPEC ＋ 出貨包含分類 image | BE、FE、test、兩支套件（發版）、build 線 | 舊入口在前端找不到；POC 既有 run 的審閱頁與報表仍打得開；e2e 走完「上傳→分類→確認→歸檔→任務看到證據」全鏈綠燈；安裝包內有 `evidence-classifier` image |

依賴：**.1 → .2 → .3 → .4 直線；.5 可與 .3／.4 平行**（只碰 prompt、catalog 讀法與 profile 表，不碰批次流程）；**.6 最後**。所有裁決已於 2026-09-16 完成，開工不再有前置裁決。

**動套件的棒（.1／.2／.3／.4／.5）一律走 poetry path dependency 開發、feature 完成才發版**（`jedi-package-dev` skill）；path 改動不 commit，.6 統一還原 pin。

---

## 需求背景與端到端流程 {#why nav="背景"}

前作 FR-030 把自動分類做在 Google Drive 上：檔案從 Drive 來、結果寫回 Drive 的 `_state.json`、歸檔只是把檔複製到 Drive 的另一個資料夾，**系統的任務證據表 `job_evidences` 一筆都沒寫**。落地版（FR-063～FR-065）客戶沒有 Drive，這條線等於不存在。同時 FR-069 模組化後儲存層已統一走 `jedi-file-upload` 的 `IUploadFileProvider`，分類線是唯一還直接綁 Drive 的功能。

四個結構性問題（詳見討論稿 §2）：來源與終點都是 Drive；容器自己連 DB、解密 Drive token、下載檔案；框架知識寫死在程式裡（CMMC L1 的 17 條、`[a]` 字母硬剝、套件內 `cmmc_l1_aos.json`）；`upload_files` 沒有租戶／擁有者／RLS，暫存的孤兒檔沒人管。

### 端到端流程（目標）

```
專案經理選輪次 → 開新批次 → 拖入 N 份檔（存進系統儲存後端，upload_files 帶 tenant）
  → 按「開始分類」（批次封口）
  → 套件解析 profile ＋ 動態目標集 → 宿主把整批檔拉到 job 工作目錄 ＋ 寫 prompt.json
  → docker run evidence-classifier（只掛目錄、只帶 AI 金鑰）→ 回結果 JSON
  → 結果存 evidence_classification_runs（掛批次）→ 批次進「審閱」
  → 審閱頁加減檢查點、標不適用、儲存
  → 按「歸檔進任務」→ 每檔每檢查點寫一筆 job_evidences（source=AI_CLASSIFIED，冪等）
  → 沒任務的標「無對應任務」→ 問要不要清掉剩餘檔（實刪）
```

---

## 決策定案表（D1–D12） {#decided nav="定案"}

決策者 2026-09-16 一次裁完。每項含定案理由與被排除方案，之後若推翻，改這裡並在 LOG 記錄。

| # | 決策 | 定案 | 理由 | 被排除方案（與原因） |
|---|------|------|------|---------------------|
| **D1** | 批次檔怎麼存 | **新表 `evidence_batch_files`**；`upload_files` 只補租戶／擁有者／RLS 三個「檔案自己的身分」欄 | 業務欄不塞系統層表；歸檔後仍留線索（這份證據是哪批分出來的）；不用為批次概念動 jedi-file-upload | `upload_files` 加 `batch_id` 欄——被十幾個模組共用的表多一欄大家都看到，且歸檔後 batch_id 要不要清兩難 |
| **D2** | 容器怎麼拿到檔 | **甲案：宿主先把整批檔拉到 job 工作目錄再起容器；容器零網路（除 AI API）、零憑證** | 容器改動最小（只刪掉自己連 Drive／DB 那段）；排查直觀（目錄裡看得到檔）；remote_agent 後端下宿主本來就負責拉檔，容器不必多一跳 | 乙案「容器透過內部 API 拉檔」——要新增 HTTP client、內部端點與一次性 token；「邊拉邊分省磁碟」在百檔級批次沒有實際收益，千檔級需求出現再評估 |
| **D3** | 舊 Drive 分類線 | **並存一版、前端入口隱藏、下一版刪**：舊 route 保留，前端「自動分類」按鈕拿掉 | POC 上有既有 run 資料與 Drive 資料夾樹，一刀切等於那些 run 的審閱頁與 FR-031 報表立刻打不開；並存一版讓報表可讀舊列 | 一刀切——POC 既有資料立刻不可讀 |
| **D4** | job 狀態 DB 化 | **併入 .3、做最小版**：批次表加 `job_started_at`／`job_heartbeat_at`；分類 thread 每 60 秒更新心跳；BE 啟動時把 `classifying` 且心跳逾時 30 分鐘的批次標 `failed`。`JobRegistry` 保留給進度百分比這類暫態 | 批次的 `classifying` 本身就是持久化的 job 狀態，再開一張通用 job 表是在分類線內長通用能力 | 完整 job 表——通用能力，不該在分類線內長；什麼都不做——BE 重啟後前端永遠看到「分類中」 |
| **D5** | 分類結果存哪 | **沿用 `evidence_classification_runs` 改掛批次**：加 `batch_id`／`round_id`／`catalog_snapshot` JSONB；`run_folder_id` 改可空；主鍵定址改用 `uid`，不再用 Drive 資料夾 id；`ap_uid` 保留給舊列 | 審閱頁與 FR-031 兩份報表都讀這張表的 `report_original`／`state` JSONB，沿用只加欄、前端與報表改動最小；快照讓報表不受 SSP 事後改動影響 | 新表——乾淨，但舊 run 與新 run 的報表要走兩條路 |
| **D6** | 未設 STORAGE_CONFIG | **只在批次上傳路徑拒絕（412＋明確訊息「請先到系統設定完成儲存設定」），定位為防呆**。安裝版 `scripts/installer/install.sh:1762 configure_object_storage()` 裝機即把 root STORAGE_CONFIG 指向內建 SeaweedFS（FR-065.5），**落地版正常不會走到未設定分支**；.1 驗收「清掉設定再上傳看到錯誤」只在 DEV 做 | 暫存區的檔要活過重啟才有意義，這條路徑先擋是必要的；全域改拒絕會影響所有上傳點，需另案盤點 | 動套件全域 `/tmp` fallback——影響面不明；記 follow-up：套件全域 fallback 改成啟動時警告 |
| **D7** | 清掉批次剩餘檔 | **實刪實體檔**（走 `IUploadFileProvider.delete_file`）＋ `evidence_batch_files.status=removed` 留紀錄列（檔名／雜湊／AI 判定結果） | 這些檔沒掛任何任務、使用者已明確說要清，留實體只是佔儲存；紀錄列可回答「這批上傳過哪些檔、AI 怎麼判」 | 軟刪實體——佔儲存、無人再讀 |
| **D8** | 批次權限 | **manager 建／上傳／分類／歸檔／清；auditor 只看**（批次列表、審閱頁唯讀） | 與現況 `IProjectRoleGuard.is_project_manager` 一致；守門在 app service 層 | 開放 auditor 歸檔——證據掛進任務是專案經理的決定 |
| **D9** | 與 Drive 同步檔重複 | **不做跨來源去重**；審閱頁以 `content_hash` 提示「與任務 X 既有證據內容相同」，不阻擋 | 批次裡的檔是新上傳的實體，與 Drive 同步那份本來就是兩份；做去重會把兩條線耦合起來，正是本案要拆開的 | 阻擋重複——耦合兩線 |
| **D10** | `upload_files` 回填租戶 | **孤兒列一律歸 ROOT（tenant_id=1）並在 migration 輸出回報筆數** | 回填不到的列沒有任何表掛它，歸 ROOT 讓系統管理員仍看得到、可事後清理 | 刪孤兒——migration 不做破壞性刪除（每版必須可直接升級不影響舊資料） |
| **D11** | 新功能落在哪 | **全部進既有套件 `jedi-evidence-classification`，不開新套件；主專案只留宿主 adapter ＋ 前端**。落點表見下 | 分類引擎的批次、狀態機、歸檔演算法是引擎自己的知識，開第二支套件等於兩處各長一半；主專案只該知道「怎麼拿檔、怎麼找任務」 | 開新套件 `jedi-evidence-batch`——兩支套件互相依賴違反「插件互不相依」契約；主專案做——重蹈 FR-069 之前「一半在主專案」的病 |

| **D12** | 分類要不要支援其他 AI 供應商 | **要，動態算**：金鑰存 `system_configs` 新 group `AI_PROVIDER_CONFIG`（租戶級，比照 `STORAGE_CONFIG`：ROOT 給預設、新租戶從 ROOT 複製、值加密不回顯明文），每家一格（`anthropic.api_key`／`openai.api_key`／預留 `azure_openai`＋endpoint／`ollama`＋base_url 無金鑰）；環境變數 `ANTHROPIC_API_KEY`／`OPENAI_API_KEY`（`.env.sample:140-141` 已存在）保留為後備，設定表沒填就退回讀 env。可選模型＝「有填金鑰（或 env）的供應商 × 該供應商的型號碼表」，碼表放套件一支 `llm_models.py`。容器抽 `LlmClient` 介面，兩實作 `AnthropicClient`／`OpenAIClient`（相容 API 的 Azure／Ollama 都走 OpenAI 實作），image 同時裝兩家 SDK，宿主起容器時只塞該供應商那把金鑰。碼表 `supports_vision=False` 的型號對圖片證據明確標「此模型不支援圖片，未分類」，不靜默跳過。詳見 §6.11 | 落地版客戶環境差異大，有些只有 OpenAI 額度、有些走內部 Azure OpenAI 代理；金鑰放系統設定才能讓客戶自己在畫面上切換與覆寫，且與現有 `STORAGE_CONFIG` 租戶級管理模式一致；型號碼表獨立於程式邏輯，之後加新模型只改碼表不改程式；容器不寫死供應商，換一顆 image 不必重 build | 只放環境變數、畫面唯讀——落地版客戶不會自己改伺服器上的 `.env`，改了也要重啟才生效，客戶端幾乎不可能自助切換供應商 |
| **D13** | 原廠 AI 金鑰放哪、三個 AI 功能怎麼統一 | 原廠給客戶機一組 AI 金鑰，存 **ROOT 租戶（tenant_id=1）的 `AI_PROVIDER_CONFIG/CONFIG`**，值走 Fernet 加密落 DB（沿用 `infra/cloud_integration/crypto/fernet_crypto.py` 實作，另開獨立環境變數存加密鑰，不與 `DRIVE_TOKEN_ENCRYPTION_KEY` 共用），**不寫 `guidant.env`**。AI 小幫手（`core/plugins/ai_bot.py`）、AI Dashboard（`di_containers/ai_dashboard/ai_dashboard_containers.py`）、證據分類三功能統一金鑰解析順序：**租戶自己有設定 → 用租戶的；沒有 → fallback ROOT（原廠）；ROOT 也沒有 → 環境變數後備**（開發機用）。三功能共用同一支解析器（從 `SystemConfigAiProviderAdapter` 抽出）。**第一版不開放客戶自填**——金鑰設定頁鎖 ROOT 平台管理員（T-5.4 已改），未來開放只改守門旗標。`install.sh` 裝機時把原廠鑰寫進 ROOT（來源 `install.conf`，比照 S3 憑證處理，值不入版控）；`--upgrade` 不覆寫既有值 | 落地版客戶機是原廠人員裝機，開箱即用 AI 功能不必客戶自己申請金鑰；DB 加密欄位比 `.env` 明文檔案風險低（防不小心操作與資料庫外洩，**不防客戶機 root 有心人**——解密鑰與密文同機，這是落地版天花板）；三功能各自一套讀法會讓以後換鑰或開放自填要改三處還可能漏改，統一成一份省掉這個風險 | 明文寫 DB——同樣風險等級但少一層防護，沒有理由不加密；加密鑰與密文同處——防護等於沒加；做「原廠代理」把金鑰整個藏在原廠伺服器後面——防護等級更高（防客戶 root），但屬獨立需求，另立案不塞進本案 |

### D11 落點表

| 新增 | 落點 |
|---|---|
| 批次表／批次檔表／狀態機／建批次、上傳到批次、列批次、刪檔、觸發、歸檔、清理 API | **套件 jedi-evidence-classification** |
| `IEvidenceStorage` port 定義、`ITaskEvidenceSink` port 定義、`IClassificationContext` port 定義 | 套件 jedi-evidence-classification |
| 三支 port 的宿主實作（接 jedi-file-upload 的 `IUploadFileProvider`；接 `get_jobs_by_round_id`＋寫 `job_evidences`；接 living SSP／framework_version 解析） | 主專案 `core/plugins/evidence_classification.py` ＋ `infra/evidence_classification/` 對應 adapter |
| `upload_files` 補 `tenant_id`／`owner_user_id`／RLS | **套件 jedi-file-upload**（它的表、它的 model）；migration SQL 落主專案 `scripts/sql/packages/jedi_file_upload/` |
| `classification_profiles` 表＋CRUD | 套件 jedi-evidence-classification |
| 容器 image＋prompt 組裝 | 隨套件走——`scripts/evidence/classify/docker/` 搬到套件 repo（版本與套件連動） |
| `job_evidences` 新 source 值＋`classification_run_id`＋冪等 UNIQUE | 主專案（`infra/flow_engine/models/job_evidence.py` 是主專案的表） |
| catalog 輸出補 `part_id`／`group_id`／`group_title` | 主專案 `app/oscal/service/ssp_control_implementation_service.py:237 build_classifier_catalog_by_ssp_id` |
| 「AI 分類設定」分頁、上傳到批次頁、審閱頁改資料來源、歸檔對話框 | FE |

---

## 現況接入點盤點 {#inventory nav="接入點"}

以下座標 2026-09-16 逐一開檔核對。套件路徑 `~/Projects/Jedicogy/module/jedi-python-package/`（下稱 `PK/`）。

| 元件 | 現況（座標） | 本案動作 |
|------|-------------|---------|
| **套件 port** | `PK/jedi-evidence-classification/.../domain/ports.py`：`IProjectDirectory`（:45）／`IProjectRoleGuard`（:56，只有 `is_project_manager`／`is_any_project_manager`）／`IEvidenceSource`（:73，只回 Drive 資料夾 id）／`IControlCatalog`（:88）／`IDocumentConverter`（:98） | 新增 `IEvidenceStorage`、`ITaskEvidenceSink`、`IClassificationContext`；`IProjectRoleGuard` 加 `is_project_participant`（D8 auditor 只看）；`IEvidenceSource` 標 deprecated（.6 隨舊線退場） |
| **套件 Drive 存取** | `PK/.../infra/evidence_drive_ops.py` `EvidenceDriveOps` 直接呼叫 `googleapiclient`；DI `di_containers/evidence_classification/evidence_classification_containers.py:61 drive_ops` Singleton | 標 deprecated、新線不用；舊 route 仍注入 |
| **套件 service** | `PK/.../app/service/evidence_classification_service.py:144 trigger_classify(project_uid, ap_uid, framework_id, confidence_threshold, current_user_id, tenant_id, evidence_folder_id_override, model, archive_files)`；`:733 put_state(run_folder_id, ...)`；`:875 archive_run(run_folder_id, ...)` 只複製到 Drive | 新增 `EvidenceBatchService`（批次狀態機）；`trigger_classify` 新增 batch 版本；`put_state`／`archive` 改以 run `uid` 定址；舊簽名保留給舊 route |
| **套件 job 狀態** | `PK/.../app/service/job_registry.py:20 JobRegistry` class 級記憶體 dict，BE 重啟即失 | 保留給進度暫態；持久化狀態進批次表（D4） |
| **套件容器 runner** | `PK/.../infra/classifier_container_runner.py:58 ClassifierContainerRunner`；`:119` `docker run --rm -v {job_dir}:/job ... --evidence-folder-id ... --catalog-file /job/catalog.json --output-dir /job`；`:206` 回讀 `/job/_report-original.json`；`jobs_base_dir` 預設 `~/.cm-jobs`（:63） | 沿用掛目錄機制；新增「起容器前把批次檔 stage 進 `/job/files/`＋寫 `/job/prompt.json`」；拿掉 `--evidence-folder-id`；`container_env` 只剩 AI 金鑰 |
| **套件 run 表** | `PK/.../infra/model/classification_run_model.py`：`run_folder_id` NOT NULL 自然鍵（:16）、`ap_uid`（:21）、**無 `round_id`**、`framework_id` 預設 `cmmc-l1`（:23）、`report_original`／`state` JSONB（:38-39） | D5：加 `batch_id`／`round_id`／`catalog_snapshot`；`run_folder_id` 改可空；定址改 `uid` |
| **套件正解表** | `PK/.../infra/model/classification_ground_truth_model.py`：`tenant_id`／`framework_id`／`mapping` JSONB | 不動；報表只認它 |
| **套件靜態 JSON** | `PK/.../resources/cmmc_l1_aos.json`、`cmmc_l1_canon.json`；`catalog_builder.py:15 build_catalog` 只認 `cmmc-l1`（:17）；`report/report_common.py:60 load_catalog`、`:193 load_canon` | .5 退場：`load_catalog` 改讀 run 的 `catalog_snapshot`；`load_canon` 改讀正解表；兩支 JSON 刪除 |
| **套件 route** | `PK/.../api/routing.py`：`:42 /project/<project_uid>/ap/<ap_uid>/classify-evidence`、`:56 /classification-run/<run_folder_id>/state`、`:60 .../archive`、`:64 .../file/<file_drive_id>/preview`、`:70 :74` 兩份報表、`:84 /classification-ground-truth` | 新增批次系列 route（見 §6.6）；`/classification-run/<run_uid>/...` 新定址；舊 `<run_folder_id>` route 保留（D3） |
| **主專案宿主 adapter** | `core/plugins/evidence_classification.py`：`:97 DriveEvidenceSourceAdapter`、`:127 LivingSspControlCatalogAdapter`（走 `build_classifier_catalog_by_ssp_id`）、`:170 build_adapters()`、`:204 _CONTAINER_ENV_KEYS`（九個：DB 四個、Drive 四個、`ANTHROPIC_API_KEY`） | 新增 `UploadProviderEvidenceStorageAdapter`、`JobEvidenceTaskSinkAdapter`、`OscalClassificationContextAdapter`；`_CONTAINER_ENV_KEYS` 縮到只剩 `ANTHROPIC_API_KEY`；`build_adapters()` 多三個參數；DI container 同步注入（檔頭「五支 adapter 同時被 DI container 注入」規則） |
| **儲存介面** | `PK/jedi-file-upload/.../domain/ports.py:54 IUploadFileProvider`：`get_file`（:59）／`save_file`（:63）／`delete_file`（:74）／`delete_files_by_uids`（:87）／`convert_to_pdf`（:100）；**無列目錄、無搬移** | 介面**不加方法**；列目錄與搬移在批次檔表這層做 |
| **`upload_files` 表** | `PK/jedi-file-upload/.../infra/models/upload_file.py:16`：`uid`／`file_name`／`storage_type`／`storage_scope`／`ref_id`／`checksum`／`sha256`；**無 `tenant_id`、無 owner、無 RLS**；`id` 是 int 主鍵 | .1：套件 model 加 `tenant_id`／`owner_user_id`；主專案 migration 加欄＋回填＋RLS |
| **背景 job 租戶脈絡** | `app/upload_file/service/managed_file_upload_service.py:324 upload_files_for_tenant()`（canonical，memory `feedback_background_job_storage_config_no_context_trap`） | 分類 thread 拉檔一律帶 `tenant_id` 走此路徑對應的 provider 解析 |
| **任務證據表** | `infra/flow_engine/models/job_evidence.py:13 job_evidences`：`file_id` int FK→`upload_files.id`（:40）；`source` 註解只有 `SYSTEM_UPLOAD / DRIVE_SYNC`（:55）；`drive_file_id` UNIQUE（:59）；`is_deleted`（:76）／`deleted_at`（:79） | .4：`source` 新值 `AI_CLASSIFIED`；新欄 `classification_run_id`；partial UNIQUE `(job_execution_id, file_id) WHERE is_deleted = false` |
| **寫證據範例** | `app/cloud_integration/service/handlers/import_drive_file_handler.py:210-256`：`JobEvidenceEntity(source="DRIVE_SYNC", ...)`＋domain add＋冪等去重 | `JobEvidenceTaskSinkAdapter` 照此 pattern |
| **AO→任務反查** | `infra/readmodel/oscal/ssp_control_implementation_query.py:74 get_jobs_by_round_id(round_id, locale)` 回 control_id／ao_part_id／job_uid；任務側 key 是 `ao_part_id`＝catalog `part_id`（形如 `AC.L1-3.1.1_obj.2`） | `ITaskEvidenceSink.resolve_jobs` 對接它 |
| **目標集** | `app/oscal/service/ssp_control_implementation_service.py:237 build_classifier_catalog_by_ssp_id(ssp_id)` 已動態，輸出 AO 用字母 | .5：輸出多帶 `part_id`／`group_id`／`group_title` |
| **容器 entrypoint** | `scripts/evidence/classify/docker/container_entrypoint.py`：`:123 DB_HOST`、`:160 DRIVE_TOKEN_ENCRYPTION_KEY`、`:504 build_system_block()` 寫死「CMMC 2.0 Level 1 expert」、`:109` `ao_id = f"{ctrl['id']}[{ao['letter']}]"`、`:742 ANTHROPIC_API_KEY` | .2：刪 DB／Drive 段、改讀 `/job/files/`；.5：`build_system_block` 改讀 `/job/prompt.json`、`ao_id` 改 `part_id`；整個目錄搬進套件 repo |
| **容器 image 出貨** | `scripts/build/` 與 `scripts/installer/` **grep 不到 `classifier`**——現行安裝包**沒有**帶分類容器 image | .2 把 image build 併進套件 repo；.6 驗安裝包帶 `evidence-classifier` image（見 §11 需裁事項） |
| **前端** | 入口 `src/views/project/ProjectAuditorOverview.vue` → `src/components/grc/project/AIEvidenceClassificationDialog.vue`；審閱頁 `src/views/evidence-classification/EvidenceClassificationReview.vue`＋`useEvidenceClassification.js`；`aoLookup.js:15` **硬組 `${ctrl.id}[${ao.letter}]` 當 key**；router `index.js:919` 以 `:runFolderId` 定址；`api.js:495-504` 十支常數 | .1 新上傳頁；.3 審閱頁改吃 run uid ＋ `aoLookup` 改以 `part_id` 為 key；.4 歸檔對話框；.5 「AI 分類設定」分頁（`ComplianceFrameworkVersionManage.vue`）；.6 拿掉 Overview 的入口 |
| **RLS policy 範本** | `scripts/sql/packages/jedi_asset/002-asset-rls-grants.sql:49-75`：`DO $$ CREATE POLICY ... USING (is_super_admin OR public.app_tenant_allowed_for_session(tenant_id))` 冪等寫法 | 三張新表＋`upload_files` 照此 pattern |
| **守門** | `common/authz/project.py:16 assert_project_manager`、`:101 assert_project_participant` | 套件內守門走 `IProjectRoleGuard`（宿主 adapter 委派 `common.authz`），不在套件裡另寫一套 |

---

## 詳細設計 {#design nav="設計"}

### 6.1 資料模型

::: {.callout .crit}
**🔴 硬約束：`upload_files` 必須補租戶／擁有者／RLS，才准當暫存區用**

現況 `upload_files` 無 tenant_id、無 owner、無 RLS，任何人拿到 uid 就能 `get_file`。以前這張表靠掛它的表（如 `job_evidences`，有 RLS）「間接」保護；暫存區的檔**還沒掛任何東西**，這層保護不存在。.1 棒第一張卡就是補這個，不是加分項，是准不准上線的門檻。
:::

#### 6.1.1 `upload_files` 補三樣（套件 jedi-file-upload 的表）

| 欄位／物件 | 型別 | 說明 |
|------|------|------|
| `tenant_id` | int NOT NULL | 回填後才加 NOT NULL；回填規則見下 |
| `owner_user_id` | int NULL | 上傳者；既有列留空 |
| RLS | `ENABLE ROW LEVEL SECURITY`＋四條 policy（select／insert／update／delete） | 照 `jedi_asset/002-asset-rls-grants.sql` pattern：`is_super_admin OR public.app_tenant_allowed_for_session(tenant_id)`；**`storage_scope='system'` 的列 select 放行給所有租戶**（框架匯入的共享資產，FR-042 語意不變） |
| 索引 | `(tenant_id, created_at)` | 批次列表與清理都以租戶為前綴 |

回填規則（D10）：migration 先列舉**所有 FK 或邏輯參照 `upload_files.id`／`uid` 的表**（.1 runner 以 `grep -rn "upload_files\|file_id\|file_uid" infra/ PK/*/infra` 實查，至少含 `job_evidences`→`workflow_execution`→專案→租戶），逐表 `UPDATE ... FROM` 回填；`storage_scope='system'` 歸 ROOT；剩下孤兒歸 ROOT（tenant_id=1），migration 末尾 `RAISE NOTICE '孤兒列 N 筆歸 ROOT'` 並在卡片回報筆數。

#### 6.1.2 新表 `compliance.evidence_batches`（批次；套件 jedi-evidence-classification）

| 欄位 | 型別 | 說明 |
|------|------|------|
| `id` | serial PK | |
| `uid` | varchar(36) UNIQUE NOT NULL | 對外定址 |
| `tenant_id`／`org_unit_id` | int NOT NULL | RLS 與租戶隔離（tenant-scoped 表必含 org_unit_id） |
| `round_id` | int NOT NULL | 批次掛稽核輪次（定案 2）。**軟參照、不建 FK**——`project_audit_rounds` 屬另一支插件（jedi-compliance-audit），插件互不相依；存在性由宿主 `IClassificationContext.resolve_round` 驗 |
| `project_id`／`project_uid` | int NOT NULL／varchar(36) | 冗餘，列表與專案角色守門用 |
| `status` | varchar(16) NOT NULL | `uploading／ready／classifying／review／archived／failed`，狀態機見 §6.2 |
| `provider`／`model`／`confidence_threshold` | varchar(32)／varchar(64)／numeric(4,2) | 本批實際用的供應商與模型 id（D12，§6.11）；profile 預設或使用者覆寫 |
| `profile_id` | int NULL | 本批解析到哪個 profile（NULL＝內建通用） |
| `current_run_id` | int NULL | 最新一次 run（重新分類會換） |
| `job_started_at`／`job_heartbeat_at` | timestamptz NULL | D4 心跳；thread 每 60 秒更新；啟動掃描 `status='classifying' AND job_heartbeat_at < now() - interval '30 min'` → `failed`，`failure_reason='heartbeat_timeout'` |
| `failure_reason` | text NULL | 容器失敗／逾時／心跳逾時 |
| `file_count`／`classified_count`／`archived_count`／`no_task_count` | int DEFAULT 0 | 統計，各階段回寫 |
| `archived_at`／`archived_by_user_id`／`purged_at`／`purged_by_user_id` | timestamptz／int | 歸檔與清理紀錄 |
| `created_user`／`updated_user`／`created_at`／`updated_at` | 慣例 | API 回傳 enrich nickname（`common/util/audit_nickname.py`） |

RLS：四條 policy 照範本；`GRANT ... TO cm_app`＋sequence 權限。

#### 6.1.3 新表 `compliance.evidence_batch_files`（批次檔；套件 jedi-evidence-classification）

| 欄位 | 型別 | 說明 |
|------|------|------|
| `id` | serial PK | |
| `batch_id` | int NOT NULL FK→`evidence_batches.id` | 同套件內可建 FK |
| `file_id` | int NOT NULL | →`upload_files.id`（**軟參照**：另一支套件的表）；**內部用 int、對外 API 一律 `file_uid`** |
| `file_uid` | varchar(50) NOT NULL | 冗餘存一份，避免每次 join |
| `original_name` | varchar(255) NOT NULL | 上傳時的檔名 |
| `size` | bigint | |
| `content_hash` | varchar(64) NULL | SHA-256，D9 提示用；從 `upload_files.sha256` 抄 |
| `status` | varchar(16) NOT NULL | `pending／classified／archived／no_task／removed` |
| `classification` | JSONB NULL | 歸檔當下的判定快照 `[{"part_id":..., "confidence":..., "source":"ai|manual"}]`——`removed` 列靠這欄保留 AI 判定結果（D7） |
| `archived_evidence_uids` | JSONB NULL | 歸檔後寫入的 `job_evidences.uid` 清單（追溯） |
| `removed_at`／`removed_by_user_id` | timestamptz／int | D7 清理紀錄 |
| `tenant_id`／`org_unit_id` | int NOT NULL | RLS |
| 慣例四欄 | | |

UNIQUE `(batch_id, file_id)`；RLS 四條 policy。

#### 6.1.4 `evidence_classification_runs` 改動（D5；套件 jedi-evidence-classification）

| 改動 | 說明 |
|------|------|
| `run_folder_id` NOT NULL → NULL | 舊列保留值；新列為 NULL。既有 UNIQUE／自然鍵約束改為 partial（`WHERE run_folder_id IS NOT NULL`） |
| 新增 `batch_id` int NULL FK→`evidence_batches.id` | 新列 NOT NULL（應用層保證）；舊列 NULL |
| 新增 `round_id` int NULL | 軟參照 |
| 新增 `catalog_snapshot` JSONB NULL | run 當時送 AI 的目標集（含 `part_id`／`group_id`／`group_title`／字母）；報表讀這份不重算 |
| 新增 `prompt_snapshot` JSONB NULL | 三段 prompt 實際內容（persona／guidance／hints），除錯與稽核用 |
| 定址 | 新 route 一律 `/classification-run/<run_uid>/...`；`uid` 已在 BaseModel 慣例欄位內 |
| `state` JSONB 內 key | 檔案 key 從 Drive file id 改 `file_uid`；AO key 從 `AC.L1-3.1.1[a]` 改 `part_id`；每筆保留 `ao_letter` 供顯示 |

一個批次可有多次 run（重新分類），`evidence_batches.current_run_id` 指最新。

#### 6.1.5 新表 `compliance.classification_profiles`（AI 分類設定；套件 jedi-evidence-classification）

| 欄位 | 型別 | 說明 |
|------|------|------|
| `id`／`uid` | 慣例 | |
| `tenant_id`／`org_unit_id` | int NOT NULL | 租戶自己的設定；ROOT 那筆是出廠通用設定 |
| `framework_version_id` | int NULL | 軟參照 `oscal.framework_versions.id`（另一支插件的表）；NULL＝通用 |
| `name` | varchar(128) NOT NULL | 顯示名 |
| `persona` | text NOT NULL | AI 角色，例「你是 CMMC 2.0 Level 1 評估專家」 |
| `guidance` | text NULL | 分類指引（怎麼判、什麼算證據） |
| `evidence_hints` | JSONB NULL | 依 `part_id` 或 `group_id` 給的提示，例 `{"AC.L1-3.1.1_obj.2": ["帳號清單", "AD 截圖"]}` |
| `provider_default`／`model_default`／`confidence_threshold_default` | varchar(32)／varchar(64)／numeric(4,2) | 批次沒指定時用的供應商與模型 id（D12） |
| `enable` | bool DEFAULT true | 關掉就退回下一層 |
| 慣例四欄 | | |

放 `compliance` schema 而非 `oscal`：表由本套件擁有，schema 跟著擁有者；與 `framework_versions` 的關聯是軟參照。UNIQUE `(tenant_id, framework_version_id)`（每租戶每框架版本一筆，NULL 視為通用那筆）。

#### 6.1.6 `job_evidences` 改動（主專案）

| 改動 | 說明 |
|------|------|
| `source` 新值 `AI_CLASSIFIED` | 欄位是 varchar(20) 無 CHECK，改 model 註解與 enum 即可 |
| 新欄 `classification_run_id` int NULL | 追溯是哪次分類掛進來的；FR-031 報表據此算「歸檔後正解」 |
| partial UNIQUE `(job_execution_id, file_id) WHERE is_deleted = false` | **冪等鍵**：同一批連按兩次歸檔、或同檔兩個 AO 剛好對到同一任務，都只留一筆。加索引前先查 DEV 有無既存重複列（有就在卡片回報，不自動合併） |

#### 6.1.7 migration 落點與順序

| 棒 | migration 檔 | 動哪些表 |
|----|------------|---------|
| .1 | `scripts/sql/packages/jedi_file_upload/00N-upload-files-tenant-owner-rls.sql` | `upload_files` 三樣＋回填 |
| .1 | `scripts/sql/packages/jedi_evidence_classification/00N-evidence-batches.sql` | `evidence_batches`、`evidence_batch_files`＋RLS＋GRANT |
| .3 | `scripts/sql/packages/jedi_evidence_classification/00N-runs-attach-batch.sql` | runs 表五欄改動 |
| .4 | `scripts/sql/2026-MM-DD-fr107-job-evidences-ai-classified.sql` | `job_evidences` 新欄＋partial UNIQUE |
| .5 | `scripts/sql/packages/jedi_evidence_classification/00N-classification-profiles.sql` | `classification_profiles`＋ROOT 通用 seed |

每支只套 DEV（localhost:5432）；收尾 `INSERT public.schema_migrations`；每棒回報「出貨基線待重產」，.6 一次重產。

### 6.2 批次狀態機

```{.mermaid cap="圖 1 — 批次狀態機（D4 心跳逾時併入 failed）"}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
stateDiagram-v2
    [*] --> uploading : 建批次
    uploading --> uploading : 加檔／刪檔
    uploading --> ready : 至少一檔且按「完成上傳」（或按分類時自動）
    ready --> uploading : 再加檔（分類尚未開始）
    ready --> classifying : 按「開始分類」（封口，加檔 409）
    classifying --> review : 容器回結果
    classifying --> failed : 容器失敗／逾時／心跳逾時 30 分
    failed --> classifying : 重跑（同批次，不必重傳）
    review --> review : 審閱調整、儲存
    review --> classifying : 重新分類（換 model／threshold）
    review --> archived : 歸檔進任務
    archived --> archived : 清掉剩餘檔（purge，只動未掛任務的檔）
    archived --> [*]
```

轉換規則表（套件 `EvidenceBatchService` 內以一張 `ALLOWED_TRANSITIONS` dict 實作，非法轉換一律 `ConflictError`）：

| 動作 | 允許的來源狀態 | 目標狀態 | 額外前置條件 |
|------|--------------|---------|-------------|
| 加檔／刪檔 | `uploading`、`ready`（刪檔另允許 `review`，只能刪 `pending`／`classified` 列） | `uploading` | 非 `classifying`（否則 409 `EC_BATCH_SEALED`） |
| 完成上傳 | `uploading` | `ready` | `file_count ≥ 1` |
| 開始分類 | `ready`、`uploading`（自動先 ready）、`failed`、`review` | `classifying` | STORAGE_CONFIG 已設；目標集非空；同專案無其他 `classifying` 批次（沿用 `JobRegistry` 單活性） |
| 容器回結果 | `classifying` | `review` | |
| 失敗 | `classifying` | `failed` | |
| 歸檔 | `review` | `archived` | 至少一檔有判定（AI 或人工） |
| 清理 | `archived` | `archived` | 只動 `status IN ('pending','classified','no_task')` 的檔 |

### 6.3 port 簽名（套件 `domain/ports.py` 新增）

```python
@dataclass(frozen=True)
class StagedFile:
    file_id: int          # 內部 int（upload_files.id）
    file_uid: str         # 對外 uid
    original_name: str
    local_path: Path      # 已拉到 job 目錄的實體路徑
    content_hash: str | None


class IEvidenceStorage(Protocol):
    """證據檔的取檔／放檔。主專案接 jedi-file-upload 的 IUploadFileProvider。
    套件與容器只認這個；後端是 local／minio／seaweedfs／remote_agent 由宿主決定。"""

    def is_configured(self, tenant_id: int) -> bool: ...
    # D6：未設 STORAGE_CONFIG 回 False，套件在批次上傳路徑拋 412 EC_STORAGE_NOT_CONFIGURED

    def save(self, tenant_id: int, owner_user_id: int, file: FileStorage) -> tuple[int, str, str | None]: ...
    # 存一份檔，回 (file_id, file_uid, sha256)；宿主帶 tenant 脈絡寫 upload_files

    def get_bytes(self, tenant_id: int, file_uid: str) -> bytes: ...
    # 拉一份檔的內容（審閱頁預覽用）

    def stage_to_dir(self, tenant_id: int, file_uids: list[str], work_dir: Path) -> list[StagedFile]: ...
    # 甲案：把整批拉到本機工作目錄；宿主一律走 upload_files_for_tenant 對應的 provider 解析

    def delete(self, tenant_id: int, file_uid: str) -> bool: ...
    # D7 實刪；宿主走 IUploadFileProvider.delete_file


@dataclass(frozen=True)
class AttachResult:
    evidence_uid: str
    existed: bool         # True＝冪等命中既有列，沒有新寫


class ITaskEvidenceSink(Protocol):
    """AO → 任務對照，與把檔掛進任務。主專案接 get_jobs_by_round_id 與 job_evidences。"""

    def resolve_jobs(self, round_id: int) -> dict[str, int]: ...
    # 回 {part_id: job_execution_id}；同一 part_id 只會有一個任務（DEV 792 筆實查成立）

    def attach(self, *, job_execution_id: int, file_id: int, file_uid: str,
               classification_run_id: int, actor_user_id: int,
               content_hash: str | None, description: str | None) -> AttachResult: ...
    # 寫 job_evidences(source="AI_CLASSIFIED")；命中 (job_execution_id, file_id) 既有未刪列時回 existed=True

    def find_same_content(self, round_id: int, content_hash: str) -> list[tuple[int, str]]: ...
    # D9 提示：回 [(job_execution_id, evidence_uid)]，不阻擋


class IClassificationContext(Protocol):
    """解析 profile 要用的框架版本鏈，與輪次存在性。全是宿主（OSCAL／稽核輪次）的疆界。"""

    def resolve_round(self, round_uid: str) -> tuple[int, int] | None: ...
    # 回 (round_id, project_id)；不存在回 None

    def framework_version_chain(self, project_id: int) -> list[int]: ...
    # 依「專案指定 → living SSP → catalog」順序回 framework_version_id 清單（去重、有序）
```

`IProjectRoleGuard` 加一支 `is_project_participant(project_id, user_id) -> bool`（D8 auditor 只看）；宿主 adapter 委派 `common/authz/project.py:101 assert_project_participant`。

### 6.4 容器工作目錄契約（D2 甲案）

宿主起容器前把 `jobs_base_dir/<batch_uid>-<run_uid>/` 準備成：

```
/job/
  files/<file_uid>.<ext>      ← IEvidenceStorage.stage_to_dir 拉下來的實體檔
  manifest.json               ← [{file_uid, original_name, mimetype, size}]
  catalog.json                ← 目標集（含 part_id／group_id／group_title／ao_letter／prose）
  prompt.json                 ← {persona, guidance, hints_by_part_id}  三段（§6.5）
```

容器指令：`docker run --rm --network <只允許 AI API 的 network 或 host 代理> -v <job_dir>:/job -e ANTHROPIC_API_KEY=... evidence-classifier:<tag> service-classify --input-dir /job --output-dir /job --model ... --min-confidence ... --workers ...`。**沒有 `--tenant-id`、沒有 `--evidence-folder-id`、沒有 DB／Drive 環境變數**。

容器回：

```
/job/
  _report-original.json       ← {"files":[{"file_uid","matches":[{"part_id","confidence"}],"primary":part_id|null,"error":null}], "usage":{...}, "estimated_cost_usd":...}
  _container.log
```

宿主讀回 `_report-original.json` → 寫 run（`report_original`、初始 `state`＝report 過門檻的部分、`catalog_snapshot`、`prompt_snapshot`）→ 批次 `review` → **刪 `files/`**（工作目錄不留實體檔，避免第二份真相；log 與 json 留 7 天供排查）。

### 6.5 prompt 三段組裝與 profile 解析順序

宿主（套件 app service，非容器）組 `prompt.json`：

| 段 | 來源 | 缺省 |
|----|------|------|
| ① `persona` | `classification_profiles.persona` | 內建：「You are a compliance assessment expert. Classify each evidence file against the assessment objectives listed below.」（**不含任何框架名**） |
| ② `guidance` | `classification_profiles.guidance` | 內建通用判準三句（證據要能直接證明該檢查點；一檔可對多項；不確定就低信心） |
| ③ 動態目標集 | `catalog.json`（`build_classifier_catalog_by_ssp_id` 輸出）＋ `evidence_hints` 對應到目標集內 `part_id`／`group_id` 的提示併入各項 | 無 hints 就只列目標集 |

容器 `build_system_block()` 改成純讀 `prompt.json` 拼接，**不再有任何字串常數提到 CMMC**；AI 回傳的 key 一律 `part_id`，容器驗 `part_id ∈ catalog`。

profile 解析順序（定案 5）：

```
for fv_id in context.framework_version_chain(project_id):   # 專案指定 → living SSP → catalog
    p = profiles.find(tenant_id, fv_id, enable=True) or profiles.find(ROOT, fv_id, enable=True)
    if p: return p
p = profiles.find(tenant_id, None, enable=True) or profiles.find(ROOT, None, enable=True)   # 通用
return p or BUILTIN_MINIMAL
```

批次觸發時把解析到的 `profile_id` 寫進 `evidence_batches.profile_id`，批次的 `provider`／`model`／`confidence_threshold` 取「使用者覆寫 → profile 預設 → 系統預設」（D12，§6.11）。

### 6.6 API（套件 route，prefix `/api/1.0`）

| 方法 | 路徑 | 守門 | 說明 |
|------|------|------|------|
| POST | `/evidence-batches` | manager | body `{round_uid}` → 建批次（`uploading`）；先 `is_configured` 否則 412 |
| GET | `/evidence-batches?round_uid=&status=` | participant | 列批次（分頁，`RequestMetaSchema`） |
| GET | `/evidence-batches/<batch_uid>` | participant | 批次詳情＋檔案列表（含 D9 提示） |
| POST | `/evidence-batches/<batch_uid>/files` | manager | multipart 單檔或多檔；`classifying` 起 409 |
| DELETE | `/evidence-batches/<batch_uid>/files/<file_uid>` | manager | 刪檔（實刪＋列標 `removed`） |
| POST | `/evidence-batches/<batch_uid>/seal` | manager | `uploading → ready` |
| POST | `/evidence-batches/<batch_uid>/classify` | manager | body `{model?, confidence_threshold?}` → `classifying`；回 `run_uid` |
| POST | `/evidence-batches/<batch_uid>/archive` | manager | 歸檔（§6.7）；回 `{attached_files, evidence_created, evidence_existed, no_task_files}` |
| POST | `/evidence-batches/<batch_uid>/purge` | manager | D7 清理；回刪了幾檔 |
| GET／PUT | `/classification-run/<run_uid>/state` | participant／manager | 審閱頁讀寫（新定址） |
| GET | `/classification-run/<run_uid>/file/<file_uid>/preview` | participant | 走 `IEvidenceStorage.get_bytes`＋`IDocumentConverter` |
| GET | `/classification-run/<run_uid>/report/validation`、`.../adjudication` | participant | 讀 `catalog_snapshot`＋正解表 |
| GET／POST／PUT／DELETE | `/classification-profiles`、`/classification-profiles/<uid>` | 平台管理員（框架版本管理頁同一守門軸） | .5 |

舊 route（`/project/<project_uid>/ap/<ap_uid>/classify-evidence`、`/classification-run/<run_folder_id>/...`）**原樣保留**，只在 docstring 標 legacy（D3）。

error code 新增（套件 `error_code.py`，前綴沿用套件既有 `EC_`）：`EC_STORAGE_NOT_CONFIGURED`（412）、`EC_BATCH_SEALED`（409）、`EC_BATCH_INVALID_TRANSITION`（409）、`EC_BATCH_NOT_FOUND`（404）、`EC_ROUND_NOT_FOUND`（404）、`EC_NO_TARGET_CATALOG`（412）；FE `error-code.json` 同步。

### 6.7 歸檔演算法（.4）

```
輸入：batch（status=review）、run＝batch.current_run、actor
1. jobs = sink.resolve_jobs(batch.round_id)              # {part_id: job_execution_id}
2. for bf in batch_files where status in (pending, classified):
     parts = run.state[bf.file_uid].parts                 # 審閱後最終判定（含人工加減；標「不適用」的已排除）
     if not parts: continue                               # 沒判定的檔留 pending
     hit = [p for p in parts if p in jobs]
     if not hit:
         bf.status = no_task; bf.classification = parts; continue
     for p in hit:                                        # 一檔多 AO → 多筆
         r = sink.attach(job_execution_id=jobs[p], file_id=bf.file_id, file_uid=bf.file_uid,
                         classification_run_id=run.id, actor_user_id=actor,
                         content_hash=bf.content_hash, description=f"AI 分類：{p}")
         created += (not r.existed); existed += r.existed
         bf.archived_evidence_uids.append(r.evidence_uid)
     bf.status = archived; bf.classification = parts
     miss = [p for p in parts if p not in jobs]           # 部分沒任務：檔仍算 archived，缺的記在 classification 內 no_task=true
3. batch.status = archived; 統計回寫（archived_count／no_task_count）；run.archived_at
4. 回 {attached_files, evidence_created, evidence_existed, no_task_files}
```

冪等：整段在一個 `@transaction` 內；重按第二次時每個 `attach` 都 `existed=True`、`created=0`，狀態不變。**不複製實體檔**——`job_evidences.file_id` 指向同一筆 `upload_files`。清理（purge）只刪 `status IN (pending, classified, no_task)` 的檔，`archived` 列永不刪。

### 6.8 背景 thread 的租戶脈絡

分類 thread 沒有 request 脈絡，讀 STORAGE_CONFIG 會挑錯租戶（memory `feedback_background_job_storage_config_no_context_trap`）。規則：`classify` 端點在 request 內先解析好 `tenant_id`、`profile`、`catalog`、provider 設定，**全部以參數傳進 thread**；thread 內 `IEvidenceStorage.stage_to_dir(tenant_id, ...)` 由宿主 adapter 以 `tenant_id` 明確解析 provider（走 `managed_file_upload_service.py:324 upload_files_for_tenant` 同一條解析路徑），不靠 thread-local。心跳每 60 秒 `UPDATE evidence_batches SET job_heartbeat_at = now()`（獨立短 transaction）。

### 6.9 架構圖與時序圖

```{.mermaid cap="圖 2 — 目標架構：套件定 port、主專案接後端、容器只看得到目錄"}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
flowchart LR
  subgraph FE[前端]
    U1[上傳到批次頁]
    U2[審閱頁（沿用、改資料來源）]
    U3[歸檔對話框]
    U4[AI 分類設定分頁]
  end
  subgraph PKG[套件 jedi-evidence-classification]
    R[route：批次／分類／歸檔／profile]
    S[EvidenceBatchService 狀態機 ＋ 分類編排 ＋ 歸檔演算法]
    T[(evidence_batches ／ evidence_batch_files ／ runs ／ classification_profiles)]
    P1[port IEvidenceStorage]
    P2[port IControlCatalog]
    P3[port IClassificationContext]
    P4[port ITaskEvidenceSink]
    P5[port IProjectRoleGuard]
  end
  subgraph HOST[主專案 core/plugins/evidence_classification.py]
    A1[adapter → IUploadFileProvider]
    A2[adapter → build_classifier_catalog_by_ssp_id]
    A3[adapter → living SSP／framework_version／輪次]
    A4[adapter → get_jobs_by_round_id ＋ job_evidences]
    A5[adapter → common.authz project 軸]
    DB[(upload_files ／ job_evidences ／ oscal ／ rounds)]
  end
  subgraph STOR[儲存後端（擇一）]
    L[local]
    M[minio／seaweedfs]
    RA[remote agent]
  end
  C[容器 evidence-classifier：只讀 /job/files ＋ prompt.json ＋ catalog.json，寫 _report-original.json]
  U1 --> R
  U2 --> R
  U3 --> R
  U4 --> R
  R --> S
  S --> T
  S --> P1
  S --> P2
  S --> P3
  S --> P4
  S --> P5
  P1 -.-> A1
  P2 -.-> A2
  P3 -.-> A3
  P4 -.-> A4
  P5 -.-> A5
  A1 --> L
  A1 --> M
  A1 --> RA
  A2 --> DB
  A3 --> DB
  A4 --> DB
  A5 --> DB
  S -->|stage 檔到 job 目錄後 docker run| C
  C -->|結果 JSON| S
```

```{.mermaid cap="圖 3 — 上傳 → 分類 → 確認 → 歸檔端到端時序"}
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
sequenceDiagram
    participant U as 使用者（專案經理）
    participant FE as 前端
    participant PKG as 套件（在 BE 內）
    participant HOST as 主專案 adapter
    participant ST as 儲存後端
    participant C as 容器 evidence-classifier
    participant DB as PostgreSQL

    U->>FE: 選輪次，開新批次，拖入 N 份檔
    FE->>PKG: POST /evidence-batches（round_uid）
    PKG->>HOST: IEvidenceStorage.is_configured(tenant)
    HOST-->>PKG: False → 412 EC_STORAGE_NOT_CONFIGURED（D6）／True → 建批次
    loop 每一份檔
      FE->>PKG: POST /evidence-batches/{uid}/files
      PKG->>HOST: IEvidenceStorage.save(tenant, owner, file)
      HOST->>ST: IUploadFileProvider.save_file()
      HOST->>DB: upload_files（tenant_id／owner_user_id）
      PKG->>DB: evidence_batch_files（pending）
    end
    U->>FE: 按「開始分類」
    FE->>PKG: POST /evidence-batches/{uid}/classify
    PKG->>DB: status → classifying（之後加檔 409）
    PKG->>HOST: IClassificationContext.framework_version_chain(project)
    PKG->>DB: 解析 profile → prompt 三段；IControlCatalog → 目標集
    PKG->>HOST: IEvidenceStorage.stage_to_dir(tenant, file_uids, job_dir)
    HOST->>ST: 逐檔 get_file() 落 /job/files/
    PKG->>C: docker run（掛 /job；env 只有 AI 金鑰）
    C->>C: 讀 prompt.json＋catalog.json，逐檔呼叫 AI
    C-->>PKG: _report-original.json（每檔 → [part_id, confidence]）
    PKG->>DB: runs（batch_id／catalog_snapshot／prompt_snapshot）；status → review；心跳停
    U->>FE: 審閱頁：加減 AO、標不適用、儲存
    FE->>PKG: PUT /classification-run/{run_uid}/state
    U->>FE: 按「歸檔進任務」
    FE->>PKG: POST /evidence-batches/{uid}/archive
    PKG->>HOST: ITaskEvidenceSink.resolve_jobs(round_id)
    HOST->>DB: get_jobs_by_round_id → {part_id: job_execution_id}
    loop 每檔每命中 AO
      PKG->>HOST: ITaskEvidenceSink.attach(...)
      HOST->>DB: job_evidences(source=AI_CLASSIFIED) 冪等
    end
    PKG->>DB: 沒任務的標 no_task；status → archived
    PKG-->>FE: {attached_files, evidence_created, evidence_existed, no_task_files}
    FE->>U: 問「剩餘 Z 檔要清掉嗎？」
    U->>FE: 清掉
    FE->>PKG: POST /evidence-batches/{uid}/purge
    PKG->>HOST: IEvidenceStorage.delete(tenant, file_uid)
    HOST->>ST: delete_file()（實刪，D7）
    PKG->>DB: evidence_batch_files.status → removed
```

### 6.10 框架去硬編碼——實作面要動的點（.5）

| 硬編碼處 | 現況 | 改法 |
|---------|------|------|
| 目標集輸出 | `build_classifier_catalog_by_ssp_id` 已動態，AO 用 `[a]` 字母 | 每個 AO 多帶 `part_id`（`AC.L1-3.1.1_obj.2`）、每個控制項多帶 `group_id`／`group_title`；字母保留為 `ao_letter` 顯示用 |
| AO 代號解析 | 容器 `container_entrypoint.py:109` 組 `ctrl.id[letter]`；套件 `put_state`／`archive_run` 內巢狀 `ensure_ao_folder` 硬解析 | 全線以 `part_id` 為 key；Drive 資料夾命名那段隨舊線留在舊 route |
| 領域 | `control_id.split('.')[0]` | 用 catalog `group_id`／`group_title` |
| 事後讀 catalog | `catalog_builder.py:15` 只認 `cmmc-l1`；`report_common.py:60 load_catalog` 讀套件 JSON | 讀 run 的 `catalog_snapshot`；`catalog_builder.build_catalog` 刪除 |
| 正解 | `report_common.py:193 load_canon` 讀 `cmmc_l1_canon.json` | 只認 `evidence_classification_ground_truth` 表；`resources/` 兩支 JSON 刪除 |
| prompt | `container_entrypoint.py:504 build_system_block()` 寫死 | 讀 `/job/prompt.json` 三段拼接（§6.5） |
| 前端 key | `aoLookup.js:15` 組 `${ctrl.id}[${ao.letter}]` | 以 `part_id` 為 key，`ao_letter` 顯示 |
| 容器 image 名 | `cmmc-classifier:latest`（套件 `plugin/contract.py:50 classifier_image` 預設） | `evidence-classifier:<套件版號>`；預設值改套件 contract |

### 6.11 多供應商 AI（D12）

決策者 2026-09-16 追加：分類要能用 OpenAI 等其他供應商的模型，看系統填了哪幾家的 API 金鑰決定可選哪家、哪個模型。

**金鑰存系統設定**：`system_configs` 新 group `AI_PROVIDER_CONFIG`（租戶級，比照 `STORAGE_CONFIG` 的形狀——ROOT 給預設、新租戶從 ROOT 複製、值加密不回顯明文）：

```
AI_PROVIDER_CONFIG/CONFIG = {
  "anthropic": {"api_key": "..."},
  "openai":     {"api_key": "..."},
  "azure_openai": {"api_key": "...", "endpoint": "..."},   # 預留，本案不接
  "ollama":     {"base_url": "..."}                        # 預留，無金鑰
}
```

環境變數 `ANTHROPIC_API_KEY`／`OPENAI_API_KEY`（`.env.sample:140-141` 已存在）保留為後備：`AI_PROVIDER_CONFIG` 沒填該供應商的金鑰就退回讀對應環境變數。兩者都沒有＝該供應商不可選。

**可用模型動態算**：「有填金鑰（或環境變數有值）的供應商」× 「該供應商的型號碼表」。碼表放套件 `jedi-evidence-classification` 一支 `llm_models.py`：

```python
@dataclass(frozen=True)
class LlmModelSpec:
    provider: str        # "anthropic" | "openai" | ...
    model_id: str         # API 呼叫用的實際字串
    display_name: str     # 畫面顯示
    supports_vision: bool
    default: bool          # 該供應商的預設選項


LLM_MODELS: list[LlmModelSpec] = [
    LlmModelSpec("anthropic", "claude-sonnet-4-6", "Claude Sonnet 4.6", True, default=True),
    LlmModelSpec("anthropic", "claude-opus-4-8",   "Claude Opus 4.8",   True, default=False),
    LlmModelSpec("openai",    "gpt-5",              "GPT-5",             True, default=True),
    ...
]
```

加型號只改這支碼表，不改程式邏輯。現有 `evidence_classification_service.py:81` 的 `ALLOWED_MODELS` 白名單退場，改由碼表 + 可用供應商聯集動態算。

**容器抽 `LlmClient` 介面**（套件 `domain/ports.py` 或容器模組內）：

```python
class LlmClient(Protocol):
    def classify(self, system_block: str, user_parts: list[dict]) -> dict: ...
    # 回傳格式與現有 classify_with_claude() 一致：(parsed_result, usage) 或含 usage 的 dict
```

兩個實作：`AnthropicClient`（現有 `container_entrypoint.py:635` 起 `classify_with_claude()` 那段搬進來，改名不改邏輯）、`OpenAIClient`（走 `chat.completions`，相容 API 的 Azure OpenAI／Ollama 都走它，endpoint 可配）。容器 image 同時裝兩家 SDK；**宿主起容器時只塞「這一批實際要用的那個供應商」的金鑰環境變數**，不兩家都塞（§6.4 容器工作目錄契約的「沒有 DB／Drive 環境變數」延伸——現在也只有一家 AI 金鑰進容器）。

**視覺支援**：碼表 `supports_vision=False` 的型號，遇到圖片證據要**明確標記**「此模型不支援圖片，未分類」寫進結果，不可靜默跳過（否則審閱頁看到的是「沒判定」，使用者無法區分是真的判不出來還是模型天生看不了圖）。

**前端**：「AI 分類設定」分頁（T-5.4 所在頁）多一區「供應商金鑰」——只顯示每家「已設定」／「未設定」，可覆寫，不回顯明文。分類觸發對話框與 profile 編輯頁的模型下拉改打新端點：

```
GET /api/1.0/classification/available-models
→ [{"provider": "anthropic", "model_id": "...", "display_name": "...", "supports_vision": true}, ...]
```

只回傳「該租戶目前可用」的模型（金鑰已設定的供應商 × 碼表）。

**驗收**：切到 OpenAI 跑一次同一批證據，validation／adjudication 兩份評測報表要能正常產出，且與 Claude 那次跑出來的報表可比對（欄位形狀一致，不要求判定結果相同）。

### 6.12 金鑰解析三層與第一版不開放租戶自填（D13）

D12 定義了 `AI_PROVIDER_CONFIG` 這張設定表的形狀；D13 把它的**寫入來源**與**誰能填**定案。

**金鑰解析三層**（AI 小幫手／AI Dashboard／證據分類三功能共用同一支解析器）：

```
resolve(tenant_id, provider):
    租戶自己的 AI_PROVIDER_CONFIG 有填 provider 的金鑰？ → 用它
    否則 ROOT（tenant_id=1）的 AI_PROVIDER_CONFIG 有填？ → 用它（原廠鑰）
    否則環境變數（ANTHROPIC_API_KEY／OPENAI_API_KEY）有值？ → 用它（開發機退路）
    都沒有 → None（該供應商不可用）
```

三個既有讀法（AI 小幫手 `os.getenv("ANTHROPIC_API_KEY")`、AI Dashboard `_ai_api_keys()` 直讀三個環境變數、證據分類 `SystemConfigAiProviderAdapter.configured_providers()` 只回 bool 未回實際金鑰）改統一走這支解析器，行為對現有兩功能必須零改變——沒設定 ROOT 金鑰時退回 env，與改動前一致。

**值的加密**：`AI_PROVIDER_CONFIG` 的金鑰欄位落 DB 前用 Fernet 加密（沿用 `infra/cloud_integration/crypto/fernet_crypto.py` 既有實作類別），加密鑰走獨立環境變數，**不與 `DRIVE_TOKEN_ENCRYPTION_KEY` 共用**——不同用途共用一把鑰匙，其中一邊外洩會牽連另一邊。

**第一版不開放客戶自填**：租戶層級的 AI 供應商金鑰設定 UI 不開放——T-5.4 那頁的「供應商金鑰」區塊鎖 ROOT 平台管理員可見可寫，租戶管理員看不到這個入口（與 profile 內容編輯不同軸，profile 是租戶管理員可編，見「寫作時發現的事」第 4 點 2026-09-17 修正）。全部客戶第一版共用原廠那把 ROOT 金鑰。未來要開放客戶自填，只改一個守門旗標即可，不必動解析邏輯本身。

**風險天花板**：這個機制的安全等級與現有 Drive OAuth token 加密同級——防的是不小心的操作與資料庫外洩，**不防客戶機上有 root 權限的有心人**（因為解密鑰與密文存在同一台機器的 `guidant.env` 與 DB）。要防到那個等級需要「原廠代理」（金鑰完全不落地客戶機，改由原廠伺服器代打 AI API），那是獨立需求，不在本案範圍。

**install.sh 裝機寫入**：原廠鑰的來源是 `install.conf`（裝機人員填寫，比照現有 S3／物件儲存憑證段的處理方式），裝機時寫入 ROOT 租戶的 `AI_PROVIDER_CONFIG`（走加密）；`install.conf.example` 只留欄位名與說明，值留空不入版控。`--upgrade` 模式**不覆寫**已存在的 ROOT 設定——客戶機上的值可能已經被後續操作改過，升級不該蓋回去。

---

## 拆分表（六棒 25 張子任務卡） {#split nav="拆分"}

粒度原則：每張卡單一 session 做得完；動套件的卡標 **[套件]**，開發走 poetry path dependency、**完成不還原 pin**（.6 T-6.2 統一還原）。驗收條件全部是 runner 與決策者都能親手做的動作。

### FR-107.1 暫存批次＋安全地基 — 依賴：無（子需求卡 [CM-1844](https://app.notion.com/p/FR-107-1-upload_files-RLS-API-4-3dd346da4cd081e9b6dbe8d5b670c6a2)）

| # | 子任務 | 範圍（動哪些檔／表／套件） | 驗收條件 | 依賴 |
|---|--------|--------------------------|---------|------|
| T-1.1<br>[CM-1850](https://app.notion.com/p/FR-107-1-T-1-1-RLS-jedi-file-upload-3dd346da4cd08182ac33dc609ea72f7f) <br>**✅ 驗收通過 2026-09-16（套件 `651e33c`／BE `a48b41cc`）**| **[套件 jedi-file-upload]** `upload_files` 補 `tenant_id`／`owner_user_id`／RLS＋回填 | 套件 `infra/models/upload_file.py` 加兩欄；主專案 migration `scripts/sql/packages/jedi_file_upload/00N-*.sql`（加欄→回填→NOT NULL→四條 policy→索引）；`save_file` 呼叫鏈補寫 `tenant_id`／`owner_user_id`（grep 全部寫入者：`managed_file_upload_service.py`、Drive 匯入、框架匯入、detection 回收） | DEV 套完 `SELECT count(*) FROM upload_files WHERE tenant_id IS NULL` = 0；migration 輸出孤兒歸 ROOT 筆數並寫進卡片；以 `cm_app` 帳號 `SET app.*` 模擬租戶 B 查不到租戶 A 的列；既有上傳點（任務證據上傳、框架匯入）各手測一次仍正常；`storage_scope='system'` 列跨租戶仍讀得到 | — |
| T-1.2<br>[CM-1851](https://app.notion.com/p/FR-107-1-T-1-2-entity-repo-jedi-evidence-classification-3dd346da4cd0816ebb6af1f980c7c2d8) <br>**✅ 驗收通過 2026-09-16（套件 `a6475b4`／BE `c98c119d`／FE `f2f0339`）**| **[套件 jedi-evidence-classification]** 批次兩張表＋entity／repo／狀態機骨架 | 套件 `infra/model/evidence_batch_model.py`、`evidence_batch_file_model.py`、domain entity／query entity／repo interface＋impl（繼承 `BaseRepositoryImpl`）、`app/service/evidence_batch_service.py`（`ALLOWED_TRANSITIONS`，先做 `uploading`／`ready`）；主專案 migration `scripts/sql/packages/jedi_evidence_classification/00N-evidence-batches.sql`（表＋RLS＋GRANT＋sequence）；DI container 註冊 | DEV `\d compliance.evidence_batches` 欄位與 §6.1.2 一致；`pg_policies` 各四條；套件單元測試：非法轉換拋 `ConflictError`（突變測試：把 dict 改壞要紅） | — |
| T-1.3<br>[CM-1852](https://app.notion.com/p/FR-107-1-T-1-3-API-3dd346da4cd0816ebfa7ce1059b64833) <br>**✅ 驗收通過 2026-09-16（套件 `7e066d6`／BE `276e1369`／FE `6d0ccf6`）**| **[套件 jedi-evidence-classification]** 批次 API 五支＋`IEvidenceStorage` port 定義＋宿主存檔 adapter＋D6 防呆＋D8 守門 | 套件 `domain/ports.py` 加 `IEvidenceStorage`（§6.3）與 `IProjectRoleGuard.is_project_participant`；route：建批次／列批次／批次詳情／上傳檔／刪檔／seal；error code 四支；主專案 `core/plugins/evidence_classification.py` 加 `UploadProviderEvidenceStorageAdapter`（`is_configured`／`save`／`get_bytes`／`delete`；`stage_to_dir` 留 .2）＋`ExactProjectManagerGuard.is_project_participant`；`build_adapters()`＋DI container 同步 | 兩個租戶帳號各建批次上傳，A 打 B 的批次 `GET`／`POST files` 拿 403；auditor 帳號 `GET` 200、`POST files` 403；DEV 清掉 tenant 的 STORAGE_CONFIG 再建批次拿 412 且訊息說得出去哪設定；`upload_files` 新列 `tenant_id`／`owner_user_id` 都有值；刪檔後儲存後端實體不見、批次檔列 `removed` | T-1.1、T-1.2 |
| T-1.4<br>[CM-1853](https://app.notion.com/p/FR-107-1-T-1-4-3dd346da4cd0811d9327c6cd0b15cccb) <br>**✅ 驗收通過（依 T-1.4m mockup 重刻，FE `3dc7e93` 起）**| **[FE]** 上傳到批次頁（舊版被退回；重做規格見 T-1.4m） | ~~新頁 `EvidenceBatchUpload.vue`（選輪次→建批次→拖拉多檔→列表→刪檔→完成上傳）~~ ← 決策者裁定整頁重新設計：流程引導＋歷史清單頁＋新建與上傳分開＋更名「自動分類證據」＋檔案可預覽＋大量檔案分頁 | ~~原驗收條件~~（作廢，新驗收條件待 T-1.4m mockup 過目後隨重派卡片重寫） | T-1.3 |
| T-1.4m<br>[CM-1878](https://app.notion.com/p/FR-107-1-T-1-4m-HTML-mockup-T-1-4-3de346da4cd081ef872bd5ca8e676799) <br>**✅ 驗收通過（BE `b86fd129`／`393582ec`）**| **[FE 設計稿]** 自動分類證據兩頁 HTML mockup（歷史清單頁＋四步流程頁） | `docs/features/FR-107-2609-evidence-classification-v2/mockup/auto-classify-list.html`、`auto-classify-batch.html`（自含 CSS 靜態頁，非 Vue 實作）；頁 A 批次歷史清單、頁 B 四步流程（上傳→AI 分類中→確認分類→歸檔完成），第③步僅佔位（重用既有審閱頁屬 T-3.4） | 決策者打開兩個 HTML 檔看過並表態；首腦核 design tokens／PrimeVue Steps quirks／icon tooltip | 無 |

### FR-107.2 儲存 port＋容器改取檔 — 依賴：FR-107.1（子需求卡 [CM-1845](https://app.notion.com/p/FR-107-2-port-Drive-3-3dd346da4cd0810f8a56e2e4ccf686fa)）

| # | 子任務 | 範圍 | 驗收條件 | 依賴 |
|---|--------|------|---------|------|
| T-2.1<br>[CM-1854](https://app.notion.com/p/FR-107-2-T-2-1-Drive-3dd346da4cd081beb4a4f0d7db11377b) <br>**✅ 驗收通過（套件 `8261475`）**| **[套件 jedi-evidence-classification]** `stage_to_dir`＋`ITaskEvidenceSink`／`IClassificationContext` port 定義＋舊 Drive port 標 deprecated | `domain/ports.py` 補 `StagedFile`／`AttachResult`／兩支 port（§6.3）；`IEvidenceSource`、`EvidenceDriveOps` docstring 標 deprecated（不刪，舊 route 仍用）；`plugin/contract.py` `EvidenceClassificationAdapters` 加三個欄位（有預設 None，讓舊宿主不炸） | 套件 `tests/` 通過；`python -c "from jedi_evidence_classification.domain.ports import IEvidenceStorage, ITaskEvidenceSink, IClassificationContext"` 成功；`plugin.register` 少給新 adapter 時起得來但打新 route 回明確 500 訊息（不是 AttributeError） | T-1.3 |
| T-2.2<br>[CM-1855](https://app.notion.com/p/FR-107-2-T-2-2-DI-3dd346da4cd0810f949ed3760fb34da3) <br>**✅ 驗收通過**| **[BE]** 宿主三支 adapter 補齊＋DI wiring | `core/plugins/evidence_classification.py`：`UploadProviderEvidenceStorageAdapter.stage_to_dir`（以 `tenant_id` 明確解析 provider，§6.8）、`JobEvidenceTaskSinkAdapter`（`resolve_jobs` 接 `ssp_control_implementation_query.py:74`；`attach` 留 .4 只先 `NotImplementedError`）、`OscalClassificationContextAdapter`（`resolve_round`／`framework_version_chain`）；`di_containers/evidence_classification/evidence_classification_containers.py` 同步注入；`_CONTAINER_ENV_KEYS` 縮到 `ANTHROPIC_API_KEY` | 寫一支一次性腳本對 DEV 某批次呼叫 `stage_to_dir` → 目錄裡檔數＝批次檔數、內容 sha256 相符；切 STORAGE_CONFIG local→seaweedfs 再跑一次，零程式改動；`resolve_jobs(round_id)` 回的 dict 筆數與 DEV `get_jobs_by_round_id` 一致；**實打**新 route 確認 DI 簽章沒漂移 | T-2.1 |
| T-2.3<br>[CM-1856](https://app.notion.com/p/FR-107-2-T-2-3-repo-Drive-evidence-classifier-3dd346da4cd08199a94dc4219d7f9f70) <br>**✅ 驗收通過（套件 `6119da5`）**| **[套件 jedi-evidence-classification／容器]** 容器 image 搬套件 repo＋改讀目錄＋刪 Drive／DB＋改名 | `scripts/evidence/classify/docker/` 整包 `git mv` 到套件 repo `jedi-evidence-classification/docker/`（主專案留 README 指路）；`container_entrypoint.py` 刪 DB／Drive／token 段（`:123`／`:160` 那些）、`service-classify` 改 `--input-dir`、讀 `manifest.json`；`Dockerfile` 拿掉 DB／Drive 依賴；image 名 `evidence-classifier`、tag＝套件版號；`classifier_container_runner.py:119` 指令改（拿掉 `--evidence-folder-id`／`--tenant-id`，加 `--network none` 或代理參數可配）；`plugin/contract.py:50` 預設值改；`rebuild.sh` 改 | 用 T-2.2 stage 好的目錄手動 `docker run --network none -v dir:/job -e ANTHROPIC_API_KEY evidence-classifier:dev service-classify --input-dir /job --output-dir /job` 跑出 `_report-original.json`；`docker inspect` 環境變數只剩 AI 金鑰；`grep -n "DB_HOST\|DRIVE" container_entrypoint.py` 零命中；主專案 `scripts/evidence/classify/` 只剩 README | T-2.2 |

### FR-107.3 分類跑在批次上 — 依賴：FR-107.2（子需求卡 [CM-1846](https://app.notion.com/p/FR-107-3-4-3dd346da4cd081c8a8afffc24ae85a01)）

| # | 子任務 | 範圍 | 驗收條件 | 依賴 |
|---|--------|------|---------|------|
| T-3.1<br>[CM-1857](https://app.notion.com/p/FR-107-3-T-3-1-uid-3dd346da4cd081ac9475e6bf5b9430c9) <br>**✅ 驗收通過（套件 `710e146`）**| **[套件 jedi-evidence-classification]** runs 表改動（D5） | `classification_run_model.py` 五處改動（§6.1.4）；repo 加 `get_by_uid`／`get_latest_by_batch`；主專案 migration `00N-runs-attach-batch.sql`（`run_folder_id` DROP NOT NULL、既有 UNIQUE 改 partial、加四欄） | DEV 套完舊列 `run_folder_id` 值仍在、新欄全 NULL；FR-031 兩份報表對 POC 既有 run（DEV 有鏡像資料）仍打得開 | T-2.3 |
| T-3.2<br>[CM-1858](https://app.notion.com/p/FR-107-3-T-3-2-3dd346da4cd08181ac85e6d5085faaf8) <br>**✅ 驗收通過（套件 `8d34642`／BE `9da5f3f6`）**| **[套件 jedi-evidence-classification]** 批次版觸發＋狀態機 `classifying`／`review`／`failed`＋D4 心跳＋啟動掃逾時＋thread 租戶脈絡 | `evidence_batch_service.py` 加 `classify(batch_uid, user, model?, threshold?)`（request 內解析 tenant／catalog／provider 全帶進 thread，§6.8）；`evidence_classification_service.py` 加 `run_batch(...)`（stage→寫 `catalog.json`／`manifest.json`／`prompt.json` 最小版（.5 前 persona 用內建）→docker run→讀回→寫 run→`review`）；心跳 thread；`plugin/register` 加啟動 hook 掃逾時；`JobRegistry` 只留進度；route `classify` | 上傳一批→classify→分類中 `POST files` 409 `EC_BATCH_SEALED`→完成 `review`，run 有 `batch_id`／`catalog_snapshot`；分類中 `kill -9` BE、把 DEV 該批 `job_heartbeat_at` 改成 40 分鐘前、重啟 BE → 批次 `failed`、`failure_reason='heartbeat_timeout'`，再按 classify 能重跑不必重傳；`grep` thread 內無 `get_user_context()` | T-3.1 |
| T-3.3<br>[CM-1859](https://app.notion.com/p/FR-107-3-T-3-3-API-3dd346da4cd081c085b9e5266ad1c2d9) <br>**✅ 驗收通過（套件 `839786c`／BE `a9a319c9`）**| **[套件 jedi-evidence-classification]** run 新定址 route＋批次詳情帶 run | `/classification-run/<run_uid>/state`（GET／PUT）、`/file/<file_uid>/preview`（走 `IEvidenceStorage.get_bytes`＋轉 PDF）、兩份報表改讀 `catalog_snapshot`（沒有快照的舊列 fallback 舊 JSON，.5 才刪）；`state` 內 key 改 `file_uid`／`part_id`（§6.1.4）；D9 提示：批次詳情每檔帶 `same_content_evidences`（`find_same_content`） | 新 route 對新 run 全 200；舊 route 對舊 run 全 200（D3）；把一份已在任務裡的檔再上傳進批次，詳情裡看到「與任務 X 既有證據內容相同」提示 | T-3.2 |
| T-3.4<br>[CM-1860](https://app.notion.com/p/FR-107-3-T-3-4-3dd346da4cd08113ad63e3c434346ed7) <br>**🔄 派出中**| **[FE]** 審閱頁換資料來源＋批次列表／狀態頁 | `EvidenceClassificationReview.vue`／`useEvidenceClassification.js` 改吃 `run_uid`＋`part_id`（`aoLookup.js` 改 key，`ao_letter` 只顯示）；新頁 `EvidenceBatchList.vue`（輪次下批次列表、狀態、進入審閱）；router 新路徑 `/project/projects/:id/evidence-batches/:batchUid/review/:runUid`；舊路徑保留；輪詢批次狀態取代舊 job 輪詢 | 決策者親手：批次列表看到狀態→分類中畫面有「分類中」且上傳按鈕禁用→分完進審閱頁加減檢查點、標不適用、儲存→重整還在；舊 run 從舊入口仍打得開 | T-3.3 |

### FR-107.4 歸檔進任務 — 依賴：FR-107.3（子需求卡 [CM-1847](https://app.notion.com/p/FR-107-4-job_evidences-3-3dd346da4cd0817793bcf8d00048d4ce)）

| # | 子任務 | 範圍 | 驗收條件 | 依賴 |
|---|--------|------|---------|------|
| T-4.1<br>[CM-1861](https://app.notion.com/p/FR-107-4-T-4-1-AI-3dd346da4cd081b5b0d8ff15e83fc22b) <br>**🔄 派出中**| **[BE]** `job_evidences` 新 source＋`classification_run_id`＋partial UNIQUE＋`JobEvidenceTaskSinkAdapter.attach`／`find_same_content` | migration `2026-MM-DD-fr107-job-evidences-ai-classified.sql`（先查既存重複列，有就卡片回報不自動合併）；`infra/flow_engine/models/job_evidence.py` 加欄、`source` 註解；entity；`core/plugins/evidence_classification.py` `attach`（照 `import_drive_file_handler.py:210-256` pattern，命中既有未刪列回 `existed=True`）；任務證據列表 API 回傳多帶 `source`／`classification_run_id` | 一次性腳本對 DEV 呼叫 `attach` 兩次同參數 → 第二次 `existed=True`、`job_evidences` 只一筆；`\d compliance.job_evidences` 有 partial UNIQUE；既有 Drive 同步與任務上傳手測各一次仍正常 | T-3.3 |
| T-4.2<br>[CM-1862](https://app.notion.com/p/FR-107-4-T-4-2-API-3dd346da4cd08145b7e0ee83f658e2c1) | **[套件 jedi-evidence-classification]** 歸檔 API＋清理 API | `evidence_batch_service.py` `archive(batch_uid, user)`（§6.7 演算法，單一 `@transaction`）、`purge(batch_uid, user)`（D7：`IEvidenceStorage.delete`＋列標 `removed`＋`classification` 快照）；route 兩支；統計回寫 | 一檔命中 3 個 AO 其中 2 個有任務 → `job_evidences` 2 筆、批次檔 `archived` 且 `classification` 內第三個標 `no_task`；一檔全部 AO 無任務 → 列 `no_task`；連按兩次 archive 第二次回 `evidence_created=0`；purge 後 DEV `upload_files` 該列走套件既有刪除慣例、儲存後端實體不存在、`archived` 列未動 | T-4.1 |
| T-4.3<br>[CM-1863](https://app.notion.com/p/FR-107-4-T-4-3-AI-3dd346da4cd081319bb1fa57fa257a4d) | **[FE]** 歸檔對話框＋清理詢問＋任務證據頁標籤 | 審閱頁「歸檔進任務」按鈕→結果摘要對話框（已掛 X 檔／Y 筆證據／Z 檔無對應任務）→「要清掉剩餘 Z 檔嗎？」→ purge；任務證據列表（我的任務證據頁）對 `source=AI_CLASSIFIED` 顯示「AI 分類」標籤；批次列表 `archived` 狀態只剩「清理」動作 | 決策者親手：歸檔→看到摘要→選清掉→回批次列表狀態「已歸檔」→到「我的任務」該任務證據頁看到那份檔帶「AI 分類」標籤→同一批再按一次歸檔按鈕不存在（狀態機） | T-4.2 |

### FR-107.5 框架去硬編碼 — 依賴：FR-107.2（可與 .3／.4 平行）（子需求卡 [CM-1848](https://app.notion.com/p/FR-107-5-catalog-AI-prompt-CMMC-4-3dd346da4cd081e29849ce168532bdda)）

| # | 子任務 | 範圍 | 驗收條件 | 依賴 |
|---|--------|------|---------|------|
| T-5.1<br>[CM-1864](https://app.notion.com/p/FR-107-5-T-5-1-AI-API-3dd346da4cd08129b63eeb80c7de21e3) <br>**✅ 驗收通過（套件 `4d5b431`／BE `d914b7eb`）**| **[套件 jedi-evidence-classification]** `classification_profiles` 表＋CRUD API＋解析鏈＋多供應商設定（D12） | model／entity／repo／`classification_profile_service.py`（`resolve(tenant_id, project_id)` 照 §6.5 順序，走 `IClassificationContext.framework_version_chain`）；route 四支（平台管理員守門，走宿主 `IProjectRoleGuard` 之外的既有平台管理員 port——若套件沒有，加 `IPlatformAdminGuard` 一支）；migration `00N-classification-profiles.sql`＋ROOT 通用 seed 一筆；`BUILTIN_MINIMAL` 常數（無框架名）；`model_default` 欄拆成 `provider_default`／`model_default`（D12，§6.11）；`system_configs` 新 group `AI_PROVIDER_CONFIG` CRUD（租戶級，比照 `STORAGE_CONFIG`）；新端點 `GET /classification/available-models`（依已配置金鑰動態算可選供應商 × `llm_models.py` 碼表） | DEV 不建任何 profile 呼叫 `resolve` 回 ROOT 通用 seed；停用 seed 回 `BUILTIN_MINIMAL`；建租戶 A 對 framework_version X 的 profile，專案 living SSP 對到 X 時解析到它、對到 Y 時回通用；租戶 B 看不到 A 的 profile；只填 OpenAI 金鑰時 `available-models` 只回 OpenAI 那組模型 | T-2.1 |
| T-5.2<br>[CM-1865](https://app.notion.com/p/FR-107-5-T-5-2-CMMC-3dd346da4cd08184b53ec0b010f2ff2e) <br>**✅ 驗收通過（BE `c68afa2a`／套件 `795306b`／FE `bc11786`）**| **[BE＋套件]** catalog 輸出補 `part_id`／`group`＋報表改讀快照＋靜態 JSON 退場 | 主專案 `ssp_control_implementation_service.py:237` 輸出每 AO 加 `part_id`、每控制項加 `group_id`／`group_title`（字母保留 `ao_letter`）；套件 `report_common.py:60 load_catalog` 改讀 `catalog_snapshot`、`:193 load_canon` 改讀正解表、`catalog_builder.py` 刪、`resources/*.json` 刪、`put_state`／`archive_run` 內 `ensure_ao_folder` 的 `[a]` 解析只留舊 route 路徑 | `build_classifier_catalog_by_ssp_id` 對 DEV catalog 2.13 回的每個 AO 有 `part_id` 形如 `AC.L1-3.1.1_obj.2`；`ls resources/` 無 JSON；對新 run 跑兩份報表正常；對 DEV 既有舊 run（無快照）跑報表回明確錯誤「此 run 無目標集快照」而非 500 | T-3.1 |
| T-5.3a<br>[CM-1866](https://app.notion.com/p/FR-107-5-T-5-3-AI-3dd346da4cd08123b2cbcb89991d10ac) <br>**✅ 驗收通過（套件 `90f0ce3`／BE `46e8ef0f`）**| **[套件 jedi-evidence-classification／容器]** prompt 三段組裝 | `run_batch` 組 `prompt.json`（persona／guidance／hints 併進目標集項）並存 `prompt_snapshot`；容器 `build_system_block()` 改純讀 `prompt.json`；`ao_id` 全線改 `part_id`；容器驗 `part_id ∈ catalog` | 建一個 profile persona 寫「你是 XYZ 框架專家」→ 跑分類 → job 目錄 `prompt.json` 與 run `prompt_snapshot` 開頭是那句；`grep -n "CMMC" container_entrypoint.py` 零命中；結果 JSON 的 key 全是 `part_id` | T-5.1、T-5.2、T-3.2 |
| T-5.3b<br>[CM-1877](https://app.notion.com/p/FR-107-5-T-5-3b-AI-client-Anthropic-OpenAI-3dd346da4cd081c699affcf6eb8d4344) <br>**🔄 派出中**| **[套件 jedi-evidence-classification／容器]** 多供應商 `LlmClient`（Anthropic＋OpenAI，D12） | 套件新增 `llm_models.py` 碼表（`provider`／`model_id`／`display_name`／`supports_vision`／`default`）；`domain/ports.py` 定義 `LlmClient.classify(system_block, user_parts) -> dict`；`AnthropicClient`（搬 `container_entrypoint.py:635` 起 `classify_with_claude()` 邏輯進來）與 `OpenAIClient`（`chat.completions`，相容 API 的 Azure／Ollama 走它）兩實作；容器 image 同時裝 `anthropic`＋`openai` SDK；宿主起容器時只塞當批選定供應商的金鑰環境變數；`evidence_classification_service.py:81` 的 `ALLOWED_MODELS` 白名單退場改讀碼表；圖片證據遇 `supports_vision=False` 型號標「此模型不支援圖片，未分類」不靜默跳過 | 切到 OpenAI 跑一次同批證據，validation／adjudication 兩份報表可正常產出且與 Claude 那次可比對（欄位形狀一致）；`ALLOWED_MODELS` 常數已刪；圖片證據對不支援視覺的模型跑出「未分類」訊息而非空白 | T-5.3a、T-2.3 |
| T-5.4<br>[CM-1867](https://app.notion.com/p/FR-107-5-T-5-4-AI-3dd346da4cd0817291b2fcc26733ff24) <br>**✅ 驗收通過（三輪修正後，FE `c230a4d`／`b28f651`／`4488670`；套件 `1f1c73b`；BE `851e2d92`／`09f3a1d0`／`1b96ac93`）**| **[FE]** 框架版本管理頁「AI 分類設定」分頁（含供應商金鑰，D12） | `ComplianceFrameworkVersionManage.vue` 加分頁（PrimeVue TabView 雷區見 frontend-overview §3.6）：列該框架版本的 profile（租戶自己的＋ROOT 通用唯讀）、新增／編輯 persona／guidance／hints（JSON 編輯器或 key-value 表）／provider／model／threshold／enable；新增「供應商金鑰」區塊——顯示各供應商已設定／未設定狀態、可覆寫、不回顯明文；分類觸發對話框與 profile 模型下拉改打 `GET /classification/available-models`；`api.js`；i18n | 決策者親手：進框架版本 2.13 → 分頁 → 新增 profile → 儲存 → 重整還在 → 停用 → 跑分類 prompt 回通用；供應商金鑰區只填 OpenAI 時模型下拉只列 OpenAI 選項 | T-5.1 |
| T-5.5<br>[CM-1879](https://app.notion.com/p/FR-107-5-T-5-5-AI-ROOT-env-install-sh-3de346da4cd0816e98a7df52286ac393) <br>**✅ 驗收通過（BE `f87468ff`）**| **[BE]** 三個 AI 功能統一金鑰來源＋原廠鑰裝機寫入（D13） | AI 小幫手／AI Dashboard／證據分類三功能改接共用金鑰解析器（租戶設定 → ROOT 原廠鑰 → 環境變數）；`AI_PROVIDER_CONFIG` 寫入側補 Fernet 加密（沿用 `fernet_crypto.py`，獨立加密鑰不與 `DRIVE_TOKEN_ENCRYPTION_KEY` 共用）；分類容器 `container_env` 改從解析結果注入；`install.sh` 裝機把原廠鑰寫進 ROOT（來源 `install.conf`），`--upgrade` 不覆寫既有值 | 清空環境變數只設 ROOT 金鑰，三功能都能正常運作；DB 直查金鑰欄位非明文；`docker inspect` 分類容器 env 只有解析後那把；install.sh 模擬跑一次寫入 ROOT 且 upgrade 不覆寫；grep 三功能 `os.environ.get("ANTHROPIC_API_KEY")` 歸零 | T-5.1、T-5.4 |

### FR-107.6 舊線退場＋收口 — 依賴：全部（子需求卡 [CM-1849](https://app.notion.com/p/FR-107-6-Drive-e2e-SPEC-4-3dd346da4cd081e39df8f237677e01e6)）

| # | 子任務 | 範圍 | 驗收條件 | 依賴 |
|---|--------|------|---------|------|
| T-6.1<br>[CM-1868](https://app.notion.com/p/FR-107-6-T-6-1-Drive-legacy-3dd346da4cd081ceb9e7e85d6365c904) | **[FE＋BE]** 舊入口隱藏、舊 route 標 legacy（D3） | FE 拿掉 `ProjectAuditorOverview.vue` 的「自動分類」入口（`AIEvidenceClassificationDialog.vue` 保留檔案不掛）；舊 review 路由保留供舊 run；BE 舊 route docstring 標 legacy＋下一版刪的 follow-up 記進 memory | 前端全站 grep 無舊入口按鈕；DEV 既有舊 run 從舊 URL 直接開仍可看；新入口只有批次 | .4、.5 |
| T-6.2<br>[CM-1869](https://app.notion.com/p/FR-107-6-T-6-2-image-3dd346da4cd08128809aca7f4dc501e5) | **[兩支套件]** 發版＋pin 還原＋出貨基線重產回報＋安裝包驗 image | `jedi-package-dev` skill：jedi-file-upload、jedi-evidence-classification 各發一版（**user 明示才發**）；主專案 `pyproject.toml` path 改回 pin；`poetry update jedi-file-upload jedi-evidence-classification`；**在 pin 還原後重打全部新 route**；回報「出貨基線待重產」清單（五支 migration）；驗 `scripts/build/`／installer 是否帶 `evidence-classifier` image（見 §11 需裁事項） | `pyproject.toml` 無 path 形式；`.venv` 內兩支套件是 wheel 不是 editable（逐支實查）；新 route 全 200；出貨基線 migration 清單寫進卡片 | T-6.1 |
| T-6.3<br>[CM-1870](https://app.notion.com/p/FR-107-6-T-6-3-3dd346da4cd081d39779ea387c06a2f5) | **[test＋SPEC]** e2e 全鏈＋SPEC 更新 | test repo `site-regression/` 加「上傳→分類→確認→歸檔→任務看到證據」場景（分類容器可用 stub image 回固定 JSON）；加「切到 OpenAI 跑一批」場景（D12，驗 validation／adjudication 兩份報表可產出且可比對）；`docs/spec-site/current/` 對應頁（專案總覽／審閱頁／框架版本管理／我的任務證據）走 `writing-feature-specs`；FR-107 `FINAL-SPEC.md` | e2e headless 綠燈；SPEC 四頁檔頭變更紀錄各一行；FINAL-SPEC 五段齊；OpenAI 批次場景綠燈 | T-6.2 |
| T-6.4<br>[CM-1871](https://app.notion.com/p/FR-107-6-T-6-4-image-image-3dd346da4cd081d0a1a7e8678c238b47) | **[BE／build 線]** 分類容器 image 進出貨包 | `scripts/build/build_all.sh` 加第四顆 image（`evidence-classifier`，走 FR-065 build 線）；`build_bundle.sh` 打包進安裝包；installer compose 加對應 service | 全新裝機（不手動 `docker pull`／`docker load` 分類 image）後能跑完一次分類；安裝包內 `grep evidence-classifier` 命中 image tar／compose | T-2.3、T-6.2 |

---

## 端到端驗收（決策者親手走一遍） {#acceptance nav="驗收"}

環境：DEV（BE 本機、DB localhost:5432、STORAGE_CONFIG 指 local 或 seaweedfs 皆可）。準備：一個有 living SSP（catalog 2.13）且輪次已建任務的專案；manager 帳號 A、auditor 帳號 B、另一租戶 manager 帳號 C；5 份證據檔（其中 1 份已存在某任務證據裡）。

1. **地基**：C 登入打 A 專案的批次列表 → 403。DEV `SELECT count(*) FROM upload_files WHERE tenant_id IS NULL` → 0。
2. **上傳**：A 進專案總覽 → 「上傳證據批次」→ 選輪次 → 拖 5 檔 → 列表 5 筆 → 刪 1 → 4 筆 → 完成上傳 → 狀態「就緒」。B 登入看得到列表、沒有上傳按鈕。
3. **防呆**（只 DEV）：清掉 tenant 的 STORAGE_CONFIG → A 建新批次 → 畫面明確錯誤；還原設定。
4. **分類**：A 按「開始分類」→ 狀態「分類中」、上傳按鈕禁用 → `docker ps` 看到 `evidence-classifier` 容器、`docker inspect` 環境變數只有 AI 金鑰 → 完成進「審閱」。
5. **心跳**：另建一批按分類 → 立刻 `kill -9` BE → DEV 把該批 `job_heartbeat_at` 改成 40 分鐘前 → 重啟 BE → 批次「失敗」→ 按重跑 → 不必重傳。
6. **審閱**：4 檔各對到檢查點；其中那份已在任務裡的檔有「與任務 X 既有證據內容相同」提示；加一個檢查點、標一個不適用、儲存、重整還在。
7. **歸檔**：按「歸檔進任務」→ 摘要「已掛 X 檔／Y 筆證據／Z 檔無對應任務」→ 選清掉 → 狀態「已歸檔」。到「我的任務」對應任務證據頁看到檔帶「AI 分類」標籤。DEV：`job_evidences` 有 `source='AI_CLASSIFIED'` 且 `classification_run_id` 非空；`upload_files` 沒多列；`evidence_batch_files` 有 `removed` 列且 `classification` 非空；儲存後端上被清的實體檔不存在。
8. **冪等**：DEV 手動把批次 `status` 改回 `review` 再按歸檔 → 摘要 `evidence_created=0`、`job_evidences` 筆數不變。
9. **框架**：框架版本 2.13 →「AI 分類設定」→ 新增 profile persona「你是 XYZ 專家」→ 再跑一批 → run `prompt_snapshot` 開頭是那句；停用 → 再跑 → 通用。套件 `resources/` 無 JSON。
10. **後端無關**：STORAGE_CONFIG 切 seaweedfs → 步驟 2、4、7 重走一次，零程式改動。
11. **舊線**：前端找不到舊「自動分類」按鈕；DEV 既有舊 run 從舊 URL 開仍可看、兩份報表可開。
12. **出貨**：`pyproject.toml` 兩支套件是 pin；安裝包內有 `evidence-classifier` image（依 §11 裁示）。

---

## 風險與債 {#risks nav="風險"}

| # | 風險／債 | 影響 | 對策 |
|---|---------|------|------|
| 1 | Drive 分類線退場影響既有客戶資料 | POC 既有 run 的審閱頁與報表可能打不開；Drive 上已歸檔的資料夾樹不會自動搬進任務 | D3 並存一版；舊 run 列保留 `run_folder_id`；不做自動遷移（Drive 歸檔的檔本來就沒進 `job_evidences`，要進任務走既有 Drive 同步） |
| 2 | 背景 job 租戶脈絡陷阱 | 分類 thread 讀 STORAGE_CONFIG 沒 request 脈絡會挑錯租戶 | §6.8：request 內解析完全帶進 thread；adapter 以 `tenant_id` 明確解析 |
| 3 | 儲存後端切換舊檔不搬家（memory `followup_storage_backend_switch_migration_gap`） | 切換後批次裡的舊檔 `get_file` 找不到 | 本案不解；批次詳情對 `upload_files.storage_type ≠ 現行設定` 的檔顯示「儲存後端已變更，請重新上傳」 |
| 4 | `upload_files.id` 是 int 不是 uid | 內部轉換漏掉會掛錯檔且不報錯 | port 簽名明確分 `file_id:int` 與 `file_uid:str`；批次檔表兩者都存；驗收查 DEV 對照 |
| 5 | `upload_files` 回填孤兒 | 回填不到的列歸 ROOT | D10；migration 回報筆數，決策者裁要不要清 |
| 6 | 審閱頁資料形狀變（`[a]` → `part_id`） | `aoLookup.js:15` 硬組 `${ctrl.id}[${ao.letter}]`，不改會整頁對不到 | T-3.4 明列改點；`ao_letter` 仍由 BE 帶 |
| 7 | 一檔多 AO 的證據紀錄數 | 任務證據頁「同一份檔到處都是」 | 定案 4 的預期結果；「AI 分類」標籤＋`classification_run_id` 可追溯 |
| 8 | 容器 image 改名與版號 | 舊 `cmmc-classifier:latest` 與新 `evidence-classifier:<ver>` 並存期 | T-2.3 改 rebuild 腳本；出貨只帶新顆；**現行安裝包本來就沒帶分類 image**（§11） |
| 9 | 出貨基線 | 五支 migration | 每棒回報「待重產」，T-6.2 一次回報，重產屬決策者裁示 |
| 10 | 跨插件軟參照（`round_id`／`framework_version_id`／`file_id` 不建 FK） | 被參照列被刪時本套件的列變孤兒、無 DB 級擋 | 插件互不相依是 D6 契約硬規則，接受；宿主 port 在寫入前驗存在性；批次列表對找不到輪次的批次顯示「輪次已刪除」 |
| 11 | 心跳逾時 30 分鐘是常數 | 超大批次真跑超過 30 分會被誤標失敗 | 心跳是「thread 還活著」不是「跑完」，只要 thread 活著就不會逾時；常數放套件 config 可調 |

---

## 邊界（本案不做） {#boundary nav="邊界"}

- **Drive 同步功能本身**（`cloud_integration`）：不動。它負責「任務資料夾 ↔ Drive 資料夾」雙向；本案的批次不建 Drive 資料夾。
- **儲存後端切換搬家工具**：既有 follow-up，不併本案（風險 3）。
- **全域 `/tmp` fallback 防呆**：D6 只擋批次上傳路徑；套件全域行為另案（follow-up：改成啟動時警告）。
- **通用 job 表**：D4 最小版，不在分類線內長通用能力。
- **跨來源去重**：D9 只提示不阻擋。
- **`IUploadFileProvider` 介面**：不加方法；列目錄與搬移在批次檔表這層做。
- **舊 Drive 分類 route 刪除**：D3 並存一版，下一版另開卡刪。

---

## 寫作時發現的事（決策者 2026-09-16 已裁） {#open nav="已裁"}

寫作時對照程式碼發現的矛盾與缺口，不在 D1–D11 範圍內，開卡前提交決策者裁定：

1. **現行安裝包沒有帶分類容器 image**。`scripts/build/` 與 `scripts/installer/` 兩處 `grep classifier` 零命中——落地版客戶裝完就算有 API 也跑不了分類（`docker run` 找不到 image）。本案 T-2.3 把 image build 搬進套件 repo 後，**image 要不要進 installer bundle、由誰 build、放哪個 registry（Harbor？）**，需要裁；T-6.2 的驗收依裁示調整。
   **裁示**：進安裝包，隨三顆主 image 一起 build（走 FR-065 build 線）；拆分表 .6 新增 **T-6.4「分類容器 image 進出貨包」**（範圍：`scripts/build/build_all.sh` 加第四顆 image、`build_bundle.sh` 打包、installer compose 加 service；驗收：全新裝機後不手動拉 image 能跑完一次分類）。
2. **討論稿把 `attach_to_job`／`remove_from_batch` 放在 `IEvidenceStorage`**；D11 後批次檔表在套件內，「掛進任務」屬任務疆界、「移出批次」是套件自己的表操作。本文改成：`IEvidenceStorage` 只管檔案實體（存／取／stage／刪），`ITaskEvidenceSink` 管找任務與掛證據，移出批次在套件內做。與討論稿不同，請確認。
   **裁示**：照 design 這個切法——`IEvidenceStorage` 只管檔案實體，`ITaskEvidenceSink` 管找任務＋掛證據。
3. **`classification_profiles` 放 `compliance` schema、`framework_version_id` 不建 FK**（討論稿寫 `oscal.classification_profiles` 帶 FK）。理由：表由 jedi-evidence-classification 擁有，跨插件不建 FK（插件互不相依）。`round_id`、`file_id` 同理。若首腦要 DB 級完整性，要接受套件依賴 jedi-compliance-audit／jedi-oscal-v2／jedi-file-upload 的 model，與 D6 契約衝突。
   **裁示**：不建 FK，一律軟參照，程式層查存在性；與現有插件契約一致。
4. **profile CRUD 的守門軸**：討論稿沒寫。「AI 分類設定」在框架版本管理頁，該頁是平台管理員軸；套件現有 `IProjectRoleGuard` 只有專案角色，需要新加一支平台管理員 port（本文 T-5.1 暫寫 `IPlatformAdminGuard`）。是否改為「租戶管理員可編自己租戶的 profile」需裁。
   **裁示**：只給平台管理員，新加 `IPlatformAdminGuard` port；租戶層級等需求再開。
   **2026-09-17 修正**：profile 守門放寬到**租戶管理員**（軸④capability `storage-config.update`，與 `STORAGE_CONFIG` 同一顆，理由見 `PlatformAdminGuardAdapter` docstring）——四層解析鏈裡租戶那兩層本來就只有租戶管理員自己建得出來，只給平台管理員的話那兩層永遠沒人用得到。**AI 供應商金鑰設定頁維持鎖 ROOT 平台管理員**（D13），兩者是不同守門軸，不要混為一談。
5. **`job_evidences` partial UNIQUE 前的既存重複列**：DEV／POC 可能已有同 `(job_execution_id, file_id)` 未刪的重複列（Drive 同步早期版本）。T-4.1 先查、有就回報，**不自動合併**；若有，合併規則要裁。
   **裁示**：先查不合併，查出有再裁。
6. **舊 route 與新 route 並存期的 FE `api.js` 常數**：舊十支保留給舊 run，新加約十二支；`EvidenceClassificationService.js` 會有兩套。可接受（D3 一版），但 .6 刪舊線時要一起清，記進 T-6.1 的 follow-up。
   **裁示**：.6 刪舊線一起清（已在 T-6.1）。
