FR-107 · 儲存後端無關的暫存區 → 分類 → 確認 → 歸檔進任務 — 需求討論稿 · 2026-09-16(v1 · 待審)
現在的自動分類整條長在 Google Drive 上:檔案從 Drive 來、分類結果寫回 Drive、歸檔只是把檔複製到 Drive 的另一個資料夾,系統裡的任務證據表一筆都沒寫。落地版客戶沒有 Drive,這條線等於不存在。本案把分類線改成只認系統自己的儲存後端(本機/MinIO/SeaweedFS/遠端 agent 都行),使用者上傳到暫存批次 → AI 分類 → 使用者確認 → 檔案搬進對應任務成為正式證據;同時把寫死在程式裡的 CMMC 框架知識抽成資料,任何框架版本都能跑。
整案一句話:把「證據自動分類」從 Google Drive 搬回系統自己的儲存空間,分完的檔直接搬進任務變成正式證據,而且不管哪個合規框架都能用。
白話解釋幾個詞:
| 棒 | 做什麼(一句話) | 產出 | 跨 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。
jedi-evidence-classification+容器 cmmc-classifier)→ FR-031(分類結果兩份報表落 DB)jedi-file-upload 的 IUploadFileProvider,分類線是唯一還直接綁 Drive 的功能現在這條線有四個結構性問題:
Evidences/ 資料夾,分類結果存 Drive 的 _state.json,歸檔只是把檔複製到 Drive 的 [域]/[控制項]/[AO] 資料夾。系統的任務證據表 job_evidences 一筆都沒寫——稽核員在任務頁看不到分類過的證據。[a] 字母從描述文字硬剝,領域用控制項代號前綴硬拆,事後路徑硬讀套件內的 cmmc_l1_aos.json。客戶自己匯入的框架(DEV 已有 catalog 2.13)分類跑不起來。upload_files 表沒有租戶、沒有擁有者、沒有 RLS(資料列層級的存取控制),沒掛任務的檔天然是孤兒——正好可以當暫存區的物理基礎,但目前沒有清單、沒有到期、沒有歸屬。目標:使用者在系統內上傳一批證據 → AI 分到檢查點 → 使用者確認 → 檔案搬進對應任務成為正式證據;不依賴 Drive、不依賴特定儲存後端、不依賴特定框架。Google Drive 只保留既有的「同步」功能。
以下座標撰稿當天逐一開檔核對過。
| 環節 | 現況(座標) | 本案動作 |
|---|---|---|
| 觸發 | 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 表(已定案) |
套件 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。
| 項目 | 現況 |
|---|---|
| 統一介面 | 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) |
| 項目 | 現況 |
|---|---|
| 證據表 | 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」——保留不動 |
前端 /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 分類設定」分頁的落點。
✅ 定案 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 匯出到客戶手上)。
四個參與者:套件(流程與 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
重點:容器的世界縮到「一個目錄+一段 prompt」,不再知道 Drive、不再知道 DB、不再知道儲存後端是哪一種。換後端只換主專案 adapter;換框架只換 profile 與 catalog。
%%{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 實刪或軟刪)
%%{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 可重跑不必重傳——檔已經在系統儲存裡。
🔴 硬約束: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 只能看自己租戶)。這不是本案的加分項,是准不准上線的門檻。
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 |
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)。
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 兩份報表都是讀這些,這樣前端與報表改動最小。
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),不動。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(不含任何框架名)。
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。
| 甲:宿主先拉到 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)。
定案 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 同版號) |
六棒與依賴見「分工概述」表;這裡補每棒的範圍邊界與不做的事。
| 棒 | 範圍內 | 明確不做 | 前置裁決 |
|---|---|---|---|
| .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 寫完要回報「出貨基線待重產」。
⏸ 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 既有證據內容相同」即可,不阻擋。做去重會把兩條線耦合起來,正是本案要拆開的。
| # | 風險/債 | 影響 | 對策 |
|---|---|---|---|
| 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 收口時一次重產 |
cloud_integration):不動。它負責「任務資料夾 ↔︎ Drive 資料夾」雙向;本案的批次不建 Drive 資料夾。job_evidences.classification_run_id;報表演算法不動。upload_files 三欄+RLS(走套件 model,dev 期 path dependency,完成後發版);IUploadFileProvider 介面不加方法——列目錄與搬移都在 evidence_batch_files 這層做,不需要儲存後端支援資料夾。