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
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:
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
@@ -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
|
||||
# =============================================================================
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 審核後封存重複草稿",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
})}
|
||||
|
||||
Reference in New Issue
Block a user