FR-107 · 儲存後端無關的暫存區 → 分類 → 確認 → 歸檔進任務 — 需求討論稿 · 2026-09-16(v1 · 待審)

讓證據分類不再綁 Google Drive,分完直接掛進任務

現在的自動分類整條長在 Google Drive 上:檔案從 Drive 來、分類結果寫回 Drive、歸檔只是把檔複製到 Drive 的另一個資料夾,系統裡的任務證據表一筆都沒寫。落地版客戶沒有 Drive,這條線等於不存在。本案把分類線改成只認系統自己的儲存後端(本機/MinIO/SeaweedFS/遠端 agent 都行),使用者上傳到暫存批次 → AI 分類 → 使用者確認 → 檔案搬進對應任務成為正式證據;同時把寫死在程式裡的 CMMC 框架知識抽成資料,任何框架版本都能跑。

已定案 5 項(決策者拍板) 待決策 D1–D9 跨 4 repo:BE/FE/jedi-evidence-classification/jedi-file-upload+容器 image 前作:FR-030 自動分類 · FR-031 分類報表 硬約束:upload_files 補租戶/擁有者/RLS
§1

分工概述(30 秒版)

整案一句話:把「證據自動分類」從 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。


§2

需求背景與目標

  • 前作: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 只保留既有的「同步」功能。


§3

現況盤點

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

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 分類設定」分頁的落點。


§4

已定案(決策者拍板)

✅ 定案 1 — 分類線只認系統儲存後端;Google Drive 只留同步

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

✅ 定案 2 — 暫存區=批次,掛輪次;分類開始後批次封口

每次上傳開一個新批次,批次掛在稽核輪次(不是 AP)。分類一旦開始批次不可再加檔;要加就開新批次。

✅ 定案 3 — 歸檔用「搬」的

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

✅ 定案 4 — 一 AO=該輪一任務;多命中=一檔多筆證據紀錄;沒任務就留著

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

✅ 定案 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 匯出到客戶手上)。


§5

目標架構

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

%%{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
圖 1 — 目標架構:套件定介面、主專案接後端、容器只看得到 port

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


§6

端到端流程

%%{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 實刪或軟刪)
圖 2 — 上傳 → 分類 → 確認 → 歸檔端到端時序

§7

批次狀態機

%%{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 --> [*]
圖 3 — 批次狀態機

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


§8

資料模型草案

🔴 硬約束: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(不含任何框架名)。


§9

儲存 port 設計

9.1 套件新 port IEvidenceStorage

放在套件 domain/ports.py,取代 IEvidenceSource+EvidenceDriveOps 兩份 Drive 實作:

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)。


§10

框架去硬編碼

定案 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 同版號)

§11

階段拆分

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

棒 範圍內 明確不做 前置裁決
.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 寫完要回報「出貨基線待重產」。


§12

待決策

⏸ D1 — 批次檔:新表 evidence_batch_files vs upload_files 加 batch_id 欄

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

建議:新表。業務欄不塞系統層表;歸檔後仍留線索(哪批分出來的);不用動 jedi-file-upload 套件、不用發版。upload_files 只補租戶/擁有者/RLS 這三個「檔案自己的身分」欄,那是所有 consumer 都該有的,不算業務欄。

⏸ D2 — 容器取檔:甲(宿主先拉到 job 目錄)vs 乙(容器透過內部 API 拉)

見 §9.2 對照表。

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

⏸ 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 不需再看,可改一刀切。

⏸ D4 — job 狀態 DB 化(JobRegistry 記憶體 → DB)是否併入本案

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

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

⏸ D5 — 分類結果:沿用 evidence_classification_runs vs 新表

建議:沿用改掛(見 §8.3)。審閱頁與 FR-031 報表都讀這張表的 JSONB 結構,新表等於兩邊各改一份;沿用只是加 batch_id/round_id/目標集快照三欄、run_folder_id 改可空。新表的唯一好處是乾淨,但代價是舊 run 與新 run 的報表要走兩條路。

⏸ D6 — 未設 STORAGE_CONFIG 時的 /tmp fallback:本案改成拒絕上傳?

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

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

⏸ D7 — 清掉批次剩餘檔:軟刪 vs 實刪

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

⏸ D8 — 批次的權限:誰能建、誰能看、誰能歸檔

建議:建批次/上傳/分類/歸檔=專案 manager(與現況 IProjectRoleGuard.is_project_manager 一致);看批次與審閱=專案 manager+auditor;purge=manager。守門走 common.authz project 軸,在 app service 層。

⏸ D9 — 一份檔同時是「舊 Drive 同步進來的證據」又被放進批次分類,怎麼算

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

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


§13

風險與債

# 風險/債 影響 對策
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 收口時一次重產

§14

附:與既有功能的邊界

  • 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 這層做,不需要儲存後端支援資料夾。