Files
awoooi/apps/api/src/services/governance_km_stale_review_service.py
Your Name 4cfc6a4c79
All checks were successful
CD Pipeline / tests (push) Successful in 1m8s
Code Review / ai-code-review (push) Successful in 11s
Type Sync Check / check-type-sync (push) Successful in 26s
CD Pipeline / build-and-deploy (push) Successful in 4m9s
CD Pipeline / post-deploy-checks (push) Successful in 1m33s
feat(governance): preview stale km completion batches
2026-05-24 23:15:03 +08:00

2402 lines
88 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 Stale Review Service
==================================
Owner-review intake for stale KM priority candidates.
這層只把 stale KM 候選排入治理工作項與 audit trail不改寫 KM 內容。
真正 refresh / archive / supersede 仍需 owner 後續審核。
"""
from __future__ import annotations
import hashlib
import json
from datetime import timedelta
from typing import Any, Literal
import structlog
from sqlalchemy import func, select, text
from sqlalchemy.ext.asyncio import AsyncSession
from src.db.base import get_db_context
from src.db.models import (
AiGovernanceEvent,
GovernanceRemediationDispatch,
KnowledgeEntryRecord,
generate_uuid,
taipei_now,
)
from src.models.governance import (
KnowledgeReviewDraftStaleRatioSnapshot,
KnowledgeStaleCandidate,
KnowledgeStaleOwnerReviewBatchItem,
KnowledgeStaleOwnerReviewBatchQueueRequest,
KnowledgeStaleOwnerReviewBatchQueueResponse,
KnowledgeStaleOwnerReviewBurnDownItem,
KnowledgeStaleOwnerReviewBurnDownResponse,
KnowledgeStaleOwnerReviewCompleteRequest,
KnowledgeStaleOwnerReviewCompleteResponse,
KnowledgeStaleOwnerReviewCompletionBatchPreviewRequest,
KnowledgeStaleOwnerReviewCompletionBatchPreviewResponse,
KnowledgeStaleOwnerReviewCompletionQueueItem,
KnowledgeStaleOwnerReviewCompletionQueueResponse,
KnowledgeStaleOwnerReviewInboxItem,
KnowledgeStaleOwnerReviewInboxResponse,
KnowledgeStaleOwnerReviewRequest,
KnowledgeStaleOwnerReviewResponse,
)
from src.models.knowledge import EntryStatus
from src.services.governance_agent import KM_STALE_DAYS, KM_STALE_RATIO
from src.services.governance_query_service import (
_build_km_stale_candidate,
query_km_stale_candidates,
)
from src.utils.timezone import now_taipei
logger = structlog.get_logger(__name__)
_EXECUTOR_TYPE = "hermes_km_stale_owner_review"
_BATCH_EXECUTOR_TYPE = "hermes_km_stale_owner_review_batch"
_COMPLETE_EXECUTOR_TYPE = "hermes_km_stale_owner_review_complete"
_RECHECK_EXECUTOR_TYPE = "hermes_km_stale_ratio_recheck"
_ACTIVE_DISPATCH_STATUSES = frozenset({"pending", "dispatched", "executing"})
_TERMINAL_FAILED_DISPATCH_STATUSES = frozenset({"failed", "cancelled"})
_OWNER_REVIEW_DISPATCH_STATUSES = frozenset({
"pending",
"dispatched",
"executing",
"succeeded",
"failed",
"skipped",
"cancelled",
})
class KmStaleOwnerReviewError(Exception):
"""KM stale owner-review 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 query_km_stale_owner_review_inbox(
*,
project_id: str = "awoooi",
dispatch_status: str = "pending",
limit: int = 20,
) -> KnowledgeStaleOwnerReviewInboxResponse:
"""Read owner-review dispatches with KM priority context for the operator console."""
if dispatch_status != "all" and dispatch_status not in _OWNER_REVIEW_DISPATCH_STATUSES:
raise KmStaleOwnerReviewError(422, "unsupported stale KM owner-review dispatch_status")
generated_at = now_taipei()
items = await _load_owner_review_inbox_items(
project_id=project_id,
dispatch_status=dispatch_status,
generated_at=generated_at,
)
items.sort(
key=lambda item: (
_priority_rank(item.priority_tier),
item.priority_score,
item.stale_days,
item.queued_at.isoformat() if item.queued_at else "",
),
reverse=True,
)
limited = items[:limit]
return KnowledgeStaleOwnerReviewInboxResponse(
project_id=project_id,
dispatch_status=dispatch_status,
total=len(items),
returned=len(limited),
items=limited,
generated_at=generated_at,
)
async def query_km_stale_owner_review_completion_queue(
*,
project_id: str = "awoooi",
status_bucket: Literal["all", "ready", "blocked", "completed", "failed", "pending"] = "all",
priority_tiers: list[str] | None = None,
recommended_completion_outcome: str = "all",
batch_governance_event_id: str | None = None,
can_preview: bool | None = None,
limit: int = 20,
) -> KnowledgeStaleOwnerReviewCompletionQueueResponse:
"""Read owner-review completion readiness without writing KM content."""
normalized_priority_tiers = _normalize_priority_tiers(priority_tiers)
normalized_outcome = _normalize_completion_outcome(recommended_completion_outcome)
generated_at = now_taipei()
inbox_items = await _load_owner_review_inbox_items(
project_id=project_id,
dispatch_status="all",
generated_at=generated_at,
)
queue_items = [
_build_completion_queue_item(item)
for item in inbox_items
]
queue_items.sort(
key=lambda item: (
item.readiness == "ready",
_priority_rank(item.priority_tier),
item.priority_score,
item.stale_days,
item.queued_at.isoformat() if item.queued_at else "",
),
reverse=True,
)
filtered_items = [
item for item in queue_items
if _completion_queue_bucket_matches(item, status_bucket=status_bucket)
]
filtered_items = _filter_completion_queue_items(
filtered_items,
priority_tiers=normalized_priority_tiers,
recommended_completion_outcome=normalized_outcome,
batch_governance_event_id=batch_governance_event_id,
can_preview=can_preview,
)
limited = filtered_items[:limit]
return KnowledgeStaleOwnerReviewCompletionQueueResponse(
project_id=project_id,
status_bucket=status_bucket,
priority_tiers=normalized_priority_tiers,
recommended_completion_outcome=normalized_outcome,
batch_governance_event_id=batch_governance_event_id,
can_preview=can_preview,
total=len(filtered_items),
returned=len(limited),
pending_count=sum(
1 for item in queue_items
if item.dispatch_status in _ACTIVE_DISPATCH_STATUSES
),
ready_count=sum(1 for item in queue_items if item.readiness == "ready"),
blocked_count=sum(1 for item in queue_items if item.readiness == "blocked"),
completed_count=sum(1 for item in queue_items if item.readiness == "completed"),
failed_count=sum(1 for item in queue_items if item.readiness == "failed"),
items=limited,
generated_at=generated_at,
)
async def preview_km_stale_owner_review_completion_batch(
*,
request: KnowledgeStaleOwnerReviewCompletionBatchPreviewRequest,
) -> KnowledgeStaleOwnerReviewCompletionBatchPreviewResponse:
"""Preview a bounded completion batch without writing KM or governance audit rows."""
generated_at = now_taipei()
queue = await query_km_stale_owner_review_completion_queue(
project_id=request.project_id,
status_bucket=request.status_bucket,
priority_tiers=list(request.priority_tiers),
recommended_completion_outcome=request.recommended_completion_outcome,
batch_governance_event_id=request.batch_governance_event_id,
can_preview=None,
limit=request.limit,
)
items = queue.items
previewable_items = [item for item in items if item.can_preview]
fingerprint = _build_completion_batch_preview_fingerprint(
request=request,
items=items,
)
return KnowledgeStaleOwnerReviewCompletionBatchPreviewResponse(
project_id=request.project_id,
owner=request.owner,
owner_note=request.owner_note,
status_bucket=request.status_bucket,
priority_tiers=list(dict.fromkeys(request.priority_tiers)),
recommended_completion_outcome=request.recommended_completion_outcome,
batch_governance_event_id=request.batch_governance_event_id,
requested_limit=request.limit,
candidate_count=len(items),
previewable_count=len(previewable_items),
blocked_count=sum(1 for item in items if item.readiness == "blocked"),
completed_count=sum(1 for item in items if item.readiness == "completed"),
failed_count=sum(1 for item in items if item.readiness == "failed"),
dry_run_plan_fingerprint=fingerprint,
items=items,
generated_at=generated_at,
)
async def _load_owner_review_inbox_items(
*,
project_id: str,
dispatch_status: str,
generated_at: Any,
) -> list[KnowledgeStaleOwnerReviewInboxItem]:
status_filter = (
"TRUE"
if dispatch_status == "all"
else "d.dispatch_status::text = :dispatch_status"
)
sql = text(f"""
SELECT
d.id,
d.governance_event_id,
d.dispatch_status,
d.decision_context,
d.dispatched_at,
d.started_at,
d.completed_at,
COALESCE(
d.decision_context -> 'workflow' ->> 'entry_id',
d.decision_context ->> 'entry_id'
) AS entry_id,
COALESCE(
d.decision_context -> 'workflow' ->> 'project_id',
d.decision_context ->> 'project_id',
d.decision_context -> 'candidate' ->> 'project_id'
) AS project_id
FROM governance_remediation_dispatch d
WHERE d.executor_type = :executor_type
AND {status_filter}
AND COALESCE(
d.decision_context -> 'workflow' ->> 'project_id',
d.decision_context ->> 'project_id',
d.decision_context -> 'candidate' ->> 'project_id'
) = :project_id
ORDER BY d.dispatched_at DESC
""")
params: dict[str, Any] = {
"executor_type": _EXECUTOR_TYPE,
"project_id": project_id,
}
if dispatch_status != "all":
params["dispatch_status"] = dispatch_status
async with get_db_context() as db:
result = await db.execute(sql, params)
rows = result.fetchall()
entry_ids = [
str(row.entry_id)
for row in rows
if isinstance(row.entry_id, str) and row.entry_id
]
records_by_id: dict[str, KnowledgeEntryRecord] = {}
if entry_ids:
record_result = await db.execute(
select(KnowledgeEntryRecord).where(KnowledgeEntryRecord.id.in_(entry_ids))
)
records_by_id = {
str(record.id): record
for record in record_result.scalars().all()
}
items: list[KnowledgeStaleOwnerReviewInboxItem] = []
for row in rows:
entry_id = str(row.entry_id or "")
record = records_by_id.get(entry_id)
if record is None:
continue
candidate = _build_km_stale_candidate(
record,
now=generated_at,
threshold_days=KM_STALE_DAYS,
)
decision_context = row.decision_context if isinstance(row.decision_context, dict) else {}
items.append(
_build_owner_review_inbox_item(
row=row,
candidate=candidate,
decision_context=decision_context,
)
)
return items
async def query_km_stale_owner_review_burndown(
*,
project_id: str = "awoooi",
limit: int = 20,
) -> KnowledgeStaleOwnerReviewBurnDownResponse:
"""Read stale KM owner-review completion progress and current burn-down status."""
generated_at = now_taipei()
async with get_db_context() as db:
current_snapshot = await _compute_km_stale_ratio_snapshot(db)
counts = await _load_owner_review_burndown_counts(db, project_id=project_id)
completion_rows = await _load_owner_review_completion_rows(
db,
project_id=project_id,
limit=limit,
)
items = _build_owner_review_burndown_items(completion_rows, project_id=project_id)
latest = items[0] if items else None
return KnowledgeStaleOwnerReviewBurnDownResponse(
project_id=project_id,
burn_down_status=_stale_burndown_status(current_snapshot),
current_snapshot=current_snapshot,
entries_to_threshold=_entries_to_stale_threshold(current_snapshot),
pending_owner_reviews=counts["pending_owner_reviews"],
completed_owner_reviews=counts["completed_owner_reviews"],
completion_audit_total=counts["completion_audit_total"],
stale_ratio_recheck_total=counts["stale_ratio_recheck_total"],
latest_stale_count_delta=latest.stale_count_delta if latest else None,
latest_stale_ratio_delta=latest.stale_ratio_delta if latest else None,
returned=len(items),
items=items,
generated_at=generated_at,
)
async def queue_km_stale_owner_review(
*,
entry_id: str,
request: KnowledgeStaleOwnerReviewRequest,
) -> KnowledgeStaleOwnerReviewResponse:
"""Queue a stale KM candidate for owner review without modifying KM content."""
record = await _load_stale_candidate_record(entry_id)
candidate = _build_km_stale_candidate(
record,
now=now_taipei(),
threshold_days=KM_STALE_DAYS,
)
existing = await _load_active_owner_review_dispatch(entry_id)
if existing is not None:
return _build_response(
entry_id=entry_id,
project_id=candidate.project_id,
status="already_queued",
governance_event_id=str(existing.governance_event_id),
dispatch_id=str(existing.id),
recommended_action=candidate.recommended_action,
owner=request.owner,
owner_note=request.owner_note,
writes_governance_audit=False,
)
if request.dry_run:
return _build_response(
entry_id=entry_id,
project_id=candidate.project_id,
status="dry_run",
recommended_action=candidate.recommended_action,
owner=request.owner,
owner_note=request.owner_note,
writes_governance_audit=False,
)
now = taipei_now()
event_id = generate_uuid()
dispatch_id = generate_uuid()
event_details = _build_stale_owner_review_event_details(
entry_id=entry_id,
candidate=candidate.model_dump(mode="json"),
owner=request.owner,
owner_note=request.owner_note,
)
decision_context = _build_stale_owner_review_decision_context(
governance_event_id=event_id,
entry_id=entry_id,
candidate=candidate.model_dump(mode="json"),
owner=request.owner,
owner_note=request.owner_note,
)
async with get_db_context() as db:
event = AiGovernanceEvent(
id=event_id,
event_type="knowledge_degradation",
triggered_at=now,
details=event_details,
resolved=False,
)
dispatch = GovernanceRemediationDispatch(
id=dispatch_id,
governance_event_id=event_id,
event_type="knowledge_degradation",
dispatch_status="pending",
playbook_id=None,
incident_id=None,
approval_id=None,
decision_context=decision_context,
executor_type=_EXECUTOR_TYPE,
attempt_count=0,
max_attempts=1,
dispatched_at=now,
created_by=request.owner[:100],
)
db.add(event)
db.add(dispatch)
await db.flush()
logger.info(
"km_stale_owner_review_queued",
entry_id=entry_id,
project_id=candidate.project_id,
governance_event_id=event_id,
dispatch_id=dispatch_id,
recommended_action=candidate.recommended_action,
)
return _build_response(
entry_id=entry_id,
project_id=candidate.project_id,
status="queued",
governance_event_id=event_id,
dispatch_id=dispatch_id,
recommended_action=candidate.recommended_action,
owner=request.owner,
owner_note=request.owner_note,
writes_governance_audit=True,
)
async def batch_queue_km_stale_owner_reviews(
*,
request: KnowledgeStaleOwnerReviewBatchQueueRequest,
) -> KnowledgeStaleOwnerReviewBatchQueueResponse:
"""Queue a bounded P0/P1 stale-KM batch for owner review without KM writes."""
selected = await _select_batch_stale_candidates(request)
items = _build_batch_plan_items(selected, dry_run=request.dry_run)
snapshot = await _load_current_km_stale_ratio_snapshot()
fingerprint = _build_batch_queue_plan_fingerprint(
request=request,
items=items,
snapshot=snapshot,
)
queueable = [item for item in items if item.status == "would_queue"]
if request.dry_run:
return _build_batch_queue_response(
request=request,
status="dry_run",
items=items,
stale_ratio_snapshot=snapshot,
dry_run_plan_fingerprint=fingerprint,
writes_governance_audit=False,
)
if not request.dry_run_plan_fingerprint:
raise KmStaleOwnerReviewError(
403,
"dry_run_plan_fingerprint from a dry-run preview is required before queueing a stale KM batch",
)
if request.dry_run_plan_fingerprint != fingerprint:
raise KmStaleOwnerReviewError(
409,
"dry_run_plan_fingerprint does not match the latest stale KM batch queue plan",
)
if not queueable:
return _build_batch_queue_response(
request=request,
status="noop_already_queued",
items=items,
stale_ratio_snapshot=snapshot,
dry_run_plan_fingerprint=fingerprint,
writes_governance_audit=False,
)
write_result = await _write_batch_owner_review_dispatches(
request=request,
candidates=selected,
plan_items=items,
stale_ratio_snapshot=snapshot,
plan_fingerprint=fingerprint,
)
return _build_batch_queue_response(
request=request,
status="queued",
items=write_result["items"],
stale_ratio_snapshot=snapshot,
dry_run_plan_fingerprint=fingerprint,
writes_governance_audit=True,
batch_governance_event_id=write_result["batch_governance_event_id"],
batch_dispatch_id=write_result["batch_dispatch_id"],
)
async def _select_batch_stale_candidates(
request: KnowledgeStaleOwnerReviewBatchQueueRequest,
) -> list[KnowledgeStaleCandidate]:
"""Load a bounded priority batch from the read model instead of duplicating scoring logic."""
fetch_limit = min(100, max(request.limit * 4, request.limit))
response = await query_km_stale_candidates(
project_id=request.project_id,
limit=fetch_limit,
)
wanted_tiers = set(request.priority_tiers)
selected: list[KnowledgeStaleCandidate] = []
for candidate in response.items:
if candidate.priority_tier not in wanted_tiers:
continue
selected.append(candidate)
if len(selected) >= request.limit:
break
return selected
def _build_batch_plan_items(
candidates: list[KnowledgeStaleCandidate],
*,
dry_run: bool,
) -> list[KnowledgeStaleOwnerReviewBatchItem]:
items: list[KnowledgeStaleOwnerReviewBatchItem] = []
for candidate in candidates:
owner_status = str(candidate.owner_review_status or "")
if owner_status in _ACTIVE_DISPATCH_STATUSES:
status: Literal["would_queue", "queued", "already_queued", "skipped"] = "already_queued"
reason = "active_owner_review_exists"
workflow_stage = candidate.owner_review_stage or "waiting_owner_review"
elif owner_status == "succeeded":
status = "skipped"
reason = "already_reviewed_or_completed"
workflow_stage = candidate.owner_review_stage or "km_candidate_reviewed"
else:
status = "would_queue" if dry_run else "would_queue"
reason = None
workflow_stage = "waiting_owner_review"
items.append(
KnowledgeStaleOwnerReviewBatchItem(
entry_id=candidate.entry_id,
title=candidate.title,
priority_tier=candidate.priority_tier,
recommended_action=candidate.recommended_action,
status=status,
reason=reason,
dispatch_id=candidate.owner_review_dispatch_id,
workflow_stage=workflow_stage,
)
)
return items
def _build_batch_queue_plan_fingerprint(
*,
request: KnowledgeStaleOwnerReviewBatchQueueRequest,
items: list[KnowledgeStaleOwnerReviewBatchItem],
snapshot: KnowledgeReviewDraftStaleRatioSnapshot,
) -> str:
payload = {
"schema_version": "km_stale_owner_review_batch_plan_v1",
"project_id": request.project_id,
"priority_tiers": list(dict.fromkeys(request.priority_tiers)),
"limit": request.limit,
"owner": request.owner,
"owner_note_sha256": (
hashlib.sha256(request.owner_note.encode("utf-8")).hexdigest()
if request.owner_note
else None
),
"stale_ratio_snapshot": snapshot.model_dump(),
"items": [
{
"entry_id": item.entry_id,
"priority_tier": item.priority_tier,
"recommended_action": item.recommended_action,
"status": item.status,
"dispatch_id": item.dispatch_id,
}
for item in items
],
}
encoded = json.dumps(
payload,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
return "sha256:" + hashlib.sha256(encoded.encode("utf-8")).hexdigest()
async def _write_batch_owner_review_dispatches(
*,
request: KnowledgeStaleOwnerReviewBatchQueueRequest,
candidates: list[KnowledgeStaleCandidate],
plan_items: list[KnowledgeStaleOwnerReviewBatchItem],
stale_ratio_snapshot: KnowledgeReviewDraftStaleRatioSnapshot,
plan_fingerprint: str,
) -> dict[str, Any]:
now = taipei_now()
batch_event_id = generate_uuid()
batch_dispatch_id = generate_uuid()
candidate_by_id = {candidate.entry_id: candidate for candidate in candidates}
queued_ids = {item.entry_id for item in plan_items if item.status == "would_queue"}
queued_item_ids: dict[str, tuple[str, str]] = {}
async with get_db_context() as db:
batch_event = AiGovernanceEvent(
id=batch_event_id,
event_type="knowledge_degradation",
triggered_at=now,
details=_build_batch_owner_review_event_details(
request=request,
items=plan_items,
stale_ratio_snapshot=stale_ratio_snapshot,
),
resolved=True,
resolved_at=now,
)
batch_dispatch = GovernanceRemediationDispatch(
id=batch_dispatch_id,
governance_event_id=batch_event_id,
event_type="knowledge_degradation",
dispatch_status="succeeded",
decision_context=_build_batch_owner_review_decision_context(
batch_governance_event_id=batch_event_id,
batch_dispatch_id=batch_dispatch_id,
request=request,
items=plan_items,
stale_ratio_snapshot=stale_ratio_snapshot,
plan_fingerprint=plan_fingerprint,
),
executor_type=_BATCH_EXECUTOR_TYPE,
attempt_count=0,
max_attempts=1,
dispatched_at=now,
started_at=now,
completed_at=now,
created_by=request.owner[:100],
)
db.add(batch_event)
db.add(batch_dispatch)
for item in plan_items:
if item.entry_id not in queued_ids:
continue
candidate = candidate_by_id[item.entry_id]
event_id = generate_uuid()
dispatch_id = generate_uuid()
candidate_payload = candidate.model_dump(mode="json")
event_details = _build_stale_owner_review_event_details(
entry_id=item.entry_id,
candidate=candidate_payload,
owner=request.owner,
owner_note=request.owner_note,
)
event_details["batch"] = {
"batch_governance_event_id": batch_event_id,
"batch_dispatch_id": batch_dispatch_id,
"dry_run_plan_fingerprint": plan_fingerprint,
}
decision_context = _build_stale_owner_review_decision_context(
governance_event_id=event_id,
entry_id=item.entry_id,
candidate=candidate_payload,
owner=request.owner,
owner_note=request.owner_note,
)
decision_context = _attach_batch_to_owner_review_context(
decision_context,
batch_governance_event_id=batch_event_id,
batch_dispatch_id=batch_dispatch_id,
plan_fingerprint=plan_fingerprint,
)
db.add(
AiGovernanceEvent(
id=event_id,
event_type="knowledge_degradation",
triggered_at=now,
details=event_details,
resolved=False,
)
)
db.add(
GovernanceRemediationDispatch(
id=dispatch_id,
governance_event_id=event_id,
event_type="knowledge_degradation",
dispatch_status="pending",
playbook_id=None,
incident_id=None,
approval_id=None,
decision_context=decision_context,
executor_type=_EXECUTOR_TYPE,
attempt_count=0,
max_attempts=1,
dispatched_at=now,
created_by=request.owner[:100],
)
)
queued_item_ids[item.entry_id] = (event_id, dispatch_id)
await db.flush()
updated_items: list[KnowledgeStaleOwnerReviewBatchItem] = []
for item in plan_items:
ids = queued_item_ids.get(item.entry_id)
if ids is None:
updated_items.append(item)
continue
event_id, dispatch_id = ids
updated_items.append(
item.model_copy(update={
"status": "queued",
"governance_event_id": event_id,
"dispatch_id": dispatch_id,
"workflow_stage": "waiting_owner_review",
})
)
logger.info(
"km_stale_owner_review_batch_queued",
project_id=request.project_id,
batch_governance_event_id=batch_event_id,
batch_dispatch_id=batch_dispatch_id,
queued_count=len(queued_item_ids),
candidate_count=len(plan_items),
)
return {
"batch_governance_event_id": batch_event_id,
"batch_dispatch_id": batch_dispatch_id,
"items": updated_items,
}
def _attach_batch_to_owner_review_context(
context: dict[str, Any],
*,
batch_governance_event_id: str,
batch_dispatch_id: str,
plan_fingerprint: str,
) -> dict[str, Any]:
merged = dict(context)
workflow = dict(merged.get("workflow") if isinstance(merged.get("workflow"), dict) else {})
workflow.update({
"batch_governance_event_id": batch_governance_event_id,
"batch_dispatch_id": batch_dispatch_id,
"batch_plan_fingerprint": plan_fingerprint,
})
merged["workflow"] = workflow
merged["batch"] = {
"batch_governance_event_id": batch_governance_event_id,
"batch_dispatch_id": batch_dispatch_id,
"dry_run_plan_fingerprint": plan_fingerprint,
}
return merged
def _build_batch_owner_review_event_details(
*,
request: KnowledgeStaleOwnerReviewBatchQueueRequest,
items: list[KnowledgeStaleOwnerReviewBatchItem],
stale_ratio_snapshot: KnowledgeReviewDraftStaleRatioSnapshot,
) -> dict[str, Any]:
return {
"schema_version": "km_stale_owner_review_batch_event_v1",
"trigger_source": "stale_km_priority_batch_queue",
"next_action": "owner_review_stale_km_batch",
"impact": {
"status": "batch_owner_review_queued",
"project_id": request.project_id,
"priority_tiers": list(dict.fromkeys(request.priority_tiers)),
"requested_limit": request.limit,
"candidate_count": len(items),
"queued_count": _count_batch_items(items, "would_queue"),
"already_queued_count": _count_batch_items(items, "already_queued"),
"skipped_count": _count_batch_items(items, "skipped"),
"stale_ratio": stale_ratio_snapshot.stale_ratio,
"threshold": stale_ratio_snapshot.threshold,
},
"remediation": {
"next_action": "owner_review_stale_km_batch",
"items": [
"review_p0_p1_stale_km_candidates",
"complete_each_candidate_after_owner_approval",
"run_stale_ratio_recheck_after_writeback",
],
},
"ownership": _stale_owner_review_ownership(),
"owner": request.owner,
"owner_note": request.owner_note,
"stale_ratio_snapshot": stale_ratio_snapshot.model_dump(),
"items": [item.model_dump() for item in items],
}
def _build_batch_owner_review_decision_context(
*,
batch_governance_event_id: str,
batch_dispatch_id: str,
request: KnowledgeStaleOwnerReviewBatchQueueRequest,
items: list[KnowledgeStaleOwnerReviewBatchItem],
stale_ratio_snapshot: KnowledgeReviewDraftStaleRatioSnapshot,
plan_fingerprint: str,
) -> dict[str, Any]:
queued_count = _count_batch_items(items, "would_queue")
return {
"schema_version": "km_stale_owner_review_batch_dispatch_v1",
"version": "v1",
"trigger_source": "stale_km_priority_batch_queue",
"triggered_metric": "knowledge_degradation",
"metric_value": stale_ratio_snapshot.stale_ratio,
"threshold": stale_ratio_snapshot.threshold,
"suggested_action": "owner_review_stale_km_batch",
"next_action": "owner_review_stale_km_batch",
"decision_path": "batch_owner_review_queued",
"ownership": _stale_owner_review_ownership(),
"workflow": {
"work_item_id": (
"governance:knowledge_degradation:"
f"{batch_governance_event_id}:km_stale_owner_review_batch"
),
"work_kind": "km_stale_owner_review_batch",
"current_stage": "batch_owner_review_queued",
"project_id": request.project_id,
"priority_tiers": list(dict.fromkeys(request.priority_tiers)),
"requested_limit": request.limit,
"batch_governance_event_id": batch_governance_event_id,
"batch_dispatch_id": batch_dispatch_id,
"steps": [
"detected",
"prioritized_stale_candidate",
"batch_owner_review_queued",
"waiting_owner_review",
"owner_updates_or_archives_km",
"stale_ratio_recheck",
],
"stage_by_dispatch_status": {
"pending": "batch_owner_review_queued",
"dispatched": "batch_owner_review_queued",
"executing": "batch_owner_review_queued",
"succeeded": "batch_owner_review_queued",
"failed": "needs_manual_km_triage",
"skipped": "needs_manual_km_triage",
"cancelled": "cancelled",
},
"next_action": "owner_review_stale_km_batch",
"needs_human_review": True,
"writes_km_without_approval": False,
"writes_km": False,
"writes_governance_audit": True,
"dry_run_plan_fingerprint": plan_fingerprint,
"stale_ratio_snapshot": stale_ratio_snapshot.model_dump(),
},
"worker_result": {
"status": "batch_owner_review_queued",
"candidate_count": len(items),
"queued_count": queued_count,
"already_queued_count": _count_batch_items(items, "already_queued"),
"skipped_count": _count_batch_items(items, "skipped"),
"writes_km": False,
},
"owner": request.owner,
"owner_note": request.owner_note,
"items": [item.model_dump() for item in items],
}
def _build_batch_queue_response(
*,
request: KnowledgeStaleOwnerReviewBatchQueueRequest,
status: Literal["dry_run", "queued", "noop_already_queued"],
items: list[KnowledgeStaleOwnerReviewBatchItem],
stale_ratio_snapshot: KnowledgeReviewDraftStaleRatioSnapshot,
dry_run_plan_fingerprint: str,
writes_governance_audit: bool,
batch_governance_event_id: str | None = None,
batch_dispatch_id: str | None = None,
) -> KnowledgeStaleOwnerReviewBatchQueueResponse:
queued_status = "would_queue" if status == "dry_run" else "queued"
workflow_stage = {
"dry_run": "batch_owner_review_previewed",
"queued": "batch_owner_review_queued",
"noop_already_queued": "batch_noop_already_queued",
}[status]
return KnowledgeStaleOwnerReviewBatchQueueResponse(
project_id=request.project_id,
status=status,
owner=request.owner,
owner_note=request.owner_note,
dry_run=request.dry_run,
priority_tiers=list(dict.fromkeys(request.priority_tiers)),
requested_limit=request.limit,
candidate_count=len(items),
queued_count=_count_batch_items(items, queued_status),
already_queued_count=_count_batch_items(items, "already_queued"),
skipped_count=_count_batch_items(items, "skipped"),
batch_governance_event_id=batch_governance_event_id,
batch_dispatch_id=batch_dispatch_id,
workflow_stage=workflow_stage,
writes_km=False,
writes_governance_audit=writes_governance_audit,
stale_ratio_snapshot=stale_ratio_snapshot,
dry_run_plan_fingerprint=dry_run_plan_fingerprint,
items=items,
generated_at=now_taipei(),
)
def _count_batch_items(
items: list[KnowledgeStaleOwnerReviewBatchItem],
status: str,
) -> int:
return sum(1 for item in items if item.status == status)
def _build_owner_review_inbox_item(
*,
row: Any,
candidate: KnowledgeStaleCandidate,
decision_context: dict[str, Any],
) -> KnowledgeStaleOwnerReviewInboxItem:
dispatch_status = str(row.dispatch_status)
workflow = decision_context.get("workflow") if isinstance(decision_context.get("workflow"), dict) else {}
batch = decision_context.get("batch") if isinstance(decision_context.get("batch"), dict) else {}
return KnowledgeStaleOwnerReviewInboxItem(
dispatch_id=str(row.id),
governance_event_id=str(row.governance_event_id),
entry_id=candidate.entry_id,
project_id=candidate.project_id,
title=candidate.title,
dispatch_status=dispatch_status,
workflow_stage=_extract_owner_review_workflow_stage(decision_context, dispatch_status),
next_action=_extract_owner_review_next_action(decision_context),
owner=_extract_owner_review_owner(decision_context),
owner_note=_extract_owner_review_owner_note(decision_context),
batch_governance_event_id=_first_non_empty_string(
workflow.get("batch_governance_event_id"),
batch.get("batch_governance_event_id"),
),
batch_dispatch_id=_first_non_empty_string(
workflow.get("batch_dispatch_id"),
batch.get("batch_dispatch_id"),
),
priority_tier=candidate.priority_tier,
priority_score=candidate.priority_score,
recommended_action=candidate.recommended_action,
stale_days=candidate.stale_days,
view_count=candidate.view_count,
correlation_sources=candidate.correlation_sources,
reasons=candidate.reasons,
related_incident_id=candidate.related_incident_id,
related_playbook_id=candidate.related_playbook_id,
related_approval_id=candidate.related_approval_id,
dry_run_plan_fingerprint=_extract_plan_fingerprint(decision_context),
queued_at=row.dispatched_at,
started_at=row.started_at,
completed_at=row.completed_at,
)
def _build_completion_queue_item(
item: KnowledgeStaleOwnerReviewInboxItem,
) -> KnowledgeStaleOwnerReviewCompletionQueueItem:
outcome = _completion_outcome_for_owner_review_item(item)
blockers = _completion_queue_blockers(item, outcome=outcome)
readiness = _completion_queue_readiness(item, blockers=blockers)
can_preview = readiness == "ready"
return KnowledgeStaleOwnerReviewCompletionQueueItem(
dispatch_id=item.dispatch_id,
governance_event_id=item.governance_event_id,
entry_id=item.entry_id,
project_id=item.project_id,
title=item.title,
dispatch_status=item.dispatch_status,
workflow_stage=item.workflow_stage,
readiness=readiness,
recommended_completion_outcome=outcome,
next_action=_completion_queue_next_action(readiness),
blockers=blockers,
required_owner_fields=_completion_required_owner_fields(outcome),
can_preview=can_preview,
can_confirm_after_preview=can_preview,
writes_km_on_confirm=can_preview,
owner=item.owner,
owner_note=item.owner_note,
batch_governance_event_id=item.batch_governance_event_id,
batch_dispatch_id=item.batch_dispatch_id,
priority_tier=item.priority_tier,
priority_score=item.priority_score,
recommended_action=item.recommended_action,
stale_days=item.stale_days,
view_count=item.view_count,
correlation_sources=item.correlation_sources,
reasons=item.reasons,
related_incident_id=item.related_incident_id,
related_playbook_id=item.related_playbook_id,
related_approval_id=item.related_approval_id,
dry_run_plan_fingerprint=item.dry_run_plan_fingerprint,
queued_at=item.queued_at,
started_at=item.started_at,
completed_at=item.completed_at,
)
def _completion_queue_bucket_matches(
item: KnowledgeStaleOwnerReviewCompletionQueueItem,
*,
status_bucket: str,
) -> bool:
if status_bucket == "all":
return True
if status_bucket == "pending":
return item.dispatch_status in _ACTIVE_DISPATCH_STATUSES
return item.readiness == status_bucket
def _normalize_priority_tiers(priority_tiers: list[str] | None) -> list[str]:
if not priority_tiers:
return []
allowed = {"P0", "P1", "P2"}
normalized = list(dict.fromkeys(str(tier).upper() for tier in priority_tiers))
unsupported = [tier for tier in normalized if tier not in allowed]
if unsupported:
raise KmStaleOwnerReviewError(
422,
"unsupported stale KM owner-review completion priority_tier",
)
return normalized
def _normalize_completion_outcome(outcome: str) -> Literal[
"all",
"refresh_with_evidence",
"archive",
"supersede",
]:
if outcome == "all":
return "all"
if outcome == "refresh_with_evidence":
return "refresh_with_evidence"
if outcome == "archive":
return "archive"
if outcome == "supersede":
return "supersede"
raise KmStaleOwnerReviewError(
422,
"unsupported stale KM owner-review completion outcome",
)
def _filter_completion_queue_items(
items: list[KnowledgeStaleOwnerReviewCompletionQueueItem],
*,
priority_tiers: list[str],
recommended_completion_outcome: str,
batch_governance_event_id: str | None,
can_preview: bool | None,
) -> list[KnowledgeStaleOwnerReviewCompletionQueueItem]:
filtered = items
if priority_tiers:
wanted = set(priority_tiers)
filtered = [item for item in filtered if item.priority_tier in wanted]
if recommended_completion_outcome != "all":
filtered = [
item for item in filtered
if item.recommended_completion_outcome == recommended_completion_outcome
]
if batch_governance_event_id:
filtered = [
item for item in filtered
if item.batch_governance_event_id == batch_governance_event_id
]
if can_preview is not None:
filtered = [item for item in filtered if item.can_preview is can_preview]
return filtered
def _build_completion_batch_preview_fingerprint(
*,
request: KnowledgeStaleOwnerReviewCompletionBatchPreviewRequest,
items: list[KnowledgeStaleOwnerReviewCompletionQueueItem],
) -> str:
payload = {
"schema_version": "km_stale_owner_review_completion_batch_preview_v1",
"project_id": request.project_id,
"status_bucket": request.status_bucket,
"priority_tiers": list(dict.fromkeys(request.priority_tiers)),
"recommended_completion_outcome": request.recommended_completion_outcome,
"batch_governance_event_id": request.batch_governance_event_id,
"limit": request.limit,
"owner": request.owner,
"owner_note_sha256": (
hashlib.sha256(request.owner_note.encode("utf-8")).hexdigest()
if request.owner_note
else None
),
"items": [
{
"dispatch_id": item.dispatch_id,
"entry_id": item.entry_id,
"readiness": item.readiness,
"recommended_completion_outcome": item.recommended_completion_outcome,
"can_preview": item.can_preview,
"workflow_stage": item.workflow_stage,
}
for item in items
],
}
encoded = json.dumps(
payload,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
return "sha256:" + hashlib.sha256(encoded.encode("utf-8")).hexdigest()
def _completion_outcome_for_owner_review_item(
item: KnowledgeStaleOwnerReviewInboxItem,
) -> Literal["refresh_with_evidence", "archive", "supersede"]:
if item.recommended_action == "archive_or_supersede":
return "archive"
return "refresh_with_evidence"
def _completion_required_owner_fields(
outcome: Literal["refresh_with_evidence", "archive", "supersede"],
) -> list[str]:
if outcome == "refresh_with_evidence":
return ["owner_note_or_updated_content"]
if outcome == "supersede":
return ["superseded_by_entry_id", "owner_note"]
return ["owner_note"]
def _completion_queue_blockers(
item: KnowledgeStaleOwnerReviewInboxItem,
*,
outcome: Literal["refresh_with_evidence", "archive", "supersede"],
) -> list[str]:
blockers: list[str] = []
if not item.dispatch_id:
blockers.append("missing_dispatch_id")
if not item.entry_id:
blockers.append("missing_entry_id")
if item.dispatch_status == "skipped":
blockers.append("owner_review_skipped")
if item.dispatch_status not in (
_ACTIVE_DISPATCH_STATUSES
| _TERMINAL_FAILED_DISPATCH_STATUSES
| frozenset({"succeeded", "skipped"})
):
blockers.append("unsupported_dispatch_status")
if outcome == "supersede":
blockers.append("superseded_by_entry_id_required")
return blockers
def _completion_queue_readiness(
item: KnowledgeStaleOwnerReviewInboxItem,
*,
blockers: list[str],
) -> Literal["ready", "blocked", "completed", "failed"]:
if item.dispatch_status == "succeeded":
return "completed"
if item.dispatch_status in _TERMINAL_FAILED_DISPATCH_STATUSES:
return "failed"
if item.dispatch_status in _ACTIVE_DISPATCH_STATUSES and not blockers:
return "ready"
return "blocked"
def _completion_queue_next_action(
readiness: Literal["ready", "blocked", "completed", "failed"],
) -> str:
return {
"ready": "preview_stale_km_review_completion",
"blocked": "inspect_owner_review_blocker",
"completed": "view_stale_km_completion_audit",
"failed": "manual_km_owner_review_triage",
}[readiness]
async def _load_owner_review_burndown_counts(
db: AsyncSession,
*,
project_id: str,
) -> dict[str, int]:
sql = text("""
SELECT
COUNT(*) FILTER (
WHERE d.executor_type = :owner_executor
AND d.dispatch_status::text IN ('pending', 'dispatched', 'executing')
) AS pending_owner_reviews,
COUNT(*) FILTER (
WHERE d.executor_type = :owner_executor
AND d.dispatch_status::text = 'succeeded'
) AS completed_owner_reviews,
COUNT(*) FILTER (
WHERE d.executor_type = :complete_executor
AND d.dispatch_status::text = 'succeeded'
) AS completion_audit_total,
COUNT(*) FILTER (
WHERE d.executor_type = :complete_executor
AND d.dispatch_status::text = 'succeeded'
AND d.decision_context -> 'stale_ratio_recheck' ->> 'dispatch_id' IS NOT NULL
) AS stale_ratio_recheck_total
FROM governance_remediation_dispatch d
WHERE d.event_type = 'knowledge_degradation'
AND d.executor_type IN (:owner_executor, :complete_executor)
AND COALESCE(
d.decision_context -> 'workflow' ->> 'project_id',
d.decision_context ->> 'project_id',
d.decision_context -> 'candidate' ->> 'project_id'
) = :project_id
""")
result = await db.execute(
sql,
{
"owner_executor": _EXECUTOR_TYPE,
"complete_executor": _COMPLETE_EXECUTOR_TYPE,
"project_id": project_id,
},
)
row = result.first()
return {
"pending_owner_reviews": int(row.pending_owner_reviews or 0) if row else 0,
"completed_owner_reviews": int(row.completed_owner_reviews or 0) if row else 0,
"completion_audit_total": int(row.completion_audit_total or 0) if row else 0,
"stale_ratio_recheck_total": int(row.stale_ratio_recheck_total or 0) if row else 0,
}
async def _load_owner_review_completion_rows(
db: AsyncSession,
*,
project_id: str,
limit: int,
) -> list[Any]:
sql = text("""
SELECT
d.id,
d.governance_event_id,
d.dispatch_status,
d.decision_context,
d.dispatched_at,
d.started_at,
d.completed_at
FROM governance_remediation_dispatch d
WHERE d.executor_type = :complete_executor
AND d.dispatch_status::text = 'succeeded'
AND COALESCE(
d.decision_context -> 'workflow' ->> 'project_id',
d.decision_context ->> 'project_id'
) = :project_id
ORDER BY d.completed_at DESC NULLS LAST, d.dispatched_at DESC
LIMIT :limit
""")
result = await db.execute(
sql,
{
"complete_executor": _COMPLETE_EXECUTOR_TYPE,
"project_id": project_id,
"limit": limit,
},
)
return list(result.fetchall())
def _build_owner_review_burndown_items(
rows: list[Any],
*,
project_id: str,
) -> list[KnowledgeStaleOwnerReviewBurnDownItem]:
chronological: list[KnowledgeStaleOwnerReviewBurnDownItem] = []
previous_snapshot: KnowledgeReviewDraftStaleRatioSnapshot | None = None
for row in reversed(rows):
context = row.decision_context if isinstance(row.decision_context, dict) else {}
snapshot = _snapshot_from_context(context)
stale_count_delta: int | None = None
stale_ratio_delta: float | None = None
if snapshot is not None and previous_snapshot is not None:
stale_count_delta = snapshot.stale_count - previous_snapshot.stale_count
stale_ratio_delta = round(snapshot.stale_ratio - previous_snapshot.stale_ratio, 3)
if snapshot is not None:
previous_snapshot = snapshot
chronological.append(
_build_owner_review_burndown_item(
row=row,
context=context,
project_id=project_id,
stale_count_delta=stale_count_delta,
stale_ratio_delta=stale_ratio_delta,
)
)
return list(reversed(chronological))
def _build_owner_review_burndown_item(
*,
row: Any,
context: dict[str, Any],
project_id: str,
stale_count_delta: int | None,
stale_ratio_delta: float | None,
) -> KnowledgeStaleOwnerReviewBurnDownItem:
workflow = context.get("workflow") if isinstance(context.get("workflow"), dict) else {}
worker_result = context.get("worker_result") if isinstance(context.get("worker_result"), dict) else {}
snapshot = _snapshot_from_context(context)
dispatch_status = str(row.dispatch_status)
return KnowledgeStaleOwnerReviewBurnDownItem(
completion_dispatch_id=str(row.id),
governance_event_id=str(row.governance_event_id),
source_dispatch_id=_first_non_empty_string(
workflow.get("source_dispatch_id"),
context.get("source_dispatch_id"),
),
recheck_dispatch_id=_extract_recheck_dispatch_id(context),
entry_id=_first_non_empty_string(
workflow.get("entry_id"),
context.get("entry_id"),
worker_result.get("entry_id"),
),
project_id=_first_non_empty_string(
workflow.get("project_id"),
context.get("project_id"),
) or project_id,
dispatch_status=dispatch_status,
workflow_stage=_extract_complete_workflow_stage(context, dispatch_status),
review_outcome=_normalize_optional_review_outcome(
workflow.get("review_outcome") or context.get("review_outcome") or worker_result.get("review_outcome")
),
owner=_first_non_empty_string(context.get("owner")),
completed_at=row.completed_at,
stale_ratio_snapshot=snapshot,
stale_count_delta=stale_count_delta,
stale_ratio_delta=stale_ratio_delta,
above_threshold=(
snapshot.stale_ratio > snapshot.threshold
if snapshot is not None
else None
),
)
def _stale_burndown_status(
snapshot: KnowledgeReviewDraftStaleRatioSnapshot | None,
) -> Literal["above_threshold", "at_or_below_threshold", "no_data"]:
if snapshot is None or snapshot.total_count <= 0:
return "no_data"
if snapshot.stale_ratio > snapshot.threshold:
return "above_threshold"
return "at_or_below_threshold"
def _entries_to_stale_threshold(
snapshot: KnowledgeReviewDraftStaleRatioSnapshot | None,
) -> int:
if snapshot is None or snapshot.total_count <= 0:
return 0
allowed_stale = int(snapshot.total_count * snapshot.threshold)
return max(0, snapshot.stale_count - allowed_stale)
def _priority_rank(priority_tier: str) -> int:
return {"P0": 3, "P1": 2, "P2": 1}.get(priority_tier, 0)
def _extract_owner_review_workflow_stage(
context: dict[str, Any],
dispatch_status: str,
) -> str:
workflow = context.get("workflow")
if isinstance(workflow, dict):
stages = workflow.get("stage_by_dispatch_status")
if isinstance(stages, dict):
value = stages.get(dispatch_status)
if isinstance(value, str) and value:
return value
value = workflow.get("current_stage")
if isinstance(value, str) and value:
return value
return {
"pending": "waiting_owner_review",
"dispatched": "waiting_owner_review",
"executing": "owner_review_in_progress",
"succeeded": "km_candidate_reviewed",
"failed": "needs_manual_km_triage",
"skipped": "waiting_owner_review",
"cancelled": "cancelled",
}.get(dispatch_status, "unknown")
def _extract_owner_review_next_action(context: dict[str, Any]) -> str | None:
workflow = context.get("workflow")
if isinstance(workflow, dict):
value = workflow.get("next_action")
if isinstance(value, str) and value:
return value
value = context.get("next_action")
return value if isinstance(value, str) and value else None
def _extract_owner_review_owner(context: dict[str, Any]) -> str | None:
value = context.get("owner")
return value if isinstance(value, str) and value else None
def _extract_owner_review_owner_note(context: dict[str, Any]) -> str | None:
value = context.get("owner_note")
return value if isinstance(value, str) and value else None
def _first_non_empty_string(*values: Any) -> str | None:
for value in values:
if isinstance(value, str) and value:
return value
return None
async def complete_km_stale_owner_review(
*,
entry_id: str,
request: KnowledgeStaleOwnerReviewCompleteRequest,
) -> KnowledgeStaleOwnerReviewCompleteResponse:
"""Complete stale KM owner review after explicit owner approval."""
completed = await _load_completed_owner_review_dispatch(entry_id)
if completed is not None and not request.dry_run:
decision_context = completed.decision_context if isinstance(completed.decision_context, dict) else {}
workflow = decision_context.get("workflow") if isinstance(decision_context, dict) else {}
worker_result = decision_context.get("worker_result") if isinstance(decision_context, dict) else {}
return _build_complete_response(
entry_id=entry_id,
project_id=str(workflow.get("project_id") or decision_context.get("project_id") or "awoooi"),
status="already_completed",
review_outcome=_normalize_review_outcome(worker_result.get("review_outcome")),
governance_event_id=str(completed.governance_event_id),
dispatch_id=str(workflow.get("source_dispatch_id") or request.dispatch_id or ""),
audit_dispatch_id=str(completed.id),
stale_ratio_recheck_dispatch_id=_extract_recheck_dispatch_id(decision_context),
workflow_stage=_extract_complete_workflow_stage(decision_context, "succeeded"),
owner=request.owner,
owner_approved=request.owner_approved,
dry_run=request.dry_run,
writes_km=False,
writes_governance_audit=False,
stale_ratio_snapshot=_snapshot_from_context(decision_context),
dry_run_plan_fingerprint=_extract_plan_fingerprint(decision_context),
)
record = await _load_stale_or_reviewed_record(entry_id)
dispatch = await _load_owner_review_dispatch_for_completion(
entry_id=entry_id,
dispatch_id=request.dispatch_id,
)
_validate_completion_request(record, request)
superseded_by = await _load_superseded_by_record(request.superseded_by_entry_id)
fingerprint = _build_completion_plan_fingerprint(
record=record,
dispatch=dispatch,
request=request,
superseded_by=superseded_by,
)
if request.dry_run:
return _build_complete_response(
entry_id=entry_id,
project_id=str(record.project_id),
status="dry_run",
review_outcome=request.review_outcome,
governance_event_id=str(dispatch.governance_event_id),
dispatch_id=str(dispatch.id),
workflow_stage=_completion_stage_for_outcome(request.review_outcome),
owner=request.owner,
owner_approved=request.owner_approved,
dry_run=True,
writes_km=False,
writes_governance_audit=False,
stale_ratio_snapshot=await _load_current_km_stale_ratio_snapshot(),
dry_run_plan_fingerprint=fingerprint,
)
if not request.owner_approved:
raise KmStaleOwnerReviewError(
403,
"owner_approved=true is required before completing stale KM owner review",
)
if not request.dry_run_plan_fingerprint:
raise KmStaleOwnerReviewError(
403,
"dry_run_plan_fingerprint from a dry-run preview is required before writing KM",
)
if request.dry_run_plan_fingerprint != fingerprint:
raise KmStaleOwnerReviewError(
409,
"dry_run_plan_fingerprint does not match the latest stale KM review plan",
)
result = await _complete_owner_review_and_write_audit(
entry_id=entry_id,
request=request,
resolved_dispatch_id=str(dispatch.id),
plan_fingerprint=fingerprint,
)
return _build_complete_response(
entry_id=entry_id,
project_id=result["project_id"],
status="completed",
review_outcome=request.review_outcome,
governance_event_id=result["governance_event_id"],
dispatch_id=result["dispatch_id"],
audit_dispatch_id=result["audit_dispatch_id"],
stale_ratio_recheck_dispatch_id=result["recheck_dispatch_id"],
workflow_stage=_completion_stage_for_outcome(request.review_outcome),
owner=request.owner,
owner_approved=True,
dry_run=False,
writes_km=True,
writes_governance_audit=True,
stale_ratio_snapshot=result["stale_ratio_snapshot"],
dry_run_plan_fingerprint=fingerprint,
)
async def _load_stale_candidate_record(entry_id: str) -> KnowledgeEntryRecord:
cutoff = now_taipei() - timedelta(days=KM_STALE_DAYS)
async with get_db_context() as db:
result = await db.execute(
select(KnowledgeEntryRecord).where(KnowledgeEntryRecord.id == entry_id)
)
record = result.scalar_one_or_none()
if record is None:
raise KmStaleOwnerReviewError(404, "KM entry not found")
if _enum_value(record.status) == EntryStatus.ARCHIVED.value:
raise KmStaleOwnerReviewError(409, "archived KM entries cannot be queued for stale review")
updated_at = record.updated_at
if updated_at is not None and updated_at.tzinfo is None:
updated_at = updated_at.replace(tzinfo=cutoff.tzinfo)
if updated_at is None or updated_at >= cutoff:
raise KmStaleOwnerReviewError(409, "KM entry is no longer past the stale threshold")
return record
async def _load_active_owner_review_dispatch(
entry_id: str,
) -> GovernanceRemediationDispatch | None:
sql = text("""
SELECT *
FROM governance_remediation_dispatch d
WHERE d.executor_type = :executor_type
AND d.dispatch_status::text IN ('pending', 'dispatched', 'executing')
AND (
d.decision_context -> 'workflow' ->> 'entry_id' = :entry_id
OR d.decision_context ->> 'entry_id' = :entry_id
)
ORDER BY d.dispatched_at DESC
LIMIT 1
""")
async with get_db_context() as db:
result = await db.execute(
select(GovernanceRemediationDispatch).from_statement(sql),
{"executor_type": _EXECUTOR_TYPE, "entry_id": entry_id},
)
return result.scalar_one_or_none()
async def _load_owner_review_dispatch_for_completion(
*,
entry_id: str,
dispatch_id: str | None,
) -> GovernanceRemediationDispatch:
"""Load the active owner-review dispatch that guards KM writes."""
id_filter = "AND d.id = :dispatch_id" if dispatch_id else ""
sql = text(f"""
SELECT *
FROM governance_remediation_dispatch d
WHERE d.executor_type = :executor_type
AND d.dispatch_status::text IN ('pending', 'dispatched', 'executing')
AND (
d.decision_context -> 'workflow' ->> 'entry_id' = :entry_id
OR d.decision_context ->> 'entry_id' = :entry_id
)
{id_filter}
ORDER BY d.dispatched_at DESC
LIMIT 1
""")
params = {
"executor_type": _EXECUTOR_TYPE,
"entry_id": entry_id,
}
if dispatch_id:
params["dispatch_id"] = dispatch_id
async with get_db_context() as db:
result = await db.execute(
select(GovernanceRemediationDispatch).from_statement(sql),
params,
)
dispatch = result.scalar_one_or_none()
if dispatch is None:
raise KmStaleOwnerReviewError(
409,
"active stale KM owner-review dispatch not found; queue review first",
)
return dispatch
async def _load_completed_owner_review_dispatch(
entry_id: str,
) -> GovernanceRemediationDispatch | None:
sql = text("""
SELECT *
FROM governance_remediation_dispatch d
WHERE d.executor_type = :executor_type
AND d.dispatch_status::text = 'succeeded'
AND (
d.decision_context -> 'workflow' ->> 'entry_id' = :entry_id
OR d.decision_context ->> 'entry_id' = :entry_id
)
ORDER BY d.completed_at DESC NULLS LAST, d.dispatched_at DESC
LIMIT 1
""")
async with get_db_context() as db:
result = await db.execute(
select(GovernanceRemediationDispatch).from_statement(sql),
{"executor_type": _COMPLETE_EXECUTOR_TYPE, "entry_id": entry_id},
)
return result.scalar_one_or_none()
async def _load_stale_or_reviewed_record(entry_id: str) -> KnowledgeEntryRecord:
async with get_db_context() as db:
result = await db.execute(
select(KnowledgeEntryRecord).where(KnowledgeEntryRecord.id == entry_id)
)
record = result.scalar_one_or_none()
if record is None:
raise KmStaleOwnerReviewError(404, "KM entry not found")
if _enum_value(record.status) == EntryStatus.ARCHIVED.value:
raise KmStaleOwnerReviewError(409, "archived KM entries cannot be completed as stale review")
return record
async def _load_superseded_by_record(
superseded_by_entry_id: str | None,
) -> KnowledgeEntryRecord | None:
if not superseded_by_entry_id:
return None
async with get_db_context() as db:
result = await db.execute(
select(KnowledgeEntryRecord).where(
KnowledgeEntryRecord.id == superseded_by_entry_id
)
)
record = result.scalar_one_or_none()
if record is None:
raise KmStaleOwnerReviewError(404, "superseded_by_entry_id KM entry not found")
if _enum_value(record.status) == EntryStatus.ARCHIVED.value:
raise KmStaleOwnerReviewError(409, "superseded_by_entry_id cannot be archived")
return record
def _validate_completion_request(
record: KnowledgeEntryRecord,
request: KnowledgeStaleOwnerReviewCompleteRequest,
) -> None:
"""Validate owner intent before any KM write is allowed."""
if request.review_outcome == "supersede":
if not request.superseded_by_entry_id:
raise KmStaleOwnerReviewError(
422,
"superseded_by_entry_id is required when review_outcome=supersede",
)
if request.superseded_by_entry_id == str(record.id):
raise KmStaleOwnerReviewError(409, "KM entry cannot supersede itself")
if request.review_outcome == "refresh_with_evidence" and not (
request.owner_note or request.updated_title or request.updated_content
):
raise KmStaleOwnerReviewError(
422,
"owner_note or updated KM content is required for refresh_with_evidence",
)
def _build_completion_plan_fingerprint(
*,
record: KnowledgeEntryRecord,
dispatch: GovernanceRemediationDispatch,
request: KnowledgeStaleOwnerReviewCompleteRequest,
superseded_by: KnowledgeEntryRecord | None,
) -> str:
"""Fingerprint the exact owner-reviewed plan that may write KM."""
payload = {
"schema_version": "km_stale_owner_review_completion_plan_v1",
"entry_id": str(record.id),
"dispatch_id": str(dispatch.id),
"governance_event_id": str(dispatch.governance_event_id),
"review_outcome": request.review_outcome,
"current_status": _enum_value(record.status),
"current_updated_at": record.updated_at.isoformat() if record.updated_at else None,
"updated_title": request.updated_title,
"updated_content_sha256": (
hashlib.sha256(request.updated_content.encode("utf-8")).hexdigest()
if request.updated_content
else None
),
"owner_note_sha256": (
hashlib.sha256(request.owner_note.encode("utf-8")).hexdigest()
if request.owner_note
else None
),
"superseded_by_entry_id": str(superseded_by.id) if superseded_by else None,
"superseded_by_updated_at": (
superseded_by.updated_at.isoformat()
if superseded_by and superseded_by.updated_at
else None
),
}
encoded = json.dumps(
payload,
ensure_ascii=False,
sort_keys=True,
separators=(",", ":"),
)
return "sha256:" + hashlib.sha256(encoded.encode("utf-8")).hexdigest()
async def _complete_owner_review_and_write_audit(
*,
entry_id: str,
request: KnowledgeStaleOwnerReviewCompleteRequest,
resolved_dispatch_id: str,
plan_fingerprint: str,
) -> dict[str, Any]:
"""Apply owner-approved KM outcome and write terminal dispatch trail."""
now = now_taipei()
async with get_db_context() as db:
record_result = await db.execute(
select(KnowledgeEntryRecord)
.where(KnowledgeEntryRecord.id == entry_id)
.with_for_update()
)
record = record_result.scalar_one_or_none()
if record is None:
raise KmStaleOwnerReviewError(404, "KM entry not found")
if _enum_value(record.status) == EntryStatus.ARCHIVED.value:
raise KmStaleOwnerReviewError(409, "KM entry was archived before completion")
dispatch_result = await db.execute(
select(GovernanceRemediationDispatch)
.where(GovernanceRemediationDispatch.id == resolved_dispatch_id)
.with_for_update()
)
dispatch = dispatch_result.scalar_one_or_none()
if dispatch is None:
raise KmStaleOwnerReviewError(
409,
"active stale KM owner-review dispatch not found during write",
)
if str(dispatch.dispatch_status) not in {"pending", "dispatched", "executing"}:
raise KmStaleOwnerReviewError(
409,
"stale KM owner-review dispatch is no longer active",
)
if str(dispatch.id) != (request.dispatch_id or str(dispatch.id)):
raise KmStaleOwnerReviewError(409, "dispatch_id does not match active owner review")
dispatch_entry_id = _dispatch_entry_id(dispatch)
if dispatch_entry_id != entry_id:
raise KmStaleOwnerReviewError(409, "dispatch_id does not match KM entry")
_apply_owner_review_outcome(record, request, now=now)
dispatch.dispatch_status = "succeeded"
dispatch.started_at = dispatch.started_at or taipei_now()
dispatch.completed_at = taipei_now()
dispatch.decision_context = _merge_owner_review_completion_into_context(
dispatch.decision_context if isinstance(dispatch.decision_context, dict) else {},
request=request,
plan_fingerprint=plan_fingerprint,
completed_at=now.isoformat(),
)
await db.flush()
stale_ratio_snapshot = await _compute_km_stale_ratio_snapshot(db)
recheck = GovernanceRemediationDispatch(
id=generate_uuid(),
governance_event_id=str(dispatch.governance_event_id),
event_type="knowledge_degradation",
dispatch_status="succeeded",
decision_context=_build_stale_ratio_recheck_context(
governance_event_id=str(dispatch.governance_event_id),
entry_id=entry_id,
request=request,
stale_ratio_snapshot=stale_ratio_snapshot,
plan_fingerprint=plan_fingerprint,
project_id=str(record.project_id),
),
executor_type=_RECHECK_EXECUTOR_TYPE,
attempt_count=0,
max_attempts=1,
dispatched_at=taipei_now(),
started_at=taipei_now(),
completed_at=taipei_now(),
created_by=request.owner[:100],
)
audit = GovernanceRemediationDispatch(
id=generate_uuid(),
governance_event_id=str(dispatch.governance_event_id),
event_type="knowledge_degradation",
dispatch_status="succeeded",
decision_context=_build_owner_review_completion_audit_context(
governance_event_id=str(dispatch.governance_event_id),
source_dispatch_id=str(dispatch.id),
entry=record,
request=request,
stale_ratio_snapshot=stale_ratio_snapshot,
recheck_dispatch_id=str(recheck.id),
plan_fingerprint=plan_fingerprint,
completed_at=now.isoformat(),
),
executor_type=_COMPLETE_EXECUTOR_TYPE,
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)
db.add(audit)
await db.flush()
return {
"project_id": str(record.project_id),
"governance_event_id": str(dispatch.governance_event_id),
"dispatch_id": str(dispatch.id),
"audit_dispatch_id": str(audit.id),
"recheck_dispatch_id": str(recheck.id),
"stale_ratio_snapshot": stale_ratio_snapshot,
}
def _dispatch_entry_id(dispatch: GovernanceRemediationDispatch) -> str | None:
context = dispatch.decision_context if isinstance(dispatch.decision_context, dict) else {}
workflow = context.get("workflow") if isinstance(context.get("workflow"), dict) else {}
for source in (workflow, context):
value = source.get("entry_id") if isinstance(source, dict) else None
if isinstance(value, str) and value:
return value
return None
def _apply_owner_review_outcome(
record: KnowledgeEntryRecord,
request: KnowledgeStaleOwnerReviewCompleteRequest,
*,
now,
) -> None:
if request.updated_title:
record.title = request.updated_title
if request.updated_content:
record.content = request.updated_content
if request.review_outcome in ("archive", "supersede"):
record.status = EntryStatus.ARCHIVED
record.tags = _append_owner_review_tags(
[str(tag) for tag in (record.tags or [])],
request=request,
reviewed_at=now.isoformat(),
)
record.updated_at = now
def _append_owner_review_tags(
tags: list[str],
*,
request: KnowledgeStaleOwnerReviewCompleteRequest,
reviewed_at: str,
) -> list[str]:
additions = [
"reviewed_by:km_stale_owner_review",
f"stale_review_outcome:{request.review_outcome}",
f"stale_review_owner:{request.owner[:80]}",
f"stale_reviewed_at:{reviewed_at}",
]
if request.superseded_by_entry_id:
additions.append(f"superseded_by:{request.superseded_by_entry_id}")
merged = list(tags)
for tag in additions:
if tag not in merged:
merged.append(tag)
return merged
def _merge_owner_review_completion_into_context(
context: dict[str, Any],
*,
request: KnowledgeStaleOwnerReviewCompleteRequest,
plan_fingerprint: str,
completed_at: str,
) -> dict[str, Any]:
merged = dict(context)
workflow = dict(merged.get("workflow") if isinstance(merged.get("workflow"), dict) else {})
workflow.update({
"current_stage": _completion_stage_for_outcome(request.review_outcome),
"next_action": "stale_ratio_recheck",
"review_outcome": request.review_outcome,
"owner_approved": request.owner_approved,
"dry_run_plan_fingerprint": plan_fingerprint,
"completed_at": completed_at,
"writes_km": True,
"writes_governance_audit": True,
})
workflow["steps"] = list(dict.fromkeys([
*[str(step) for step in workflow.get("steps", []) if step is not None],
"owner_updates_or_archives_km",
_completion_stage_for_outcome(request.review_outcome),
"stale_ratio_recheck",
]))
workflow["stage_by_dispatch_status"] = {
**(
workflow.get("stage_by_dispatch_status")
if isinstance(workflow.get("stage_by_dispatch_status"), dict)
else {}
),
"succeeded": _completion_stage_for_outcome(request.review_outcome),
"failed": "needs_manual_km_triage",
}
merged["workflow"] = workflow
merged["next_action"] = "stale_ratio_recheck"
merged["decision_path"] = f"owner_approved_{request.review_outcome}"
merged["worker_result"] = {
"status": "owner_review_completed",
"review_outcome": request.review_outcome,
"writes_km": True,
"completed_at": completed_at,
}
return merged
def _build_owner_review_completion_audit_context(
*,
governance_event_id: str,
source_dispatch_id: str,
entry: KnowledgeEntryRecord,
request: KnowledgeStaleOwnerReviewCompleteRequest,
stale_ratio_snapshot: KnowledgeReviewDraftStaleRatioSnapshot,
recheck_dispatch_id: str,
plan_fingerprint: str,
completed_at: str,
) -> dict[str, Any]:
stage = _completion_stage_for_outcome(request.review_outcome)
return {
"schema_version": "km_stale_owner_review_complete_audit_v1",
"version": "v1",
"project_id": str(entry.project_id),
"decision_path": f"owner_approved_{request.review_outcome}",
"next_action": "stale_ratio_recheck",
"ownership": _stale_owner_review_ownership(),
"workflow": {
"work_item_id": (
"governance:knowledge_degradation:"
f"{governance_event_id}:km_stale_owner_review_complete:{entry.id}"
),
"work_kind": "km_stale_owner_review_complete",
"current_stage": stage,
"entry_id": str(entry.id),
"project_id": str(entry.project_id),
"source_dispatch_id": source_dispatch_id,
"steps": [
"detected",
"prioritized_stale_candidate",
"waiting_owner_review",
"owner_updates_or_archives_km",
stage,
"stale_ratio_recheck",
],
"stage_by_dispatch_status": {
"succeeded": stage,
"failed": "needs_manual_km_triage",
"cancelled": "cancelled",
},
"next_action": "stale_ratio_recheck",
"writes_km_without_approval": False,
"writes_km": True,
"writes_governance_audit": True,
"dry_run_plan_fingerprint": plan_fingerprint,
"stale_ratio_snapshot": stale_ratio_snapshot.model_dump(),
},
"worker_result": {
"status": "owner_review_completed",
"entry_id": str(entry.id),
"review_outcome": request.review_outcome,
"owner_approved": request.owner_approved,
"writes_km": True,
"stale_ratio": stale_ratio_snapshot.stale_ratio,
"threshold": stale_ratio_snapshot.threshold,
"above_threshold": stale_ratio_snapshot.stale_ratio > stale_ratio_snapshot.threshold,
},
"owner": request.owner,
"owner_note": request.owner_note,
"entry_id": str(entry.id),
"review_outcome": request.review_outcome,
"source_dispatch_id": source_dispatch_id,
"superseded_by_entry_id": request.superseded_by_entry_id,
"updated_title": request.updated_title,
"updated_content_provided": bool(request.updated_content),
"dry_run_plan_fingerprint": plan_fingerprint,
"stale_ratio_recheck": {
"status": "completed",
"dispatch_id": recheck_dispatch_id,
"executor_type": _RECHECK_EXECUTOR_TYPE,
},
"completed_at": completed_at,
}
def _build_stale_ratio_recheck_context(
*,
governance_event_id: str,
entry_id: str,
request: KnowledgeStaleOwnerReviewCompleteRequest,
stale_ratio_snapshot: KnowledgeReviewDraftStaleRatioSnapshot,
plan_fingerprint: str,
project_id: str | None = None,
) -> dict[str, Any]:
resolved_project_id = project_id or "awoooi"
return {
"schema_version": "km_stale_owner_review_recheck_v1",
"version": "v1",
"project_id": resolved_project_id,
"trigger_source": "km_stale_owner_review_complete",
"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": f"stale_ratio_recheck_after_{request.review_outcome}",
"ownership": _stale_owner_review_ownership(),
"workflow": {
"work_item_id": (
"governance:knowledge_degradation:"
f"{governance_event_id}:stale_ratio_recheck:{entry_id}"
),
"work_kind": "km_stale_ratio_recheck",
"current_stage": "stale_ratio_recheck",
"entry_id": entry_id,
"project_id": resolved_project_id,
"review_outcome": request.review_outcome,
"steps": [
"detected",
"waiting_owner_review",
"owner_updates_or_archives_km",
"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": "km_governance_close_or_continue",
"writes_km_without_approval": False,
"writes_km": False,
"dry_run_plan_fingerprint": plan_fingerprint,
"stale_ratio_snapshot": stale_ratio_snapshot.model_dump(),
},
"worker_result": {
"status": "stale_ratio_rechecked",
"entry_id": entry_id,
"review_outcome": request.review_outcome,
"stale_ratio": stale_ratio_snapshot.stale_ratio,
"threshold": stale_ratio_snapshot.threshold,
"above_threshold": stale_ratio_snapshot.stale_ratio > stale_ratio_snapshot.threshold,
},
}
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:
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,
)
def _completion_stage_for_outcome(
outcome: Literal["refresh_with_evidence", "archive", "supersede"],
) -> str:
return {
"refresh_with_evidence": "km_writeback_after_approval",
"archive": "km_archive_after_approval",
"supersede": "km_supersede_after_approval",
}[outcome]
def _normalize_review_outcome(value: Any) -> Literal[
"refresh_with_evidence",
"archive",
"supersede",
]:
if value in ("archive", "supersede", "refresh_with_evidence"):
return value
return "refresh_with_evidence"
def _normalize_optional_review_outcome(value: Any) -> Literal[
"refresh_with_evidence",
"archive",
"supersede",
] | None:
if value in ("archive", "supersede", "refresh_with_evidence"):
return value
return None
def _extract_recheck_dispatch_id(context: dict[str, Any]) -> str | None:
recheck = context.get("stale_ratio_recheck")
if isinstance(recheck, dict):
value = recheck.get("dispatch_id")
if isinstance(value, str) and value:
return value
return None
def _extract_complete_workflow_stage(context: dict[str, Any], dispatch_status: str) -> str:
workflow = context.get("workflow")
if isinstance(workflow, dict):
stages = workflow.get("stage_by_dispatch_status")
if isinstance(stages, dict):
value = stages.get(dispatch_status)
if isinstance(value, str) and value:
return value
value = workflow.get("current_stage")
if isinstance(value, str) and value:
return value
return "km_writeback_after_approval"
def _snapshot_from_context(
context: dict[str, Any],
) -> KnowledgeReviewDraftStaleRatioSnapshot | None:
for source in (context, context.get("workflow")):
if not isinstance(source, dict):
continue
raw = source.get("stale_ratio_snapshot")
if isinstance(raw, dict):
try:
return KnowledgeReviewDraftStaleRatioSnapshot(**raw)
except Exception:
return None
return None
def _extract_plan_fingerprint(context: dict[str, Any]) -> str | None:
for source in (context, context.get("workflow")):
if not isinstance(source, dict):
continue
value = source.get("dry_run_plan_fingerprint")
if isinstance(value, str) and value:
return value
return None
def _build_complete_response(
*,
entry_id: str,
project_id: str,
status: Literal["dry_run", "completed", "already_completed"],
review_outcome: Literal["refresh_with_evidence", "archive", "supersede"],
governance_event_id: str,
dispatch_id: str,
workflow_stage: str,
owner: str,
owner_approved: bool,
dry_run: bool,
writes_km: bool,
writes_governance_audit: bool,
audit_dispatch_id: str | None = None,
stale_ratio_recheck_dispatch_id: str | None = None,
stale_ratio_snapshot: KnowledgeReviewDraftStaleRatioSnapshot | None = None,
dry_run_plan_fingerprint: str | None = None,
) -> KnowledgeStaleOwnerReviewCompleteResponse:
return KnowledgeStaleOwnerReviewCompleteResponse(
entry_id=entry_id,
project_id=project_id,
status=status,
review_outcome=review_outcome,
governance_event_id=governance_event_id,
dispatch_id=dispatch_id,
audit_dispatch_id=audit_dispatch_id,
stale_ratio_recheck_dispatch_id=stale_ratio_recheck_dispatch_id,
workflow_stage=workflow_stage,
owner=owner,
owner_approved=owner_approved,
dry_run=dry_run,
writes_km=writes_km,
writes_governance_audit=writes_governance_audit,
stale_ratio_snapshot=stale_ratio_snapshot,
dry_run_plan_fingerprint=dry_run_plan_fingerprint,
generated_at=now_taipei(),
)
def _build_stale_owner_review_event_details(
*,
entry_id: str,
candidate: dict[str, Any],
owner: str,
owner_note: str | None,
) -> dict[str, Any]:
return {
"schema_version": "km_stale_owner_review_event_v1",
"trigger_source": "stale_km_priority_queue",
"next_action": "owner_review_stale_km_candidate",
"impact": {
"status": "waiting_owner_review",
"metric": "stale_days",
"value": candidate.get("stale_days"),
"entry_id": entry_id,
"priority_tier": candidate.get("priority_tier"),
"priority_score": candidate.get("priority_score"),
"stale_ratio_threshold": KM_STALE_RATIO,
"stale_days_threshold": KM_STALE_DAYS,
},
"remediation": {
"next_action": "owner_review_stale_km_candidate",
"items": [
"review_current_incident_sentry_signoz_playbook_evidence",
"refresh_archive_or_supersede_after_owner_approval",
"run_stale_ratio_recheck_after_writeback",
],
},
"ownership": _stale_owner_review_ownership(),
"candidate": candidate,
"owner": owner,
"owner_note": owner_note,
}
def _build_stale_owner_review_decision_context(
*,
governance_event_id: str,
entry_id: str,
candidate: dict[str, Any],
owner: str,
owner_note: str | None,
) -> dict[str, Any]:
recommended_action = str(candidate.get("recommended_action") or "owner_review")
return {
"schema_version": "km_stale_owner_review_dispatch_v1",
"version": "v1",
"trigger_source": "stale_km_priority_queue",
"triggered_metric": "knowledge_degradation",
"metric_value": candidate.get("stale_days"),
"threshold": KM_STALE_DAYS,
"suggested_action": recommended_action,
"next_action": "owner_review_stale_km_candidate",
"decision_path": "pending_owner_review",
"ownership": _stale_owner_review_ownership(),
"entry_id": entry_id,
"candidate": candidate,
"workflow": {
"work_item_id": (
"governance:knowledge_degradation:"
f"{governance_event_id}:km_stale_owner_review:{entry_id}"
),
"work_kind": "km_stale_owner_review",
"current_stage": "waiting_owner_review",
"entry_id": entry_id,
"project_id": candidate.get("project_id"),
"priority_tier": candidate.get("priority_tier"),
"priority_score": candidate.get("priority_score"),
"recommended_action": recommended_action,
"steps": [
"detected",
"prioritized_stale_candidate",
"waiting_owner_review",
"owner_updates_or_archives_km",
"stale_ratio_recheck",
],
"stage_by_dispatch_status": {
"pending": "waiting_owner_review",
"dispatched": "waiting_owner_review",
"executing": "owner_review_in_progress",
"succeeded": "km_candidate_reviewed",
"failed": "needs_manual_km_triage",
"skipped": "waiting_owner_review",
"cancelled": "cancelled",
},
"next_action": "owner_review_stale_km_candidate",
"needs_human_review": True,
"writes_km_without_approval": False,
"writes_km": False,
"stale_ratio_recheck_after_writeback": True,
},
"worker_result": {
"status": "queued_owner_review",
"entry_id": entry_id,
"recommended_action": recommended_action,
"writes_km": False,
},
"owner": owner,
"owner_note": owner_note,
}
def _stale_owner_review_ownership() -> dict[str, Any]:
return {
"lead_agent": "Hermes",
"support_agents": [
"OpenClaw補 Incident / 規則 / PlayBook 脈絡,不直接批量改寫 KM。",
"ElephantAlpharead-only 稽核 owner review 草稿與風險。",
],
"human_owner": "KM owner / SRE owner",
}
def _build_response(
*,
entry_id: str,
project_id: str,
status: Literal["dry_run", "queued", "already_queued"],
recommended_action: Literal[
"refresh_with_evidence",
"owner_review",
"archive_or_supersede",
],
owner: str,
writes_governance_audit: bool,
owner_note: str | None = None,
governance_event_id: str | None = None,
dispatch_id: str | None = None,
) -> KnowledgeStaleOwnerReviewResponse:
return KnowledgeStaleOwnerReviewResponse(
entry_id=entry_id,
project_id=project_id,
status=status,
governance_event_id=governance_event_id,
dispatch_id=dispatch_id,
workflow_stage="waiting_owner_review",
recommended_action=recommended_action,
owner=owner,
owner_note=owner_note,
writes_km=False,
writes_governance_audit=writes_governance_audit,
generated_at=now_taipei(),
)
def _enum_value(value: Any) -> str:
return str(value.value if hasattr(value, "value") else value)