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

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


§1

整體里程碑

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 從專案頁觸發到審閱完成

§2

S1 — Docker Container

目標

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

任務分解

S1.1 Dockerfile(建 image)

位置:scripts/evidence/classify/docker/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 兩用
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

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 驗收


§3

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

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:

{
  "framework_id": "cmmc-l1",
  "confidence_threshold": 0.80
}

Response (202 Accepted):

{
  "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:

{
  "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:

{
  "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 驗收


§4

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

路由

// 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

// 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

// 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:

{
  "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 驗收


§5

S4 — FE 觸發整合(專案總覽頁)

修改既有檔

src/views/grc-project/ProjectOverview.vue(或 wherever 是現有 project overview)

新增區塊

<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 驗收


§6

風險 / 注意事項

項目 風險 對策
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

§7

跨 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

§8

開發前已確認項目

項目 答案
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 hosthost.docker.internal

§9

下一步

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