# 自動證據分類 — Implementation Plan (Phase 3)

> Spec：`./design.md`
> 開發順序：S1 → S2 → S3 → S4（vertical slice）
> 容器環境：Local dev (host docker)
> FE：full PrimeVue + 嚴格遵守 FE CLAUDE.md 規範

---

## 整體里程碑

| Slice | 範圍 | 工作量 | 完成可驗證 |
|---|---|---|---|
| **S1** Docker container | 把現有 script 包成 `cmmc-classifier:latest`；CLI args + env；輸出 `_state.json` + `_report-original.json` 到 mount volume | 1-2 天 | `docker run` 跑 134 檔產出正確 JSON |
| **S2** BE 3 個 endpoint | `POST classify-evidence` / `GET state` / `PUT state` (含 diff + Drive ops) | 2-3 天 | curl 打 API 跑通完整流程 |
| **S3** FE 審閱頁 | 把雛形 HTML 拆成 Vue SFC + PrimeVue Ultima，接 S2 API | 2-3 天 | 審閱頁打開能載 state、編輯、儲存 |
| **S4** FE 觸發整合 | 專案總覽頁加按鈕 / 進度 / 進審閱連結 | 0.5-1 天 | E2E 從專案頁觸發到審閱完成 |

---

## S1 — Docker Container

### 目標

把 `scripts/evidence/classify/classify_evidence_drive.py` 包成可獨立執行的 docker image，**不再依賴主專案 Python 環境**。

### 任務分解

#### S1.1 Dockerfile（建 image）

位置：`scripts/evidence/classify/docker/Dockerfile`

```dockerfile
FROM python:3.11-slim
WORKDIR /app

# OS deps for python-docx etc.
RUN apt-get update && apt-get install -y --no-install-recommends \
    build-essential \
    && rm -rf /var/lib/apt/lists/*

# Python deps — minimal subset only
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# App code — only the classifier + dependencies (no full BE)
COPY classify_evidence_drive.py /app/
COPY jedi_helpers/ /app/jedi_helpers/   # FernetCrypto + GoogleOAuthClient extracted

ENTRYPOINT ["python", "/app/classify_evidence_drive.py"]
```

`requirements.txt` 只放必要：
- `anthropic>=0.77`
- `google-api-python-client>=2.196`
- `google-auth>=2.53`
- `python-docx>=1.2`
- `python-dotenv>=1.0`
- `psycopg2-binary>=2.9`
- `sqlalchemy>=2.0`
- `cryptography>=41`

#### S1.2 改寫 script — 支援 service mode

現有 script 有 `init` / `classify` subcommand。為了 BE 用，新增：

- 新 CLI mode `service-classify`：吃 args + env，跑完 dump `_state.json` 到 `--output-dir`
- 多輸出兩份檔到 output dir：
  - `_state.json` — UI 用的完整 state（含 placements）
  - `_report-original.json` — 原始 Claude 報告（immutable 備份）
- 不再做 Drive copy（純分類，BE 後續才 copy）— or 留個 `--copy-to-drive` flag 兩用

```bash
docker run --rm \
  -v /tmp/cm-jobs/<job_uid>:/job \
  -e DB_HOST=... -e DB_SECRET=... \
  -e DRIVE_TOKEN_ENCRYPTION_KEY=... \
  -e ANTHROPIC_API_KEY=... \
  -e GOOGLE_DRIVE_OAUTH_CLIENT_ID=... \
  -e GOOGLE_DRIVE_OAUTH_CLIENT_SECRET=... \
  -e GOOGLE_DRIVE_OAUTH_REDIRECT_URI=... \
  cmmc-classifier:latest \
  service-classify \
    --tenant-id 102 \
    --evidence-folder-id 1uNOR... \
    --catalog-file /job/catalog.json \
    --output-dir /job \
    --min-confidence 0.80 \
    --workers 5 \
    --copy-to-drive
```

#### S1.3 抽離 jedi 依賴

主專案的 `FernetCrypto` / `GoogleOAuthClient` 不能直接 docker 內 import（主專案太肥）。三個選項：

- A. 把 `infra.cloud_integration.crypto` + `google_drive` 對應檔複製到 `scripts/evidence/classify/docker/jedi_helpers/`（簡單但要同步維護）
- B. 包成獨立 PyPI 套件（過度工程）
- C. 直接內聯這兩個 helper（最簡單，跟 jedi-* 套件本身解耦）

**選 C** — FernetCrypto 19 行、GoogleOAuthClient `refresh_access_token` ~20 行，直接複製進 docker context。

#### S1.4 Build & Test

```bash
cd scripts/evidence/classify/docker
docker build -t cmmc-classifier:latest .
docker images | grep cmmc-classifier   # 確認 image size 合理 (<500MB)
mkdir -p /tmp/cm-jobs/test
docker run --rm \
  -v /tmp/cm-jobs/test:/job \
  --env-file ../../../.env \
  cmmc-classifier:latest service-classify \
    --tenant-id 102 \
    --evidence-folder-id 1uNORgRHPHNCnrb8tk0BZJdplezDFz6oK \
    --catalog-file /job/catalog.json \   # 預先 mount 進去
    --output-dir /job
ls /tmp/cm-jobs/test/                    # 看到 _state.json + _report-original.json
```

### S1 驗收

- [ ] docker image build 成功 < 500MB
- [ ] `docker run` 跑 134 檔生成正確的 `_state.json` 結構（per `design.md` schema）
- [ ] 跑完容器自動 exit (`--rm` 移除)
- [ ] 跟主機環境完全隔離（不依賴主機 Python / 主專案 code）
- [ ] catalog JSON 由外部 mount 進來（不打進 image，未來換框架不用 rebuild）

---

## S2 — BE 3 個 endpoint

### DDD 層級規劃

依 BE CLAUDE.md 規範：
- `api/evidence_classification/` — routes（不直接查 DB）
- `app/evidence_classification/` — application services（`@transaction`，orchestrate）
- `domain/evidence_classification/` — entity + service interface
- `infra/evidence_classification/` — repo impl, Drive ops, container subprocess

### 模組結構

```
api/evidence_classification/
  __init__.py
  routes/
    evidence_classification_route.py

app/evidence_classification/
  __init__.py
  service/
    evidence_classification_app_service.py    # POST classify-evidence orchestration
    classification_state_service.py            # GET/PUT state proxy
    classification_diff_service.py             # diff old vs new state → Drive ops
  dto/
    classification_state_dto.py

domain/evidence_classification/
  entity/
    classification_run_entity.py
    classification_state_entity.py
  service/
    classification_orchestrator.py             # interface

infra/evidence_classification/
  classifier_container_runner.py               # subprocess wrapper
  classification_state_drive_client.py         # read/write _state.json from Drive
  classification_drive_ops.py                  # copy / trash file ops

di_containers/evidence_classification/
  evidence_classification_container.py

common/code/
  evidence_classification_error_code.py        # GRC_404XXX / 409XXX etc.
```

### Error code 規劃

`common/code/evidence_classification_error_code.py`：

```python
class EvidenceClassificationErrorCode(BaseCode):
    EC_DRIVE_NOT_CONNECTED       = ("專案未連接 Google Drive", "EC_412001")
    EC_NO_FILES_IN_EVIDENCE      = ("Evidences 資料夾無檔案可分類", "EC_412002")
    EC_JOB_ALREADY_RUNNING       = ("已有分類 job 正在執行", "EC_409001")
    EC_RUN_FOLDER_NOT_FOUND      = ("分類結果資料夾不存在", "EC_404001")
    EC_STATE_FILE_NOT_FOUND      = ("狀態檔不存在或已損毀", "EC_404002")
    EC_CONTAINER_FAILED          = ("分類容器執行失敗", "EC_500001")
    EC_DRIVE_OP_FAILED           = ("Drive 檔案操作失敗", "EC_500002")
    EC_NOT_MANAGER               = ("僅專案管理者可觸發分類", "EC_403001")
```

### Endpoint 規格

#### EP1: `POST /api/projects/<project_uid>/classify-evidence`

**權限檢查**（app service 層，透過 domain service）：
- User 必須為該 project 的 manager
- Project 必須有 Drive integration 且 status = CONNECTED
- Evidences 資料夾必須存在且至少有 1 個檔
- 無 active job for this project

**Request**:
```json
{
  "framework_id": "cmmc-l1",
  "confidence_threshold": 0.80
}
```

**Response** (202 Accepted):
```json
{
  "status": true,
  "data": {
    "job_uid": "abc-123",
    "run_folder_id": "(pending until container creates folder)",
    "status": "queued",
    "estimated_minutes": 10
  }
}
```

**BE 工作**:
1. 驗證權限 + 前置條件
2. 從 `oscal.*` 撈 framework catalog → dump 成 JSON 到 `/tmp/cm-jobs/<job_uid>/catalog.json`
3. Background thread 起 container：`subprocess.Popen(['docker', 'run', ...])`
4. Container 跑完 → BE 讀 `/tmp/cm-jobs/<job_uid>/_state.json` → 把整份上傳到 Drive run folder 內
5. job 表（in-memory dict for 實驗階段，未來轉 DB）更新 status

> **實驗階段簡化**：job 狀態用 `app/evidence_classification/service` 內的 in-memory dict 暫存，process restart 後遺失沒關係（跑中的 container 不會中斷）。正式版才搬 DB。

#### EP2: `GET /api/classification-runs/<run_folder_id>/state`

**Response**:
```json
{
  "status": true,
  "data": { ...完整 _state.json 內容... }
}
```

**BE 工作**: 用 tenant Drive OAuth client 拉 run folder 內 `_state.json` → return raw JSON。

#### EP3: `PUT /api/classification-runs/<run_folder_id>/state`

**Request body**: 完整新版 `_state.json`

**Response**:
```json
{
  "status": true,
  "data": {
    "operations_applied": 12,
    "drive_copied": 8,
    "drive_trashed": 3,
    "errors": []
  }
}
```

**BE 工作**:
1. 從 Drive 拉舊版 `_state.json`
2. Diff 舊 vs 新 `placements` per file
3. 對每個 diff 跑 Drive copy / trash
4. 把新版 `_state.json` 覆寫回 Drive
5. 回 summary（成功幾筆、失敗幾筆）

### API URL 命名

依 BE CLAUDE.md：
- 單筆操作（GET state / PUT state）→ 單數 `classification-run/<run-id>/state`
- ~~`/classification-runs/...`~~

實際採用：`/api/classification-run/<run_folder_id>/state` + `/api/project/<uid>/classify-evidence`（觸發是動作不是集合）

### S2 驗收

- [ ] curl 三個 endpoint 都能正常呼叫
- [ ] 權限檢查、前置條件、error code 都按規範
- [ ] DDD 層級不違反（route 不查 DB / app 用 domain service / repo 不寫 Singleton）
- [ ] 跑完整 flow：trigger → container → state.json 寫 Drive → 拉 state → 編輯後 PUT → diff → Drive ops 正確

---

## S3 — FE 審閱頁

### 對應 FE CLAUDE.md 規範

- 繁中為主
- Vue 3 + `<script setup>` + 4-space
- PrimeVue 3 元件
- Service 繼承 BaseService
- Loading 一律用 `LoadingState` 元件，禁止 inline `<ProgressSpinner>`
- Typography 用 PrimeFlex class（`text-2xl` for title, `text-xl` for sub, `text-sm` for body）
- Add 按鈕 `outlined`
- Icon-only 按鈕用 `v-tooltip.top`
- 樣式用既有 global class（`hint-bar--warn` / `dark-card` / `folder-node` / `mini-stat` 等）

### 檔案結構（在 compliance-manager-fe）

```
src/
  config/api/api.js                                   # 加 3 個 endpoint 常數
  service/
    EvidenceClassificationService.js                  # 接 BE
  composables/
    useEvidenceClassification.js                      # state + pending edits + save
  views/evidence-classification/
    EvidenceClassificationReview.vue                  # 頁面 entry
    components/
      ClassificationStatsRow.vue                      # 5 stat cards
      ClassificationFilterBar.vue                     # search / domain / status / conf
      ClassificationFileList.vue                      # DataTable
      ClassificationFileDetail.vue                    # detail panel
      PlacementCard.vue                               # 已配對 AO card
      CandidateCard.vue                               # 未分類檔候選 card
      AOPickerDialog.vue                              # 加入 AO Dialog (search)
  config/router/index.js                              # 新 route
  config/locales/i18n/{en,tw,cn}/
    evidence-classification.json                      # 翻譯
    menu.json                                         # breadcrumb
```

### 元件對應（雛形 HTML → PrimeVue）

| 雛形 | PrimeVue 元件 |
|---|---|
| Stats card | `<Card>` × 5 + PrimeFlex grid |
| Filter bar | `<InputText>`（search）/ `<Dropdown>`（domain/status）/ `<Slider>`（confidence）|
| File list (DataTable) | `<DataTable>` with `:selection` |
| Detail panel | `<Card>` 包多個 `<PlacementCard>` |
| Add AO Dialog | `<Dialog>` + `<Tree>` 或 grouped `<Listbox>` + `<InputText>` for search |
| Save bar | `<Toolbar>` (sticky) |
| Tags | `<Tag>` (severity-based) |
| Loading | `<LoadingState size="page" />` 蓋住主 content 直到 state 載完 |
| Notification | 透過既有 toast event bus |

### 路由

```js
// src/config/router/index.js
{
  path: '/project/:project_uid/classify-evidence',
  name: 'classification-run-list',
  component: () => import('@/views/evidence-classification/ClassificationRunListView.vue'),
  meta: { breadcrumb: 'evidence_classification.runs' }
},
{
  path: '/project/:project_uid/classify-evidence/run/:run_folder_id',
  name: 'classification-review',
  component: () => import('@/views/evidence-classification/EvidenceClassificationReview.vue'),
  meta: { breadcrumb: 'evidence_classification.review' }
}
```

### State management — composable

```js
// src/composables/useEvidenceClassification.js
import { ref, computed } from 'vue'
import EvidenceClassificationService from '@/service/EvidenceClassificationService'

export function useEvidenceClassification(runFolderId) {
    const service = new EvidenceClassificationService()
    const state = ref(null)                  // 完整 _state.json
    const loading = ref(true)
    const saving = ref(false)
    const pendingEdits = ref(new Map())      // 瀏覽器 in-memory edits

    const stats = computed(() => {
        if (!state.value) return null
        const files = state.value.files || []
        return {
            total: files.length,
            classified: files.filter(f => f.placements.length > 0).length,
            unclassified: files.filter(f => f.placements.length === 0).length,
            placements: files.reduce((s, f) => s + f.placements.length, 0),
        }
    })

    const hasUnsavedChanges = computed(() => pendingEdits.value.size > 0)

    async function load() {
        loading.value = true
        try {
            state.value = await service.getState(runFolderId)
        } finally {
            loading.value = false
        }
    }

    function applyEditLocally(fileId, edit) { /* mutate state.files in memory */ }
    async function save() {
        saving.value = true
        try {
            await service.putState(runFolderId, state.value)
            pendingEdits.value.clear()
            await load()
        } finally {
            saving.value = false
        }
    }

    return { state, stats, loading, saving, hasUnsavedChanges, load, applyEditLocally, save }
}
```

### Service

```js
// src/service/EvidenceClassificationService.js
import BaseService from './BaseService'
import { API } from '@/config/api/api'

export default class EvidenceClassificationService extends BaseService {
    triggerClassify(projectUid, payload) {
        return this.post(`${API.PROJECT}/${projectUid}/classify-evidence`, payload)
    }
    getState(runFolderId) {
        return this.get(`${API.CLASSIFICATION_RUN}/${runFolderId}/state`)
    }
    putState(runFolderId, state) {
        return this.put(`${API.CLASSIFICATION_RUN}/${runFolderId}/state`, state)
    }
    listRuns(projectUid) {
        return this.get(`${API.PROJECT}/${projectUid}/classification-runs`)
    }
}
```

### i18n

`src/config/locales/i18n/tw/evidence-classification.json`:
```json
{
  "lang": {
    "evidence_classification": {
      "page_title": "證據分類審閱",
      "trigger_button": "自動分類證據",
      "stats": {
        "total": "處理檔案",
        "classified": "已分類",
        "unclassified": "未分類",
        "placements": "AO 配對總數",
        "coverage": "AO 涵蓋"
      },
      "filter": {
        "search_placeholder": "搜尋檔案名稱...",
        "domain": "領域",
        "status": "狀態",
        "min_conf": "最高信心 ≥",
        "reset": "重設"
      },
      "actions": {
        "add_ao": "加入其他 AO 配對",
        "remove_ao": "移除",
        "mark_na": "標為非證據",
        "delete_file": "從證據池刪除",
        "save_changes": "儲存變更",
        "discard": "捨棄變更"
      },
      "unclassified_hero": {
        "title": "此檔未被自動分類",
        "desc": "最高信心 {conf} 低於設定閾值，所有候選 AO 都被切掉。可從下方挑選 AO 加入。"
      }
    }
  }
}
```

### S3 驗收

- [ ] 審閱頁打開 → 顯示 LoadingState → 載完 state → 顯示 stats / file list / detail
- [ ] 編輯 → pending changes 標記 → 按儲存 → BE 處理 → 重新載入
- [ ] 全 PrimeVue 元件，無 prototype 殘餘的自寫 CSS
- [ ] 遵守 FE CLAUDE.md（typography / icon tooltip / outlined button）
- [ ] 繁中 i18n
- [ ] 鍵盤導航（↑↓）保留

---

## S4 — FE 觸發整合（專案總覽頁）

### 修改既有檔

`src/views/grc-project/ProjectOverview.vue`（或 wherever 是現有 project overview）

### 新增區塊

```vue
<Card v-if="hasDriveIntegration" class="mt-3">
  <template #title>
    <div class="flex align-items-center gap-2">
      <i class="pi pi-sparkles" style="color: var(--primary)"></i>
      <span class="text-xl">{{ t('lang.evidence_classification.section_title') }}</span>
    </div>
  </template>
  <template #content>
    <LoadingState v-if="loadingRunList" size="panel" />
    <template v-else>
      <Button
        :label="t('lang.evidence_classification.trigger_button')"
        icon="pi pi-sparkles"
        :loading="triggering"
        :disabled="hasRunningJob || noEvidenceFiles"
        @click="confirmTrigger"
      />
      <div v-if="lastRun" class="mt-3">
        <div class="text-sm text-color-secondary">
          {{ t('lang.evidence_classification.last_run', {
              datetime: formatDate(lastRun.completed_at)
          }) }}
        </div>
        <Button
          :label="t('lang.evidence_classification.review_button')"
          icon="pi pi-list-check"
          link
          @click="goToReview(lastRun.run_folder_id)"
        />
      </div>
    </template>
  </template>
</Card>
```

### 觸發 flow

1. User 按 `自動分類證據` → 跳 ConfirmDialog 確認
2. 確認後 POST → 收 `job_uid` → 用 polling（每 5s）查 job status
3. 進度顯示在 button label：`分類中... 已處理 23/130`
4. 完成 → toast 提示「分類完成」+ 顯示「審閱分類結果」按鈕
5. 按按鈕跳轉到 `/project/<uid>/classify-evidence/run/<run_folder_id>`

### Job 進度 polling

實驗階段最簡：每 5 秒 GET `/api/project/<uid>/classify-evidence/current-job` 看狀態。完成則 stop polling。

### S4 驗收

- [ ] 專案頁有「AI 證據分類」區塊（只在 Drive 連線時顯示）
- [ ] 按鈕狀態正確（無檔 disabled / 跑中 disabled）
- [ ] 觸發 → polling → 完成通知 → 進審閱頁
- [ ] E2E flow 順暢

---

## 風險 / 注意事項

| 項目 | 風險 | 對策 |
|---|---|---|
| Docker socket 權限 | BE process 要能 `docker run` | dev 階段 BE user 加 docker group |
| Container 內 DB 連線 | host docker 跑要設 `host.docker.internal` 或 `--network host` | 用 `--network host` 最簡單 |
| Container Crash 失敗回報 | 容器 stderr 跑進 BE log？ | subprocess capture stderr 寫 log |
| 並發編輯 _state.json | 兩人同時編輯衝突 | ETag / If-Match；後改的拒絕（v1.1 加） |
| Drive API rate limit | UI 連按多次儲存 | UI 儲存中 disable + debounce |
| Container image size | 太肥推 docker hub 慢 | 用 python:3.11-slim base + multi-stage |
| 跨 repo 開發紀律 | BE/FE/test 三個 repo 都動 | 每 repo 各自 branch + 各自 commit |

---

## 跨 repo 涉及檔案

### compliance-manager-be
- `scripts/evidence/classify/docker/Dockerfile` (新)
- `scripts/evidence/classify/docker/requirements.txt` (新)
- `scripts/evidence/classify/docker/jedi_helpers/` (新，inlined)
- `scripts/evidence/classify/classify_evidence_drive.py` (改：加 service-classify subcommand)
- `api/evidence_classification/` (新模組)
- `app/evidence_classification/` (新模組)
- `domain/evidence_classification/` (新模組)
- `infra/evidence_classification/` (新模組)
- `di_containers/evidence_classification/` (新模組)
- `common/code/evidence_classification_error_code.py` (新)
- `config/app_modules.py` (註冊新模組)
- `docs/api/evidence-classification/api-spec.md` (新)

### compliance-manager-fe
- `src/views/evidence-classification/` (新)
- `src/composables/useEvidenceClassification.js` (新)
- `src/service/EvidenceClassificationService.js` (新)
- `src/config/api/api.js` (加常數)
- `src/config/router/index.js` (加 route)
- `src/config/locales/i18n/{en,tw,cn}/evidence-classification.json` (新)
- `src/config/locales/i18n/{en,zh-tw}/menu.json` (加 breadcrumb)
- `src/views/grc-project/ProjectOverview.vue` (修：加 AI 分類區塊)

### compliance-manager-test
- 之後 S4 完成後再加 e2e

---

## 開發前已確認項目

| 項目 | 答案 |
|---|---|
| Project 頁面位置 | `src/views/project/ProjectPlanningView.vue`（manager 視角；已包含 DocumentPoolPanel + ProjectCloudIntegrationsPanel） |
| Drive 連線判斷 | 用 `src/service/CloudIntegrationService.js`；可參考 `src/components/grc/project/ProjectCloudIntegrationsPanel.vue` 的查詢 pattern |
| Date library | **moment v2.30.1**（不是 dayjs）；無中央 formatDate util，各頁自己 `import moment`；如需共用可加 `src/utils/dateUtil.js` |
| Confirm dialog | `useConfirm()` from `primevue/useconfirm`；App.vue 已全域 `<ConfirmDialog>`；參考 `src/components/grc/AuditVerdictPanel.vue` 用法 |
| Docker network | **預設 bridge** 即可（user 確認「對外連網即可」）；container 預設能 outbound HTTPS + 連 LAN DB host；不需特別 `--network host` 或 `host.docker.internal` |

---

## 下一步

1. 與 stakeholder 過 implementation plan
2. 開始 S1 — 寫 Dockerfile + 改 script
3. S1 完成驗證後進 S2
