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
2402 lines
88 KiB
Python
2402 lines
88 KiB
Python
"""
|
||
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。",
|
||
"ElephantAlpha:read-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)
|