# FR-031 — 證據分類「分析結果報表」設計書（SD）

| 項目 | 內容 |
|------|------|
| FR | FR-031（relates to FR-030 證據自動分類 arc）|
| Branch | `feature/ai-analysis-result-landing`（BE + FE 同名）|
| 白話需求 | [`raw-requirement.md`](./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 兩處進入。

---

## 0. 已拍板的決策（discussion 收斂）

| # | 決策 | 選擇 |
|---|------|------|
| 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 匯出不做 |

---

## 1. 資料流（已追 code 驗證）

```
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 後仍可解析。

---

## 2. DDD 分層 — 新增 / 異動檔案

### 新增（持久層，模組目前完全沒有 DB infra）

| 層 | 檔案 | 角色 |
|---|---|---|
| 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 端點 |

### 異動（既有 service 補 DB 寫入點 — 決策 D2）

| 檔案 | 異動 |
|---|---|
| `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 行為），符合「不動既有畫面」。

---

## 3. DB Schema

### 主表 `compliance.evidence_classification_runs`

放 `compliance` schema（與 `compliance.projects` 同 schema）。跨 schema 用 Integer soft reference（不建 FK constraint，依專案慣例）。

```sql
-- 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;
```

**設計理由**：
- 三個 Drive 產物（`report_original` / `state` / `container_log`）整包進 JSONB/TEXT → 滿足「欄位一直調整」零改 schema。
- 只把要查/排序/列表/降級判定的少數欄位升正式 column。
- `run_folder_id` UNIQUE → upsert 自然鍵；報表/state 端點用它查 tenant（解 restart 問題）。
- **不需** `approved_*` 欄位（報告① 走即時，D4）。
- RLS：依專案慣例，DB 層 RLS 處理 tenant 隔離，Python 查詢不另加 tenant filter；`tenant_id` 欄位供 RLS policy / 列表用。

### Entity（domain，乾淨欄位 + 兩個 JSONB 當 dict）

`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 查詢）。

---

## 4. 既有 service 的 DB 寫入整合（決策 D2）

三個既有 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。
- **容錯**：DB 寫入失敗**不可**讓既有 Drive 流程回滾（Drive 已成功是事實）。包 try/except + `logger.error`，但仍照專案「不 silent fail」原則記錄；報表頁讀不到 DB row 時回 404（`EC_RUN_NOT_PERSISTED`）。
- **歷史 run 回填**：既有 Drive 上已存在、DB 沒有的 run → GET state / report 時 lazy 從 Drive 載入並 upsert 一次（沿用既有 Drive scan recovery 概念）。避免本期之前的 run 看不到報表。

---

## 5. 報表計算演算法（移植，純函式）

兩個 builder 吃 `(report_original: dict, state: dict, catalog, canon)` → 回結構化 JSON。**純計算、無 DB/Drive 副作用**，好測。

### 5.1 共用（`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`。
- **family 不需要**：報告①（控制項/AO 層級）與報告②（控制項精確比對，「即使同家族也算」）都不依賴 family；family 僅標準工具 `gen_problem_files`（未納入）使用。
- **控制項中文名**：catalog 的 `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`）。

### 5.2 報告①（`validation_report_builder.py`，移植 `validate.py`）

**降級判定**（D5）：`state.metadata.last_edited_at` 或 `archived_at` 皆空 → `mode="degraded"`。

```jsonc
{
  "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}] } ]
}
```
- 缺漏四級 label：`版本/格式差異` / `AO選錯(同控制項)` / `分到其他控制項` / `AI完全未分類`；多餘四級對稱。
- 控制項層級用 `doc_key` 聯集比對；AO 層級精確檔名。
- **degraded** 時 `summary` / `control_rows` 的 recall/precision 省略，只給 AI 放置清單 + `unclassified` + `cost`。

### 5.3 報告②（`adjudication_report_builder.py`，移植 `gen_ao_adjudication.py`）

CANON 缺（framework 無對應 JSON）→ `available=false`，FE 顯示「此框架尚未建立正解基準」。

```jsonc
{
  "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）
}
```
- 判定：控制項精確比對（CANON 列出＝對、放別的＝錯，即使同家族）；CANON 空集合＝超範圍；不在 CANON＝未裁定（不判對錯）。
- User 側 = `user_placements(state)`；AI 側 = `ai_placements(report_original)`。

### 5.4 CANON 資源結構（`cmmc_l1_canon.json`）

從現有 `gen_ao_adjudication.py` 的 `CANON` dict 移植，**通用結構化**：

```json
{
  "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 無對應" }
  }
}
```
- 比對 key = 檔名（NFC）；`controls` 空陣列＝超出範圍。
- 這階段**固定 seed**，直接編檔即改判定（同現行做法）。未來 FR 才做規則表/維護 UI（D6）。

---

## 6. API 端點

沿用既有路由前綴 `/api/1.0/classification-run/<run_folder_id>/...`。權限同既有 GET state（v0：僅檢 JWT，不檢 project 角色 — 與既有報表/審閱頁一致；正式版再收緊）。

### GET `/classification-run/<run_folder_id>/report/validation`
回 §5.2 結構。

### GET `/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`（正常情境，非錯誤）。

---

## 7. FE — 兩個獨立報表頁 + 兩入口

### 路由（新增，獨立頁）

| 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)`。
- PrimeVue 重畫：KPI 卡（`Card`）、控制項表（`DataTable`）、逐 AO 明細（`Accordion`/`Panel`）、四級/判定用 `Tag` severity + design tokens。**不沿用**標準工具 inline-CSS HTML。
- 成本卡受既有 `SHOW_RUN_COST` 概念 flag 控制。
- degraded：頂部 `Message` 提示「尚未經人工審閱，召回/精確僅供參考」；隱藏 KPI/control 表，只顯示放置清單 + 未分類 + 成本。

### 入口連結（**僅加導覽連結，不改既有行為**）

1. **run 詳情頁 = 既有審閱頁** `EvidenceClassificationReview.vue` header → 加兩個 outlined Button「驗證報表」「各 AO 誰對誰錯」`router.push` 到上面兩 route。
2. **AP 總覽 → 自動分類證據 Dialog** `AIEvidenceClassificationDialog.vue` 歷史執行每筆 job row（`歷史執行資料後面`）→ 既有「審閱」Button 旁加兩個報表連結（`status==='completed' && run_folder_id`）。

> 這是唯一觸及既有元件的地方，且**只新增 navigation，不動歸檔/比較/審閱邏輯**。

---

## 8. 降級與邊界

- 報告① `mode` 由 `last_edited_at`/`archived_at` 決定（D5）；即時讀 DB `state`（D2 確保 DB 同步）。
- 報告② `note_user_equals_ai`：未審閱時 User 側=AI，FE 標示避免誤讀（開放問題 4）。
- 比對以**檔名**為準（不比內容）；同名不同內容無法分辨（沿用標準工具限制）。
- 框架範圍外（L2 證據）→ CANON 空集合 → 標超範圍，不硬塞。
- 多 run 跨批比較（不同 prompt/model）這階段不做跨 run 彙整頁（`gen_summary` 對應），未來再說。

---

## 9. 測試（pytest，在 BE）

- **report builder 純函式測試**（重點）：餵造好的 `report_original` + `state` + 迷你 catalog + 迷你 CANON dict → 斷言 recall/precision/四級/未分類三類/User標錯/AI多放/超範圍/未裁定。涵蓋：
  - 完整版 vs 降級版（`last_edited_at` 有無）
  - `doc_key` 同文件不同格式
  - CANON 空集合（超範圍）、不在 CANON（未裁定）
  - 報告② User=AI（未審閱）case
- **DB 整合測試**：`_finalize_drive_output` / `put_state` / `archive_run` 後 run row 欄位正確（mock Drive ops）。
- **app service test 必加 logger patch autouse fixture**（jedi DBLogHandler 對 SessionLocal=None 會炸，見 memory `feedback_test_logger_patch_db_handler`）。
- E2E（在 compliance-manager-test，後續）：跑分類 → 開兩報表頁 → 斷言關鍵數字/區塊。

---

## 10. 整合點 / 既有資產

| 資產 | 用途 |
|---|---|
| `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 寫出格式來源 |

---

## 11. 範圍邊界

**做**：2 報表頁（含降級）、2 報表 API、run 落 DB（DB+Drive）、CANON/catalog 固定資源、2 入口連結、pytest。

**不做（未來 FR）**：動態 CANON / `canon_rules` 規則表 / 稽核員維護 UI / 管理員核可流程 / HTML·PDF 匯出 / 多框架 / 跨 run 彙整總表 / 停用 Drive 只用 DB。

---

## 12. 開放問題（待 user 拍板，不卡主結構）

1. **歷史 run 回填**：本期之前已在 Drive 的舊 run，要不要在首次開報表時 lazy upsert 進 DB？（傾向要，否則舊 run 無報表）
2. **report builder 移植方式**：純 Python 重寫（建議，融入 DDD、好測）vs 直接包現有腳本當 subprocess（快但難測、難維護）。design 採**重寫**。
3. **report② User=AI 未審閱時**的 UI 標示文案。
4. ~~catalog 是否含 family~~ **已驗證**：catalog 結構 `domains[]→controls[]`（`id`/`name`英文/`statement`/AOs）；報告①②**用不到 family**（已從 §5.1 移除）。唯一補充：控制項中文名 catalog 沒有 → 移植 `CTRL_NAME` 16 條小 map。
5. 成本顯示 flag：沿用現有 `SHOW_RUN_COST` 思路，報表頁是否預設開（測試期）/ 關（對外）。

---

## 13. 實作後對齊（Reconciliation，2026-05-31 收尾補）

實作期間（含實機對照外部 script + user 回饋）對原設計的**重大偏離**，逐條記錄：

### 13.1 新增「正解匯入」(Option B) — 偏離 D4
- **原設計**：報告① 的 ground truth = 審閱後的 `_state` placements（D4）。
- **實際**：實機看到**未審閱的 run** 報告①極稀疏（`_state`==AI → 召回/精確無意義、缺漏多餘全空）。而驗證情境本來就有現成的 User 正解（亞航 `Source_Evidences`）。
- **改動**：新增 per-tenant 正解基準
  - 新表 `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）。
  - **報告① + 報告② 都改成：有匯入正解就用它當 User 側**（`build_*_report(..., ground_truth=)`），否則 fallback 審閱後 `_state`。有正解 → 永遠 full、`gt_source="import"`。
  - 亞航 63 檔正解已從 `Source_Evidences`（crosswalk 在產製時做掉、存 NIST）seed 進 DB（tenant 102）。
- **未做（follow-up）**：FE 正解上傳 UI（目前只有 API + 手動 seed）；動態正解（user 審閱後自動成正解）。

### 13.2 報告② 也吃匯入正解 — 修「兩份報告矛盾」
- **Bug**：報告① 用匯入正解當 User、報告② 仍用 `_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）。

### 13.3 報告① 結構強化 + 移除 attention — 對齊 script HTML
- 逐 AO 明細從**扁平** `ao_detail` 改成 **`ao_by_control`（依控制項分組 + 每控制項 summary：n_diff/n_aos/缺漏/多餘）**，且全列（含雙方皆空/完全一致）。
- 未分類診斷加：**4 路救回彙整**（`recover_summary`：降門檻可救/救控制項/候選不符/0分）+ **「降門檻救不回」清單**（`no_recover`）+ 「AI評0分」表（`zero_files`）。
- 加「**重點解讀**」note。
- **移除「需要關注的項目（依嚴重度）」段**（attention）——原本已做，**user 指示拿掉**。

### 13.4 FE 渲染方式改變 — 偏離 D7
- **原設計 D7**：FE 用 PrimeVue「重畫」結構化 JSON。
- **實際**：為了跟外部 script 兩份 HTML **視覺一致**（user 要求），FE 改成**1:1 移植 script HTML 的版面 + CSS 配色**（custom scoped CSS：KPI 卡著色 / 缺漏多餘彩色 chip / 召回長條 / 巢狀 `<details>` / verdict 著色），PrimeVue 只保留 `Button`/`LoadingState`。字級放大兩階、內容寬 1140px。
- 驗證方法：Playwright serve script HTML → 截圖 → Read 比對（解決「Claude 看不到 render 結果」的盲點）。

### 13.5 其它
- 新增 error code `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`](./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}.md`
- arc 收尾：`docs/features/FR-030.3-2605-evidence-classify-xlsx-pptx/handoff/2026-05-30-evidence-classify-arc-SUMMARY.md`
