diff --git a/apps/api/src/models/governance.py b/apps/api/src/models/governance.py index f323bd50a..15a05d7e6 100644 --- a/apps/api/src/models/governance.py +++ b/apps/api/src/models/governance.py @@ -105,6 +105,9 @@ class DispatchItem(BaseModel): human_owner: str | None = None kb_draft_entry_id: str | None = None worker_status: str | None = None + dry_run_plan_fingerprint: str | None = None + archived_count: int | None = None + stale_ratio_snapshot: dict | None = None class GovernanceQueueResponse(BaseModel): diff --git a/apps/api/src/services/governance_query_service.py b/apps/api/src/services/governance_query_service.py index 16b810845..0411ec501 100644 --- a/apps/api/src/services/governance_query_service.py +++ b/apps/api/src/services/governance_query_service.py @@ -427,6 +427,9 @@ async def _query_dispatch_table( human_owner=_extract_human_owner(decision_ctx), kb_draft_entry_id=_extract_kb_draft_entry_id(decision_ctx), worker_status=_extract_worker_status(decision_ctx), + dry_run_plan_fingerprint=_extract_dry_run_plan_fingerprint(decision_ctx), + archived_count=_extract_archived_count(decision_ctx), + stale_ratio_snapshot=_extract_stale_ratio_snapshot(decision_ctx), )) return GovernanceQueueResponse( @@ -562,6 +565,57 @@ def _extract_worker_status(decision_ctx: dict) -> str | None: return val[:80] if isinstance(val, str) and val else None +def _extract_dry_run_plan_fingerprint(decision_ctx: dict) -> str | None: + for source in ( + decision_ctx, + decision_ctx.get("workflow"), + ): + if not isinstance(source, dict): + continue + val = source.get("dry_run_plan_fingerprint") + if isinstance(val, str) and val: + return val[:80] + return None + + +def _extract_archived_count(decision_ctx: dict) -> int | None: + raw = decision_ctx.get("archived_count") + if isinstance(raw, int): + return max(raw, 0) + archived = decision_ctx.get("archived_entry_ids") + if isinstance(archived, list): + return len(archived) + workflow = decision_ctx.get("workflow") + if isinstance(workflow, dict): + archived = workflow.get("archived_entry_ids") + if isinstance(archived, list): + return len(archived) + return None + + +def _extract_stale_ratio_snapshot(decision_ctx: dict) -> dict | None: + for source in ( + decision_ctx, + decision_ctx.get("workflow"), + ): + if not isinstance(source, dict): + continue + snapshot = source.get("stale_ratio_snapshot") + if isinstance(snapshot, dict): + return { + key: snapshot.get(key) + for key in ( + "stale_count", + "total_count", + "stale_ratio", + "threshold", + "stale_days", + ) + if key in snapshot + } + return None + + # ============================================================================= # Endpoint 2B: KM review draft dedupe # ============================================================================= diff --git a/apps/api/tests/test_ai_governance_endpoints.py b/apps/api/tests/test_ai_governance_endpoints.py index 15437c6ca..c8f3a34b7 100644 --- a/apps/api/tests/test_ai_governance_endpoints.py +++ b/apps/api/tests/test_ai_governance_endpoints.py @@ -45,9 +45,12 @@ from src.services.governance_km_review_service import ( ) from src.services.governance_query_service import ( _build_km_review_draft_dedupe_groups, + _extract_archived_count, + _extract_dry_run_plan_fingerprint, _extract_governance_event_id_from_tags, _extract_kb_draft_entry_id, _extract_remediation, + _extract_stale_ratio_snapshot, _extract_worker_status, _merge_dispatch_ids, _query_dispatch_table, @@ -402,6 +405,36 @@ class TestQueueEndpoint: assert _extract_kb_draft_entry_id(context) == "km-workflow" assert _extract_worker_status(context) == "draft_created" + def test_extract_km_archive_audit_context_from_decision_context(self): + """queue read model 應把 archive audit / stale ratio recheck 證據交給 Work Items。""" + context = { + "archived_count": 5, + "dry_run_plan_fingerprint": "sha256:" + "a" * 64, + "stale_ratio_snapshot": { + "stale_count": 120, + "total_count": 200, + "stale_ratio": 0.6, + "threshold": 0.2, + "stale_days": 7, + "extra": "ignored", + }, + "workflow": { + "archived_entry_ids": ["fallback"], + "dry_run_plan_fingerprint": "sha256:" + "b" * 64, + }, + } + + assert _extract_dry_run_plan_fingerprint(context) == "sha256:" + "a" * 64 + assert _extract_archived_count(context) == 5 + snapshot = _extract_stale_ratio_snapshot(context) + assert snapshot == { + "stale_count": 120, + "total_count": 200, + "stale_ratio": 0.6, + "threshold": 0.2, + "stale_days": 7, + } + def test_queue_query_uses_production_dispatch_schema(self): """queue 查詢必須對齊 migration schema:使用 dispatched_at,不讀不存在的 created_at/operator_note.""" import inspect diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 24da9f0ca..c045a55b3 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -1919,6 +1919,18 @@ "review_canonical_and_archive_duplicate_drafts": "Review canonical and archive duplicate drafts", "unknown": "Owner action pending" }, + "archiveHistory": { + "title": "Archive / recheck history", + "empty": "No owner archive or stale-ratio recheck dispatch yet; it will appear here after confirmation.", + "executors": { + "hermes_km_review_dedupe_owner_archive": "Hermes: owner-confirmed archive", + "hermes_km_stale_ratio_recheck": "Hermes: stale-ratio recheck", + "unknown": "Unknown executor" + }, + "item": "{executor}: {status}; stage {stage}; archived {archived}", + "fingerprint": "Plan fingerprint: {fingerprint}", + "snapshot": "Recheck snapshot: stale {stale} / total {total}; ratio {ratio}; threshold {threshold}" + }, "archiveActions": { "archive": "Archive duplicate drafts", "preview": "Dry-run preview", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index b580a50e8..33e58eece 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -1920,6 +1920,18 @@ "review_canonical_and_archive_duplicate_drafts": "審核 canonical 並封存重複草稿", "unknown": "待補 owner 動作" }, + "archiveHistory": { + "title": "封存 / 回測歷史", + "empty": "尚無 owner 封存或 stale ratio 回測 dispatch;完成確認封存後會出現在這裡。", + "executors": { + "hermes_km_review_dedupe_owner_archive": "Hermes:owner 確認封存", + "hermes_km_stale_ratio_recheck": "Hermes:stale ratio 回測", + "unknown": "未知 executor" + }, + "item": "{executor}:{status};階段 {stage};封存 {archived} 份", + "fingerprint": "Plan fingerprint:{fingerprint}", + "snapshot": "回測 snapshot:stale {stale} / total {total};ratio {ratio};門檻 {threshold}" + }, "archiveActions": { "archive": "封存重複草稿", "preview": "乾跑預覽", diff --git a/apps/web/src/app/[locale]/awooop/work-items/page.tsx b/apps/web/src/app/[locale]/awooop/work-items/page.tsx index 85b6b9243..d73813cd5 100644 --- a/apps/web/src/app/[locale]/awooop/work-items/page.tsx +++ b/apps/web/src/app/[locale]/awooop/work-items/page.tsx @@ -74,6 +74,15 @@ type GovernanceQueueItem = { human_owner?: string | null; kb_draft_entry_id?: string | null; worker_status?: string | null; + dry_run_plan_fingerprint?: string | null; + archived_count?: number | null; + stale_ratio_snapshot?: { + stale_count?: number | null; + total_count?: number | null; + stale_ratio?: number | null; + threshold?: number | null; + stale_days?: number | null; + } | null; }; type GovernanceQueueResponse = { @@ -776,6 +785,36 @@ function draftDedupeGroupForQueueItem( return groups.find((group) => group.governance_event_id === item.governance_event_id) ?? null; } +function kmArchiveTraceForDedupeGroup( + group: KnowledgeReviewDraftDedupeGroup, + queueItems: GovernanceQueueItem[] +) { + return queueItems + .filter((item) => + item.governance_event_id === group.governance_event_id && + ( + item.executor_type === "hermes_km_review_dedupe_owner_archive" || + item.executor_type === "hermes_km_stale_ratio_recheck" + ) + ) + .sort((a, b) => + String(b.completed_at ?? b.started_at ?? b.created_at ?? "").localeCompare( + String(a.completed_at ?? a.started_at ?? a.created_at ?? "") + ) + ) + .slice(0, 3); +} + +function kmArchiveExecutorKey(executorType?: string | null) { + if ( + executorType === "hermes_km_review_dedupe_owner_archive" || + executorType === "hermes_km_stale_ratio_recheck" + ) { + return executorType; + } + return "unknown"; +} + function kmDedupeActionKey(action?: string | null) { if ( action === "owner_review_canonical_then_archive_duplicates" || @@ -1747,6 +1786,7 @@ function KnowledgeGovernancePanel({ ); const previewKey = groupArchiveStatusKey(previewResult?.status); const finalResultKey = groupArchiveStatusKey(finalResult?.status); + const archiveTrace = kmArchiveTraceForDedupeGroup(group, items); return (
+ {t("archiveHistory.title")} +
+ {archiveTrace.length === 0 ? ( ++ {t("archiveHistory.empty")} +
+ ) : ( ++ {t("archiveHistory.item", { + executor: t( + `archiveHistory.executors.${kmArchiveExecutorKey(trace.executor_type)}` as never + ), + status: t( + `statuses.${governanceKmDispatchStatusKey(trace.dispatch_status)}` as never + ), + stage: t( + `stages.${governanceKmStageKey(trace.workflow_stage)}` as never + ), + archived: trace.archived_count ?? "--", + })} +
+ {trace.dry_run_plan_fingerprint ? ( ++ {t("archiveHistory.fingerprint", { + fingerprint: trace.dry_run_plan_fingerprint, + })} +
+ ) : null} + {trace.stale_ratio_snapshot ? ( ++ {t("archiveHistory.snapshot", { + stale: trace.stale_ratio_snapshot.stale_count ?? "--", + total: trace.stale_ratio_snapshot.total_count ?? "--", + ratio: typeof trace.stale_ratio_snapshot.stale_ratio === "number" + ? formatStaleRatio(trace.stale_ratio_snapshot.stale_ratio) + : "--", + threshold: typeof trace.stale_ratio_snapshot.threshold === "number" + ? formatStaleRatio(trace.stale_ratio_snapshot.threshold) + : "--", + })} +
+ ) : null} +