Files
awoooi/apps/api/src/services/governance_km_review_service.py
Your Name 584d2a77ff
All checks were successful
Code Review / ai-code-review (push) Successful in 14s
Type Sync Check / check-type-sync (push) Successful in 31s
CD Pipeline / tests (push) Successful in 4m8s
CD Pipeline / build-and-deploy (push) Successful in 4m48s
CD Pipeline / post-deploy-checks (push) Successful in 2m13s
feat(governance): bind km archive confirm to dry-run fingerprint
2026-05-20 09:19:32 +08:00

628 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
Governance KM Review Service
============================
Owner-approved operations for Hermes KM healthcheck review drafts.
設計原則:
- read model 仍在 governance_query_service本檔只處理 owner 審核後的寫入。
- 封存採 KnowledgeEntry.status=archived不刪除資料。
- 寫入前重新比對最新 dedupe plan避免前端 stale click 封存錯資料。
- 每次成功封存都寫 governance_remediation_dispatch terminal row 作 audit trail。
"""
from __future__ import annotations
import hashlib
import json
from datetime import timedelta
from typing import Any, Literal
import structlog
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.base import get_db_context
from src.db.models import (
GovernanceRemediationDispatch,
KnowledgeEntryRecord,
generate_uuid,
taipei_now,
)
from src.models.governance import (
KnowledgeReviewDraftArchiveRequest,
KnowledgeReviewDraftArchiveResponse,
KnowledgeReviewDraftDedupeGroup,
KnowledgeReviewDraftStaleRatioSnapshot,
)
from src.models.knowledge import EntryStatus, EntryType
from src.services.governance_agent import KM_STALE_DAYS, KM_STALE_RATIO
from src.services.governance_query_service import query_km_review_draft_dedupe
from src.utils.timezone import now_taipei
logger = structlog.get_logger(__name__)
class KmReviewDraftArchiveError(Exception):
"""KM review draft archive request failed validation."""
def __init__(self, status_code: int, detail: str) -> None:
super().__init__(detail)
self.status_code = status_code
self.detail = detail
async def archive_km_review_draft_duplicates(
*,
governance_event_id: str,
request: KnowledgeReviewDraftArchiveRequest,
) -> KnowledgeReviewDraftArchiveResponse:
"""Archive duplicate Hermes KM review drafts after explicit owner approval."""
duplicate_ids = _unique_ids(request.duplicate_entry_ids)
if not duplicate_ids:
raise KmReviewDraftArchiveError(422, "duplicate_entry_ids is required")
if request.canonical_entry_id in duplicate_ids:
raise KmReviewDraftArchiveError(409, "canonical_entry_id cannot be archived")
if not request.dry_run and not request.owner_approved:
raise KmReviewDraftArchiveError(
403,
"owner_approved=true is required before archiving duplicate KM drafts",
)
plan = await query_km_review_draft_dedupe(limit=200)
group = next(
(
item
for item in plan.groups
if item.governance_event_id == governance_event_id
),
None,
)
if group is None:
already_archived = await _load_already_archived_duplicate_ids(
duplicate_ids,
canonical_entry_id=request.canonical_entry_id,
governance_event_id=governance_event_id,
)
if set(already_archived) == set(duplicate_ids):
return _build_archive_response(
governance_event_id=governance_event_id,
request=request,
duplicate_ids=duplicate_ids,
status="noop_already_archived",
skipped_entry_ids=duplicate_ids,
writes_km=False,
writes_governance_audit=False,
)
raise KmReviewDraftArchiveError(
409,
"latest dedupe plan no longer contains this governance_event_id",
)
_validate_archive_request_against_plan(group, request, duplicate_ids)
dry_run_plan_fingerprint = _build_dry_run_plan_fingerprint(
governance_event_id,
group,
)
if not request.dry_run:
_validate_dry_run_plan_fingerprint(
request.dry_run_plan_fingerprint,
dry_run_plan_fingerprint,
)
if request.dry_run:
return _build_archive_response(
governance_event_id=governance_event_id,
request=request,
duplicate_ids=duplicate_ids,
status="dry_run",
would_archive_entry_ids=duplicate_ids,
writes_km=False,
writes_governance_audit=False,
stale_ratio_snapshot=await _load_current_km_stale_ratio_snapshot(),
stale_ratio_recheck_status="dry_run",
dry_run_plan_fingerprint=dry_run_plan_fingerprint,
)
archive_result = await _archive_duplicates_and_write_audit(
governance_event_id=governance_event_id,
request=request,
duplicate_ids=duplicate_ids,
)
return _build_archive_response(
governance_event_id=governance_event_id,
request=request,
duplicate_ids=duplicate_ids,
status="archived",
archived_entry_ids=archive_result["archived_ids"],
writes_km=bool(archive_result["archived_ids"]),
writes_governance_audit=True,
audit_dispatch_id=archive_result["audit_dispatch_id"],
stale_ratio_snapshot=archive_result["stale_ratio_snapshot"],
stale_ratio_recheck_status=archive_result["recheck_status"],
stale_ratio_recheck_dispatch_id=archive_result["recheck_dispatch_id"],
dry_run_plan_fingerprint=dry_run_plan_fingerprint,
)
def _unique_ids(ids: list[str]) -> list[str]:
"""Normalize duplicate ids while preserving operator-visible order."""
result: list[str] = []
for raw in ids:
value = str(raw).strip()
if value and value not in result:
result.append(value[:120])
return result
def _validate_archive_request_against_plan(
group: KnowledgeReviewDraftDedupeGroup,
request: KnowledgeReviewDraftArchiveRequest,
duplicate_ids: list[str],
) -> None:
"""Ensure the owner action is based on the latest read model."""
if group.canonical_entry_id != request.canonical_entry_id:
raise KmReviewDraftArchiveError(
409,
"canonical_entry_id does not match the latest dedupe plan",
)
current_duplicate_ids = _unique_ids(group.duplicate_entry_ids)
if set(current_duplicate_ids) != set(duplicate_ids):
raise KmReviewDraftArchiveError(
409,
"duplicate_entry_ids does not match the latest dedupe plan",
)
def _build_dry_run_plan_fingerprint(
governance_event_id: str,
group: KnowledgeReviewDraftDedupeGroup,
) -> str:
"""Build a stable fingerprint the owner must echo from dry-run to confirm."""
payload = {
"schema_version": "km_review_draft_archive_plan_v1",
"governance_event_id": governance_event_id,
"canonical_entry_id": group.canonical_entry_id,
"duplicate_entry_ids": sorted(_unique_ids(group.duplicate_entry_ids)),
"owner_action": group.owner_action,
"preferred_source": group.preferred_source,
"suggested_action": group.suggested_action,
"total_entries": group.total_entries,
}
encoded = json.dumps(
payload,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
return "sha256:" + hashlib.sha256(encoded.encode("utf-8")).hexdigest()
def _validate_dry_run_plan_fingerprint(
provided: str | None,
expected: str,
) -> None:
"""Require a matching dry-run preview fingerprint before any write."""
if not provided:
raise KmReviewDraftArchiveError(
403,
"dry_run_plan_fingerprint from a dry-run preview is required before archiving",
)
if provided != expected:
raise KmReviewDraftArchiveError(
409,
"dry_run_plan_fingerprint does not match the latest dedupe plan",
)
async def _load_already_archived_duplicate_ids(
duplicate_ids: list[str],
*,
canonical_entry_id: str,
governance_event_id: str,
) -> list[str]:
"""Treat double-clicks as idempotent only when prior archive tags prove it."""
if not duplicate_ids:
return []
async with get_db_context() as db:
result = await db.execute(
select(KnowledgeEntryRecord).where(
KnowledgeEntryRecord.id.in_(duplicate_ids)
)
)
records = result.scalars().all()
archived: list[str] = []
for record in records:
tags = [str(tag) for tag in (record.tags or [])]
if (
_enum_value(record.status) == EntryStatus.ARCHIVED.value
and f"dedupe_canonical:{canonical_entry_id}" in tags
and f"governance_event:{governance_event_id}" in tags
):
archived.append(str(record.id))
return archived
async def _archive_duplicates_and_write_audit(
*,
governance_event_id: str,
request: KnowledgeReviewDraftArchiveRequest,
duplicate_ids: list[str],
) -> dict[str, Any]:
"""Soft-archive duplicate rows and append a terminal audit dispatch."""
now = now_taipei()
async with get_db_context() as db:
result = await db.execute(
select(KnowledgeEntryRecord).where(
KnowledgeEntryRecord.id.in_(duplicate_ids)
)
)
records = result.scalars().all()
records_by_id = {str(record.id): record for record in records}
missing = [entry_id for entry_id in duplicate_ids if entry_id not in records_by_id]
if missing:
raise KmReviewDraftArchiveError(
409,
f"duplicate KM drafts missing or no longer visible: {', '.join(missing[:3])}",
)
archived_ids: list[str] = []
for entry_id in duplicate_ids:
record = records_by_id[entry_id]
tags = [str(tag) for tag in (record.tags or [])]
if not _is_archive_candidate(
record,
governance_event_id=governance_event_id,
):
raise KmReviewDraftArchiveError(
409,
f"KM draft {entry_id} is no longer an archive candidate",
)
record.status = EntryStatus.ARCHIVED
record.tags = _append_archive_tags(
tags,
governance_event_id=governance_event_id,
canonical_entry_id=request.canonical_entry_id,
owner=request.owner,
archived_at=now.isoformat(),
)
record.updated_at = now
archived_ids.append(entry_id)
await db.flush()
stale_ratio_snapshot = await _compute_km_stale_ratio_snapshot(db)
recheck_dispatch_id, recheck_status = await _ensure_stale_ratio_recheck_dispatch(
db,
governance_event_id=governance_event_id,
request=request,
archived_ids=archived_ids,
stale_ratio_snapshot=stale_ratio_snapshot,
)
audit = GovernanceRemediationDispatch(
id=generate_uuid(),
governance_event_id=governance_event_id,
event_type="knowledge_degradation",
dispatch_status="succeeded",
decision_context=_build_archive_audit_context(
governance_event_id=governance_event_id,
request=request,
archived_ids=archived_ids,
stale_ratio_snapshot=stale_ratio_snapshot,
recheck_dispatch_id=recheck_dispatch_id,
recheck_status=recheck_status,
),
executor_type="hermes_km_review_dedupe_owner_archive",
attempt_count=0,
max_attempts=1,
dispatched_at=taipei_now(),
started_at=taipei_now(),
completed_at=taipei_now(),
created_by=request.owner[:100],
)
db.add(audit)
await db.flush()
logger.info(
"km_review_draft_duplicates_archived",
governance_event_id=governance_event_id,
canonical_entry_id=request.canonical_entry_id,
duplicate_count=len(archived_ids),
audit_dispatch_id=audit.id,
recheck_dispatch_id=recheck_dispatch_id,
recheck_status=recheck_status,
)
return {
"archived_ids": archived_ids,
"audit_dispatch_id": str(audit.id),
"stale_ratio_snapshot": stale_ratio_snapshot,
"recheck_dispatch_id": recheck_dispatch_id,
"recheck_status": recheck_status,
}
def _is_archive_candidate(
record: KnowledgeEntryRecord,
*,
governance_event_id: str,
) -> bool:
tags = [str(tag) for tag in (record.tags or [])]
return (
_enum_value(record.entry_type) == EntryType.AUTO_RUNBOOK.value
and _enum_value(record.status) == EntryStatus.REVIEW.value
and f"governance_event:{governance_event_id}" in tags
)
def _append_archive_tags(
tags: list[str],
*,
governance_event_id: str,
canonical_entry_id: str,
owner: str,
archived_at: str,
) -> list[str]:
additions = [
"archived_by:km_review_dedupe",
f"governance_event:{governance_event_id}",
f"dedupe_canonical:{canonical_entry_id}",
f"dedupe_owner:{owner[:80]}",
f"archived_at:{archived_at}",
]
merged = list(tags)
for tag in additions:
if tag not in merged:
merged.append(tag)
return merged
def _build_archive_audit_context(
*,
governance_event_id: str,
request: KnowledgeReviewDraftArchiveRequest,
archived_ids: list[str],
stale_ratio_snapshot: KnowledgeReviewDraftStaleRatioSnapshot | None = None,
recheck_dispatch_id: str | None = None,
recheck_status: str = "not_requested",
) -> dict[str, Any]:
return {
"schema_version": "km_review_draft_archive_audit_v1",
"decision_path": "owner_approved_archive_duplicates",
"workflow": {
"current_stage": "km_duplicate_archive_after_owner_approval",
"steps": [
"detected",
"queued_kb_healthcheck",
"draft_km_updates",
"waiting_owner_review",
"owner_approved_duplicate_archive",
"stale_ratio_recheck",
],
"next_action": "stale_ratio_recheck",
"writes_km": True,
"writes_governance_audit": True,
},
"owner_action": "review_canonical_and_archive_duplicate_drafts",
"owner": request.owner,
"governance_event_id": governance_event_id,
"canonical_entry_id": request.canonical_entry_id,
"archived_entry_ids": archived_ids,
"archived_count": len(archived_ids),
"dry_run_plan_fingerprint": request.dry_run_plan_fingerprint,
"stale_ratio_snapshot": (
stale_ratio_snapshot.model_dump() if stale_ratio_snapshot else None
),
"stale_ratio_recheck": {
"status": recheck_status,
"dispatch_id": recheck_dispatch_id,
"executor_type": "hermes_km_stale_ratio_recheck",
},
"dry_run": request.dry_run,
"owner_approved": request.owner_approved,
}
def _build_archive_response(
*,
governance_event_id: str,
request: KnowledgeReviewDraftArchiveRequest,
duplicate_ids: list[str],
status: Literal["dry_run", "archived", "noop_already_archived"],
archived_entry_ids: list[str] | None = None,
skipped_entry_ids: list[str] | None = None,
would_archive_entry_ids: list[str] | None = None,
writes_km: bool,
writes_governance_audit: bool,
audit_dispatch_id: str | None = None,
stale_ratio_snapshot: KnowledgeReviewDraftStaleRatioSnapshot | None = None,
stale_ratio_recheck_status: Literal[
"dry_run",
"completed",
"already_active",
"not_requested",
] = "not_requested",
stale_ratio_recheck_dispatch_id: str | None = None,
dry_run_plan_fingerprint: str | None = None,
) -> KnowledgeReviewDraftArchiveResponse:
return KnowledgeReviewDraftArchiveResponse(
governance_event_id=governance_event_id,
canonical_entry_id=request.canonical_entry_id,
requested_duplicate_entry_ids=duplicate_ids,
archived_entry_ids=archived_entry_ids or [],
skipped_entry_ids=skipped_entry_ids or [],
would_archive_entry_ids=would_archive_entry_ids or [],
status=status,
owner=request.owner,
owner_approved=request.owner_approved,
dry_run=request.dry_run,
writes_km=writes_km,
writes_governance_audit=writes_governance_audit,
audit_dispatch_id=audit_dispatch_id,
stale_ratio_snapshot=stale_ratio_snapshot,
stale_ratio_recheck_status=stale_ratio_recheck_status,
stale_ratio_recheck_dispatch_id=stale_ratio_recheck_dispatch_id,
dry_run_plan_fingerprint=(
dry_run_plan_fingerprint or request.dry_run_plan_fingerprint
),
generated_at=now_taipei(),
)
def _enum_value(value: Any) -> str:
return str(value.value if hasattr(value, "value") else value)
async def _load_current_km_stale_ratio_snapshot() -> KnowledgeReviewDraftStaleRatioSnapshot:
async with get_db_context() as db:
return await _compute_km_stale_ratio_snapshot(db)
async def _compute_km_stale_ratio_snapshot(
db: AsyncSession,
) -> KnowledgeReviewDraftStaleRatioSnapshot:
"""Use the same KM stale definition as GovernanceAgent.check_knowledge_degradation."""
stale_cutoff = now_taipei() - timedelta(days=KM_STALE_DAYS)
total_result = await db.execute(
select(func.count()).select_from(KnowledgeEntryRecord).where(
KnowledgeEntryRecord.status != EntryStatus.ARCHIVED,
)
)
total = int(total_result.scalar() or 0)
stale_result = await db.execute(
select(func.count()).select_from(KnowledgeEntryRecord).where(
KnowledgeEntryRecord.status != EntryStatus.ARCHIVED,
KnowledgeEntryRecord.updated_at < stale_cutoff,
)
)
stale = int(stale_result.scalar() or 0)
ratio = round(stale / total, 3) if total > 0 else 0.0
return KnowledgeReviewDraftStaleRatioSnapshot(
stale_count=stale,
total_count=total,
stale_ratio=ratio,
threshold=KM_STALE_RATIO,
stale_days=KM_STALE_DAYS,
)
async def _ensure_stale_ratio_recheck_dispatch(
db: AsyncSession,
*,
governance_event_id: str,
request: KnowledgeReviewDraftArchiveRequest,
archived_ids: list[str],
stale_ratio_snapshot: KnowledgeReviewDraftStaleRatioSnapshot,
) -> tuple[str | None, Literal["completed", "already_active"]]:
"""Record the post-archive recheck unless another active dispatch owns the event."""
active_result = await db.execute(
select(GovernanceRemediationDispatch)
.where(
GovernanceRemediationDispatch.governance_event_id == governance_event_id,
GovernanceRemediationDispatch.dispatch_status.in_([
"pending",
"dispatched",
"executing",
]),
)
.order_by(GovernanceRemediationDispatch.dispatched_at.desc())
.limit(1)
)
active = active_result.scalar_one_or_none()
if active is not None:
return str(active.id), "already_active"
recheck = GovernanceRemediationDispatch(
id=generate_uuid(),
governance_event_id=governance_event_id,
event_type="knowledge_degradation",
dispatch_status="succeeded",
decision_context=_build_stale_ratio_recheck_context(
governance_event_id=governance_event_id,
request=request,
archived_ids=archived_ids,
stale_ratio_snapshot=stale_ratio_snapshot,
),
executor_type="hermes_km_stale_ratio_recheck",
attempt_count=0,
max_attempts=1,
dispatched_at=taipei_now(),
started_at=taipei_now(),
completed_at=taipei_now(),
created_by=request.owner[:100],
)
db.add(recheck)
await db.flush()
return str(recheck.id), "completed"
def _build_stale_ratio_recheck_context(
*,
governance_event_id: str,
request: KnowledgeReviewDraftArchiveRequest,
archived_ids: list[str],
stale_ratio_snapshot: KnowledgeReviewDraftStaleRatioSnapshot,
) -> dict[str, Any]:
return {
"version": "v1",
"trigger_source": "km_review_dedupe_archive",
"triggered_metric": "knowledge_degradation",
"metric_value": stale_ratio_snapshot.stale_ratio,
"threshold": stale_ratio_snapshot.threshold,
"suggested_action": "run_stale_ratio_recheck",
"next_action": "run_stale_ratio_recheck",
"decision_path": "owner_approved_recheck_after_archive",
"ownership": {
"lead_agent": "Hermes",
"support_agents": [
"OpenClaw提供知識劣化風險脈絡不直接批量改寫 KM。",
"ElephantAlpharead-only 稽核 stale ratio recheck 結果。",
],
"human_owner": "KM owner / SRE owner",
},
"workflow": {
"work_item_id": f"governance:knowledge_degradation:{governance_event_id}:stale_ratio_recheck",
"work_kind": "km_stale_ratio_recheck",
"current_stage": "stale_ratio_recheck",
"steps": [
"detected",
"draft_km_updates",
"waiting_owner_review",
"owner_approved_duplicate_archive",
"stale_ratio_recheck",
"km_governance_close_or_continue",
],
"stage_by_dispatch_status": {
"pending": "stale_ratio_recheck",
"dispatched": "stale_ratio_recheck",
"executing": "stale_ratio_recheck",
"succeeded": "km_governance_rechecked",
"failed": "needs_manual_km_triage",
"skipped": "needs_manual_km_triage",
"cancelled": "cancelled",
},
"next_action": "run_stale_ratio_recheck",
"writes_km_without_approval": False,
"writes_km": False,
"source_archive_action": "review_canonical_and_archive_duplicate_drafts",
"canonical_entry_id": request.canonical_entry_id,
"archived_entry_ids": archived_ids,
"dry_run_plan_fingerprint": request.dry_run_plan_fingerprint,
"stale_ratio_snapshot": stale_ratio_snapshot.model_dump(),
},
"worker_result": {
"status": "stale_ratio_rechecked",
"canonical_entry_id": request.canonical_entry_id,
"archived_count": len(archived_ids),
"stale_ratio": stale_ratio_snapshot.stale_ratio,
"threshold": stale_ratio_snapshot.threshold,
"above_threshold": stale_ratio_snapshot.stale_ratio > stale_ratio_snapshot.threshold,
},
}