From edb6daef88a31ad8d0b4e61ea538a3ef8e6f4ff3 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 20 May 2026 10:20:01 +0800 Subject: [PATCH] feat(governance): attach km archive history to dedupe groups --- apps/api/src/models/governance.py | 1 + .../src/services/governance_query_service.py | 149 +++++++++++++----- .../api/tests/test_ai_governance_endpoints.py | 41 +++++ .../app/[locale]/awooop/work-items/page.tsx | 4 + docs/LOGBOOK.md | 91 +++++++++++ ...-04-15-MASTER-ai-autonomous-flywheel-v2.md | 9 ++ 6 files changed, 255 insertions(+), 40 deletions(-) diff --git a/apps/api/src/models/governance.py b/apps/api/src/models/governance.py index 15a05d7e..344983dd 100644 --- a/apps/api/src/models/governance.py +++ b/apps/api/src/models/governance.py @@ -138,6 +138,7 @@ class KnowledgeReviewDraftDedupeGroup(BaseModel): owner_action: str writes_on_read: bool = False can_archive_without_owner_approval: bool = False + archive_history: list[DispatchItem] = Field(default_factory=list) class KnowledgeReviewDraftDedupeResponse(BaseModel): diff --git a/apps/api/src/services/governance_query_service.py b/apps/api/src/services/governance_query_service.py index 0411ec50..3794fe05 100644 --- a/apps/api/src/services/governance_query_service.py +++ b/apps/api/src/services/governance_query_service.py @@ -394,43 +394,7 @@ async def _query_dispatch_table( items: list[DispatchItem] = [] for row in page_rows: decision_ctx: dict = (row.decision_context or {}) if hasattr(row, "decision_context") else {} - proposed_action = _extract_proposed_action(decision_ctx) - - # playbook_trust: Track D 完成後改為 JOIN playbooks 表取 trust_score - # 現階段從 decision_context 取 mock 值 - playbook_trust_raw = decision_ctx.get("playbook_trust") - try: - playbook_trust = float(playbook_trust_raw) if playbook_trust_raw is not None else None - except (TypeError, ValueError): - playbook_trust = None - - items.append(DispatchItem( - id=str(row.id), - governance_event_id=str(row.governance_event_id), - event_type=str(row.event_type), - dispatch_status=str(row.dispatch_status), - executor_type=str(row.executor_type) if row.executor_type else None, - proposed_action=proposed_action, - playbook_id=str(row.playbook_id) if row.playbook_id else None, - playbook_trust=playbook_trust, - created_at=row.created_at, - dispatched_at=row.dispatched_at, - started_at=row.started_at, - completed_at=row.completed_at, - operator_note=row.operator_note, - decision_path=_extract_decision_path(decision_ctx), - workflow_stage=_extract_workflow_stage(decision_ctx, str(row.dispatch_status)), - workflow_steps=_extract_workflow_steps(decision_ctx), - next_action=_extract_next_action(decision_ctx), - lead_agent=_extract_lead_agent(decision_ctx), - support_agents=_extract_support_agents(decision_ctx), - 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), - )) + items.append(_to_dispatch_item(row, decision_ctx)) return GovernanceQueueResponse( items=items, @@ -441,6 +405,47 @@ async def _query_dispatch_table( ) +def _to_dispatch_item(row: Any, decision_ctx: dict) -> DispatchItem: + """把 governance_remediation_dispatch SQL row 轉成 Work Items read model。""" + proposed_action = _extract_proposed_action(decision_ctx) + + # playbook_trust: Track D 完成後改為 JOIN playbooks 表取 trust_score + # 現階段從 decision_context 取 mock 值 + playbook_trust_raw = decision_ctx.get("playbook_trust") + try: + playbook_trust = float(playbook_trust_raw) if playbook_trust_raw is not None else None + except (TypeError, ValueError): + playbook_trust = None + + return DispatchItem( + id=str(row.id), + governance_event_id=str(row.governance_event_id), + event_type=str(row.event_type), + dispatch_status=str(row.dispatch_status), + executor_type=str(row.executor_type) if row.executor_type else None, + proposed_action=proposed_action, + playbook_id=str(row.playbook_id) if row.playbook_id else None, + playbook_trust=playbook_trust, + created_at=row.created_at, + dispatched_at=row.dispatched_at, + started_at=row.started_at, + completed_at=row.completed_at, + operator_note=row.operator_note, + decision_path=_extract_decision_path(decision_ctx), + workflow_stage=_extract_workflow_stage(decision_ctx, str(row.dispatch_status)), + workflow_steps=_extract_workflow_steps(decision_ctx), + next_action=_extract_next_action(decision_ctx), + lead_agent=_extract_lead_agent(decision_ctx), + support_agents=_extract_support_agents(decision_ctx), + 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), + ) + + def _extract_proposed_action(decision_ctx: dict) -> str: """ 從 decision_context JSONB 抽取 proposed_action,≤120 字。 @@ -626,12 +631,14 @@ async def query_km_review_draft_dedupe( ) -> KnowledgeReviewDraftDedupeResponse: """產生 Hermes KM healthcheck review drafts 的 read-only 去重計畫。""" rows = await _load_km_healthcheck_review_drafts(limit=limit) - preferred = await _load_preferred_km_draft_ids_by_event([ + event_ids = [ event_id for row in rows if (event_id := _extract_governance_event_id_from_tags(row.get("tags"))) - ]) - groups = _build_km_review_draft_dedupe_groups(rows, preferred) + ] + preferred = await _load_preferred_km_draft_ids_by_event(event_ids) + archive_history = await _load_km_archive_history_by_event(event_ids) + groups = _build_km_review_draft_dedupe_groups(rows, preferred, archive_history) return KnowledgeReviewDraftDedupeResponse( total_review_drafts=len(rows), @@ -720,6 +727,65 @@ async def _load_preferred_km_draft_ids_by_event( return preferred +async def _load_km_archive_history_by_event( + event_ids: list[str], +) -> dict[str, list[DispatchItem]]: + """讀取 KM duplicate archive / stale ratio recheck 的 terminal audit trail。""" + if not event_ids: + return {} + + unique_event_ids = list(dict.fromkeys(event_ids)) + sql = text(""" + SELECT + d.id, + d.governance_event_id, + e.event_type, + d.dispatch_status, + d.executor_type, + d.decision_context, + d.playbook_id, + d.dispatched_at AS created_at, + d.dispatched_at, + d.started_at, + d.completed_at, + NULL::text AS operator_note + FROM governance_remediation_dispatch d + JOIN ai_governance_events e ON e.id = d.governance_event_id + WHERE d.governance_event_id IN :event_ids + AND d.executor_type IN ( + 'hermes_km_review_dedupe_owner_archive', + 'hermes_km_stale_ratio_recheck' + ) + ORDER BY + d.governance_event_id, + d.completed_at DESC NULLS LAST, + d.started_at DESC NULLS LAST, + d.dispatched_at DESC + """).bindparams(bindparam("event_ids", expanding=True)) + + try: + async with get_db_context() as db: + result = await db.execute(sql, {"event_ids": unique_event_ids}) + rows = result.fetchall() + except ProgrammingError as exc: + logger.warning( + "km_review_dedupe_archive_history_table_not_ready", + error=str(exc), + ) + return {} + + history: dict[str, list[DispatchItem]] = {} + for row in rows: + event_id = str(row.governance_event_id) + bucket = history.setdefault(event_id, []) + if len(bucket) >= 3: + continue + decision_ctx: dict = (row.decision_context or {}) if hasattr(row, "decision_context") else {} + bucket.append(_to_dispatch_item(row, decision_ctx)) + + return history + + def _extract_governance_event_id_from_tags(tags: Any) -> str | None: if not isinstance(tags, list): return None @@ -734,9 +800,11 @@ def _extract_governance_event_id_from_tags(tags: Any) -> str | None: def _build_km_review_draft_dedupe_groups( rows: list[dict[str, Any]], preferred_draft_ids_by_event: dict[str, str] | None = None, + archive_history_by_event: dict[str, list[DispatchItem]] | None = None, ) -> list[KnowledgeReviewDraftDedupeGroup]: """把 KM review drafts 依 governance_event tag 分組並產生 owner action。""" preferred_draft_ids_by_event = preferred_draft_ids_by_event or {} + archive_history_by_event = archive_history_by_event or {} grouped: dict[str, list[dict[str, Any]]] = {} for row in rows: event_id = _extract_governance_event_id_from_tags(row.get("tags")) @@ -779,6 +847,7 @@ def _build_km_review_draft_dedupe_groups( owner_action="review_canonical_and_archive_duplicate_drafts", writes_on_read=False, can_archive_without_owner_approval=False, + archive_history=archive_history_by_event.get(event_id, []), )) return sorted( diff --git a/apps/api/tests/test_ai_governance_endpoints.py b/apps/api/tests/test_ai_governance_endpoints.py index c8f3a34b..56635b98 100644 --- a/apps/api/tests/test_ai_governance_endpoints.py +++ b/apps/api/tests/test_ai_governance_endpoints.py @@ -471,6 +471,20 @@ class TestKmReviewDraftDedupe: owner_action="review_canonical_and_archive_duplicate_drafts", writes_on_read=False, can_archive_without_owner_approval=False, + archive_history=[ + DispatchItem( + id="dispatch-archive-001", + governance_event_id="event-001", + event_type="knowledge_degradation", + dispatch_status="succeeded", + executor_type="hermes_km_review_dedupe_owner_archive", + proposed_action="Owner archived duplicate KM drafts", + created_at=NOW, + workflow_stage="km_duplicate_archive_after_owner_approval", + dry_run_plan_fingerprint="sha256:" + "a" * 64, + archived_count=2, + ) + ], ) ], ) @@ -490,6 +504,10 @@ class TestKmReviewDraftDedupe: assert data["groups"][0]["canonical_entry_id"] == "km-canonical" assert data["groups"][0]["writes_on_read"] is False assert data["groups"][0]["can_archive_without_owner_approval"] is False + assert data["groups"][0]["archive_history"][0]["executor_type"] == ( + "hermes_km_review_dedupe_owner_archive" + ) + assert data["groups"][0]["archive_history"][0]["archived_count"] == 2 def test_governance_event_tag_extraction(self): assert _extract_governance_event_id_from_tags([ @@ -524,6 +542,27 @@ class TestKmReviewDraftDedupe: groups = _build_km_review_draft_dedupe_groups( rows, {"event-001": "km-canonical"}, + { + "event-001": [ + DispatchItem( + id="dispatch-recheck-001", + governance_event_id="event-001", + event_type="knowledge_degradation", + dispatch_status="succeeded", + executor_type="hermes_km_stale_ratio_recheck", + proposed_action="Rechecked stale ratio", + created_at=NOW, + workflow_stage="stale_ratio_recheck", + stale_ratio_snapshot={ + "stale_count": 10, + "total_count": 100, + "stale_ratio": 0.1, + "threshold": 0.2, + "stale_days": 7, + }, + ) + ] + }, ) first = groups[0] @@ -532,6 +571,8 @@ class TestKmReviewDraftDedupe: assert first.preferred_source == "dispatch_context" assert first.duplicate_entry_ids == ["km-latest"] assert first.writes_on_read is False + assert first.archive_history[0].executor_type == "hermes_km_stale_ratio_recheck" + assert first.archive_history[0].stale_ratio_snapshot["stale_ratio"] == pytest.approx(0.1) def test_archive_endpoint_requires_owner_shape_and_returns_audit_result(self, client): """Owner 批准後的 archive endpoint 應回傳 KM write 與 audit write 結果。""" 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 d73813cd..ad531106 100644 --- a/apps/web/src/app/[locale]/awooop/work-items/page.tsx +++ b/apps/web/src/app/[locale]/awooop/work-items/page.tsx @@ -273,6 +273,7 @@ type KnowledgeReviewDraftDedupeGroup = { owner_action: string; writes_on_read: boolean; can_archive_without_owner_approval: boolean; + archive_history?: GovernanceQueueItem[]; }; type KnowledgeReviewDraftDedupeResponse = { @@ -789,6 +790,9 @@ function kmArchiveTraceForDedupeGroup( group: KnowledgeReviewDraftDedupeGroup, queueItems: GovernanceQueueItem[] ) { + if (group.archive_history?.length) { + return group.archive_history.slice(0, 3); + } return queueItems .filter((item) => item.governance_event_id === group.governance_event_id && diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index 9612a95b..60a53352 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -1,3 +1,94 @@ +## 2026-05-20|T98 KM archive history 綁定 dedupe group read model + +**觸發**: + +- T97 已在 Work Items 的 KM 草稿去重卡片顯示「封存 / 回測歷史」。 +- 但 history row 來源仍是同頁 `governance queue?event_type=knowledge_degradation&size=20` 的分頁資料;當 dispatch 總量變大或 archive/recheck row 排在 20 筆之外時,Operator 可能仍看不到已發生的 owner 封存 / stale ratio 回測。 +- 這會讓「前端是否真的同步流程狀態」留下 pagination blind spot。 + +**修正**: + +- `KnowledgeReviewDraftDedupeGroup` 新增 `archive_history: list[DispatchItem]`。 +- `query_km_review_draft_dedupe()` 會針對本次 dedupe groups 的 `governance_event_id` 額外讀取: + - `hermes_km_review_dedupe_owner_archive` + - `hermes_km_stale_ratio_recheck` +- 每個 event 最多帶最近 3 筆 archive / recheck dispatch history,並重用 `DispatchItem` read model,因此欄位和 Work Items queue 一致: + - `dispatch_status` + - `executor_type` + - `workflow_stage` + - `dry_run_plan_fingerprint` + - `archived_count` + - `stale_ratio_snapshot` +- Work Items 前端改成優先讀 `group.archive_history`;若 production API 尚未升級或 group 無 history,再 fallback 到 T97 的 queue item 匹配。 +- 邊界:T98 仍是 read-only read model,不自動寫 KM、不自動封存、不改 owner approval / fingerprint guard。 + +**Local verification**: + +```text +python3 -m py_compile apps/api/src/models/governance.py apps/api/src/services/governance_query_service.py + -> ok +/Users/ogt/awoooi/apps/api/.venv/bin/python -m ruff check apps/api/src/models/governance.py apps/api/src/services/governance_query_service.py apps/api/tests/test_ai_governance_endpoints.py + -> All checks passed +DATABASE_URL=postgresql+asyncpg://test:test@localhost:5432/test /Users/ogt/awoooi/apps/api/.venv/bin/python -m pytest apps/api/tests/test_ai_governance_endpoints.py -q + -> 39 passed +DATABASE_URL=postgresql+asyncpg://test:test@localhost:5432/test /Users/ogt/awoooi/apps/api/.venv/bin/python -m pytest apps/api/tests/test_ai_governance_endpoints.py apps/api/tests/test_governance_dispatcher.py apps/api/tests/test_hermes_kb_growth_worker.py -q + -> 62 passed +pnpm --dir apps/web exec next lint --file 'src/app/[locale]/awooop/work-items/page.tsx' + -> No ESLint warnings or errors +pnpm --dir apps/web exec tsc --noEmit --pretty false + -> ok +node -e 'JSON.parse(...)' + -> i18n json ok +cd apps/api && /Users/ogt/awoooi/apps/api/.venv/bin/python ../../scripts/generate-schemas.py && cd ../../packages/shared-types && pnpm generate:types + -> packages/shared-types 無 diff +NEXT_PUBLIC_API_URL=https://awoooi.wooo.work pnpm --dir apps/web run build + -> compiled and generated 90/90 static pages +git diff --check + -> pass +``` + +**Local interactive smoke(live production API bridge,只允許 GET,無寫入)**: + +```text +Local dev: + NEXT_PUBLIC_API_URL=https://awoooi.wooo.work pnpm --dir apps/web dev --hostname 127.0.0.1 --port 3030 + +Playwright: + -> live production GET bridge + -> 對 km-review-drafts/dedupe response 注入 1 筆 archive_history row + -> 不允許任何非 GET request + +Checks: + -> injectedHistory=true + -> historyVisible=true + -> rowVisible=true + -> blockedWrites=0 + -> pageErrors=0 + +Note: + -> bridge smoke 有 2 個非目標 API resource 599,未影響 archive_history row 驗證;production deploy 後需用正式域名再驗證 API schema。 + +Screenshot: + /tmp/awoooi-t98-km-archive-history-row-local.png +``` + +**Production deploy / smoke**: + +```text +Pending:推 Gitea main 後驗證 dedupe API group 已含 archive_history key,並以 production Work Items 確認頁面無 console / page error。 +``` + +**目前整體進度**: + +- AwoooP 告警可觀測鏈:約 99.1%。 +- 低風險自動修復閉環:約 95%。 +- 前端 AI 自動化管理介面同步:約 98.7%。 +- 治理告警可讀性 / 可處置性:約 99.0%。 +- AI Agent ownership 可追溯性:約 98.2%。 +- KM healthcheck 派工可追蹤性:約 99.7%。 +- Hermes KB growth 草稿 / owner review 閉環:約 99.6%。 +- 完整 AI 自動化管理產品化:約 97.5%。 + ## 2026-05-20|T97 KM archive / stale ratio history 持久呈現 **觸發**: diff --git a/docs/superpowers/specs/2026-04-15-MASTER-ai-autonomous-flywheel-v2.md b/docs/superpowers/specs/2026-04-15-MASTER-ai-autonomous-flywheel-v2.md index 7f6ca89b..7a01c233 100644 --- a/docs/superpowers/specs/2026-04-15-MASTER-ai-autonomous-flywheel-v2.md +++ b/docs/superpowers/specs/2026-04-15-MASTER-ai-autonomous-flywheel-v2.md @@ -2359,6 +2359,15 @@ Phase 6 完成後 - Production:`14697ba2 feat(governance): surface km archive audit history` 已推 Gitea main;deploy marker `8a306975 chore(cd): deploy 14697ba [skip ci]`;Gitea runs `2559` CD、`2560` Code Review、`2561` Type Sync 全 success。Production health healthy/prod/mock_mode=false。`GET /ai/governance/queue?dispatch_status=all&event_type=knowledge_degradation&size=20` 回 `total=205`、`table_pending=false`,sample item 已含 `dry_run_plan_fingerprint` / `archived_count` / `stale_ratio_snapshot` 欄位。`GET /ai/governance/km-review-drafts/dedupe?limit=100` 回 `total_review_drafts=100`、`event_group_total=45`、`duplicate_draft_total=55`。Work Items production smoke 顯示 nav、KM healthcheck panel、KM 草稿去重視圖、封存 / 回測歷史區塊與 empty state,pageErrors=0 / consoleErrors=0 / failedResponses=[],截圖 `/tmp/awoooi-t97-km-archive-history-production.png`。目前 `historyRowVisible=false` 是正確狀態,因為本輪沒有對 production KM 做 owner confirm 封存;一旦 owner confirm 寫入 archive / recheck dispatch,該區塊會顯示 history row。 - 目前進度更新:前端 AI 自動化管理介面同步約 98.6%;治理告警可讀性 / 可處置性約 98.9%;AI Agent ownership 可追溯性約 98.0%;KM healthcheck 派工可追蹤性約 99.7%;Hermes KB growth 草稿 / owner review 閉環約 99.5%;完整 AI 自動化管理產品化約 97.3%。 +**T98 KM archive history 綁定 dedupe group read model(2026-05-20 台北)**: +- 觸發:T97 的 history UI 仍依賴同頁 governance queue `size=20` 分頁資料;archive/recheck dispatch row 一旦不在該頁,Operator 仍可能看不到已發生的 owner 封存或 stale ratio 回測。 +- 修正:`KnowledgeReviewDraftDedupeGroup` 新增 `archive_history: list[DispatchItem]`。`query_km_review_draft_dedupe()` 會針對本次 dedupe groups 的 `governance_event_id` 直接讀取最近 3 筆 `hermes_km_review_dedupe_owner_archive` / `hermes_km_stale_ratio_recheck` dispatch,並重用 `DispatchItem` read model 帶出 status、stage、fingerprint、archived_count、stale_ratio_snapshot。 +- UI:Work Items 前端優先讀 `group.archive_history`;若 API 尚未升級或 group 無 history,再 fallback 到 T97 的 queue item 匹配。這把「封存 / 回測歷史」從旁路分頁猜測改成 dedupe group 自帶證據。 +- 邊界:T98 仍是 read-only read model,不自動寫 KM、不封存 production KM、不改 owner approval / fingerprint guard。 +- Local verification:`py_compile` ok;ruff ok;治理 endpoint 單檔 `39 passed`;治理 endpoint / dispatcher / Hermes worker tests `62 passed`;Work Items Next lint ok;`tsc --noEmit` ok;i18n JSON parse ok;shared-types regenerate 後無 diff;production build 成功產出 90/90 static pages;`git diff --check` pass。Local Playwright live production API bridge 對 dedupe response 注入 1 筆 archive_history row,確認 `Hermes:owner 確認封存` 與 fingerprint 顯示,blockedWrites=0,截圖 `/tmp/awoooi-t98-km-archive-history-row-local.png`。 +- Production:pending,推 Gitea main 後驗證 dedupe API group 已含 archive_history key,並以 production Work Items 確認頁面無 console / page error。 +- 目前進度更新:前端 AI 自動化管理介面同步約 98.7%;治理告警可讀性 / 可處置性約 99.0%;AI Agent ownership 可追溯性約 98.2%;KM healthcheck 派工可追蹤性約 99.7%;Hermes KB growth 草稿 / owner review 閉環約 99.6%;完整 AI 自動化管理產品化約 97.5%。 + --- ### 2026-04-20 晚 (台北) — C1-C4 全流程串接 — Playbook 鏈路保護(commit de2d34d)