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 從專案頁觸發到審閱完成 |
把 scripts/evidence/classify/classify_evidence_drive.py 包成可獨立執行的 docker image,不再依賴主專案 Python 環境。
位置: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.77google-api-python-client>=2.196google-auth>=2.53python-docx>=1.2python-dotenv>=1.0psycopg2-binary>=2.9sqlalchemy>=2.0cryptography>=41現有 script 有 init / classify subcommand。為了 BE 用,新增:
service-classify:吃 args + env,跑完 dump _state.json 到 --output-dir_state.json — UI 用的完整 state(含 placements)_report-original.json — 原始 Claude 報告(immutable 備份)--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主專案的 FernetCrypto / GoogleOAuthClient 不能直接 docker 內 import(主專案太肥)。三個選項:
infra.cloud_integration.crypto + google_drive 對應檔複製到 scripts/evidence/classify/docker/jedi_helpers/(簡單但要同步維護)選 C — FernetCrypto 19 行、GoogleOAuthClient refresh_access_token ~20 行,直接複製進 docker context。
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依 BE CLAUDE.md 規範:
api/evidence_classification/ — routes(不直接查 DB)app/evidence_classification/ — application services(@transaction,orchestrate)domain/evidence_classification/ — entity + service interfaceinfra/evidence_classification/ — repo impl, Drive ops, container subprocessapi/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.
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")POST /api/projects/<project_uid>/classify-evidence權限檢查(app service 層,透過 domain service):
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 工作:
oscal.* 撈 framework catalog → dump 成 JSON 到 /tmp/cm-jobs/<job_uid>/catalog.jsonsubprocess.Popen(['docker', 'run', ...])/tmp/cm-jobs/<job_uid>/_state.json → 把整份上傳到 Drive run folder 內實驗階段簡化:job 狀態用
app/evidence_classification/service內的 in-memory dict 暫存,process restart 後遺失沒關係(跑中的 container 不會中斷)。正式版才搬 DB。
GET /api/classification-runs/<run_folder_id>/stateResponse:
{
"status": true,
"data": { ...完整 _state.json 內容... }
}BE 工作: 用 tenant Drive OAuth client 拉 run folder 內 _state.json → return raw JSON。
PUT /api/classification-runs/<run_folder_id>/stateRequest body: 完整新版 _state.json
Response:
{
"status": true,
"data": {
"operations_applied": 12,
"drive_copied": 8,
"drive_trashed": 3,
"errors": []
}
}BE 工作:
_state.jsonplacements per file_state.json 覆寫回 Drive依 BE CLAUDE.md:
classification-run/<run-id>/state/classification-runs/...實際採用:/api/classification-run/<run_folder_id>/state + /api/project/<uid>/classify-evidence(觸發是動作不是集合)
<script setup> + 4-spaceLoadingState 元件,禁止 inline <ProgressSpinner>text-2xl for title, text-xl for sub, text-sm for body)outlinedv-tooltip.tophint-bar--warn / dark-card / folder-node / mini-stat 等)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
| 雛形 | 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' }
}// 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 }
}// 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`)
}
}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 加入。"
}
}
}
}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>
自動分類證據 → 跳 ConfirmDialog 確認job_uid → 用 polling(每 5s)查 job status分類中... 已處理 23/130/project/<uid>/classify-evidence/run/<run_folder_id>實驗階段最簡:每 5 秒 GET /api/project/<uid>/classify-evidence/current-job 看狀態。完成則 stop polling。
| 項目 | 風險 | 對策 |
|---|---|---|
| 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 |
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 (新)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 分類區塊)| 項目 | 答案 |
|---|---|
| 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 |