| 項目 | 內容 |
|---|---|
| FR | FR-031(relates to FR-030 證據自動分類 arc) |
| Branch | feature/ai-analysis-result-landing(BE + FE 同名) |
| 白話需求 | raw-requirement.md(Phase 0,已收斂) |
| 狀態 | Phase 1 設計 — 待 spec review + user 確認 → 進 Phase 3 implementation-plan |
| 範圍 | BE(新增 DDD 持久層 + 報表計算 + 2 報表 API)+ FE(2 報表頁 + 2 入口連結),跨 2 repo;jedi-* 不動 |
白話「為什麼做 / 客戶情境 / 兩份報表內容」見
raw-requirement.md,本文件只談技術設計。
把既有獨立 Python 報表專案(validate.py / gen_ao_adjudication.py)的兩份報表演算法移植進 BE,每次分類 run 的結果落 DB,並提供 2 個系統內獨立唯讀報表頁(PrimeVue 重畫),從 run 詳情頁 + AP 總覽 Dialog 兩處進入。
| # | 決策 | 選擇 |
|---|---|---|
| D1 | run 結果儲存 | DB + Drive 兩邊都存(這階段);DB 主表 JSONB 鏡像三個 Drive JSON |
| D2 | 報表頁「即時讀」後端接法 | (b) 在既有 put_state / archive_run / _finalize_drive_output 補寫 DB;報表讀 DB(DB 為唯一真實來源) |
| D3 | CANON 固定基準存放 | (a) repo JSON 資源檔(cmmc_l1_canon.json),零 migration、可直接編輯 |
| D4 | 報告① ground truth | run 的 _state.json placements(User 審閱後);即時讀,不做確認按鈕/快照 |
| D5 | 報告① 完整 vs 降級 | 讀 metadata last_edited_at/archived_at:任一非空 = 完整版;皆空 = 降級「純 AI 檢視」 |
| D6 | 動態 CANON / 規則表 / 維護 UI / 核可流程 | 不在本期(未來 FR) |
| D7 | 報表輸出形態 | BE 算結構化 JSON、FE 渲染(規格 Option B);HTML/PDF 匯出不做 |
POST classify-evidence (既有)
→ container write_outputs: _report-original.json (AI 原始, immutable)
_state.json (placements 初始 = matches≥threshold, source="ai")
→ BE _finalize_drive_output: 建 run folder、上傳兩 JSON、(完整歸檔時) 複製檔進 AO 樹
★ 新增:UPSERT compliance.evidence_classification_runs(鏡像兩 JSON + container_log)
→ [既有審閱頁] put_state: diff placements、Drive copy/trash、覆寫 Drive _state.json
★ 新增:UPDATE runs.state + last_edited_at/by
→ [既有] archive_run: 補複製、翻 archive_files
★ 新增:UPDATE runs.state + archive_files/archived_at/by
→ [新報表頁] GET .../report/{validation|adjudication}
讀 DB run row(report_original + state + container_log)
× cmmc_l1_aos.json (catalog) × cmmc_l1_canon.json (CANON)
→ 報表計算 → 結構化 JSON → FE 渲染
對應關係(標準工具 → 系統內,免 crosswalk,系統內已是 NIST):
| 概念 | 系統內來源 |
|---|---|
| AI 放置 | runs.report_original → 每檔 matches confidence ≥ threshold |
| User 正解(ground truth) | runs.state → placements(ao_id → files,審閱後) |
| CANON(報告②) | cmmc_l1_canon.json(檔名 → 控制項集合 + 理由) |
| catalog | 既有 cmmc_l1_aos.json(ao_id / control / 名稱 / AO 文字);family 用本模組靜態 map |
| Docker log | runs.container_log(text) |
附帶解掉的 v0 限制:目前 _resolve_tenant_for_run_folder 靠 in-memory job 表反查 tenant,BE restart 後失效(api-spec「v0 限制」段)。有了 DB run 表,報表/state 端點可直接從 runs.tenant_id 反查 → restart 後仍可解析。
| 層 | 檔案 | 角色 |
|---|---|---|
| domain | domain/evidence_classification/entity/classification_run_entity.py |
run entity + ClassificationRunQueryEntity |
| domain | domain/evidence_classification/repository/classification_run_repository.py |
repo interface(abstract) |
| domain | domain/evidence_classification/service/classification_run_domain_service.py |
domain service |
| infra | infra/evidence_classification/model/classification_run_model.py |
SQLAlchemy ORM model |
| infra | infra/evidence_classification/mapper/classification_run_mapper.py |
entity ↔︎ model |
| infra | infra/evidence_classification/repository/classification_run_repository_impl.py |
繼承 BaseRepositoryImpl |
| 層 | 檔案 | 角色 |
|---|---|---|
| app | app/evidence_classification/service/report/validation_report_builder.py |
報告① 演算法(移植 validate.py) |
| app | app/evidence_classification/service/report/adjudication_report_builder.py |
報告② 演算法(移植 gen_ao_adjudication.py) |
| app | app/evidence_classification/service/report/report_common.py |
共用:doc_key / normalize_aoid / catalog 載入 / family map / container_log 解析 |
| 資源 | app/evidence_classification/resources/cmmc_l1_canon.json |
CANON 固定基準(移植現有 63 檔 → 通用結構) |
| api | api/evidence_classification/routes/evidence_classification_route.py |
異動:加 2 個 report 端點 |
| 檔案 | 異動 |
|---|---|
app/evidence_classification/service/evidence_classification_service.py |
__init__ 注入 classification_run_domain_service;_finalize_drive_output / put_state / archive_run 各補一次 DB upsert/update;新增 get_validation_report / get_adjudication_report 兩個 @transaction method |
di_containers/evidence_classification/evidence_classification_containers.py |
wire run repo / domain service;service 加參數 |
common/code/evidence_classification_error_code.py |
補 error code(見 §6) |
既有審閱/歸檔/比較畫面與其行為零改動;DB 寫入是後端旁路(不改 user-facing 行為),符合「不動既有畫面」。
compliance.evidence_classification_runs放 compliance schema(與 compliance.projects 同 schema)。跨 schema 用 Integer soft reference(不建 FK constraint,依專案慣例)。
-- Date: 2026-05-31
-- FR-031 證據分類分析結果報表 — run 結果落 DB
CREATE TABLE compliance.evidence_classification_runs (
id BIGSERIAL PRIMARY KEY, -- (2026-05-31)
run_folder_id VARCHAR(128) NOT NULL, -- Drive run folder id(自然鍵)
run_folder_name VARCHAR(255),
tenant_id INTEGER NOT NULL, -- soft ref
project_id INTEGER NOT NULL, -- soft ref compliance.projects.id
project_uid VARCHAR(36), -- FE 列表/URL 用
ap_uid VARCHAR(36), -- soft ref(AP uid;FE 列表用。改 uid 因 _finalize 只 thread 得到 uid)
org_unit_id INTEGER,
framework_id VARCHAR(64) NOT NULL DEFAULT 'cmmc-l1',
model VARCHAR(64),
confidence_threshold NUMERIC(4,2),
status VARCHAR(32) NOT NULL DEFAULT 'completed', -- queued/running/completed/failed
archive_files BOOLEAN NOT NULL DEFAULT TRUE,
input_file_count INTEGER,
classified_count INTEGER,
estimated_cost_usd NUMERIC(10,4), -- 另留 metadata.token_usage 內
triggered_at TIMESTAMPTZ,
completed_at TIMESTAMPTZ,
last_edited_at TIMESTAMPTZ, -- 報告① 降級判定用
archived_at TIMESTAMPTZ,
triggered_by_user_id INTEGER,
last_edited_by_user_id INTEGER,
archived_by_user_id INTEGER,
report_original JSONB, -- _report-original.json 全文(AI 原始, immutable)
state JSONB, -- _state.json 全文(當前狀態, 與 Drive 同步)
container_log TEXT, -- _container-log.txt
-- audit 欄位由 jedi_common BaseModel 提供,欄名固定為下列(勿改名)
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
created_user VARCHAR(50),
updated_user VARCHAR(50)
);
CREATE UNIQUE INDEX uq_ecr_run_folder_id ON compliance.evidence_classification_runs (run_folder_id); -- (2026-05-31)
CREATE INDEX ix_ecr_project_ap ON compliance.evidence_classification_runs (project_uid, ap_uid); -- (2026-05-31)
CREATE INDEX ix_ecr_tenant ON compliance.evidence_classification_runs (tenant_id); -- (2026-05-31)
-- 權限(CLAUDE.md 規範)
GRANT SELECT, INSERT, UPDATE, DELETE ON compliance.evidence_classification_runs TO cm_app;
GRANT USAGE, SELECT ON SEQUENCE compliance.evidence_classification_runs_id_seq TO cm_app;設計理由:
report_original / state / container_log)整包進 JSONB/TEXT → 滿足「欄位一直調整」零改 schema。run_folder_id UNIQUE → upsert 自然鍵;報表/state 端點用它查 tenant(解 restart 問題)。approved_* 欄位(報告① 走即時,D4)。tenant_id 欄位供 RLS policy / 列表用。ClassificationRunEntity:對應上表欄位,report_original / state 以 dict 持有、container_log 以 str。ClassificationRunQueryEntity(run_folder_id / project_uid / ap_uid / tenant_id)。
Phase 0 驗證修正:
_finalize_drive_output只有tenant_id。project_id/project_uid/model/confidence_threshold/framework_id/archive_files/triggered_by從JobRegistry.get(job_uid)取;org_unit_id從get_user_context()取;ap_uid需在trigger_classify的JobRegistry.create(...)補存ap_uid=ap_uid(registry 原本沒存)。故 soft ref 用ap_uid(非ap_id,省一次 AP 查詢)。
三個既有 method 各補一次 DB 操作,不改既有 Drive 行為、不改回傳:
| Method | 既有行為 | 新增 DB 操作 |
|---|---|---|
_finalize_drive_output |
建 run folder、上傳兩 JSON、(完整歸檔) 複製檔 | run_ds.upsert() 建 row:寫入 project/ap/tenant/model/threshold/status/archive_files/timestamps + report_original + state + container_log |
put_state |
diff、Drive copy/trash、覆寫 Drive _state.json |
run_ds.update_state(run_folder_id, new_state, user_id):更新 state JSONB + last_edited_at/last_edited_by_user_id |
archive_run |
補複製、翻 archive_files=true |
run_ds.update_state(...) + archive_files/archived_at/archived_by_user_id |
@transaction scope 內(既有),domain service 沿用同 transaction。logger.error,但仍照專案「不 silent fail」原則記錄;報表頁讀不到 DB row 時回 404(EC_RUN_NOT_PERSISTED)。兩個 builder 吃 (report_original: dict, state: dict, catalog, canon) → 回結構化 JSON。純計算、無 DB/Drive 副作用,好測。
report_common.py)移植自兩支 Python,砍掉 crosswalk(系統內已 NIST):
doc_key(fn):去副檔名 / 6+ 位日期 / 已簽名 / 空白底線括號 → 小寫(同文件不同格式判定)。normalize_aoid(ao_id, valid_aoids):AI 偶發漏 [字母],單一 AO 自動補。load_catalog(framework_id):讀 cmmc_l1_aos.json(結構 domains[] → controls[],含 id/name(英文)/statement/assessment_objectives)→ 攤平成 {ao_id: {control_id, control_name, ao_text}} + valid_aoids。gen_problem_files(未納入)使用。name 是英文;報表頁要中文顯示,用本模組小靜態 map 或 i18n 補(移植 gen_ao_adjudication.py::CTRL_NAME 16 條)。parse_container_log(text):exit_code / processed / classified / elapsed / stderr / errors / 逐檔 unclassified。ai_placements(report_original, threshold, valid_aoids) → {ao_id: [file]}、unplaced、file_diag(候選/best/primary)。user_placements(state) → {ao_id: [file]}(讀 state.files[].placements,可選排除 is_deleted/is_na)。validation_report_builder.py,移植 validate.py)降級判定(D5):state.metadata.last_edited_at 或 archived_at 皆空 → mode="degraded"。
{
"mode": "full" | "degraded",
"run": { run_folder_id, run_folder_name, model, confidence_threshold,
triggered_at, completed_at, last_edited_at, archived_at },
"cost": { input_tokens, output_tokens, cache_read_input_tokens,
total_tokens, estimated_cost_usd } | null, // 受 FE flag 控制顯示
"summary": { // 僅 full
"ao_level": { recall_strict, recall_soft, precision_strict, gt_total, ai_total, match, soft_match },
"control_level": { recall, precision, hit, gt, ai, miss, extra },
"missing": { total, unclassified, wrong_control, wrong_ao, version_format },
"extra": { total, never, wrong_control, wrong_ao, version_format }
},
"control_rows": [ { control_id, control_name, gt, ai, hit, miss:[file], extra:[file] } ],
"unclassified": { // 池子層級三類診斷(恆有,degraded 也算)
pool_count, unplaced_count,
"catA": [ { file, user_locations:[{ao_id}], candidates:[{ao_id,confidence}], recover:{type,text} } ],
"catB": [ { file, ai_sibling, candidates } ],
"catC": [ { file, candidates } ],
"zero_files": [ { file, category, user_locations } ]
},
"docker_log": { exit_code, processed, classified, elapsed, stderr, errors:[], not_logged:[], fail:[] } | null,
"ao_detail": [ { control_id, control_name, ao_id, ao_text, status,
gt:[file], ai:[file], match:[file],
missing:[{file,label}], extra:[{file,label}] } ]
}
版本/格式差異 / AO選錯(同控制項) / 分到其他控制項 / AI完全未分類;多餘四級對稱。doc_key 聯集比對;AO 層級精確檔名。summary / control_rows 的 recall/precision 省略,只給 AI 放置清單 + unclassified + cost。adjudication_report_builder.py,移植 gen_ao_adjudication.py)CANON 缺(framework 無對應 JSON)→ available=false,FE 顯示「此框架尚未建立正解基準」。
{
"available": true | false,
"run": { run_folder_id, model, confidence_threshold },
"user_wrong": [ { file, placed_control, placed_control_name, canon:[control_id], ai_has_canon: bool, reason } ],
"ai_wrong": [ { file, placed_control, placed_control_name, canon:[control_id], reason } ],
"out_scope": [ { file, user:[control_id], ai:[control_id], reason } ],
"undecided": [ { file } ], // 不在 CANON(未裁定)
"by_control": [ { control_id, control_name, has_issue,
aos:[ { ao_id, ao_text,
files:[ { file, user_verdict, ai_verdict, conclusion, canon:[control_id] } ] } ] } ],
"note_user_equals_ai": bool // last_edited 為空時 = true(User 側=AI,提醒避免誤讀,開放問題4)
}
user_placements(state);AI 側 = ai_placements(report_original)。cmmc_l1_canon.json)從現有 gen_ao_adjudication.py 的 CANON dict 移植,通用結構化:
{
"framework_id": "cmmc-l1",
"rules": {
"Login_Fido_Setting.png": { "controls": ["IA.L1-3.5.2"], "rationale": "FIDO 登入設定=身分鑑別機制" },
"NTP_Setting.png": { "controls": [], "rationale": "時鐘同步屬 L2(AU.L2-3.3.7),L1 無對應" }
}
}controls 空陣列=超出範圍。沿用既有路由前綴 /api/1.0/classification-run/<run_folder_id>/...。權限同既有 GET state(v0:僅檢 JWT,不檢 project 角色 — 與既有報表/審閱頁一致;正式版再收緊)。
/classification-run/<run_folder_id>/report/validation回 §5.2 結構。
/classification-run/<run_folder_id>/report/adjudication回 §5.3 結構。
Errors(新增 code):
| Code | HTTP | When |
|---|---|---|
EC_RUN_NOT_PERSISTED |
404 | DB 無此 run row 且 Drive 回填也失敗 |
EC_CATALOG_NOT_FOUND |
404 | framework catalog 載入失敗(沿用 / 或新增) |
報告② 無 CANON 不報錯,回
available=false(正常情境,非錯誤)。
| name | path | 元件 |
|---|---|---|
evidence-classification-report-validation |
.../classify-evidence/run/:runFolderId/report/validation |
EvidenceClassificationValidationReport.vue |
evidence-classification-report-adjudication |
.../classify-evidence/run/:runFolderId/report/adjudication |
EvidenceClassificationAdjudicationReport.vue |
EvidenceClassificationService 新增 getValidationReport(runFolderId) / getAdjudicationReport(runFolderId)。Card)、控制項表(DataTable)、逐 AO 明細(Accordion/Panel)、四級/判定用 Tag severity + design tokens。不沿用標準工具 inline-CSS HTML。SHOW_RUN_COST 概念 flag 控制。Message 提示「尚未經人工審閱,召回/精確僅供參考」;隱藏 KPI/control 表,只顯示放置清單 + 未分類 + 成本。EvidenceClassificationReview.vue header → 加兩個 outlined Button「驗證報表」「各 AO 誰對誰錯」router.push 到上面兩 route。AIEvidenceClassificationDialog.vue 歷史執行每筆 job row(歷史執行資料後面)→ 既有「審閱」Button 旁加兩個報表連結(status==='completed' && run_folder_id)。這是唯一觸及既有元件的地方,且只新增 navigation,不動歸檔/比較/審閱邏輯。
mode 由 last_edited_at/archived_at 決定(D5);即時讀 DB state(D2 確保 DB 同步)。note_user_equals_ai:未審閱時 User 側=AI,FE 標示避免誤讀(開放問題 4)。gen_summary 對應),未來再說。report_original + state + 迷你 catalog + 迷你 CANON dict → 斷言 recall/precision/四級/未分類三類/User標錯/AI多放/超範圍/未裁定。涵蓋:
last_edited_at 有無)doc_key 同文件不同格式_finalize_drive_output / put_state / archive_run 後 run row 欄位正確(mock Drive ops)。feedback_test_logger_patch_db_handler)。| 資產 | 用途 |
|---|---|
app/.../service/catalog_builder.py + cmmc_l1_aos.json |
catalog 來源(控制項/AO 定義) |
infra/.../evidence_drive_ops.py |
既有 Drive 讀寫(回填歷史 run 時讀 Drive JSON) |
BaseRepositoryImpl(jedi_common) |
run repo 繼承 |
state-json-schema.md |
state / report_original 權威欄位 schema |
container_entrypoint.py::write_outputs |
兩 JSON 寫出格式來源 |
做:2 報表頁(含降級)、2 報表 API、run 落 DB(DB+Drive)、CANON/catalog 固定資源、2 入口連結、pytest。
不做(未來 FR):動態 CANON / canon_rules 規則表 / 稽核員維護 UI / 管理員核可流程 / HTML·PDF 匯出 / 多框架 / 跨 run 彙整總表 / 停用 Drive 只用 DB。
domains[]→controls[](id/name英文/statement/AOs);報告①②用不到 family(已從 §5.1 移除)。唯一補充:控制項中文名 catalog 沒有 → 移植 CTRL_NAME 16 條小 map。SHOW_RUN_COST 思路,報表頁是否預設開(測試期)/ 關(對外)。實作期間(含實機對照外部 script + user 回饋)對原設計的重大偏離,逐條記錄:
_state placements(D4)。_state==AI → 召回/精確無意義、缺漏多餘全空)。而驗證情境本來就有現成的 User 正解(亞航 Source_Evidences)。compliance.evidence_classification_ground_truth(tenant_id, framework_id, mapping JSONB),mapping = {file_name: [ao_id]},unique (tenant_id, framework_id);migration scripts/sql/2026-05-31_fr031_evidence_classification_ground_truth.sql(已套 DEV)。完整 DDD 持久層(entity/model/mapper/repo/domain service)。POST /api/1.0/classification-ground-truth(body {framework_id, mapping, note?},per-tenant upsert)。build_*_report(..., ground_truth=)),否則 fallback 審閱後 _state。有正解 → 永遠 full、gt_source="import"。Source_Evidences(crosswalk 在產製時做掉、存 NIST)seed 進 DB(tenant 102)。_state(未審閱=AI) → 同一 run 兩個不同的「User」,互相矛盾(報告②顯示 User=AI、只有 5 格;報告①顯示 User 分了一大堆)。build_adjudication_report 加 ground_truth 參數,與報告① 同源。實機 run 對外部 script 13-53:User標錯/AI多放/超範圍 = 30/5/2,完全一致(修正前因 User=AI 只有 5/5)。ao_detail 改成 ao_by_control(依控制項分組 + 每控制項 summary:n_diff/n_aos/缺漏/多餘),且全列(含雙方皆空/完全一致)。recover_summary:降門檻可救/救控制項/候選不符/0分)+ 「降門檻救不回」清單(no_recover)+ 「AI評0分」表(zero_files)。<details> / verdict 著色),PrimeVue 只保留 Button/LoadingState。字級放大兩階、內容寬 1140px。EC_GROUND_TRUTH_INVALID (EC_400002)(除 EC_RUN_NOT_PERSISTED EC_404008)。evidence_classification_runs.project_id 放寬 nullable(Drive 回填舊 run 拿不到 project_id)。各AO誰對誰錯.html 與當前 gen_ao_adjudication.py(CANON 控制項精確比對版)同源;我移植的即此版(非更舊的 family 版)。raw-requirement.md~/Desktop/AirAsia 真實證據/CMMC佐證/_驗證/(validate.py / gen_ao_adjudication.py / WEB整合_報告產生規格.md)app/evidence_classification/service/evidence_classification_service.py、docs/api/evidence-classification/{api-spec,state-json-schema}.mddocs/features/FR-030.3-2605-evidence-classify-xlsx-pptx/handoff/2026-05-30-evidence-classify-arc-SUMMARY.md