FR-107 · 設計文件 · 2026-09-17 · 🟢 16/31 Done,3 張派出中(T-3.4/T-4.1/T-5.3b)

分類線搬回系統儲存、分完搬進任務、框架不寫死

本文把 討論稿 依決策者 2026-09-16 的裁示(D1–D12)落成可拆卡的規格:資料表到欄位、port 到 Python 簽名、容器工作目錄契約、prompt 組裝、歸檔演算法、六棒 25 張子任務卡與端到端驗收劇本。D11 是本文與討論稿最大的差異:本案新增功能全部進既有套件 jedi-evidence-classification,主專案只留宿主 adapter 與前端。

🟢 16/31 Done,3 張派出中 母卡 CM-1843/子卡 CM-1844~1871、1877~1878 D1–D13 已裁(2026-09-16/17) 六棒 25 張子任務卡+1 張 mockup 卡 動兩支套件:jedi-evidence-classification/jedi-file-upload 討論稿:discussion.md

狀態:🟢 16/31 張 Done,3 張派出中(T-3.4/T-4.1/T-5.3b)|建立日期:2026-09-16|決策者裁示日:2026-09-16/17 Notion:母卡 CM-1843|子需求 CM-1844~1849|子任務 CM-1850~1871、1877~1879 討論稿(含現況盤點原文):discussion.html|前作:FR-030(Drive 版自動分類)/FR-031(分類結果報表)

🔴 這份是設計決策,不是現況。 內文的座標與數字停在 2026-09-16 定稿當時,之後實作改動的結果不回頭改寫這裡——現況一律看 FINAL-SPEC.md(收口時產出)。

§1

變更紀錄

日期 變更 對應
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

§2

分工概述(30 秒版)

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

先解釋幾個詞(白話):

  • 儲存後端:系統把上傳檔案實際放在哪裡——主機硬碟、物件儲存(MinIO/SeaweedFS,類似自架雲端硬碟)、或客戶內網的遠端 agent。設定切一下就換,程式不用改。
  • 暫存批次:使用者一次上傳的一堆證據檔,還沒分類、還沒掛任何任務,先放在一個「籃子」裡。
  • AO(Assessment Objective,評估目標):一條控制項底下的細分檢查點。分類就是把每份檔對到它證明的那幾個檢查點。
  • 任務:稽核輪次裡每個檢查點對應的工作項;證據最終要掛在任務上,稽核員才看得到。
  • port:套件開出來的「插座」——套件只說「我需要有人幫我拿檔/找任務」,怎麼拿由主專案(宿主)插上去的 adapter 決定。
  • 宿主 adapter:主專案為每個 port 寫的實作,本案落在 core/plugins/evidence_classification.py。
棒 做什麼(一句話) 產出 跨 repo/動哪個套件 完成怎麼判定(決策者親手可做)
.1 暫存批次+安全地基
CM-1844
建「批次」「批次檔」兩張表;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+容器改取檔
CM-1845
套件開「取檔/放檔」與「找任務/掛證據」兩個 port;主專案接到現有儲存後端;容器改成只讀掛進來的目錄,不再連 Drive 與資料庫;容器 image 搬進套件 repo 套件兩支 port + 主專案兩支 adapter + 容器 image 改版(改名 evidence-classifier) 套件 jedi-evidence-classification(port+容器 image)、BE(adapter) 一台沒有 Drive 憑證、沒有資料庫密碼的容器跑一次分類能拿到檔、能回結果;儲存後端從 local 切到 seaweedfs 再跑一次,程式零改動;docker inspect 看新容器環境變數只剩 AI 金鑰
.3 分類跑在批次上
CM-1846
觸發分類改吃「批次」不吃「AP 的 Drive 資料夾」;結果掛批次;批次狀態機落地(分類中不可加檔);job 狀態 DB 化最小版(心跳+啟動掃逾時) 觸發/查詢/狀態 API + 前端審閱頁改資料來源 套件 jedi-evidence-classification、BE(migration 落地)、FE 上傳一批 → 按分類 → 分類中再上傳被擋(有訊息)→ 分完在審閱頁看到每檔對到的檢查點,能加減、標不適用、儲存;分類中把 BE 砍掉重啟,30 分鐘後批次自動變「失敗」可重跑
.4 歸檔進任務
CM-1847
確認後把檔搬進對應任務(同一筆檔案紀錄改綁任務,不複製實體檔);一檔多檢查點=每個任務各一筆證據紀錄;沒對到任務的留在批次標「無對應任務」;搬完問要不要清掉剩餘檔(實刪) 歸檔 API + 清理 API + job_evidences 新來源值 + 前端歸檔對話框 套件 jedi-evidence-classification(歸檔/清理 API)、BE(job_evidences migration+宿主掛證據 adapter)、FE 歸檔後到「我的任務」證據頁看得到那份檔;DEV 查 job_evidences 有 source='AI_CLASSIFIED' 的列、upload_files 沒多出實體副本;同一批連按兩次歸檔不會產生重複證據;清掉剩餘檔後儲存後端上實體檔不見、批次檔表那列標 removed
.5 框架去硬編碼
CM-1848
目標集、檢查點代號、領域全走 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 舊線退場+收口
CM-1849
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。


§3

需求背景與端到端流程

前作 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,冪等)
  → 沒任務的標「無對應任務」→ 問要不要清掉剩餘檔(實刪)

§4

決策定案表(D1–D12)

決策者 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

§5

現況接入點盤點

以下座標 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),不在套件裡另寫一套

§6

詳細設計

6.1 資料模型

🔴 硬約束: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
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 批次狀態機

%%{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 --> [*]
圖 1 — 批次狀態機(D4 心跳逾時併入 failed)

轉換規則表(套件 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 新增)

@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 架構圖與時序圖

%%{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
圖 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'}}}%%
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
圖 3 — 上傳 → 分類 → 確認 → 歸檔端到端時序

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:

@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 或容器模組內):

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 設定——客戶機上的值可能已經被後續操作改過,升級不該蓋回去。


§7

拆分表(六棒 25 張子任務卡)

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

FR-107.1 暫存批次+安全地基 — 依賴:無(子需求卡 CM-1844)

# 子任務 範圍(動哪些檔/表/套件) 驗收條件 依賴
T-1.1
CM-1850
✅ 驗收通過 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
CM-1851
✅ 驗收通過 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
CM-1852
✅ 驗收通過 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
CM-1853
✅ 驗收通過(依 T-1.4m mockup 重刻,FE 3dc7e93 起)
[FE] 上傳到批次頁(舊版被退回;重做規格見 T-1.4m) 新頁 EvidenceBatchUpload.vue(選輪次→建批次→拖拉多檔→列表→刪檔→完成上傳) ← 決策者裁定整頁重新設計:流程引導+歷史清單頁+新建與上傳分開+更名「自動分類證據」+檔案可預覽+大量檔案分頁 原驗收條件(作廢,新驗收條件待 T-1.4m mockup 過目後隨重派卡片重寫) T-1.3
T-1.4m
CM-1878
✅ 驗收通過(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)

# 子任務 範圍 驗收條件 依賴
T-2.1
CM-1854
✅ 驗收通過(套件 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
CM-1855
✅ 驗收通過
**[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
CM-1856
✅ 驗收通過(套件 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)

# 子任務 範圍 驗收條件 依賴
T-3.1
CM-1857
✅ 驗收通過(套件 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
CM-1858
✅ 驗收通過(套件 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
CM-1859
✅ 驗收通過(套件 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
CM-1860
🔄 派出中
[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)

# 子任務 範圍 驗收條件 依賴
T-4.1
CM-1861
🔄 派出中
[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
CM-1862
[套件 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
CM-1863
[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)

# 子任務 範圍 驗收條件 依賴
T-5.1
CM-1864
✅ 驗收通過(套件 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
CM-1865
✅ 驗收通過(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
CM-1866
✅ 驗收通過(套件 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
CM-1877
🔄 派出中
[套件 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
CM-1867
✅ 驗收通過(三輪修正後,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
CM-1879
✅ 驗收通過(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)

# 子任務 範圍 驗收條件 依賴
T-6.1
CM-1868
[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
CM-1869
**[兩支套件] 發版+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
CM-1870
[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
CM-1871
[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

§8

端到端驗收(決策者親手走一遍)

環境: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 裁示)。

§9

風險與債

# 風險/債 影響 對策
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 可調

§10

邊界(本案不做)

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

§11

寫作時發現的事(決策者 2026-09-16 已裁)

寫作時對照程式碼發現的矛盾與缺口,不在 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)。