diff --git a/apps/api/src/api/v1/ai_governance.py b/apps/api/src/api/v1/ai_governance.py index 92985f50..be3381f4 100644 --- a/apps/api/src/api/v1/ai_governance.py +++ b/apps/api/src/api/v1/ai_governance.py @@ -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 # ============================================================================= diff --git a/apps/api/src/models/governance.py b/apps/api/src/models/governance.py index 91fe8ef8..c00c6148 100644 --- a/apps/api/src/models/governance.py +++ b/apps/api/src/models/governance.py @@ -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 # ============================================================================= diff --git a/apps/api/src/services/governance_km_stale_review_service.py b/apps/api/src/services/governance_km_stale_review_service.py index 92b0c5d3..9e8eaae3 100644 --- a/apps/api/src/services/governance_km_stale_review_service.py +++ b/apps/api/src/services/governance_km_stale_review_service.py @@ -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, diff --git a/apps/api/src/services/governance_query_service.py b/apps/api/src/services/governance_query_service.py index 3fde138b..344975b0 100644 --- a/apps/api/src/services/governance_query_service.py +++ b/apps/api/src/services/governance_query_service.py @@ -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, diff --git a/apps/api/tests/test_ai_governance_endpoints.py b/apps/api/tests/test_ai_governance_endpoints.py index d274a693..c9da08c6 100644 --- a/apps/api/tests/test_ai_governance_endpoints.py +++ b/apps/api/tests/test_ai_governance_endpoints.py @@ -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( diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 51527b58..76b97ebd 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -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", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index e84d48bb..74bf24a7 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -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 審核後封存重複草稿", diff --git a/apps/web/src/app/[locale]/awooop/work-items/page.tsx b/apps/web/src/app/[locale]/awooop/work-items/page.tsx index ccad82bc..ccdcc2c1 100644 --- a/apps/web/src/app/[locale]/awooop/work-items/page.tsx +++ b/apps/web/src/app/[locale]/awooop/work-items/page.tsx @@ -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>({}); const [staleReviewActions, setStaleReviewActions] = useState>({}); + const [staleReviewCompletionActions, setStaleReviewCompletionActions] = + useState>({}); 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( + `${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( + `${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 (
@@ -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 (
+ {queuedDispatchId ? ( +

+ {t("staleCandidates.ownerReviewState", { + status: t(`statuses.${persistedReviewStatusKey}` as never), + stage: t(`stages.${persistedReviewStageKey}` as never), + dispatch: queuedDispatchId, + })} +

+ ) : null}
{candidate.reasons.slice(0, 5).map((reason) => ( @@ -2469,6 +2663,37 @@ function KnowledgeGovernancePanel({ ? t("staleCandidates.queueingReview") : t("staleCandidates.queueReview")} + + ) : null} + {completionAction?.error ? ( +

+ {completionAction.error} +

+ ) : null} + {completionPreview ? ( +
+

+ {t( + `staleCandidates.completeActions.statuses.${completionPreviewStatusKey}` as never + )} +

+

+ {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), + })} +

+

+ {t("staleCandidates.completeActions.planFingerprint", { + fingerprint: completionPreview.dry_run_plan_fingerprint ?? "--", + })} +

+ {completionPreview.stale_ratio_snapshot ? ( +

+ {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), + })} +

+ ) : null} +
+ ) : null} + {completionResult ? ( +
+

+ {t( + `staleCandidates.completeActions.statuses.${completionResultStatusKey}` as never + )} +

+

+ {t("staleCandidates.completeActions.result", { + audit: completionResult.audit_dispatch_id ?? "--", + recheck: completionResult.stale_ratio_recheck_dispatch_id ?? "--", + })} +

+ {completionResult.stale_ratio_snapshot ? ( +

+ {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), + })} +

+ ) : null} +
+ ) : null} ); })}