feat(governance): complete stale km owner review
All checks were successful
CD Pipeline / tests (push) Successful in 5m28s
Code Review / ai-code-review (push) Successful in 12s
Type Sync Check / check-type-sync (push) Successful in 26s
CD Pipeline / build-and-deploy (push) Successful in 5m12s
CD Pipeline / post-deploy-checks (push) Successful in 1m31s

This commit is contained in:
Your Name
2026-05-24 18:28:10 +08:00
parent 00cf6f009d
commit 630cd5381c
8 changed files with 1435 additions and 2 deletions

View File

@@ -32,6 +32,8 @@ from src.models.governance import (
KnowledgeReviewDraftArchiveResponse,
KnowledgeReviewDraftDedupeResponse,
KnowledgeStaleCandidatesResponse,
KnowledgeStaleOwnerReviewCompleteRequest,
KnowledgeStaleOwnerReviewCompleteResponse,
KnowledgeStaleOwnerReviewRequest,
KnowledgeStaleOwnerReviewResponse,
)
@@ -41,6 +43,7 @@ from src.services.governance_km_review_service import (
)
from src.services.governance_km_stale_review_service import (
KmStaleOwnerReviewError,
complete_km_stale_owner_review,
queue_km_stale_owner_review,
)
from src.services.governance_query_service import (
@@ -257,6 +260,42 @@ async def post_km_stale_candidate_queue_review(
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
# =============================================================================
# POST /api/v1/ai/governance/km-stale-candidates/{entry_id}/complete-review
# =============================================================================
@router.post(
"/ai/governance/km-stale-candidates/{entry_id}/complete-review",
response_model=KnowledgeStaleOwnerReviewCompleteResponse,
)
async def post_km_stale_candidate_complete_review(
entry_id: str,
request: KnowledgeStaleOwnerReviewCompleteRequest,
) -> KnowledgeStaleOwnerReviewCompleteResponse:
"""
Owner 審核後完成 stale KM 的 refresh / archive / supersede 流程。
必須先 dry-run 取得 fingerprint真正寫入時需 owner_approved=true。
後端會寫 KM、terminal audit dispatch 與 stale ratio recheck dispatch。
"""
logger.info(
"km_stale_candidate_complete_review_request",
entry_id=entry_id,
dispatch_id=request.dispatch_id,
owner=request.owner,
review_outcome=request.review_outcome,
dry_run=request.dry_run,
owner_approved=request.owner_approved,
)
try:
return await complete_km_stale_owner_review(
entry_id=entry_id,
request=request,
)
except KmStaleOwnerReviewError as exc:
raise HTTPException(status_code=exc.status_code, detail=exc.detail) from exc
# =============================================================================
# GET /api/v1/ai/governance/summary
# =============================================================================

View File

@@ -227,6 +227,10 @@ class KnowledgeStaleCandidate(BaseModel):
related_playbook_id: str | None = None
related_approval_id: str | None = None
tags: list[str] = Field(default_factory=list)
owner_review_dispatch_id: str | None = None
owner_review_status: str | None = None
owner_review_stage: str | None = None
owner_review_next_action: str | None = None
class KnowledgeStaleCandidatesResponse(BaseModel):
@@ -268,6 +272,57 @@ class KnowledgeStaleOwnerReviewResponse(BaseModel):
generated_at: datetime
class KnowledgeStaleOwnerReviewCompleteRequest(BaseModel):
dispatch_id: str | None = Field(
default=None,
max_length=120,
description="Owner-review dispatch id. Optional when the backend can resolve the active item by entry id.",
)
owner: str = Field(default="operator_console", min_length=1, max_length=100)
owner_approved: bool = False
dry_run: bool = False
review_outcome: Literal[
"refresh_with_evidence",
"archive",
"supersede",
]
owner_note: str | None = Field(default=None, max_length=500)
updated_title: str | None = Field(default=None, min_length=1, max_length=255)
updated_content: str | None = Field(default=None, min_length=1)
superseded_by_entry_id: str | None = Field(default=None, max_length=120)
dry_run_plan_fingerprint: str | None = Field(
default=None,
max_length=80,
description="Dry-run response fingerprint that must be echoed before a write.",
)
class KnowledgeStaleOwnerReviewCompleteResponse(BaseModel):
schema_version: str = "km_stale_owner_review_complete_v1"
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
audit_dispatch_id: str | None = None
stale_ratio_recheck_dispatch_id: str | None = None
workflow_stage: str
owner: str
owner_approved: bool
dry_run: bool
writes_km: bool
writes_governance_audit: bool
stale_ratio_snapshot: KnowledgeReviewDraftStaleRatioSnapshot | None = None
dry_run_plan_fingerprint: str | None = None
next_action: str = "stale_ratio_recheck"
generated_at: datetime
# =============================================================================
# Endpoint 3: summary
# =============================================================================

View File

@@ -10,11 +10,14 @@ Owner-review intake for stale KM priority candidates.
from __future__ import annotations
import hashlib
import json
from datetime import timedelta
from typing import Any, Literal
import structlog
from sqlalchemy import select, text
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 (
@@ -25,6 +28,9 @@ from src.db.models import (
taipei_now,
)
from src.models.governance import (
KnowledgeReviewDraftStaleRatioSnapshot,
KnowledgeStaleOwnerReviewCompleteRequest,
KnowledgeStaleOwnerReviewCompleteResponse,
KnowledgeStaleOwnerReviewRequest,
KnowledgeStaleOwnerReviewResponse,
)
@@ -36,6 +42,8 @@ from src.utils.timezone import now_taipei
logger = structlog.get_logger(__name__)
_EXECUTOR_TYPE = "hermes_km_stale_owner_review"
_COMPLETE_EXECUTOR_TYPE = "hermes_km_stale_owner_review_complete"
_RECHECK_EXECUTOR_TYPE = "hermes_km_stale_ratio_recheck"
class KmStaleOwnerReviewError(Exception):
@@ -150,6 +158,110 @@ async def queue_km_stale_owner_review(
)
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:
@@ -193,6 +305,657 @@ async def _load_active_owner_review_dispatch(
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,
),
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,
) -> dict[str, Any]:
return {
"schema_version": "km_stale_owner_review_recheck_v1",
"version": "v1",
"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,
"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 _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,

View File

@@ -923,6 +923,13 @@ async def query_km_stale_candidates(
reverse=True,
)
limited = candidates[:limit]
owner_review_state = await _load_km_stale_owner_review_state_by_entry(
[candidate.entry_id for candidate in limited]
)
limited = [
candidate.model_copy(update=owner_review_state.get(candidate.entry_id, {}))
for candidate in limited
]
return KnowledgeStaleCandidatesResponse(
project_id=project_id,
@@ -1033,6 +1040,61 @@ def _build_km_stale_candidate(
)
async def _load_km_stale_owner_review_state_by_entry(
entry_ids: list[str],
) -> dict[str, dict[str, str]]:
"""讀取 stale KM owner-review / completion 的最新狀態,供前端接續處理。"""
if not entry_ids:
return {}
unique_entry_ids = list(dict.fromkeys(entry_ids))
sql = text("""
SELECT DISTINCT ON (entry_id)
entry_id,
d.id,
d.dispatch_status,
d.decision_context
FROM (
SELECT
d.*,
COALESCE(
d.decision_context -> 'workflow' ->> 'entry_id',
d.decision_context ->> 'entry_id'
) AS entry_id
FROM governance_remediation_dispatch d
WHERE d.executor_type IN (
'hermes_km_stale_owner_review',
'hermes_km_stale_owner_review_complete'
)
) d
WHERE entry_id IN :entry_ids
ORDER BY entry_id, d.dispatched_at DESC
""").bindparams(bindparam("entry_ids", expanding=True))
try:
async with get_db_context() as db:
result = await db.execute(sql, {"entry_ids": unique_entry_ids})
rows = result.fetchall()
except ProgrammingError as exc:
logger.warning(
"km_stale_owner_review_state_table_not_ready",
error=str(exc),
)
return {}
state: dict[str, dict[str, str]] = {}
for row in rows:
decision_ctx: dict = row.decision_context if isinstance(row.decision_context, dict) else {}
dispatch_status = str(row.dispatch_status)
state[str(row.entry_id)] = {
"owner_review_dispatch_id": str(row.id),
"owner_review_status": dispatch_status,
"owner_review_stage": _extract_workflow_stage(decision_ctx, dispatch_status) or "",
"owner_review_next_action": _extract_next_action(decision_ctx) or "",
}
return state
def _km_priority_tier(
score: int,
record: KnowledgeEntryRecord,

View File

@@ -22,7 +22,7 @@ from fastapi import FastAPI
from fastapi.testclient import TestClient
from src.api.v1.ai_governance import router
from src.db.models import KnowledgeEntryRecord
from src.db.models import GovernanceRemediationDispatch, KnowledgeEntryRecord
from src.models.governance import (
DailyCount,
DispatchItem,
@@ -37,6 +37,8 @@ from src.models.governance import (
KnowledgeReviewDraftStaleRatioSnapshot,
KnowledgeStaleCandidate,
KnowledgeStaleCandidatesResponse,
KnowledgeStaleOwnerReviewCompleteRequest,
KnowledgeStaleOwnerReviewCompleteResponse,
KnowledgeStaleOwnerReviewRequest,
KnowledgeStaleOwnerReviewResponse,
map_severity,
@@ -51,7 +53,13 @@ from src.services.governance_km_review_service import (
)
from src.services.governance_km_stale_review_service import (
KmStaleOwnerReviewError,
_build_completion_plan_fingerprint,
_build_owner_review_completion_audit_context,
_build_stale_owner_review_decision_context,
_completion_stage_for_outcome,
)
from src.services.governance_km_stale_review_service import (
_build_stale_ratio_recheck_context as _build_stale_owner_review_recheck_context,
)
from src.services.governance_query_service import (
_build_km_review_draft_dedupe_groups,
@@ -791,6 +799,166 @@ class TestKmReviewDraftDedupe:
assert ctx["worker_result"]["status"] == "queued_owner_review"
assert ctx["ownership"]["lead_agent"] == "Hermes"
def test_complete_stale_candidate_endpoint_returns_writeback_audit_result(self, client):
"""Owner 審核完成後要回傳 KM write / audit / stale ratio recheck 狀態。"""
fake = KnowledgeStaleOwnerReviewCompleteResponse(
entry_id="km-001",
project_id="awoooi",
status="completed",
review_outcome="refresh_with_evidence",
governance_event_id="event-001",
dispatch_id="dispatch-001",
audit_dispatch_id="dispatch-audit-001",
stale_ratio_recheck_dispatch_id="dispatch-recheck-001",
workflow_stage="km_writeback_after_approval",
owner="operator_console",
owner_approved=True,
dry_run=False,
writes_km=True,
writes_governance_audit=True,
stale_ratio_snapshot=KnowledgeReviewDraftStaleRatioSnapshot(
stale_count=119,
total_count=200,
stale_ratio=0.595,
threshold=0.2,
stale_days=7,
),
dry_run_plan_fingerprint="sha256:" + "a" * 64,
generated_at=NOW,
)
captured: dict = {}
async def mock_complete(**kwargs):
captured.update(kwargs)
return fake
with patch(
"src.api.v1.ai_governance.complete_km_stale_owner_review",
new=mock_complete,
):
r = client.post(
"/api/v1/ai/governance/km-stale-candidates/km-001/complete-review",
json={
"dispatch_id": "dispatch-001",
"owner": "operator_console",
"owner_approved": True,
"dry_run": False,
"review_outcome": "refresh_with_evidence",
"owner_note": "Reviewed Sentry and SigNoz evidence.",
"dry_run_plan_fingerprint": "sha256:" + "a" * 64,
},
)
assert r.status_code == 200
assert captured["entry_id"] == "km-001"
assert isinstance(captured["request"], KnowledgeStaleOwnerReviewCompleteRequest)
data = r.json()
assert data["schema_version"] == "km_stale_owner_review_complete_v1"
assert data["status"] == "completed"
assert data["workflow_stage"] == "km_writeback_after_approval"
assert data["writes_km"] is True
assert data["writes_governance_audit"] is True
assert data["audit_dispatch_id"] == "dispatch-audit-001"
assert data["stale_ratio_recheck_dispatch_id"] == "dispatch-recheck-001"
assert data["stale_ratio_snapshot"]["stale_ratio"] == pytest.approx(0.595)
def test_complete_stale_candidate_endpoint_maps_validation_error(self, client):
async def mock_complete(**kwargs):
raise KmStaleOwnerReviewError(403, "owner_approved=true is required")
with patch(
"src.api.v1.ai_governance.complete_km_stale_owner_review",
new=mock_complete,
):
r = client.post(
"/api/v1/ai/governance/km-stale-candidates/km-001/complete-review",
json={
"dispatch_id": "dispatch-001",
"review_outcome": "archive",
"dry_run": False,
},
)
assert r.status_code == 403
assert r.json()["detail"] == "owner_approved=true is required"
def test_stale_owner_review_completion_fingerprint_and_audit_are_visible(self):
record = KnowledgeEntryRecord(
id="km-001",
project_id="awoooi",
title="Sentry checkout failure repair",
content="old evidence",
entry_type=EntryType.AUTO_RUNBOOK,
category="AI系統",
tags=["sentry"],
source=EntrySource.AI_EXTRACTED,
status=EntryStatus.REVIEW,
updated_at=NOW - timedelta(days=35),
)
dispatch = GovernanceRemediationDispatch(
id="dispatch-001",
governance_event_id="event-001",
event_type="knowledge_degradation",
dispatch_status="pending",
decision_context={
"workflow": {
"entry_id": "km-001",
"steps": ["detected", "waiting_owner_review"],
}
},
executor_type="hermes_km_stale_owner_review",
dispatched_at=NOW,
)
request = KnowledgeStaleOwnerReviewCompleteRequest(
dispatch_id="dispatch-001",
owner="operator_console",
owner_approved=True,
dry_run=False,
review_outcome="refresh_with_evidence",
owner_note="Reviewed Incident, Sentry, SigNoz, and PlayBook.",
)
snapshot = KnowledgeReviewDraftStaleRatioSnapshot(
stale_count=119,
total_count=200,
stale_ratio=0.595,
threshold=0.2,
stale_days=7,
)
fingerprint = _build_completion_plan_fingerprint(
record=record,
dispatch=dispatch,
request=request,
superseded_by=None,
)
audit = _build_owner_review_completion_audit_context(
governance_event_id="event-001",
source_dispatch_id="dispatch-001",
entry=record,
request=request,
stale_ratio_snapshot=snapshot,
recheck_dispatch_id="dispatch-recheck-001",
plan_fingerprint=fingerprint,
completed_at=NOW.isoformat(),
)
recheck = _build_stale_owner_review_recheck_context(
governance_event_id="event-001",
entry_id="km-001",
request=request,
stale_ratio_snapshot=snapshot,
plan_fingerprint=fingerprint,
)
assert fingerprint.startswith("sha256:")
assert _completion_stage_for_outcome("refresh_with_evidence") == "km_writeback_after_approval"
assert audit["workflow"]["current_stage"] == "km_writeback_after_approval"
assert audit["workflow"]["writes_km_without_approval"] is False
assert audit["worker_result"]["status"] == "owner_review_completed"
assert audit["worker_result"]["above_threshold"] is True
assert audit["stale_ratio_recheck"]["dispatch_id"] == "dispatch-recheck-001"
assert recheck["workflow"]["current_stage"] == "stale_ratio_recheck"
assert recheck["workflow"]["stale_ratio_snapshot"]["stale_ratio"] == pytest.approx(0.595)
def test_archive_endpoint_requires_owner_shape_and_returns_audit_result(self, client):
"""Owner 批准後的 archive endpoint 應回傳 KM write 與 audit write 結果。"""
fake = KnowledgeReviewDraftArchiveResponse(

View File

@@ -2092,12 +2092,38 @@
"queueingReview": "Queueing",
"queueFailed": "Could not queue owner review; refresh and confirm this KM is still stale.",
"queueResult": "Review status: {status}; Dispatch: {dispatch}; Event: {event}",
"ownerReviewState": "Owner review: {status}; stage: {stage}; Dispatch: {dispatch}",
"guardrail": "Guardrail: writes on read={writes}; manual review={review}",
"queueStatuses": {
"dry_run": "Dry-run",
"queued": "Queued for owner review",
"already_queued": "Already in owner review"
},
"completeActions": {
"preview": "Dry-run complete",
"previewing": "Previewing",
"confirm": "Confirm complete",
"confirming": "Writing",
"previewFailed": "Dry-run preview failed; refresh and verify that the owner-review dispatch is still active.",
"confirmFailed": "Completion failed; the backend may have detected changed KM or dispatch state.",
"missingDispatch": "Missing owner-review dispatch; queue review first.",
"missingPreviewFingerprint": "Missing dry-run plan fingerprint; run the preview again first.",
"previewResult": "Dry run: {outcome}; writes KM: {writesKm}; writes audit: {writesAudit}",
"planFingerprint": "Plan fingerprint: {fingerprint}",
"result": "Completed; audit dispatch: {audit}; recheck dispatch: {recheck}",
"snapshot": "Current stale {stale} / total {total}; ratio {ratio}; threshold {threshold}",
"statuses": {
"dry_run": "Dry run complete",
"completed": "Review completed",
"already_completed": "Already completed",
"unknown": "Status pending"
},
"outcomes": {
"refresh_with_evidence": "Refresh KM with evidence",
"archive": "Archive stale KM",
"supersede": "Supersede with new KM"
}
},
"actions": {
"refresh_with_evidence": "Refresh with Incident / Sentry / SigNoz / PlayBook evidence",
"owner_review": "Route to owner review",
@@ -2193,7 +2219,10 @@
"queued_kb_healthcheck": "Queued for KM healthcheck",
"draft_km_updates": "Drafting KM updates",
"waiting_owner_review": "Waiting owner review",
"owner_updates_or_archives_km": "Owner updates or archives KM",
"km_writeback_after_approval": "KM writeback after approval",
"km_archive_after_approval": "KM archive after approval",
"km_supersede_after_approval": "KM supersede after approval",
"stale_ratio_recheck": "Stale-ratio recheck",
"owner_approved_duplicate_archive": "Owner approved duplicate archive",
"km_duplicate_archive_after_owner_approval": "Duplicate archive after owner review",

View File

@@ -2093,12 +2093,38 @@
"queueingReview": "排入中",
"queueFailed": "排入 owner review 失敗;請重新整理後再確認此 KM 是否仍為陳舊候選。",
"queueResult": "審核狀態:{status}Dispatch{dispatch}Event{event}",
"ownerReviewState": "Owner review{status};階段:{stage}Dispatch{dispatch}",
"guardrail": "防護:讀取不寫入={writes};人工覆核={review}",
"queueStatuses": {
"dry_run": "乾跑",
"queued": "已排入 owner review",
"already_queued": "已在 owner review"
},
"completeActions": {
"preview": "乾跑完成",
"previewing": "預覽中",
"confirm": "確認完成",
"confirming": "寫入中",
"previewFailed": "乾跑預覽失敗;請重新整理後確認 owner review dispatch 仍有效。",
"confirmFailed": "確認完成失敗;後端可能偵測到 KM 或 dispatch 狀態已變更。",
"missingDispatch": "缺少 owner-review dispatch請先排入審核。",
"missingPreviewFingerprint": "缺少乾跑 plan fingerprint請先重新執行乾跑預覽。",
"previewResult": "乾跑結果:{outcome};寫 KM{writesKm};寫稽核:{writesAudit}",
"planFingerprint": "Plan fingerprint{fingerprint}",
"result": "已完成;稽核 dispatch{audit};回測 dispatch{recheck}",
"snapshot": "目前 stale {stale} / total {total}ratio {ratio};門檻 {threshold}",
"statuses": {
"dry_run": "乾跑完成",
"completed": "審核完成",
"already_completed": "已完成,無需重複處理",
"unknown": "狀態待確認"
},
"outcomes": {
"refresh_with_evidence": "依證據刷新 KM",
"archive": "封存陳舊 KM",
"supersede": "以新 KM 取代"
}
},
"actions": {
"refresh_with_evidence": "依 Incident / Sentry / SigNoz / PlayBook 證據刷新",
"owner_review": "交由 owner 審核內容",
@@ -2194,7 +2220,10 @@
"queued_kb_healthcheck": "已排入 KM healthcheck",
"draft_km_updates": "產生 KM 更新草稿",
"waiting_owner_review": "等待 owner 審核",
"owner_updates_or_archives_km": "Owner 更新或封存 KM",
"km_writeback_after_approval": "審核後寫回 KM",
"km_archive_after_approval": "審核後封存 KM",
"km_supersede_after_approval": "審核後以新 KM 取代",
"stale_ratio_recheck": "回測 stale ratio",
"owner_approved_duplicate_archive": "Owner 已批准封存重複草稿",
"km_duplicate_archive_after_owner_approval": "Owner 審核後封存重複草稿",

View File

@@ -397,6 +397,10 @@ type KnowledgeStaleCandidate = {
related_playbook_id?: string | null;
related_approval_id?: string | null;
tags: string[];
owner_review_dispatch_id?: string | null;
owner_review_status?: string | null;
owner_review_stage?: string | null;
owner_review_next_action?: string | null;
};
type KnowledgeStaleCandidatesResponse = {
@@ -434,6 +438,42 @@ type KnowledgeStaleOwnerReviewAction = {
error: string | null;
};
type KnowledgeStaleOwnerReviewCompleteResponse = {
schema_version?: string;
entry_id: string;
project_id: string;
status: "dry_run" | "completed" | "already_completed";
review_outcome: "refresh_with_evidence" | "archive" | "supersede";
governance_event_id: string;
dispatch_id: string;
audit_dispatch_id?: string | null;
stale_ratio_recheck_dispatch_id?: string | null;
workflow_stage: string;
owner: string;
owner_approved: boolean;
dry_run: boolean;
writes_km: boolean;
writes_governance_audit: boolean;
stale_ratio_snapshot?: {
stale_count: number;
total_count: number;
stale_ratio: number;
threshold: number;
stale_days: number;
} | null;
dry_run_plan_fingerprint?: string | null;
next_action: string;
generated_at?: string | null;
};
type KnowledgeStaleOwnerReviewCompleteAction = {
previewLoading: boolean;
confirmLoading: boolean;
previewResult: KnowledgeStaleOwnerReviewCompleteResponse | null;
result: KnowledgeStaleOwnerReviewCompleteResponse | null;
error: string | null;
};
type DriftFingerprintState = {
schema_version?: string;
namespace?: string;
@@ -833,7 +873,10 @@ function governanceKmStageKey(stage?: string | null) {
stage === "ai_analyzed" ||
stage === "draft_km_updates" ||
stage === "waiting_owner_review" ||
stage === "owner_updates_or_archives_km" ||
stage === "km_writeback_after_approval" ||
stage === "km_archive_after_approval" ||
stage === "km_supersede_after_approval" ||
stage === "stale_ratio_recheck" ||
stage === "owner_approved_duplicate_archive" ||
stage === "km_duplicate_archive_after_owner_approval" ||
@@ -1109,6 +1152,24 @@ function kmStaleReviewStatusKey(value: string | null | undefined) {
}
}
function kmStaleReviewCompleteStatusKey(value: string | null | undefined) {
switch (value) {
case "dry_run":
case "completed":
case "already_completed":
return value;
default:
return "unknown";
}
}
function kmStaleReviewOutcomeForCandidate(
candidate: KnowledgeStaleCandidate
): "refresh_with_evidence" | "archive" | "supersede" {
if (candidate.recommended_action === "archive_or_supersede") return "archive";
return "refresh_with_evidence";
}
function kmCorrelationSourceKey(value: string | null | undefined) {
switch (value) {
case "incident":
@@ -2109,6 +2170,8 @@ function KnowledgeGovernancePanel({
const t = useTranslations("awooop.workItems.knowledgeGovernance");
const [archiveActions, setArchiveActions] = useState<Record<string, KnowledgeReviewDraftArchiveAction>>({});
const [staleReviewActions, setStaleReviewActions] = useState<Record<string, KnowledgeStaleOwnerReviewAction>>({});
const [staleReviewCompletionActions, setStaleReviewCompletionActions] =
useState<Record<string, KnowledgeStaleOwnerReviewCompleteAction>>({});
const items = queue?.items ?? [];
const draftGroups = groupKnowledgeReviewDrafts(reviewDrafts, items);
const dedupeGroups = dedupe?.groups ?? [];
@@ -2243,6 +2306,119 @@ function KnowledgeGovernancePanel({
}
}, [onArchived, t]);
const previewStaleReviewCompletion = useCallback(async (candidate: KnowledgeStaleCandidate) => {
const queuedDispatchId = (
candidate.owner_review_dispatch_id
?? staleReviewActions[candidate.entry_id]?.result?.dispatch_id
?? null
);
if (!queuedDispatchId) {
setStaleReviewCompletionActions((current) => ({
...current,
[candidate.entry_id]: {
previewLoading: false,
confirmLoading: false,
previewResult: current[candidate.entry_id]?.previewResult ?? null,
result: current[candidate.entry_id]?.result ?? null,
error: t("staleCandidates.completeActions.missingDispatch"),
},
}));
return;
}
setStaleReviewCompletionActions((current) => ({
...current,
[candidate.entry_id]: {
previewLoading: true,
confirmLoading: false,
previewResult: current[candidate.entry_id]?.previewResult ?? null,
result: null,
error: null,
},
}));
const result = await postJson<KnowledgeStaleOwnerReviewCompleteResponse>(
`${API_BASE}/api/v1/ai/governance/km-stale-candidates/${encodeURIComponent(candidate.entry_id)}/complete-review`,
{
dispatch_id: queuedDispatchId,
owner: "operator_console",
owner_approved: false,
dry_run: true,
review_outcome: kmStaleReviewOutcomeForCandidate(candidate),
owner_note: `operator_console_review:${candidate.priority_tier}:${candidate.recommended_action}`,
},
15000
);
setStaleReviewCompletionActions((current) => ({
...current,
[candidate.entry_id]: {
previewLoading: false,
confirmLoading: false,
previewResult: result,
result: null,
error: result ? null : t("staleCandidates.completeActions.previewFailed"),
},
}));
}, [staleReviewActions, t]);
const confirmStaleReviewCompletion = useCallback(async (candidate: KnowledgeStaleCandidate) => {
const action = staleReviewCompletionActions[candidate.entry_id];
const queuedDispatchId = (
candidate.owner_review_dispatch_id
?? staleReviewActions[candidate.entry_id]?.result?.dispatch_id
?? action?.previewResult?.dispatch_id
?? null
);
const fingerprint = action?.previewResult?.dry_run_plan_fingerprint;
if (!queuedDispatchId || !fingerprint) {
setStaleReviewCompletionActions((current) => ({
...current,
[candidate.entry_id]: {
previewLoading: false,
confirmLoading: false,
previewResult: current[candidate.entry_id]?.previewResult ?? null,
result: current[candidate.entry_id]?.result ?? null,
error: t("staleCandidates.completeActions.missingPreviewFingerprint"),
},
}));
return;
}
setStaleReviewCompletionActions((current) => ({
...current,
[candidate.entry_id]: {
previewLoading: false,
confirmLoading: true,
previewResult: current[candidate.entry_id]?.previewResult ?? null,
result: null,
error: null,
},
}));
const result = await postJson<KnowledgeStaleOwnerReviewCompleteResponse>(
`${API_BASE}/api/v1/ai/governance/km-stale-candidates/${encodeURIComponent(candidate.entry_id)}/complete-review`,
{
dispatch_id: queuedDispatchId,
owner: "operator_console",
owner_approved: true,
dry_run: false,
review_outcome: kmStaleReviewOutcomeForCandidate(candidate),
owner_note: `operator_console_review:${candidate.priority_tier}:${candidate.recommended_action}`,
dry_run_plan_fingerprint: fingerprint,
},
15000
);
setStaleReviewCompletionActions((current) => ({
...current,
[candidate.entry_id]: {
previewLoading: false,
confirmLoading: false,
previewResult: current[candidate.entry_id]?.previewResult ?? null,
result,
error: result ? null : t("staleCandidates.completeActions.confirmFailed"),
},
}));
if (result?.status === "completed" || result?.status === "already_completed") {
onArchived();
}
}, [onArchived, staleReviewActions, staleReviewCompletionActions, t]);
return (
<section className="border border-[#e0ddd4] bg-white">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-[#e0ddd4] bg-[#faf9f3] px-4 py-3">
@@ -2395,6 +2571,15 @@ function KnowledgeGovernancePanel({
const reviewAction = staleReviewActions[candidate.entry_id];
const reviewResult = reviewAction?.result ?? null;
const reviewStatusKey = kmStaleReviewStatusKey(reviewResult?.status);
const queuedDispatchId = candidate.owner_review_dispatch_id ?? reviewResult?.dispatch_id ?? null;
const persistedReviewStatusKey = governanceKmDispatchStatusKey(candidate.owner_review_status);
const persistedReviewStageKey = governanceKmStageKey(candidate.owner_review_stage);
const completionAction = staleReviewCompletionActions[candidate.entry_id];
const completionPreview = completionAction?.previewResult ?? null;
const completionResult = completionAction?.result ?? null;
const completionPreviewReady = Boolean(completionPreview?.dry_run_plan_fingerprint);
const completionPreviewStatusKey = kmStaleReviewCompleteStatusKey(completionPreview?.status);
const completionResultStatusKey = kmStaleReviewCompleteStatusKey(completionResult?.status);
return (
<article
key={candidate.entry_id}
@@ -2446,6 +2631,15 @@ function KnowledgeGovernancePanel({
approval: candidate.related_approval_id ?? "--",
})}
</p>
{queuedDispatchId ? (
<p>
{t("staleCandidates.ownerReviewState", {
status: t(`statuses.${persistedReviewStatusKey}` as never),
stage: t(`stages.${persistedReviewStageKey}` as never),
dispatch: queuedDispatchId,
})}
</p>
) : null}
</div>
<div className="mt-2 flex flex-wrap gap-1">
{candidate.reasons.slice(0, 5).map((reason) => (
@@ -2469,6 +2663,37 @@ function KnowledgeGovernancePanel({
? t("staleCandidates.queueingReview")
: t("staleCandidates.queueReview")}
</button>
<button
type="button"
onClick={() => previewStaleReviewCompletion(candidate)}
disabled={
!queuedDispatchId ||
completionAction?.previewLoading ||
completionAction?.confirmLoading
}
className="inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-white px-2 py-1 text-xs font-semibold text-[#2e2b26] hover:border-[#9bc7a4] hover:bg-[#f0faf2] hover:text-[#17602a] disabled:cursor-not-allowed disabled:opacity-60"
>
<SearchCheck className="h-3.5 w-3.5" aria-hidden="true" />
{completionAction?.previewLoading
? t("staleCandidates.completeActions.previewing")
: t("staleCandidates.completeActions.preview")}
</button>
<button
type="button"
onClick={() => confirmStaleReviewCompletion(candidate)}
disabled={
!queuedDispatchId ||
!completionPreviewReady ||
completionAction?.previewLoading ||
completionAction?.confirmLoading
}
className="inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-white px-2 py-1 text-xs font-semibold text-[#2e2b26] hover:border-[#d9b36f] hover:bg-[#fff7e8] hover:text-[#8a5a08] disabled:cursor-not-allowed disabled:opacity-60"
>
<ShieldCheck className="h-3.5 w-3.5" aria-hidden="true" />
{completionAction?.confirmLoading
? t("staleCandidates.completeActions.confirming")
: t("staleCandidates.completeActions.confirm")}
</button>
<Link
href={`/knowledge-base?q=${encodeURIComponent(candidate.title)}`}
className="inline-flex items-center gap-1.5 border border-[#d8d3c7] bg-white px-2 py-1 text-xs font-semibold text-[#2e2b26] hover:border-[#1f6feb] hover:bg-[#edf4ff] hover:text-[#0f4fa8]"
@@ -2491,6 +2716,69 @@ function KnowledgeGovernancePanel({
})}
</p>
) : null}
{completionAction?.error ? (
<p className="mt-2 border border-[#e2a29b] bg-[#fff0ef] px-2 py-1 text-[11px] leading-5 text-[#9f2f25]">
{completionAction.error}
</p>
) : null}
{completionPreview ? (
<div className="mt-2 border border-[#d9b36f] bg-[#fff7e8] px-2 py-1.5 text-[11px] leading-5 text-[#8a5a08]">
<p className="font-semibold">
{t(
`staleCandidates.completeActions.statuses.${completionPreviewStatusKey}` as never
)}
</p>
<p className="mt-1 text-[#5f5b52]">
{t("staleCandidates.completeActions.previewResult", {
outcome: t(
`staleCandidates.completeActions.outcomes.${completionPreview.review_outcome}` as never
),
writesKm: String(completionPreview.writes_km),
writesAudit: String(completionPreview.writes_governance_audit),
})}
</p>
<p className="mt-1 break-all font-mono text-[11px] text-[#5f5b52]">
{t("staleCandidates.completeActions.planFingerprint", {
fingerprint: completionPreview.dry_run_plan_fingerprint ?? "--",
})}
</p>
{completionPreview.stale_ratio_snapshot ? (
<p className="mt-1 text-[#5f5b52]">
{t("staleCandidates.completeActions.snapshot", {
stale: completionPreview.stale_ratio_snapshot.stale_count,
total: completionPreview.stale_ratio_snapshot.total_count,
ratio: formatStaleRatio(completionPreview.stale_ratio_snapshot.stale_ratio),
threshold: formatStaleRatio(completionPreview.stale_ratio_snapshot.threshold),
})}
</p>
) : null}
</div>
) : null}
{completionResult ? (
<div className="mt-2 border border-[#9bc7a4] bg-[#f0faf2] px-2 py-1.5 text-[11px] leading-5 text-[#17602a]">
<p className="font-semibold">
{t(
`staleCandidates.completeActions.statuses.${completionResultStatusKey}` as never
)}
</p>
<p className="mt-1 text-[#5f5b52]">
{t("staleCandidates.completeActions.result", {
audit: completionResult.audit_dispatch_id ?? "--",
recheck: completionResult.stale_ratio_recheck_dispatch_id ?? "--",
})}
</p>
{completionResult.stale_ratio_snapshot ? (
<p className="mt-1 text-[#5f5b52]">
{t("staleCandidates.completeActions.snapshot", {
stale: completionResult.stale_ratio_snapshot.stale_count,
total: completionResult.stale_ratio_snapshot.total_count,
ratio: formatStaleRatio(completionResult.stale_ratio_snapshot.stale_ratio),
threshold: formatStaleRatio(completionResult.stale_ratio_snapshot.threshold),
})}
</p>
) : null}
</div>
) : null}
</article>
);
})}