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 張派出中(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(收口時產出)。
| 日期 | 變更 | 對應 |
|---|---|---|
| 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 |
整案一句話:把「證據自動分類」從 Google Drive 搬回系統自己的儲存空間,分完的檔直接搬進任務變成正式證據,而且不管哪個合規框架都能用——新功能全長在套件 jedi-evidence-classification 裡,主專案只負責「接線」與前端。
先解釋幾個詞(白話):
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。
前作 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,冪等)
→ 沒任務的標「無對應任務」→ 問要不要清掉剩餘檔(實刪)
決策者 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),但屬獨立需求,另立案不塞進本案 |
| 新增 | 落點 |
|---|---|
| 批次表/批次檔表/狀態機/建批次、上傳到批次、列批次、刪檔、觸發、歸檔、清理 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 |
以下座標 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),不在套件裡另寫一套 |
🔴 硬約束:upload_files 必須補租戶/擁有者/RLS,才准當暫存區用
現況 upload_files 無 tenant_id、無 owner、無 RLS,任何人拿到 uid 就能 get_file。以前這張表靠掛它的表(如 job_evidences,有 RLS)「間接」保護;暫存區的檔還沒掛任何東西,這層保護不存在。.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' 並在卡片回報筆數。
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 權限。
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。
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 指最新。
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 視為通用那筆)。
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 有無既存重複列(有就在卡片回報,不自動合併) |
| 棒 | 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 一次重產。
%%{init: {'theme':'base','themeVariables':{'primaryColor':'#E2F0F1','primaryTextColor':'#14201F','primaryBorderColor':'#0E7C86','secondaryColor':'#EEF2F3','secondaryTextColor':'#14201F','tertiaryColor':'#FBFCFC','tertiaryTextColor':'#14201F','lineColor':'#4A5A5C','textColor':'#14201F','mainBkg':'#E2F0F1','nodeBorder':'#0E7C86','nodeTextColor':'#14201F','edgeLabelBackground':'#FBFCFC','titleColor':'#14201F','clusterBkg':'#FBFCFC','clusterBorder':'#E4EAEB','actorBkg':'#E2F0F1','actorTextColor':'#14201F','actorBorder':'#0E7C86','actorLineColor':'#C7D1D2','signalColor':'#4A5A5C','signalTextColor':'#14201F','labelBoxBkgColor':'#EEF2F3','labelBoxBorderColor':'#C7D1D2','labelTextColor':'#14201F','loopTextColor':'#14201F','noteBkgColor':'#F6EBD5','noteTextColor':'#14201F','noteBorderColor':'#9C6B12','activationBkgColor':'#EEF2F3','activationBorderColor':'#C7D1D2','sequenceNumberColor':'#FFFFFF'}}}%%
stateDiagram-v2
[*] --> uploading : 建批次
uploading --> uploading : 加檔/刪檔
uploading --> ready : 至少一檔且按「完成上傳」(或按分類時自動)
ready --> uploading : 再加檔(分類尚未開始)
ready --> classifying : 按「開始分類」(封口,加檔 409)
classifying --> review : 容器回結果
classifying --> failed : 容器失敗/逾時/心跳逾時 30 分
failed --> classifying : 重跑(同批次,不必重傳)
review --> review : 審閱調整、儲存
review --> classifying : 重新分類(換 model/threshold)
review --> archived : 歸檔進任務
archived --> archived : 清掉剩餘檔(purge,只動未掛任務的檔)
archived --> [*]
轉換規則表(套件 EvidenceBatchService 內以一張 ALLOWED_TRANSITIONS dict 實作,非法轉換一律 ConflictError):
| 動作 | 允許的來源狀態 | 目標狀態 | 額外前置條件 |
|---|---|---|---|
| 加檔/刪檔 | uploading、ready(刪檔另允許 review,只能刪 pending/classified 列) |
uploading |
非 classifying(否則 409 EC_BATCH_SEALED) |
| 完成上傳 | uploading |
ready |
file_count ≥ 1 |
| 開始分類 | ready、uploading(自動先 ready)、failed、review |
classifying |
STORAGE_CONFIG 已設;目標集非空;同專案無其他 classifying 批次(沿用 JobRegistry 單活性) |
| 容器回結果 | classifying |
review |
|
| 失敗 | classifying |
failed |
|
| 歸檔 | review |
archived |
至少一檔有判定(AI 或人工) |
| 清理 | archived |
archived |
只動 status IN ('pending','classified','no_task') 的檔 |
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。
宿主起容器前把 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 天供排查)。
宿主(套件 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)。
/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 同步。
輸入: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 列永不刪。
分類 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)。
%%{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
%%{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
| 硬編碼處 | 現況 | 改法 |
|---|---|---|
| 目標集輸出 | 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 |
決策者 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 那次跑出來的報表可比對(欄位形狀一致,不要求判定結果相同)。
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 設定——客戶機上的值可能已經被後續操作改過,升級不該蓋回去。
粒度原則:每張卡單一 session 做得完;動套件的卡標 [套件],開發走 poetry path dependency、完成不還原 pin(.6 T-6.2 統一還原)。驗收條件全部是 runner 與決策者都能親手做的動作。
| # | 子任務 | 範圍(動哪些檔/表/套件) | 驗收條件 | 依賴 |
|---|---|---|---|---|
| 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.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 | 無 |
| # | 子任務 | 範圍 | 驗收條件 | 依賴 |
|---|---|---|---|---|
| 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 |
| # | 子任務 | 範圍 | 驗收條件 | 依賴 |
|---|---|---|---|---|
| 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 |
| # | 子任務 | 範圍 | 驗收條件 | 依賴 |
|---|---|---|---|---|
| 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 |
| # | 子任務 | 範圍 | 驗收條件 | 依賴 |
|---|---|---|---|---|
| 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 |
| # | 子任務 | 範圍 | 驗收條件 | 依賴 |
|---|---|---|---|---|
| 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 |
環境:DEV(BE 本機、DB localhost:5432、STORAGE_CONFIG 指 local 或 seaweedfs 皆可)。準備:一個有 living SSP(catalog 2.13)且輪次已建任務的專案;manager 帳號 A、auditor 帳號 B、另一租戶 manager 帳號 C;5 份證據檔(其中 1 份已存在某任務證據裡)。
SELECT count(*) FROM upload_files WHERE tenant_id IS NULL → 0。docker ps 看到 evidence-classifier 容器、docker inspect 環境變數只有 AI 金鑰 → 完成進「審閱」。kill -9 BE → DEV 把該批 job_heartbeat_at 改成 40 分鐘前 → 重啟 BE → 批次「失敗」→ 按重跑 → 不必重傳。job_evidences 有 source='AI_CLASSIFIED' 且 classification_run_id 非空;upload_files 沒多列;evidence_batch_files 有 removed 列且 classification 非空;儲存後端上被清的實體檔不存在。status 改回 review 再按歸檔 → 摘要 evidence_created=0、job_evidences 筆數不變。prompt_snapshot 開頭是那句;停用 → 再跑 → 通用。套件 resources/ 無 JSON。pyproject.toml 兩支套件是 pin;安裝包內有 evidence-classifier image(依 §11 裁示)。| # | 風險/債 | 影響 | 對策 |
|---|---|---|---|
| 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 可調 |
cloud_integration):不動。它負責「任務資料夾 ↔︎ Drive 資料夾」雙向;本案的批次不建 Drive 資料夾。/tmp fallback 防呆:D6 只擋批次上傳路徑;套件全域行為另案(follow-up:改成啟動時警告)。IUploadFileProvider 介面:不加方法;列目錄與搬移在批次檔表這層做。寫作時對照程式碼發現的矛盾與缺口,不在 D1–D11 範圍內,開卡前提交決策者裁定:
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 能跑完一次分類)。attach_to_job/remove_from_batch 放在 IEvidenceStorage;D11 後批次檔表在套件內,「掛進任務」屬任務疆界、「移出批次」是套件自己的表操作。本文改成:IEvidenceStorage 只管檔案實體(存/取/stage/刪),ITaskEvidenceSink 管找任務與掛證據,移出批次在套件內做。與討論稿不同,請確認。 裁示:照 design 這個切法——IEvidenceStorage 只管檔案實體,ITaskEvidenceSink 管找任務+掛證據。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,一律軟參照,程式層查存在性;與現有插件契約一致。IProjectRoleGuard 只有專案角色,需要新加一支平台管理員 port(本文 T-5.1 暫寫 IPlatformAdminGuard)。是否改為「租戶管理員可編自己租戶的 profile」需裁。 裁示:只給平台管理員,新加 IPlatformAdminGuard port;租戶層級等需求再開。 2026-09-17 修正:profile 守門放寬到租戶管理員(軸④capability storage-config.update,與 STORAGE_CONFIG 同一顆,理由見 PlatformAdminGuardAdapter docstring)——四層解析鏈裡租戶那兩層本來就只有租戶管理員自己建得出來,只給平台管理員的話那兩層永遠沒人用得到。AI 供應商金鑰設定頁維持鎖 ROOT 平台管理員(D13),兩者是不同守門軸,不要混為一談。job_evidences partial UNIQUE 前的既存重複列:DEV/POC 可能已有同 (job_execution_id, file_id) 未刪的重複列(Drive 同步早期版本)。T-4.1 先查、有就回報,不自動合併;若有,合併規則要裁。 裁示:先查不合併,查出有再裁。api.js 常數:舊十支保留給舊 run,新加約十二支;EvidenceClassificationService.js 會有兩套。可接受(D3 一版),但 .6 刪舊線時要一起清,記進 T-6.1 的 follow-up。 裁示:.6 刪舊線一起清(已在 T-6.1)。