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
628 lines
22 KiB
Python
628 lines
22 KiB
Python
"""
|
||
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。",
|
||
"ElephantAlpha:read-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,
|
||
},
|
||
}
|