---
title: "證據自動分類 2.0 — 需求討論稿 (FR-107)"
brand: "Guidant AI · **FR-107** 證據自動分類 2.0"
eyebrow: "FR-107 · 儲存後端無關的暫存區 → 分類 → 確認 → 歸檔進任務 — 需求討論稿 · 2026-09-16（v1 · 待審）"
h1: "讓證據分類不再綁 Google Drive，分完直接掛進任務"
lede: "現在的自動分類整條長在 Google Drive 上：檔案從 Drive 來、分類結果寫回 Drive、歸檔只是把檔複製到 Drive 的另一個資料夾，**系統裡的任務證據表一筆都沒寫**。落地版客戶沒有 Drive，這條線等於不存在。本案把分類線改成只認系統自己的儲存後端（本機／MinIO／SeaweedFS／遠端 agent 都行），使用者上傳到暫存批次 → AI 分類 → 使用者確認 → 檔案**搬進**對應任務成為正式證據；同時把寫死在程式裡的 CMMC 框架知識抽成資料，任何框架版本都能跑。"
chips: [
  {text: "已定案 5 項（決策者拍板）", kind: ok},
  {text: "待決策 D1–D9", kind: warn},
  {text: "跨 4 repo：BE／FE／jedi-evidence-classification／jedi-file-upload＋容器 image", kind: accent},
  {text: "前作：FR-030 自動分類 · FR-031 分類報表", kind: accent},
  {text: "硬約束：upload_files 補租戶／擁有者／RLS", kind: crit}
]
footer: "FR-107 · 證據自動分類 2.0 — 需求討論稿 · 2026-09-16 v1 · 前作：FR-030（Drive 版自動分類）／FR-031（分類結果報表）· 現況依據：套件 jedi-evidence-classification／jedi-file-upload 實檔＋主專案 app/oscal、infra/flow_engine、cloud_integration 實檔＋DEV 唯讀查證"
---

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

整案一句話：**把「證據自動分類」從 Google Drive 搬回系統自己的儲存空間，分完的檔直接搬進任務變成正式證據，而且不管哪個合規框架都能用。**

白話解釋幾個詞：

- **儲存後端**：系統把上傳檔案實際放在哪裡——可能是主機硬碟、物件儲存（MinIO／SeaweedFS，類似自架的雲端硬碟）、或客戶內網的遠端 agent。系統設定切一下就換，程式不用改。
- **暫存批次**：使用者一次上傳的一堆證據檔，還沒分類、還沒掛到任何任務，先放在一個「籃子」裡。
- **AO**（Assessment Objective，評估目標）：一條控制項底下的細分檢查點，例如「AC.L1-3.1.1 的第 2 點」。分類就是把每份檔對到它證明的那幾個檢查點。
- **任務**：稽核輪次裡，每個檢查點對應的一個工作項；證據最終要掛在任務上，稽核員才看得到。

| 棒 | 做什麼（一句話） | 產出 | 跨 repo | 完成怎麼判定（決策者檢查法） |
|----|----------------|------|---------|------------------------------|
| **.1 暫存批次＋安全地基** | 建「批次」與「批次檔」資料表，`upload_files` 補租戶／擁有者／RLS；做上傳到批次、列批次、刪檔三個 API；未設儲存後端時拒絕上傳而不是偷偷寫進 `/tmp` | migration ＋ 批次 API ＋ 前端「上傳到批次」頁 | BE、FE | 用兩個不同租戶的帳號各上傳一批，A 帳號打 B 的批次 API 拿到 403；DEV 查 `upload_files` 新列都有 tenant_id；把 STORAGE_CONFIG 清空再上傳，畫面要看到明確錯誤而不是成功 |
| **.2 儲存 port＋容器改取檔** | 套件開一個「取檔／放檔」介面（port），主專案接到現有儲存後端；容器不再自己連 Drive 與資料庫拿檔 | jedi-evidence-classification 新 port ＋ 主專案 adapter ＋ 容器 image 改版 | 套件、BE、容器 image | 在一台**沒有 Drive 憑證、沒有資料庫密碼**的容器裡跑一次分類，能拿到檔、能回結果；把儲存後端從 local 切到 seaweedfs 再跑一次，程式零改動 |
| **.3 分類跑在批次上** | 觸發分類改吃「批次」而不是「AP 的 Drive 資料夾」；結果掛批次；批次狀態機落地（分類中不可加檔） | 觸發／查詢／狀態 API ＋ 前端審閱頁改資料來源 | 套件、BE、FE | 上傳一批 → 按分類 → 分類中再試上傳被擋（有訊息）→ 分完在審閱頁看到每檔對到的檢查點，能加減、標不適用、儲存 |
| **.4 歸檔進任務** | 確認後把檔**搬**進對應任務（同一筆檔案紀錄改綁任務，不複製實體檔）；一檔多檢查點＝每個任務各一筆證據紀錄；沒對到任務的留在批次標「無對應任務」；搬完問要不要清掉批次剩餘檔 | 歸檔 API ＋ `job_evidences` 新來源值 ＋ 前端歸檔對話框 | BE、FE | 歸檔後到「我的任務」證據頁看得到那份檔；DEV 查 `job_evidences` 有 `source='AI_CLASSIFIED'` 的列、`upload_files` 沒多出實體副本；同一批連按兩次歸檔不會產生重複證據 |
| **.5 框架去硬編碼** | 目標集、檢查點代號、領域全走 catalog 結構；新表 `classification_profiles` 存 AI 角色與指引；前端框架版本管理頁多「AI 分類設定」分頁；容器 prompt 改三段組裝；套件內 CMMC 靜態 JSON 退場 | migration ＋ profile CRUD API ＋ 前端分頁 ＋ 容器 prompt 改版 ＋ 報表改讀 catalog | BE、FE、套件、容器 image | 不建任何 profile 直接跑分類要能跑（通用設定生效）；建一個框架版本的 profile 後，容器 log 裡的 prompt 開頭要出現那段 persona；把套件的 `cmmc_l1_aos.json` 改名再跑一次分類與兩份報表，全部正常 |
| **.6 舊線退場＋收口** | Drive 版分類 route 與前端頁面依 D3 裁示退場或並存；e2e 回歸；spec 更新 | 退場 commit ＋ e2e ＋ SPEC | BE、FE、test | 舊入口在前端找不到（或依裁示仍在但標「舊版」）；e2e 走完「上傳→分類→確認→歸檔→任務看到證據」全鏈綠燈 |

依賴：.1 → .2 → .3 → .4 直線；**.5 可與 .3／.4 平行**（只碰 prompt 與 catalog 讀法，不碰批次流程）；.6 最後。決策者功課：.1 開工前裁 D1／D6／D7，.2 開工前裁 D2，.3 開工前裁 D4／D5，.6 開工前裁 D3。

---

## 需求背景與目標 {#why nav="背景"}

- **前作**：FR-030（Drive 版自動分類，套件 `jedi-evidence-classification`＋容器 `cmmc-classifier`）→ FR-031（分類結果兩份報表落 DB）
- **觸發**：落地版（FR-063～FR-065）客戶沒有 Google Drive；同時 FR-069 模組化後儲存層已統一走 `jedi-file-upload` 的 `IUploadFileProvider`，分類線是唯一還直接綁 Drive 的功能

現在這條線有四個結構性問題：

1. **來源與終點都是 Drive**。使用者要把檔丟進 Drive 上 per-AP 的 `Evidences/` 資料夾，分類結果存 Drive 的 `_state.json`，歸檔只是把檔複製到 Drive 的 `[域]/[控制項]/[AO]` 資料夾。**系統的任務證據表 `job_evidences` 一筆都沒寫**——稽核員在任務頁看不到分類過的證據。
2. **容器什麼都自己來**。分類容器自己連 DB、自己解密 Drive token、自己下載檔案、自己呼叫 AI。它需要 DB 密碼與 Drive 憑證，換儲存後端就得改容器。
3. **框架知識寫死在程式裡**。AI 的角色提示寫死「CMMC 2.0 Level 1 專家／17 條控制項」，檢查點代號用 `[a]` 字母從描述文字硬剝，領域用控制項代號前綴硬拆，事後路徑硬讀套件內的 `cmmc_l1_aos.json`。客戶自己匯入的框架（DEV 已有 catalog 2.13）分類跑不起來。
4. **上傳的孤兒檔沒人管**。`upload_files` 表沒有租戶、沒有擁有者、沒有 RLS（資料列層級的存取控制），沒掛任務的檔天然是孤兒——正好可以當暫存區的物理基礎，但目前沒有清單、沒有到期、沒有歸屬。

**目標**：使用者在系統內上傳一批證據 → AI 分到檢查點 → 使用者確認 → 檔案**搬進**對應任務成為正式證據；不依賴 Drive、不依賴特定儲存後端、不依賴特定框架。Google Drive 只保留既有的「同步」功能。

---

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

以下座標撰稿當天逐一開檔核對過。

### 3.1 分類線現況

| 環節 | 現況（座標） | 本案動作 |
|------|-------------|---------|
| **觸發** | `POST /project/<uid>/ap/<ap_uid>/classify-evidence`（套件 `api/routing.py`）→ `EvidenceClassificationService.trigger_classify()`（`app/service/evidence_classification_service.py:144`，參數是 `project_uid／ap_uid／framework_id／confidence_threshold／evidence_folder_id_override`）→ 起 daemon thread 跑 `docker run cmmc-classifier:latest` | 改吃批次；AP 改為輪次 |
| **容器** | `scripts/evidence/classify/docker/container_entrypoint.py`：自己連 DB、解密 Drive token、下載檔案、呼叫 Claude；prompt 在 `build_system_block()`（`:504`）寫死「CMMC 2.0 Level 1 expert／17 control practices」 | 不再自己取檔（.2）；prompt 三段組裝（.5） |
| **結果** | Drive `_state.json` 為主，`compliance.evidence_classification_runs` 表鏡像（套件 `infra/model/classification_run_model.py`，自然鍵是 `run_folder_id`＝Drive 資料夾 id，有 `ap_uid` 無 `round_id`） | 改掛批次（D5） |
| **審閱頁** | `src/views/evidence-classification/EvidenceClassificationReview.vue`＋`composables/useEvidenceClassification.js`：加減 AO、標不適用、刪除、儲存——**調整 UI 完整** | 沿用，只換資料來源 |
| **歸檔** | `POST /classification-run/<id>/archive` → `archive_run()`（`:875`）只把檔 copy 到 Drive 資料夾，**不寫 `job_evidences`** | 改為搬進任務（.4） |
| **job 狀態** | 套件 `app/service/job_registry.py` 的 `JobRegistry`，class 級記憶體 dict，**BE 重啟即失** | D4 |
| **正解基準** | `compliance.evidence_classification_ground_truth`（套件 `infra/model/classification_ground_truth_model.py`，欄位 tenant_id／framework_id／mapping JSONB）；報表另有套件內 `cmmc_l1_canon.json` | 只認 DB 表（已定案） |

### 3.2 取檔／放檔沒有 port

套件 `domain/ports.py` 定義五個 port（介面，讓主專案決定怎麼實作）：`IProjectDirectory`／`IProjectRoleGuard`／`IEvidenceSource`／`IControlCatalog`／`IDocumentConverter`。其中 `IEvidenceSource` 只有 `is_connected()` 與 `get_folder_id()`——**只回 Drive 資料夾 id**。真正的列檔、下載、複製、丟垃圾桶全在 `infra/evidence_drive_ops.py` 的 `EvidenceDriveOps` 直接呼叫 `googleapiclient`；容器內又另有一套獨立的 Drive 存取。換句話說「檔案在哪、怎麼拿」這件事在套件裡有兩份實作，都綁死 Drive。

### 3.3 儲存後端現況

| 項目 | 現況 |
|------|------|
| 統一介面 | `jedi-file-upload` 的 `IUploadFileProvider`（`domain/ports.py:56`）：只有 `get_file`／`save_file`／`delete_file`／`convert_to_pdf`；**沒有列目錄、沒有搬移、沒有資料夾概念** |
| 後端 | local／minio／seaweedfs（套件內）＋ remote_agent（主專案 `infra/upload_file/remote_agent_adapter.py`） |
| 設定 | `system_configs` group=`STORAGE_CONFIG`，租戶級；ROOT 那筆給系統共享資產（`storage_scope=system`） |
| 物件儲存 | 忽略 `save_dir`，只有 file uid；也就是說「資料夾」在物件儲存上本來就不存在，暫存區不能靠資料夾實作 |
| `public.upload_files` | **無 tenant_id、無 RLS、無 owner**（`scripts/sql/2026-06-28-fr042-upload-files-add-storage-scope.sql:14` 明載「系統層表」）；`id` 是 int 主鍵、另有 `uid` 字串 |
| 未設定時 | 靜默 fallback `/tmp/upload/`——落地版 `/tmp` 是 tmpfs，**重啟即失** |
| 背景 job | 沒有租戶脈絡時讀 STORAGE_CONFIG 會挑錯租戶；canonical 修法是 `upload_files_for_tenant`（memory `feedback_background_job_storage_config_no_context_trap`） |

### 3.4 任務證據與 AO→任務對照

| 項目 | 現況 |
|------|------|
| 證據表 | `compliance.job_evidences`（`infra/flow_engine/models/job_evidence.py`）：`job_execution_id`／`file_id`（**int**，FK→`upload_files.id`）／`evidence_type`／`source`（現值只有 `SYSTEM_UPLOAD`／`DRIVE_SYNC`）／`drive_file_id` UNIQUE／`content_hash`／軟刪 |
| 寫證據範例 | `app/cloud_integration/service/handlers/import_drive_file_handler.py:210-246`：`JobEvidenceEntity(source="DRIVE_SYNC", ...)`＋domain add＋冪等去重 |
| AO→任務反查 | `infra/readmodel/oscal/ssp_control_implementation_query.py:74 get_jobs_by_round_id(round_id)` 回 control_id／ao_part_id／job_uid；任務側 canonical key 是 **`ao_part_id`＝catalog `part_id`**（形如 `AC.L1-3.1.1_obj.2`） |
| 唯一性 | DEV 實查：有 round_id 的 792 筆對照裡，同一（round, control, ao_part）**沒有任何一筆對到兩個以上任務** → 「一 AO＝該輪一任務」在資料上成立 |
| 目標集 | 送 AI 的 in-scope 控制集已從 DB 動態建：`app/oscal/service/ssp_control_implementation_service.py:237 build_classifier_catalog_by_ssp_id`（走 living SSP in-scope resolution）；但事後路徑（套件 `catalog_builder.py` 只認 `cmmc-l1`、`put_state`／`archive_run` 內的巢狀 `ensure_ao_folder`、報表 `report_common.load_catalog/load_canon`）仍硬讀套件內 JSON |
| 輪次模型 | `jedi_compliance_audit` 的 `project_audit_rounds` 有 `assessment_plan_id`，輪次 ⊇ AP；`workflow_execution_control_mapping.round_id` 是任務對控制項的 truth |
| Drive 樹 | `compliance.drive_folder_mappings`，scope_type 9 種含 TASK／EVIDENCES；Drive 匯入是「複製一份進系統儲存＋寫 job_evidences source=DRIVE_SYNC」——**保留不動** |

### 3.5 框架版本管理

前端 `/compliance-framework/compliance-framework-version-manage`；DB `oscal.framework_versions`（id／uid／framework_id／parent_id／version／catalog_id／publish_status…）。DEV catalog 已有 2.13，AO 的 `part_id` 形如 `AC.L1-3.1.1_obj.2`、prose 開頭 `[a] ...`。這一頁是「AI 分類設定」分頁的落點。

---

## 已定案（決策者拍板） {#decided nav="已定案"}

::: {.callout .decided}
**✅ 定案 1 — 分類線只認系統儲存後端；Google Drive 只留同步**

分類的來源與終點一律是系統自己的儲存（走 `IUploadFileProvider`，local／minio／seaweedfs／remote_agent 任一）。Drive 既有的「資料夾同步進任務」功能不動，但不再是分類的來源或終點。
:::

::: {.callout .decided}
**✅ 定案 2 — 暫存區＝批次，掛輪次；分類開始後批次封口**

每次上傳開一個新批次，批次掛在**稽核輪次**（不是 AP）。分類一旦開始批次不可再加檔；要加就開新批次。
:::

::: {.callout .decided}
**✅ 定案 3 — 歸檔用「搬」的**

同一筆 `upload_files` 列改綁任務，不複製實體檔。搬完問使用者要不要清掉批次裡剩下的檔。
:::

::: {.callout .decided}
**✅ 定案 4 — 一 AO＝該輪一任務；多命中＝一檔多筆證據紀錄；沒任務就留著**

一份檔命中多個 AO → 實體檔一份、每個對應任務各一筆 `job_evidences`。AO 在該輪沒有任務 → 檔留在批次，標「無對應任務」，不硬掛。
:::

::: {.callout .decided}
**✅ 定案 5 — 框架去硬編碼；AI 設定存新表 `classification_profiles`**

目標集／AO 代號／領域全走 catalog 結構（catalog 輸出多帶 `part_id`，領域用 `group_id`／`title`）。AI 角色與指引存 `classification_profiles`（掛 `framework_version_id`；欄位 persona／guidance／evidence_hints JSONB／model_default／confidence_threshold_default／enable）；缺省有通用設定、沒設定也能跑；解析順序 **專案 → living SSP → catalog → framework_version**。前端框架版本管理頁多「AI 分類設定」分頁。評測正解只認 `evidence_classification_ground_truth` 表，套件內靜態 JSON 退場。容器 prompt 改「persona＋guidance＋動態目標集」三段組裝。

**被排除**：一框架一 JSON（要發套件版、客戶自匯框架無檔）；塞進 catalog props（會隨 OSCAL 匯出到客戶手上）。
:::

---

## 目標架構 {#arch nav="架構"}

四個參與者：**套件**（流程與 port 定義）、**主專案**（port 實作、DB、API）、**容器**（只做 AI 分類）、**儲存後端**（檔案實體）。

```{.mermaid cap="圖 1 — 目標架構：套件定介面、主專案接後端、容器只看得到 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[歸檔對話框]
  end
  subgraph PKG[套件 jedi-evidence-classification]
    R[route：批次／分類／歸檔]
    S[app service：批次狀態機＋分類編排]
    P1[port IEvidenceStorage]
    P2[port IControlCatalog]
    P3[port IClassificationProfile]
    P4[port ITaskEvidenceSink]
  end
  subgraph HOST[主專案]
    A1[adapter → IUploadFileProvider]
    A2[adapter → build_classifier_catalog_by_ssp_id]
    A3[adapter → classification_profiles 表]
    A4[adapter → job_evidences ＋ get_jobs_by_round_id]
    DB[(PostgreSQL：evidence_batches／batch_files／runs／profiles／job_evidences)]
  end
  subgraph STOR[儲存後端（擇一）]
    L[local]
    M[minio／seaweedfs]
    RA[remote agent]
  end
  C[容器 cmmc-classifier：只吃 job 目錄裡的檔＋prompt 三段，寫結果 JSON]
  U1 --> R
  U2 --> R
  U3 --> R
  R --> S
  S --> P1
  S --> P2
  S --> P3
  S --> P4
  P1 -.-> A1
  P2 -.-> A2
  P3 -.-> A3
  P4 -.-> A4
  A1 --> L
  A1 --> M
  A1 --> RA
  A2 --> DB
  A3 --> DB
  A4 --> DB
  S -->|拉檔到 job 目錄後起容器| C
  C -->|結果 JSON| S
```

重點：**容器的世界縮到「一個目錄＋一段 prompt」**，不再知道 Drive、不再知道 DB、不再知道儲存後端是哪一種。換後端只換主專案 adapter；換框架只換 profile 與 catalog。

---

## 端到端流程 {#journey nav="旅程"}

```{.mermaid cap="圖 2 — 上傳 → 分類 → 確認 → 歸檔端到端時序"}
%%{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 BE as 主專案 BE（含套件）
    participant ST as 儲存後端
    participant C as 分類容器
    participant DB as PostgreSQL

    U->>FE: 選輪次，開新批次，拖入 N 份檔
    FE->>BE: POST /evidence-batches（round_uid）→ batch_uid
    loop 每一份檔
      FE->>BE: POST /evidence-batches/{uid}/files（multipart）
      BE->>ST: IUploadFileProvider.save_file()（帶 tenant 脈絡）
      BE->>DB: upload_files（tenant_id／owner）＋ evidence_batch_files
    end
    U->>FE: 按「開始分類」
    FE->>BE: POST /evidence-batches/{uid}/classify（model／threshold 可選）
    BE->>DB: 批次 status → classifying（之後加檔一律 409）
    BE->>DB: 解析 profile（專案→SSP→catalog→framework_version→通用）＋ 動態目標集
    BE->>ST: 逐檔 get_file() 拉到 job 工作目錄（D2 甲案）
    BE->>C: docker run（掛 job 目錄；環境變數只有 AI 金鑰）
    C->>C: 讀 prompt 三段＋目標集，逐檔呼叫 AI
    C-->>BE: 結果 JSON（每檔 → [part_id, confidence]）
    BE->>DB: evidence_classification_runs（掛 batch）＋ status → review
    U->>FE: 進審閱頁：加減 AO、標不適用、刪檔、儲存
    FE->>BE: PUT /classification-runs/{id}/state
    U->>FE: 按「歸檔進任務」
    FE->>BE: POST /evidence-batches/{uid}/archive
    BE->>DB: get_jobs_by_round_id(round_id) 建 part_id → job 對照
    BE->>DB: 每檔每 AO：job_evidences（source=AI_CLASSIFIED，冪等）；沒任務的標 no_task
    BE->>DB: 批次 status → archived
    BE-->>FE: 結果摘要：已掛 X 檔／Y 筆證據，Z 檔無對應任務
    FE->>U: 問「批次剩餘 Z 檔要清掉嗎？」
    U->>FE: 選清掉
    FE->>BE: POST /evidence-batches/{uid}/purge
    BE->>ST: delete_file()（依 D7 實刪或軟刪）
```

---

## 批次狀態機 {#statemachine nav="狀態機"}

```{.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'}}}%%
stateDiagram-v2
    [*] --> uploading : 建批次
    uploading --> uploading : 加檔／刪檔
    uploading --> ready : 至少一檔且使用者按「完成上傳」（或按分類時自動）
    ready --> uploading : 使用者再加檔（分類尚未開始）
    ready --> classifying : 按「開始分類」（封口，加檔 409）
    classifying --> review : 容器回結果
    classifying --> failed : 容器失敗／逾時
    failed --> classifying : 重跑（同批次，不必重傳）
    review --> review : 審閱調整、儲存
    review --> classifying : 重新分類（換 model／threshold）
    review --> archived : 歸檔進任務
    archived --> archived : 清掉剩餘檔（purge，只動未掛任務的檔）
    archived --> [*]
```

規則一句話：**分類開始後（`classifying` 起）批次不可再加檔；歸檔後只剩「清掉剩餘檔」一個動作。** `uploading` 與 `ready` 之間可來回，是為了讓「拖檔中途離開再回來」不需要另開批次。`failed` 可重跑不必重傳——檔已經在系統儲存裡。

---

## 資料模型草案 {#datamodel nav="資料模型"}

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

現況 `upload_files` 無 tenant_id、無 owner、無 RLS，任何人拿到 uid 就能 `get_file`。以前這張表只被 `job_evidences` 這類有 RLS 的表「間接」保護——檔案自己沒有身分，是靠掛它的表擋。暫存區的檔**還沒掛任何東西**，這層保護不存在。所以 .1 棒一定要先補：`tenant_id`（NOT NULL，既有列從掛它的 `job_evidences`／其他 FK 表回填，回填不到的歸 ROOT 並記錄筆數）、`owner_user_id`（可 NULL，既有列留空）、RLS policy（cm_app 只能看自己租戶）。這不是本案的加分項，是准不准上線的門檻。
:::

### 8.1 新表 `compliance.evidence_batches`（批次）

| 欄位 | 型別 | 說明 |
|------|------|------|
| id／uid | int PK／varchar(36) UNIQUE | 慣例 |
| tenant_id／org_unit_id | int NOT NULL | RLS 與租戶隔離 |
| round_id | int NOT NULL FK→project_audit_rounds | 批次掛輪次（定案 2） |
| project_id | int NOT NULL | 冗餘，方便列表與權限（專案角色守門走 project） |
| status | varchar(16) | `uploading／ready／classifying／review／archived／failed` |
| model／confidence_threshold | varchar(64)／numeric(4,2) | 本批實際用的（來自 profile 預設或使用者覆寫） |
| profile_id | int NULL FK→classification_profiles | 本批解析到哪個 profile（NULL＝通用） |
| file_count／classified_count／archived_count／no_task_count | int | 統計，歸檔時回寫 |
| purged_at／purged_by_user_id | timestamptz／int | 清掉剩餘檔的紀錄（清理旗標） |
| created_user／updated_user／時間戳 | 慣例 | API 回傳要 enrich nickname |

### 8.2 批次檔：新表 `compliance.evidence_batch_files` vs `upload_files` 加欄（D1）

| | 新表 `evidence_batch_files` | `upload_files` 加 `batch_id` 欄 |
|---|---|---|
| 形狀 | `batch_id`＋`file_id`（int FK→upload_files.id）＋`status`（`pending／classified／archived／no_task／removed`）＋`content_hash`＋`original_name` | 在系統層表上加業務欄 |
| 一檔多批 | 天然支援（雖然定案上一檔只屬一批） | 不支援 |
| 歸檔後追蹤 | 列留著標 `archived`，可回答「這份證據是哪批分出來的」 | 搬走後 batch_id 要不要清？清了就斷線索 |
| 對套件 jedi-file-upload 的影響 | 零（它不用知道批次） | 要改套件 model＋發版 |
| 對其他 consumer | 零 | `upload_files` 被十幾個模組用，多一欄大家都看到 |

**建議走新表**（理由見 D1）。

### 8.3 分類結果：沿用 `evidence_classification_runs` 改掛批次（D5）

現有表的自然鍵是 `run_folder_id`（Drive 資料夾 id），且掛 `ap_uid`。建議**沿用同一張表**，改動：`run_folder_id` 改 NULLABLE（舊列保留值）、新增 `batch_id`（FK，新列 NOT NULL）、新增 `round_id`、`ap_uid` 保留給舊列。**一個批次可有多次 run**（重新分類），最新一筆為現行。`report_original`／`state` 這些 JSONB 結構不變——審閱頁與 FR-031 兩份報表都是讀這些，這樣前端與報表改動最小。

### 8.4 `job_evidences` 新來源值＋冪等鍵

- `source` 新值 **`AI_CLASSIFIED`**（現值 `SYSTEM_UPLOAD`／`DRIVE_SYNC`）。
- 新欄 `classification_run_id`（int NULL FK）：追溯是哪次分類掛進來的；FR-031 報表可據此算「歸檔後正解」。
- **冪等鍵**：`(job_execution_id, file_id)` 加 partial UNIQUE（`WHERE deleted_at IS NULL`）。同一批連按兩次歸檔、或同檔兩個 AO 剛好對到同一任務，都只留一筆。現有 `drive_file_id` UNIQUE 對本線無用（我們沒有 Drive id），不動。

### 8.5 新表 `oscal.classification_profiles`（AI 分類設定）

| 欄位 | 型別 | 說明 |
|------|------|------|
| id／uid | 慣例 | |
| tenant_id／org_unit_id | int NOT NULL | 租戶自己的設定；ROOT 那筆是出廠通用設定 |
| framework_version_id | int NULL FK→oscal.framework_versions | NULL＝通用（不綁框架） |
| persona | text | AI 角色，例「你是 CMMC 2.0 Level 1 評估專家」 |
| guidance | text | 分類指引（怎麼判、什麼算證據） |
| evidence_hints | JSONB | 依 part_id 或 group_id 給的提示，例 `{"AC.L1-3.1.1_obj.2": ["帳號清單", "AD 截圖"]}` |
| model_default／confidence_threshold_default | varchar(64)／numeric(4,2) | 批次沒指定時用 |
| enable | bool | 關掉就退回通用 |
| created_user／updated_user／時間戳 | 慣例 | |

解析順序（定案 5）：**專案指定 → living SSP 對到的 framework_version → catalog 對到的 framework_version → 通用（framework_version_id IS NULL）**。每一層先找租戶自己的，再找 ROOT 的。全部找不到用程式內建的最小通用 prompt（不含任何框架名）。

---

## 儲存 port 設計 {#port nav="儲存 port"}

### 9.1 套件新 port `IEvidenceStorage`

放在套件 `domain/ports.py`，取代 `IEvidenceSource`＋`EvidenceDriveOps` 兩份 Drive 實作：

```python
class IEvidenceStorage(Protocol):
    """證據暫存區的取檔／放檔介面。主專案實作；套件與容器只認這個。"""

    def list_batch_files(self, batch_id: int) -> list[BatchFileRef]: ...
    # BatchFileRef = (file_id:int, file_uid:str, name:str, size:int, content_hash:str|None, status:str)

    def get_bytes(self, file_uid: str) -> bytes: ...
    # 拉一份檔的內容；後端是哪種由主專案 adapter 決定

    def stage_to_dir(self, batch_id: int, work_dir: Path) -> list[StagedFile]: ...
    # 把整批拉到本機工作目錄（甲案用）；回 (file_uid, local_path)

    def attach_to_job(self, file_id: int, job_execution_id: int,
                      run_id: int, actor_user_id: int) -> AttachResult: ...
    # 搬進任務：同一筆 upload_files 改綁 job_evidences；冪等（已存在回 existed=True）

    def remove_from_batch(self, batch_id: int, file_id: int, hard: bool) -> None: ...
    # 清掉批次裡沒掛任務的檔；hard 由 D7 決定
```

主專案 adapter（`infra/evidence_classification/evidence_storage_adapter.py`）對接：`get_bytes` → `IUploadFileProvider.get_file()`（帶 `upload_files_for_tenant` 脈絡）；`attach_to_job` → `JobEvidenceEntity(source="AI_CLASSIFIED")`＋domain add，照 `import_drive_file_handler.py` 的冪等寫法；`remove_from_batch` → `delete_file()` 或標記。**AO→任務對照另拆一個小 port `ITaskEvidenceSink.resolve_jobs(round_id) -> dict[part_id, job_execution_id]`**，對接 `get_jobs_by_round_id`。

### 9.2 容器怎麼拿到檔：甲案 vs 乙案（D2）

| | 甲：宿主先拉到 job 目錄再起容器 | 乙：容器透過內部 API 拉檔 |
|---|---|---|
| 容器需要什麼 | 一個掛進來的目錄＋AI 金鑰 | 一個內部 URL＋一次性 token＋AI 金鑰 |
| 容器要不要懂儲存後端 | 不用 | 不用（但要懂我們的 API 契約） |
| 大批次 | 宿主磁碟要放得下一批（現況 130 檔級，可接受） | 邊拉邊分，磁碟壓力小 |
| remote_agent 後端 | 宿主拉檔本來就走 agent，容器不受影響 | 容器→BE→agent 兩跳 |
| 失敗排查 | 目錄裡看得到檔，最直觀 | 要看兩邊 log |
| 安全面 | 容器零網路需求（除 AI API）；可 `--network none` 加代理 | 容器要能連 BE，要多一套 token 機制 |
| 改動量 | 容器只刪掉 Drive／DB 那段，讀目錄本來就會 | 容器要新增 HTTP client、BE 要新增內部端點與 token |

**建議甲案**（理由見 D2）。

---

## 框架去硬編碼 {#framework nav="框架"}

定案 5 已定方向，這裡列實作面要動的點：

| 硬編碼處 | 現況 | 改法 |
|---------|------|------|
| 目標集 | `build_classifier_catalog_by_ssp_id` 已動態，但輸出用 `[a]` 字母 | 輸出多帶 `part_id`（`AC.L1-3.1.1_obj.2`）與 `group_id`／`group_title`；字母保留作顯示用 |
| AO 代號解析 | `_parse_ao_letter_text` 從 prose 剝 `[a]`；`ensure_ao_folder` 硬解析 `AC.L1-3.1.1[a]` | 全線以 `part_id` 為 key；Drive 資料夾命名那段隨舊線退場 |
| 領域 | `control_id.split('.')[0]` | 用 catalog `group_id`／`title` |
| 事後讀 catalog | `catalog_builder.py` 只認 `cmmc-l1`；`report_common.load_catalog/load_canon` 讀套件內 JSON | 統一走 `IControlCatalog`（既有 port）拿 run 當時的目標集快照——**run 表存一份目標集快照 JSONB**，報表讀快照不重算，避免 SSP 事後改動讓報表對不上 |
| 正解 | `cmmc_l1_canon.json` | 只認 `evidence_classification_ground_truth` 表 |
| prompt | `build_system_block()` 寫死 | 三段：`profile.persona`＋`profile.guidance`（＋`evidence_hints` 對應到目標集的項）＋動態目標集；宿主組好寫進 job 目錄 `prompt.json`，容器只讀不組 |
| 容器 image 名 | `cmmc-classifier` | 改中性名 `evidence-classifier`（tag 與 BE 同版號） |

---

## 階段拆分 {#phases nav="階段"}

六棒與依賴見「分工概述」表；這裡補每棒的範圍邊界與不做的事。

| 棒 | 範圍內 | 明確不做 | 前置裁決 |
|----|--------|---------|---------|
| **.1** | migration（`evidence_batches`／`evidence_batch_files`／`upload_files` 補三欄＋RLS＋回填）；批次 CRUD＋上傳＋刪檔 API（專案 manager 守門走 `common.authz` project 軸）；前端上傳頁；STORAGE_CONFIG 缺設時拒絕上傳 | 分類、歸檔、profile | D1、D6、D7 |
| **.2** | 套件 `IEvidenceStorage`＋`ITaskEvidenceSink` port；主專案 adapter；容器改讀目錄＋刪 Drive／DB 段；`EvidenceDriveOps` 標 deprecated | 改觸發 API | D2 |
| **.3** | `trigger_classify` 改吃 batch；run 表改掛 batch＋存目標集快照；狀態機落地；審閱頁換資料來源；job 狀態依 D4 | 歸檔 | D4、D5 |
| **.4** | 歸檔 API（搬進任務、冪等、no_task 標記）；`job_evidences` 新 source＋新欄＋UNIQUE；purge API；前端歸檔對話框＋清理詢問 | — | D7 |
| **.5** | `classification_profiles` 表＋CRUD＋解析鏈；前端框架版本管理頁「AI 分類設定」分頁；prompt 三段組裝；catalog 輸出補 part_id／group；報表改讀快照；套件靜態 JSON 退場 | — | 無（已定案） |
| **.6** | 舊 route／前端頁依 D3 處理；e2e 全鏈；SPEC；出貨基線重產回報 | — | D3 |

每棒都會新增 migration，**只套 DEV**；主線 `phase=active`／`envs=*` 的 migration 寫完要回報「出貨基線待重產」。

---

## 待決策 {#pending nav="待決策"}

::: {.callout .pending}
**⏸ D1 — 批次檔：新表 `evidence_batch_files` vs `upload_files` 加 `batch_id` 欄**

`upload_files` 是系統層表、住在 jedi-file-upload 套件、被十幾個模組共用；批次是分類線的業務概念。

[**建議**：新表。業務欄不塞系統層表；歸檔後仍留線索（哪批分出來的）；不用動 jedi-file-upload 套件、不用發版。`upload_files` 只補租戶／擁有者／RLS 這三個「檔案自己的身分」欄，那是所有 consumer 都該有的，不算業務欄。]{.rec}
:::

::: {.callout .pending}
**⏸ D2 — 容器取檔：甲（宿主先拉到 job 目錄）vs 乙（容器透過內部 API 拉）**

見 §9.2 對照表。

[**建議**：甲。容器改動最小（只是刪掉自己連 Drive／DB 那段）、零網路需求、排查直觀；remote_agent 後端下宿主本來就負責拉檔，容器不必多一跳。乙案的「邊拉邊分省磁碟」在現在的批次規模（百檔級）沒有實際收益，等出現千檔級需求再評估。]{.rec}
:::

::: {.callout .pending}
**⏸ D3 — 舊 Drive 分類 route 與前端頁面退場：一刀切 vs 並存一版**

舊線：`POST .../ap/<ap_uid>/classify-evidence`、`archive` 只複製到 Drive、前端專案總覽的「自動分類」按鈕。

[**建議**：**並存一版、預設隱藏**——舊 route 保留但前端入口拿掉（或以功能開關 `EVIDENCE_CLASSIFY_DRIVE_LEGACY` 控制），下一版再刪程式。理由：POC 上有既有 run 資料與 Drive 資料夾樹，一刀切等於那些 run 的審閱頁與 FR-031 報表立刻打不開；並存一版讓報表可讀舊列（`run_folder_id` 保留），同時新客戶看不到舊入口。若決策者確認 POC 那批 run 不需再看，可改一刀切。]{.rec}
:::

::: {.callout .pending}
**⏸ D4 — job 狀態 DB 化（`JobRegistry` 記憶體 → DB）是否併入本案**

現況 BE 重啟後進行中的分類 job 狀態就丟了，前端會一直看到「分類中」。

[**建議**：**併入 .3，但做最小版**——批次的 `classifying` 狀態本身就是持久化的 job 狀態；`JobRegistry` 保留給進度百分比這類暫態，批次表加 `job_started_at`／`job_heartbeat_at` 兩欄，BE 啟動時把 `classifying` 且心跳逾時（例 30 分）的批次標 `failed` 讓使用者可重跑。不做完整的 job 表——那是通用能力，不該在分類線內長。]{.rec}
:::

::: {.callout .pending}
**⏸ D5 — 分類結果：沿用 `evidence_classification_runs` vs 新表**

[**建議**：沿用改掛（見 §8.3）。審閱頁與 FR-031 報表都讀這張表的 JSONB 結構，新表等於兩邊各改一份；沿用只是加 `batch_id`／`round_id`／目標集快照三欄、`run_folder_id` 改可空。新表的唯一好處是乾淨，但代價是舊 run 與新 run 的報表要走兩條路。]{.rec}
:::

::: {.callout .pending}
**⏸ D6 — 未設 STORAGE_CONFIG 時的 `/tmp` fallback：本案改成拒絕上傳？**

現況靜默 fallback `/tmp/upload/`，落地版重啟即失。這個 fallback 是 jedi-file-upload 全域行為，不只分類線。

[**建議**：**本案在批次上傳這一條路徑上拒絕**（沒設定就 412 並提示去系統設定），**不動套件全域 fallback**。理由：全域改拒絕會影響所有上傳點，需要另案盤點每個入口；但暫存區的檔要活過重啟才有意義，這條路徑先擋是必要的。順帶記一條 follow-up：套件全域 fallback 改成啟動時警告。]{.rec}
:::

::: {.callout .pending}
**⏸ D7 — 清掉批次剩餘檔：軟刪 vs 實刪**

[**建議**：**實刪實體檔＋`evidence_batch_files.status=removed` 留紀錄列**。理由：這些檔沒掛任何任務、使用者已明確說要清，留實體只是佔儲存；批次檔表的紀錄列留著可回答「這批上傳過哪些檔」。`upload_files` 列走既有 `delete_file()` 的軟刪慣例（如果套件是軟刪就跟著軟刪，不另立規則）。]{.rec}
:::

::: {.callout .pending}
**⏸ D8 — 批次的權限：誰能建、誰能看、誰能歸檔**

[**建議**：建批次／上傳／分類／歸檔＝專案 **manager**（與現況 `IProjectRoleGuard.is_project_manager` 一致）；看批次與審閱＝專案 manager＋auditor；purge＝manager。守門走 `common.authz` project 軸，在 app service 層。]{.rec}
:::

::: {.callout .pending}
**⏸ D9 — 一份檔同時是「舊 Drive 同步進來的證據」又被放進批次分類，怎麼算**

Drive 同步的檔已在 `job_evidences`（source=DRIVE_SYNC）；使用者若再把同一份檔上傳進批次，分類後歸檔會在另一個任務多一筆。

[**建議**：**不做跨來源去重**。批次裡的檔是新上傳的實體（新 `upload_files` 列），與 Drive 同步那份在系統裡本來就是兩份；用 `content_hash` 在審閱頁提示「與任務 X 既有證據內容相同」即可，不阻擋。做去重會把兩條線耦合起來，正是本案要拆開的。]{.rec}
:::

---

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

| # | 風險／債 | 影響 | 對策 |
|---|---------|------|------|
| 1 | **Drive 分類線退場影響既有客戶資料** | POC 上既有 run 的審閱頁與報表可能打不開；Drive 上已歸檔的資料夾樹不會自動搬進任務 | D3 並存一版；舊 run 列保留 `run_folder_id`；**不做自動遷移**——Drive 歸檔的檔本來就沒進 `job_evidences`，要進任務走既有 Drive 同步即可 |
| 2 | **背景 job 租戶脈絡陷阱** | 分類 thread 裡讀 STORAGE_CONFIG 沒有 request 脈絡會挑錯租戶的儲存後端，檔拉不到或拉到別人的 | 觸發時把 tenant_id 與 STORAGE_CONFIG 解析結果一起帶進 thread；adapter 一律走 `upload_files_for_tenant`（canonical） |
| 3 | **儲存後端切換舊檔不搬家**（memory `followup_storage_backend_switch_migration_gap`） | 客戶從 local 切到 seaweedfs 後，批次裡的舊檔 `get_file` 找不到 | 本案不解；批次上傳頁在切換後對舊批次顯示「儲存後端已變更，請重新上傳」；遷移工具屬既有 follow-up，不併本案 |
| 4 | **`upload_files.file_id` 是 int 不是 uid** | `job_evidences.file_id` 與 `evidence_batch_files.file_id` 都是 int FK；API 對外一律用 uid，內部轉換漏掉會掛錯檔且不報錯 | port 簽名明確分 `file_id:int`（內部）與 `file_uid:str`（對外）；驗收查 DEV 對照 |
| 5 | **`upload_files` 回填 tenant_id** | 既有列從掛它的表回填，孤兒列（沒任何表掛）回填不到 | 歸 ROOT 並記錄筆數；migration 回報孤兒數給決策者裁要不要清 |
| 6 | **審閱頁沿用但資料形狀變**（`[a]` 字母 → `part_id`） | 前端 `useEvidenceClassification.js` 若有硬解析 `[a]` 會壞 | .3 棒先 grep 前端 composable 的解析點；顯示字母仍由 BE 帶，key 換 part_id |
| 7 | **一檔多 AO 的證據紀錄數** | 一份檔命中 5 個 AO＝5 筆 `job_evidences`，任務證據頁看起來「同一份檔到處都是」 | 這是定案 4 的預期結果；前端證據列表加「AI 分類」標籤＋`classification_run_id` 可追溯 |
| 8 | **容器 image 改名與版號** | 舊 `cmmc-classifier:latest` 與新 `evidence-classifier:<ver>` 並存期 build 管線要出兩顆 | .2 棒與 build 腳本一起改；出貨 bundle 只帶新顆 |
| 9 | **出貨基線** | 六棒每棒都有 migration | 每棒回報「出貨基線待重產」，.6 收口時一次重產 |

---

## 附：與既有功能的邊界 {#boundary nav="邊界"}

- **Drive 同步**（`cloud_integration`）：不動。它負責「任務資料夾 ↔ Drive 資料夾」雙向；本案的批次不建 Drive 資料夾。
- **FR-031 報表**：資料來源改讀 run 表的目標集快照與 `job_evidences.classification_run_id`；報表演算法不動。
- **框架版本管理頁**：只加一個分頁，不動既有版本流程。
- **jedi-file-upload**：只補 `upload_files` 三欄＋RLS（走套件 model，dev 期 path dependency，完成後發版）；`IUploadFileProvider` 介面不加方法——列目錄與搬移都在 `evidence_batch_files` 這層做，不需要儲存後端支援資料夾。
