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

1979 lines
78 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# apps/api/tests/test_ai_governance_endpoints.py | 2026-05-02 @ Asia/Taipei
"""
Unit Tests — AI Governance Endpoints (PR 1)
覆蓋範圍:
1. events endpoint 分頁邏輯正確
2. events endpoint severity 映射正確critical / warning / info
3. queue endpoint graceful fallbackmock ProgrammingError
4. summary endpoint compliance_rate 計算(含 total=0 邊界)
5. summary endpoint compliance_rate 計算(有 unresolved 的正常情況)
測試策略mock service 層函式,不依賴 DB確保 Router 邏輯正確。
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from types import SimpleNamespace
from unittest.mock import AsyncMock, patch
import pytest
from fastapi import FastAPI
from fastapi.testclient import TestClient
from src.api.v1.ai_governance import router
from src.db.models import GovernanceRemediationDispatch, KnowledgeEntryRecord
from src.models.governance import (
DailyCount,
DispatchItem,
GovernanceEvent,
GovernanceEventsResponse,
GovernanceQueueResponse,
GovernanceSummaryResponse,
KnowledgeReviewDraftArchiveRequest,
KnowledgeReviewDraftArchiveResponse,
KnowledgeReviewDraftDedupeGroup,
KnowledgeReviewDraftDedupeResponse,
KnowledgeReviewDraftStaleRatioSnapshot,
KnowledgeStaleCandidate,
KnowledgeStaleCandidatesResponse,
KnowledgeStaleOwnerReviewBatchItem,
KnowledgeStaleOwnerReviewBatchQueueRequest,
KnowledgeStaleOwnerReviewBatchQueueResponse,
KnowledgeStaleOwnerReviewBurnDownItem,
KnowledgeStaleOwnerReviewBurnDownResponse,
KnowledgeStaleOwnerReviewCompleteRequest,
KnowledgeStaleOwnerReviewCompleteResponse,
KnowledgeStaleOwnerReviewCompletionBatchPreviewRequest,
KnowledgeStaleOwnerReviewCompletionBatchPreviewResponse,
KnowledgeStaleOwnerReviewCompletionQueueItem,
KnowledgeStaleOwnerReviewCompletionQueueResponse,
KnowledgeStaleOwnerReviewInboxItem,
KnowledgeStaleOwnerReviewInboxResponse,
KnowledgeStaleOwnerReviewRequest,
KnowledgeStaleOwnerReviewResponse,
map_severity,
)
from src.models.knowledge import EntrySource, EntryStatus, EntryType
from src.services.governance_km_review_service import (
KmReviewDraftArchiveError,
_build_dry_run_plan_fingerprint,
_build_stale_ratio_recheck_context,
_validate_archive_request_against_plan,
_validate_dry_run_plan_fingerprint,
)
from src.services.governance_km_stale_review_service import (
KmStaleOwnerReviewError,
_build_batch_owner_review_decision_context,
_build_batch_queue_plan_fingerprint,
_build_completion_batch_preview_fingerprint,
_build_completion_plan_fingerprint,
_build_completion_queue_item,
_build_owner_review_burndown_items,
_build_owner_review_completion_audit_context,
_build_owner_review_inbox_item,
_build_stale_owner_review_decision_context,
_completion_stage_for_outcome,
_entries_to_stale_threshold,
)
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,
_build_km_stale_candidate,
_extract_archived_count,
_extract_dry_run_plan_fingerprint,
_extract_governance_event_id_from_tags,
_extract_kb_draft_entry_id,
_extract_remediation,
_extract_stale_ratio_snapshot,
_extract_worker_status,
_merge_dispatch_ids,
_query_dispatch_table,
_to_governance_event,
)
TAIPEI = timezone(timedelta(hours=8))
NOW = datetime(2026, 5, 2, 12, 0, tzinfo=TAIPEI)
# =============================================================================
# Fixture
# =============================================================================
@pytest.fixture
def client():
app = FastAPI()
app.include_router(router, prefix="/api/v1")
return TestClient(app)
def _make_event(
event_id: str = "evt-001",
event_type: str = "slo_violation",
resolved: bool = False,
) -> GovernanceEvent:
return GovernanceEvent(
id=event_id,
event_type=event_type,
severity=map_severity(event_type),
triggered_at=NOW,
resolved=resolved,
resolved_at=None,
impact="SLO violated",
details={"message": "test"},
remediation=None,
dispatch_ids=[],
)
# =============================================================================
# 1. severity 映射單元測試
# =============================================================================
class TestSeverityMapping:
def test_critical_types(self):
for et in ("slo_violation", "conservative_mode", "governance_slo_data_gap"):
assert map_severity(et) == "critical", f"{et} should be critical"
def test_warning_types(self):
for et in ("trust_drift", "kb_stale", "knowledge_degradation", "execution_blast_radius"):
assert map_severity(et) == "warning", f"{et} should be warning"
def test_info_types(self):
for et in ("replay_degraded", "self_demotion", "llm_hallucination", "unknown_event"):
assert map_severity(et) == "info", f"{et} should be info"
# =============================================================================
# 2. events endpoint 分頁
# =============================================================================
class TestEventsEndpoint:
def test_pagination_default(self, client):
"""page=1 size=20 預設分頁正確."""
fake_response = GovernanceEventsResponse(
items=[_make_event(str(i)) for i in range(5)],
total=5,
page=1,
size=20,
)
with patch(
"src.api.v1.ai_governance.query_governance_events",
new_callable=lambda: lambda **kw: None,
):
with patch(
"src.api.v1.ai_governance.query_governance_events",
new=AsyncMock(return_value=fake_response),
):
r = client.get("/api/v1/ai/governance/events")
assert r.status_code == 200
data = r.json()
assert data["total"] == 5
assert data["page"] == 1
assert data["size"] == 20
assert len(data["items"]) == 5
def test_pagination_custom(self, client):
"""自訂分頁參數傳入 service."""
fake_response = GovernanceEventsResponse(
items=[_make_event()],
total=50,
page=3,
size=10,
)
captured: dict = {}
async def mock_query(**kwargs):
captured.update(kwargs)
return fake_response
with patch("src.api.v1.ai_governance.query_governance_events", new=mock_query):
r = client.get("/api/v1/ai/governance/events?page=3&size=10")
assert r.status_code == 200
assert captured["page"] == 3
assert captured["size"] == 10
data = r.json()
assert data["total"] == 50
def test_severity_filter_passed(self, client):
"""severity query param 正確傳入 service."""
fake_response = GovernanceEventsResponse(items=[], total=0, page=1, size=20)
captured: dict = {}
async def mock_query(**kwargs):
captured.update(kwargs)
return fake_response
with patch("src.api.v1.ai_governance.query_governance_events", new=mock_query):
r = client.get("/api/v1/ai/governance/events?severity=critical")
assert r.status_code == 200
assert captured["severity"] == "critical"
def test_event_id_filter_passed(self, client):
"""event_id query param 供 Telegram 詳情 / 歷史精準回看."""
fake_response = GovernanceEventsResponse(items=[], total=0, page=1, size=20)
captured: dict = {}
async def mock_query(**kwargs):
captured.update(kwargs)
return fake_response
with patch("src.api.v1.ai_governance.query_governance_events", new=mock_query):
r = client.get(
"/api/v1/ai/governance/events"
"?event_id=evt-001&event_id=evt-002&status=unresolved"
)
assert r.status_code == 200
assert captured["event_ids"] == ["evt-001", "evt-002"]
assert captured["status"] == "unresolved"
def test_invalid_severity_rejected(self, client):
"""非法 severity 值應被拒絕422."""
r = client.get("/api/v1/ai/governance/events?severity=bad_value")
assert r.status_code == 422
def test_invalid_status_rejected(self, client):
"""非法 status 值應被拒絕422."""
r = client.get("/api/v1/ai/governance/events?status=invalid")
assert r.status_code == 422
def test_severity_in_response(self, client):
"""回傳的事件 severity 欄位對應 event_type 映射."""
events = [
_make_event("e1", "slo_violation"), # critical
_make_event("e2", "trust_drift"), # warning
_make_event("e3", "self_demotion"), # info
]
fake_response = GovernanceEventsResponse(items=events, total=3, page=1, size=20)
with patch(
"src.api.v1.ai_governance.query_governance_events",
new=AsyncMock(return_value=fake_response),
):
r = client.get("/api/v1/ai/governance/events")
assert r.status_code == 200
items = r.json()["items"]
assert items[0]["severity"] == "critical"
assert items[1]["severity"] == "warning"
assert items[2]["severity"] == "info"
class TestEventsReadSideNormalization:
def test_remediation_dict_is_normalized_to_string(self):
"""production details.remediation 可能是 dictresponse schema 必須仍回字串."""
remediation = _extract_remediation({
"remediation": {
"items": [
"補齊 ADR-100 SLO emitter",
"設置 PROMETHEUS_MULTIPROC_DIR",
]
}
})
assert remediation == "補齊 ADR-100 SLO emitter設置 PROMETHEUS_MULTIPROC_DIR"
def test_governance_event_accepts_dict_remediation(self):
"""dict remediation 不應讓 GovernanceEvent Pydantic validation 變成 500."""
row = type("Row", (), {
"id": "evt-001",
"event_type": "governance_slo_data_gap",
"triggered_at": NOW,
"resolved": False,
"resolved_at": None,
"details": {
"message": "SLO metrics missing",
"remediation": {"items": ["補齊 SLO emitter"]},
},
})()
event = _to_governance_event(row)
assert event.remediation == "補齊 SLO emitter"
assert event.impact == "SLO metrics missing"
def test_governance_event_uses_db_dispatch_ids_first(self):
"""events read model 應以 dispatch table ids 補齊 detail/history 鏈路。"""
row = type("Row", (), {
"id": "evt-001",
"event_type": "knowledge_degradation",
"triggered_at": NOW,
"resolved": False,
"resolved_at": None,
"details": {
"message": "KM stale",
"dispatch_ids": ["legacy-dispatch"],
},
})()
event = _to_governance_event(row, dispatch_ids=["db-dispatch", "legacy-dispatch"])
assert event.dispatch_ids == ["db-dispatch", "legacy-dispatch"]
def test_merge_dispatch_ids_deduplicates_and_preserves_db_priority(self):
"""DB truth-firstlegacy payload 只作 fallback。"""
assert _merge_dispatch_ids(
["db-2", "db-1"],
["db-1", "legacy-1", None],
) == ["db-2", "db-1", "legacy-1"]
def test_events_query_joins_dispatch_table_for_history_buttons(self):
"""events endpoint 不可只讀 details.dispatch_ids必須查 dispatch table。"""
import inspect
from src.services import governance_query_service
source = inspect.getsource(governance_query_service._load_dispatch_ids_for_events)
assert "FROM governance_remediation_dispatch d" in source
assert "d.governance_event_id IN :event_ids" in source
assert "ORDER BY d.dispatched_at DESC" in source
# =============================================================================
# 3. queue endpoint graceful fallback
# =============================================================================
class TestQueueEndpoint:
def test_graceful_fallback_on_programming_error(self, client):
"""dispatch 表不存在時回 table_pending=true不拋 500."""
fallback = GovernanceQueueResponse(
items=[], total=0, page=1, size=10, table_pending=True,
)
with patch(
"src.api.v1.ai_governance.query_governance_queue",
new=AsyncMock(return_value=fallback),
):
r = client.get("/api/v1/ai/governance/queue")
assert r.status_code == 200
data = r.json()
assert data["table_pending"] is True
assert data["items"] == []
assert data["total"] == 0
def test_normal_response_when_table_ready(self, client):
"""表就緒時正常回傳 items."""
dispatch_item = DispatchItem(
id="d-001",
governance_event_id="evt-001",
event_type="slo_violation",
dispatch_status="pending",
proposed_action="restart deployment",
playbook_id=None,
playbook_trust=None,
created_at=NOW,
dispatched_at=None,
completed_at=None,
operator_note=None,
)
normal = GovernanceQueueResponse(
items=[dispatch_item], total=1, page=1, size=10, table_pending=False,
)
with patch(
"src.api.v1.ai_governance.query_governance_queue",
new=AsyncMock(return_value=normal),
):
r = client.get("/api/v1/ai/governance/queue")
assert r.status_code == 200
data = r.json()
assert data["table_pending"] is False
assert len(data["items"]) == 1
assert data["items"][0]["dispatch_status"] == "pending"
def test_invalid_dispatch_status_rejected(self, client):
"""非法 dispatch_status 應被拒絕422."""
r = client.get("/api/v1/ai/governance/queue?dispatch_status=unknown")
assert r.status_code == 422
def test_all_status_and_event_type_filter_passed(self, client):
"""Work Items 需要 all + event_type 查 KM healthcheck 全狀態鏈。"""
normal = GovernanceQueueResponse(
items=[], total=0, page=1, size=20, table_pending=False,
)
captured: dict = {}
async def mock_query(**kwargs):
captured.update(kwargs)
return normal
with patch("src.api.v1.ai_governance.query_governance_queue", new=mock_query):
r = client.get(
"/api/v1/ai/governance/queue?dispatch_status=all"
"&event_type=knowledge_degradation&size=20"
)
assert r.status_code == 200
assert captured["dispatch_status"] == "all"
assert captured["event_types"] == ["knowledge_degradation"]
assert captured["size"] == 20
def test_queue_item_exposes_kb_draft_review_context(self, client):
"""Work Items 需要看到 Hermes 草稿 ID 與 worker 狀態,而不只 dispatch succeeded。"""
item = DispatchItem(
id="dispatch-001",
governance_event_id="event-001",
event_type="knowledge_degradation",
dispatch_status="succeeded",
executor_type="hermes_kb_growth_healthcheck",
proposed_action="Hermes 已建立 KM 更新草稿,等待 owner 審核",
created_at=NOW,
workflow_stage="waiting_owner_review",
next_action="owner_review_km_draft",
lead_agent="Hermes",
human_owner="KM owner / SRE owner",
kb_draft_entry_id="km-001",
worker_status="draft_created",
)
normal = GovernanceQueueResponse(
items=[item], total=1, page=1, size=20, table_pending=False,
)
with patch(
"src.api.v1.ai_governance.query_governance_queue",
new=AsyncMock(return_value=normal),
):
r = client.get(
"/api/v1/ai/governance/queue?dispatch_status=all"
"&event_type=knowledge_degradation&size=20"
)
assert r.status_code == 200
data = r.json()
assert data["items"][0]["kb_draft_entry_id"] == "km-001"
assert data["items"][0]["worker_status"] == "draft_created"
def test_extract_kb_draft_review_context_from_decision_context(self):
"""queue read model 從 workflow / worker_result 補出 KM 草稿追蹤欄位。"""
context = {
"workflow": {"kb_draft_entry_id": "km-workflow"},
"worker_result": {
"km_draft_entry_id": "km-worker",
"status": "draft_created",
},
}
assert _extract_kb_draft_entry_id(context) == "km-workflow"
assert _extract_worker_status(context) == "draft_created"
def test_extract_km_archive_audit_context_from_decision_context(self):
"""queue read model 應把 archive audit / stale ratio recheck 證據交給 Work Items。"""
context = {
"archived_count": 5,
"dry_run_plan_fingerprint": "sha256:" + "a" * 64,
"stale_ratio_snapshot": {
"stale_count": 120,
"total_count": 200,
"stale_ratio": 0.6,
"threshold": 0.2,
"stale_days": 7,
"extra": "ignored",
},
"workflow": {
"archived_entry_ids": ["fallback"],
"dry_run_plan_fingerprint": "sha256:" + "b" * 64,
},
}
assert _extract_dry_run_plan_fingerprint(context) == "sha256:" + "a" * 64
assert _extract_archived_count(context) == 5
snapshot = _extract_stale_ratio_snapshot(context)
assert snapshot == {
"stale_count": 120,
"total_count": 200,
"stale_ratio": 0.6,
"threshold": 0.2,
"stale_days": 7,
}
def test_queue_query_uses_production_dispatch_schema(self):
"""queue 查詢必須對齊 migration schema使用 dispatched_at不讀不存在的 created_at/operator_note."""
import inspect
source = inspect.getsource(_query_dispatch_table)
assert "d.dispatched_at AS created_at" in source
assert "ORDER BY d.dispatched_at DESC" in source
assert "NULL::text AS operator_note" in source
assert "CAST(:dispatch_status AS governance_dispatch_status)" in source
assert "e.event_type::text IN :event_types" in source
assert "d.created_at" not in source
assert "d.operator_note" not in source
class TestKmReviewDraftDedupe:
def test_endpoint_passes_limit_and_returns_owner_review_plan(self, client):
"""Work Items 需要 read-only canonical/duplicate plan不直接封存 KM。"""
fake = KnowledgeReviewDraftDedupeResponse(
total_review_drafts=3,
event_group_total=1,
duplicate_draft_total=2,
generated_at=NOW,
groups=[
KnowledgeReviewDraftDedupeGroup(
governance_event_id="event-001",
canonical_entry_id="km-canonical",
canonical_title="KM healthcheck review draft - event",
preferred_source="dispatch_context",
duplicate_entry_ids=["km-dup-1", "km-dup-2"],
duplicate_count=2,
total_entries=3,
suggested_action="owner_review_canonical_then_archive_duplicates",
owner_action="review_canonical_and_archive_duplicate_drafts",
writes_on_read=False,
can_archive_without_owner_approval=False,
archive_history=[
DispatchItem(
id="dispatch-archive-001",
governance_event_id="event-001",
event_type="knowledge_degradation",
dispatch_status="succeeded",
executor_type="hermes_km_review_dedupe_owner_archive",
proposed_action="Owner archived duplicate KM drafts",
created_at=NOW,
workflow_stage="km_duplicate_archive_after_owner_approval",
dry_run_plan_fingerprint="sha256:" + "a" * 64,
archived_count=2,
)
],
)
],
)
captured: dict = {}
async def mock_query(**kwargs):
captured.update(kwargs)
return fake
with patch("src.api.v1.ai_governance.query_km_review_draft_dedupe", new=mock_query):
r = client.get("/api/v1/ai/governance/km-review-drafts/dedupe?limit=50")
assert r.status_code == 200
assert captured["limit"] == 50
data = r.json()
assert data["duplicate_draft_total"] == 2
assert data["groups"][0]["canonical_entry_id"] == "km-canonical"
assert data["groups"][0]["writes_on_read"] is False
assert data["groups"][0]["can_archive_without_owner_approval"] is False
assert data["groups"][0]["archive_history"][0]["executor_type"] == (
"hermes_km_review_dedupe_owner_archive"
)
assert data["groups"][0]["archive_history"][0]["archived_count"] == 2
def test_governance_event_tag_extraction(self):
assert _extract_governance_event_id_from_tags([
"agent:Hermes",
"governance_event:event-001",
]) == "event-001"
assert _extract_governance_event_id_from_tags(["agent:Hermes"]) is None
def test_build_dedupe_groups_prefers_dispatch_context_draft(self):
"""若 dispatch context 指出 canonical draftdedupe plan 應以它為準。"""
rows = [
{
"id": "km-latest",
"title": "latest draft",
"tags": ["governance_event:event-001"],
"updated_at": datetime(2026, 5, 2, 14, 0, tzinfo=TAIPEI),
},
{
"id": "km-canonical",
"title": "canonical draft",
"tags": ["governance_event:event-001"],
"updated_at": datetime(2026, 5, 2, 13, 0, tzinfo=TAIPEI),
},
{
"id": "km-other",
"title": "other event draft",
"tags": ["governance_event:event-002"],
"updated_at": datetime(2026, 5, 2, 12, 0, tzinfo=TAIPEI),
},
]
groups = _build_km_review_draft_dedupe_groups(
rows,
{"event-001": "km-canonical"},
{
"event-001": [
DispatchItem(
id="dispatch-recheck-001",
governance_event_id="event-001",
event_type="knowledge_degradation",
dispatch_status="succeeded",
executor_type="hermes_km_stale_ratio_recheck",
proposed_action="Rechecked stale ratio",
created_at=NOW,
workflow_stage="stale_ratio_recheck",
stale_ratio_snapshot={
"stale_count": 10,
"total_count": 100,
"stale_ratio": 0.1,
"threshold": 0.2,
"stale_days": 7,
},
)
]
},
)
first = groups[0]
assert first.governance_event_id == "event-001"
assert first.canonical_entry_id == "km-canonical"
assert first.preferred_source == "dispatch_context"
assert first.duplicate_entry_ids == ["km-latest"]
assert first.writes_on_read is False
assert first.archive_history[0].executor_type == "hermes_km_stale_ratio_recheck"
assert first.archive_history[0].stale_ratio_snapshot["stale_ratio"] == pytest.approx(0.1)
def test_km_stale_candidates_endpoint_returns_read_only_priority_queue(self, client):
"""stale KM endpoint 應回傳 owner 可排序處理的 read-only 清單。"""
fake = KnowledgeStaleCandidatesResponse(
project_id="awoooi",
total_stale=1490,
returned=1,
threshold_days=7,
items=[
KnowledgeStaleCandidate(
entry_id="km-001",
project_id="awoooi",
title="Sentry / SigNoz checkout repair runbook",
entry_type="auto_runbook",
category="AI系統",
status="review",
source="ai_extracted",
updated_at=NOW - timedelta(days=21),
stale_days=21,
view_count=9,
priority_score=265,
priority_tier="P0",
recommended_action="refresh_with_evidence",
reasons=[
"linked_incident",
"linked_playbook",
"sentry_context",
"signoz_context",
],
correlation_sources=["incident", "playbook", "sentry", "signoz"],
related_incident_id="INC-20260513-79ED5E",
related_playbook_id="pb:auto-repair-canary",
tags=["sentry", "signoz"],
)
],
generated_at=NOW,
)
captured: dict = {}
async def mock_query(**kwargs):
captured.update(kwargs)
return fake
with patch("src.api.v1.ai_governance.query_km_stale_candidates", new=mock_query):
r = client.get(
"/api/v1/ai/governance/km-stale-candidates"
"?project_id=awoooi&limit=25"
)
assert r.status_code == 200
assert captured == {"project_id": "awoooi", "limit": 25}
data = r.json()
assert data["writes_on_read"] is False
assert data["manual_review_required"] is True
assert data["total_stale"] == 1490
assert data["items"][0]["priority_tier"] == "P0"
assert data["items"][0]["correlation_sources"] == [
"incident",
"playbook",
"sentry",
"signoz",
]
def test_build_km_stale_candidate_prioritizes_linked_evidence(self):
"""有 Incident / PlayBook / Sentry / SigNoz 脈絡的 stale KM 應排前面。"""
record = KnowledgeEntryRecord(
id="km-001",
project_id="awoooi",
title="Sentry checkout failure repair",
content="Use SigNoz trace and PlayBook verification before KM writeback.",
entry_type=EntryType.AUTO_RUNBOOK,
category="AI系統",
tags=["sentry", "signoz", "workflow:kb_growth_healthcheck"],
source=EntrySource.AI_EXTRACTED,
status=EntryStatus.REVIEW,
related_incident_id="INC-20260513-79ED5E",
related_playbook_id="pb:auto-repair-canary",
view_count=7,
updated_at=NOW - timedelta(days=35),
)
candidate = _build_km_stale_candidate(record, now=NOW, threshold_days=7)
assert candidate.priority_tier == "P0"
assert candidate.recommended_action == "refresh_with_evidence"
assert candidate.stale_days == 35
assert candidate.correlation_sources == ["incident", "playbook", "sentry", "signoz"]
assert "linked_incident" in candidate.reasons
assert "linked_playbook" in candidate.reasons
assert "sentry_context" in candidate.reasons
assert "signoz_context" in candidate.reasons
def test_queue_stale_candidate_endpoint_returns_owner_review_dispatch(self, client):
"""單筆 stale KM 可以排入 owner review但不直接改寫 KM。"""
fake = KnowledgeStaleOwnerReviewResponse(
entry_id="km-001",
project_id="awoooi",
status="queued",
governance_event_id="event-001",
dispatch_id="dispatch-001",
workflow_stage="waiting_owner_review",
recommended_action="refresh_with_evidence",
owner="operator_console",
owner_note="prioritize P0",
writes_km=False,
writes_governance_audit=True,
generated_at=NOW,
)
captured: dict = {}
async def mock_queue(**kwargs):
captured.update(kwargs)
return fake
with patch(
"src.api.v1.ai_governance.queue_km_stale_owner_review",
new=mock_queue,
):
r = client.post(
"/api/v1/ai/governance/km-stale-candidates/km-001/queue-review",
json={
"owner": "operator_console",
"owner_note": "prioritize P0",
"dry_run": False,
},
)
assert r.status_code == 200
assert captured["entry_id"] == "km-001"
assert isinstance(captured["request"], KnowledgeStaleOwnerReviewRequest)
data = r.json()
assert data["schema_version"] == "km_stale_owner_review_v1"
assert data["status"] == "queued"
assert data["dispatch_id"] == "dispatch-001"
assert data["workflow_stage"] == "waiting_owner_review"
assert data["writes_km"] is False
assert data["writes_governance_audit"] is True
def test_queue_stale_candidate_endpoint_maps_validation_error(self, client):
async def mock_queue(**kwargs):
raise KmStaleOwnerReviewError(409, "KM entry is no longer past the stale threshold")
with patch(
"src.api.v1.ai_governance.queue_km_stale_owner_review",
new=mock_queue,
):
r = client.post(
"/api/v1/ai/governance/km-stale-candidates/km-001/queue-review",
json={"owner": "operator_console"},
)
assert r.status_code == 409
assert r.json()["detail"] == "KM entry is no longer past the stale threshold"
def test_batch_queue_stale_candidate_endpoint_returns_batch_dispatch(self, client):
"""P0/P1 stale KM batch queue 應只寫治理 dispatch不改寫 KM。"""
fake = KnowledgeStaleOwnerReviewBatchQueueResponse(
project_id="awoooi",
status="queued",
owner="operator_console",
dry_run=False,
priority_tiers=["P0", "P1"],
requested_limit=10,
candidate_count=2,
queued_count=1,
already_queued_count=1,
skipped_count=0,
batch_governance_event_id="event-batch-001",
batch_dispatch_id="dispatch-batch-001",
workflow_stage="batch_owner_review_queued",
writes_km=False,
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:" + "b" * 64,
items=[
KnowledgeStaleOwnerReviewBatchItem(
entry_id="km-001",
title="Sentry checkout failure repair",
priority_tier="P0",
recommended_action="refresh_with_evidence",
status="queued",
governance_event_id="event-001",
dispatch_id="dispatch-001",
workflow_stage="waiting_owner_review",
),
KnowledgeStaleOwnerReviewBatchItem(
entry_id="km-002",
title="Already queued",
priority_tier="P1",
recommended_action="owner_review",
status="already_queued",
reason="active_owner_review_exists",
dispatch_id="dispatch-002",
workflow_stage="waiting_owner_review",
),
],
generated_at=NOW,
)
captured: dict = {}
async def mock_batch_queue(**kwargs):
captured.update(kwargs)
return fake
with patch(
"src.api.v1.ai_governance.batch_queue_km_stale_owner_reviews",
new=mock_batch_queue,
):
r = client.post(
"/api/v1/ai/governance/km-stale-candidates/batch-queue-review",
json={
"project_id": "awoooi",
"priority_tiers": ["P0", "P1"],
"limit": 10,
"owner": "operator_console",
"dry_run": False,
"dry_run_plan_fingerprint": "sha256:" + "b" * 64,
},
)
assert r.status_code == 200
assert isinstance(captured["request"], KnowledgeStaleOwnerReviewBatchQueueRequest)
data = r.json()
assert data["schema_version"] == "km_stale_owner_review_batch_v1"
assert data["status"] == "queued"
assert data["workflow_stage"] == "batch_owner_review_queued"
assert data["writes_km"] is False
assert data["writes_governance_audit"] is True
assert data["batch_dispatch_id"] == "dispatch-batch-001"
assert data["items"][0]["status"] == "queued"
assert data["items"][1]["status"] == "already_queued"
def test_batch_queue_stale_candidate_endpoint_maps_validation_error(self, client):
async def mock_batch_queue(**kwargs):
raise KmStaleOwnerReviewError(
409,
"dry_run_plan_fingerprint does not match the latest stale KM batch queue plan",
)
with patch(
"src.api.v1.ai_governance.batch_queue_km_stale_owner_reviews",
new=mock_batch_queue,
):
r = client.post(
"/api/v1/ai/governance/km-stale-candidates/batch-queue-review",
json={
"project_id": "awoooi",
"limit": 10,
"dry_run": False,
"dry_run_plan_fingerprint": "sha256:" + "c" * 64,
},
)
assert r.status_code == 409
assert "batch queue plan" in r.json()["detail"]
def test_owner_review_inbox_endpoint_returns_sorted_work_items(self, client):
"""Owner-review inbox 應回傳 pending dispatch 與 KM priority context。"""
fake = KnowledgeStaleOwnerReviewInboxResponse(
project_id="awoooi",
dispatch_status="pending",
total=1,
returned=1,
items=[
KnowledgeStaleOwnerReviewInboxItem(
dispatch_id="dispatch-001",
governance_event_id="event-001",
entry_id="km-001",
project_id="awoooi",
title="Sentry checkout failure repair",
dispatch_status="pending",
workflow_stage="waiting_owner_review",
next_action="owner_review_stale_km_candidate",
owner="operator_console",
owner_note="p0_p1_stale_km_batch",
batch_governance_event_id="event-batch-001",
batch_dispatch_id="dispatch-batch-001",
priority_tier="P0",
priority_score=265,
recommended_action="refresh_with_evidence",
stale_days=35,
view_count=7,
correlation_sources=["incident", "playbook", "sentry"],
reasons=["linked_incident", "linked_playbook"],
related_incident_id="INC-20260513-79ED5E",
related_playbook_id="pb:auto-repair-canary",
queued_at=NOW,
)
],
generated_at=NOW,
)
captured: dict = {}
async def mock_inbox(**kwargs):
captured.update(kwargs)
return fake
with patch(
"src.api.v1.ai_governance.query_km_stale_owner_review_inbox",
new=mock_inbox,
):
r = client.get(
"/api/v1/ai/governance/km-stale-owner-reviews"
"?project_id=awoooi&dispatch_status=pending&limit=20"
)
assert r.status_code == 200
assert captured == {
"project_id": "awoooi",
"dispatch_status": "pending",
"limit": 20,
}
data = r.json()
assert data["schema_version"] == "km_stale_owner_review_inbox_v1"
assert data["total"] == 1
assert data["writes_on_read"] is False
assert data["manual_review_required"] is True
assert data["items"][0]["dispatch_id"] == "dispatch-001"
assert data["items"][0]["workflow_stage"] == "waiting_owner_review"
assert data["items"][0]["batch_dispatch_id"] == "dispatch-batch-001"
def test_owner_review_inbox_context_keeps_batch_and_priority_visible(self):
record = KnowledgeEntryRecord(
id="km-001",
project_id="awoooi",
title="Sentry checkout failure repair",
content="Use Sentry and SigNoz evidence before writeback.",
entry_type=EntryType.AUTO_RUNBOOK,
category="AI系統",
tags=["sentry", "signoz"],
source=EntrySource.AI_EXTRACTED,
status=EntryStatus.REVIEW,
related_incident_id="INC-20260513-79ED5E",
related_playbook_id="pb:auto-repair-canary",
view_count=7,
updated_at=NOW - timedelta(days=35),
)
candidate = _build_km_stale_candidate(record, now=NOW, threshold_days=7)
row = type("Row", (), {
"id": "dispatch-001",
"governance_event_id": "event-001",
"dispatch_status": "pending",
"dispatched_at": NOW,
"started_at": None,
"completed_at": None,
})()
ctx = {
"owner": "operator_console",
"owner_note": "p0_p1_stale_km_batch",
"next_action": "owner_review_stale_km_candidate",
"workflow": {
"entry_id": "km-001",
"project_id": "awoooi",
"batch_governance_event_id": "event-batch-001",
"batch_dispatch_id": "dispatch-batch-001",
"current_stage": "waiting_owner_review",
"stage_by_dispatch_status": {"pending": "waiting_owner_review"},
},
}
item = _build_owner_review_inbox_item(
row=row,
candidate=candidate,
decision_context=ctx,
)
assert item.dispatch_id == "dispatch-001"
assert item.batch_dispatch_id == "dispatch-batch-001"
assert item.priority_tier == "P0"
assert item.priority_score > 200
assert item.workflow_stage == "waiting_owner_review"
assert item.recommended_action == "refresh_with_evidence"
assert item.correlation_sources == ["incident", "playbook", "sentry", "signoz"]
def test_owner_review_burndown_endpoint_returns_completion_progress(self, client):
"""Burn-down endpoint 應把 pending、completion audit 與 recheck snapshot 集中呈現。"""
fake = KnowledgeStaleOwnerReviewBurnDownResponse(
project_id="awoooi",
burn_down_status="above_threshold",
current_snapshot=KnowledgeReviewDraftStaleRatioSnapshot(
stale_count=118,
total_count=200,
stale_ratio=0.59,
threshold=0.2,
stale_days=7,
),
entries_to_threshold=78,
pending_owner_reviews=9,
completed_owner_reviews=1,
completion_audit_total=1,
stale_ratio_recheck_total=1,
latest_stale_count_delta=-1,
latest_stale_ratio_delta=-0.005,
returned=1,
items=[
KnowledgeStaleOwnerReviewBurnDownItem(
completion_dispatch_id="dispatch-audit-001",
governance_event_id="event-001",
source_dispatch_id="dispatch-001",
recheck_dispatch_id="dispatch-recheck-001",
entry_id="km-001",
project_id="awoooi",
dispatch_status="succeeded",
workflow_stage="km_writeback_after_approval",
review_outcome="refresh_with_evidence",
owner="operator_console",
completed_at=NOW,
stale_ratio_snapshot=KnowledgeReviewDraftStaleRatioSnapshot(
stale_count=118,
total_count=200,
stale_ratio=0.59,
threshold=0.2,
stale_days=7,
),
stale_count_delta=-1,
stale_ratio_delta=-0.005,
above_threshold=True,
)
],
generated_at=NOW,
)
captured: dict = {}
async def mock_burndown(**kwargs):
captured.update(kwargs)
return fake
with patch(
"src.api.v1.ai_governance.query_km_stale_owner_review_burndown",
new=mock_burndown,
):
r = client.get(
"/api/v1/ai/governance/km-stale-owner-review-burndown"
"?project_id=awoooi&limit=12"
)
assert r.status_code == 200
assert captured == {"project_id": "awoooi", "limit": 12}
data = r.json()
assert data["schema_version"] == "km_stale_owner_review_burndown_v1"
assert data["burn_down_status"] == "above_threshold"
assert data["writes_on_read"] is False
assert data["manual_review_required"] is True
assert data["pending_owner_reviews"] == 9
assert data["completion_audit_total"] == 1
assert data["stale_ratio_recheck_total"] == 1
assert data["latest_stale_count_delta"] == -1
assert data["items"][0]["recheck_dispatch_id"] == "dispatch-recheck-001"
def test_owner_review_burndown_items_compute_chronological_delta(self):
older_snapshot = {
"stale_count": 119,
"total_count": 200,
"stale_ratio": 0.595,
"threshold": 0.2,
"stale_days": 7,
}
newer_snapshot = {
"stale_count": 118,
"total_count": 200,
"stale_ratio": 0.59,
"threshold": 0.2,
"stale_days": 7,
}
rows = [
SimpleNamespace(
id="dispatch-audit-new",
governance_event_id="event-001",
dispatch_status="succeeded",
completed_at=NOW,
decision_context={
"project_id": "awoooi",
"owner": "operator_console",
"review_outcome": "refresh_with_evidence",
"workflow": {
"project_id": "awoooi",
"entry_id": "km-002",
"source_dispatch_id": "dispatch-002",
"current_stage": "km_writeback_after_approval",
"stale_ratio_snapshot": newer_snapshot,
},
"stale_ratio_recheck": {"dispatch_id": "dispatch-recheck-new"},
},
),
SimpleNamespace(
id="dispatch-audit-old",
governance_event_id="event-001",
dispatch_status="succeeded",
completed_at=NOW - timedelta(minutes=10),
decision_context={
"project_id": "awoooi",
"owner": "operator_console",
"review_outcome": "refresh_with_evidence",
"workflow": {
"project_id": "awoooi",
"entry_id": "km-001",
"source_dispatch_id": "dispatch-001",
"current_stage": "km_writeback_after_approval",
"stale_ratio_snapshot": older_snapshot,
},
"stale_ratio_recheck": {"dispatch_id": "dispatch-recheck-old"},
},
),
]
items = _build_owner_review_burndown_items(rows, project_id="awoooi")
assert items[0].completion_dispatch_id == "dispatch-audit-new"
assert items[0].stale_count_delta == -1
assert items[0].stale_ratio_delta == pytest.approx(-0.005)
assert items[0].above_threshold is True
assert items[1].stale_count_delta is None
assert _entries_to_stale_threshold(items[0].stale_ratio_snapshot) == 78
def test_owner_review_completion_queue_endpoint_returns_readiness_split(self, client):
"""Completion queue 應把 ready / blocked / completed / failed 分流給前端。"""
fake = KnowledgeStaleOwnerReviewCompletionQueueResponse(
project_id="awoooi",
status_bucket="all",
total=2,
returned=2,
pending_count=1,
ready_count=1,
blocked_count=0,
completed_count=1,
failed_count=0,
items=[
KnowledgeStaleOwnerReviewCompletionQueueItem(
dispatch_id="dispatch-ready-001",
governance_event_id="event-001",
entry_id="km-001",
project_id="awoooi",
title="Sentry checkout failure repair",
dispatch_status="pending",
workflow_stage="waiting_owner_review",
readiness="ready",
recommended_completion_outcome="refresh_with_evidence",
next_action="preview_stale_km_review_completion",
required_owner_fields=["owner_note_or_updated_content"],
can_preview=True,
can_confirm_after_preview=True,
writes_km_on_confirm=True,
priority_tier="P0",
priority_score=265,
recommended_action="refresh_with_evidence",
stale_days=35,
view_count=7,
correlation_sources=["incident", "playbook", "sentry"],
reasons=["linked_incident", "linked_playbook"],
related_incident_id="INC-20260513-79ED5E",
queued_at=NOW,
),
KnowledgeStaleOwnerReviewCompletionQueueItem(
dispatch_id="dispatch-done-001",
governance_event_id="event-001",
entry_id="km-002",
project_id="awoooi",
title="Auto repair canary runbook",
dispatch_status="succeeded",
workflow_stage="km_candidate_reviewed",
readiness="completed",
recommended_completion_outcome="archive",
next_action="view_stale_km_completion_audit",
required_owner_fields=["owner_note"],
can_preview=False,
can_confirm_after_preview=False,
writes_km_on_confirm=False,
priority_tier="P1",
priority_score=120,
recommended_action="archive_or_supersede",
stale_days=18,
view_count=2,
correlation_sources=["playbook"],
reasons=["high_stale_days"],
completed_at=NOW,
),
],
generated_at=NOW,
)
captured: dict = {}
async def mock_completion_queue(**kwargs):
captured.update(kwargs)
return fake
with patch(
"src.api.v1.ai_governance.query_km_stale_owner_review_completion_queue",
new=mock_completion_queue,
):
r = client.get(
"/api/v1/ai/governance/km-stale-owner-review-completion-queue"
"?project_id=awoooi&status_bucket=all&priority_tier=P0"
"&recommended_completion_outcome=refresh_with_evidence"
"&can_preview=true&limit=12"
)
assert r.status_code == 200
assert captured == {
"project_id": "awoooi",
"status_bucket": "all",
"priority_tiers": ["P0"],
"recommended_completion_outcome": "refresh_with_evidence",
"batch_governance_event_id": None,
"can_preview": True,
"limit": 12,
}
data = r.json()
assert data["schema_version"] == "km_stale_owner_review_completion_queue_v1"
assert data["writes_on_read"] is False
assert data["manual_review_required"] is True
assert data["batch_writes_allowed"] is False
assert data["pending_count"] == 1
assert data["completed_count"] == 1
assert data["items"][0]["readiness"] == "ready"
assert data["items"][0]["can_preview"] is True
assert data["items"][1]["writes_km_on_confirm"] is False
def test_completion_queue_batch_preview_endpoint_is_dry_run_only(self, client):
fake = KnowledgeStaleOwnerReviewCompletionBatchPreviewResponse(
project_id="awoooi",
owner="operator_console",
owner_note="review top ready items",
status_bucket="ready",
priority_tiers=["P0", "P1"],
recommended_completion_outcome="all",
requested_limit=10,
candidate_count=1,
previewable_count=1,
blocked_count=0,
completed_count=0,
failed_count=0,
dry_run_plan_fingerprint="sha256:" + "d" * 64,
items=[
KnowledgeStaleOwnerReviewCompletionQueueItem(
dispatch_id="dispatch-ready-001",
governance_event_id="event-001",
entry_id="km-001",
project_id="awoooi",
title="Sentry checkout failure repair",
dispatch_status="pending",
workflow_stage="waiting_owner_review",
readiness="ready",
recommended_completion_outcome="refresh_with_evidence",
next_action="preview_stale_km_review_completion",
required_owner_fields=["owner_note_or_updated_content"],
can_preview=True,
can_confirm_after_preview=True,
writes_km_on_confirm=True,
priority_tier="P0",
priority_score=265,
recommended_action="refresh_with_evidence",
stale_days=35,
view_count=7,
correlation_sources=["incident", "playbook", "sentry"],
reasons=["linked_incident"],
queued_at=NOW,
)
],
generated_at=NOW,
)
captured: dict = {}
async def mock_completion_batch_preview(**kwargs):
captured.update(kwargs)
return fake
with patch(
"src.api.v1.ai_governance.preview_km_stale_owner_review_completion_batch",
new=mock_completion_batch_preview,
):
r = client.post(
"/api/v1/ai/governance/km-stale-owner-review-completion-queue/batch-preview",
json={
"project_id": "awoooi",
"status_bucket": "ready",
"priority_tiers": ["P0", "P1"],
"limit": 10,
"owner": "operator_console",
"owner_note": "review top ready items",
},
)
assert r.status_code == 200
assert isinstance(captured["request"], KnowledgeStaleOwnerReviewCompletionBatchPreviewRequest)
data = r.json()
assert data["schema_version"] == "km_stale_owner_review_completion_batch_preview_v1"
assert data["status"] == "dry_run"
assert data["writes_km"] is False
assert data["writes_governance_audit"] is False
assert data["batch_writes_allowed"] is False
assert data["manual_review_required"] is True
assert data["previewable_count"] == 1
assert data["items"][0]["can_preview"] is True
def test_completion_queue_item_marks_active_review_ready(self):
inbox_item = KnowledgeStaleOwnerReviewInboxItem(
dispatch_id="dispatch-ready-001",
governance_event_id="event-001",
entry_id="km-001",
project_id="awoooi",
title="Sentry checkout failure repair",
dispatch_status="pending",
workflow_stage="waiting_owner_review",
next_action="owner_review_stale_km_candidate",
owner="operator_console",
owner_note="p0_p1_stale_km_batch",
priority_tier="P0",
priority_score=265,
recommended_action="refresh_with_evidence",
stale_days=35,
view_count=7,
correlation_sources=["incident", "playbook", "sentry"],
reasons=["linked_incident"],
related_incident_id="INC-20260513-79ED5E",
queued_at=NOW,
)
item = _build_completion_queue_item(inbox_item)
assert item.readiness == "ready"
assert item.next_action == "preview_stale_km_review_completion"
assert item.recommended_completion_outcome == "refresh_with_evidence"
assert item.required_owner_fields == ["owner_note_or_updated_content"]
assert item.can_preview is True
assert item.can_confirm_after_preview is True
assert item.writes_km_on_confirm is True
def test_completion_queue_item_marks_terminal_states_non_writable(self):
inbox_item = KnowledgeStaleOwnerReviewInboxItem(
dispatch_id="dispatch-completed-001",
governance_event_id="event-001",
entry_id="km-001",
project_id="awoooi",
title="Old auto repair note",
dispatch_status="succeeded",
workflow_stage="km_candidate_reviewed",
priority_tier="P1",
priority_score=120,
recommended_action="archive_or_supersede",
stale_days=20,
view_count=2,
correlation_sources=["playbook"],
reasons=["high_stale_days"],
completed_at=NOW,
)
item = _build_completion_queue_item(inbox_item)
assert item.readiness == "completed"
assert item.next_action == "view_stale_km_completion_audit"
assert item.recommended_completion_outcome == "archive"
assert item.can_preview is False
assert item.can_confirm_after_preview is False
assert item.writes_km_on_confirm is False
def test_completion_batch_preview_fingerprint_is_stable_and_read_only(self):
request = KnowledgeStaleOwnerReviewCompletionBatchPreviewRequest(
project_id="awoooi",
status_bucket="ready",
priority_tiers=["P0", "P1"],
limit=10,
owner="operator_console",
owner_note="top ready items",
)
items = [
KnowledgeStaleOwnerReviewCompletionQueueItem(
dispatch_id="dispatch-ready-001",
governance_event_id="event-001",
entry_id="km-001",
project_id="awoooi",
title="Sentry checkout failure repair",
dispatch_status="pending",
workflow_stage="waiting_owner_review",
readiness="ready",
recommended_completion_outcome="refresh_with_evidence",
next_action="preview_stale_km_review_completion",
required_owner_fields=["owner_note_or_updated_content"],
can_preview=True,
can_confirm_after_preview=True,
writes_km_on_confirm=True,
priority_tier="P0",
priority_score=265,
recommended_action="refresh_with_evidence",
stale_days=35,
view_count=7,
correlation_sources=["incident", "playbook"],
reasons=["linked_incident"],
queued_at=NOW,
)
]
fingerprint = _build_completion_batch_preview_fingerprint(
request=request,
items=items,
)
assert fingerprint.startswith("sha256:")
assert len(fingerprint) == 71
def test_stale_owner_review_batch_context_is_operator_visible(self):
request = KnowledgeStaleOwnerReviewBatchQueueRequest(
project_id="awoooi",
priority_tiers=["P0", "P1"],
limit=10,
owner="operator_console",
dry_run=False,
)
snapshot = KnowledgeReviewDraftStaleRatioSnapshot(
stale_count=119,
total_count=200,
stale_ratio=0.595,
threshold=0.2,
stale_days=7,
)
items = [
KnowledgeStaleOwnerReviewBatchItem(
entry_id="km-001",
title="Sentry checkout failure repair",
priority_tier="P0",
recommended_action="refresh_with_evidence",
status="would_queue",
workflow_stage="waiting_owner_review",
)
]
fingerprint = _build_batch_queue_plan_fingerprint(
request=request,
items=items,
snapshot=snapshot,
)
ctx = _build_batch_owner_review_decision_context(
batch_governance_event_id="event-batch-001",
batch_dispatch_id="dispatch-batch-001",
request=request,
items=items,
stale_ratio_snapshot=snapshot,
plan_fingerprint=fingerprint,
)
assert fingerprint.startswith("sha256:")
assert ctx["decision_path"] == "batch_owner_review_queued"
assert ctx["workflow"]["work_kind"] == "km_stale_owner_review_batch"
assert ctx["workflow"]["current_stage"] == "batch_owner_review_queued"
assert ctx["workflow"]["writes_km"] is False
assert ctx["workflow"]["writes_km_without_approval"] is False
assert ctx["worker_result"]["status"] == "batch_owner_review_queued"
assert ctx["worker_result"]["queued_count"] == 1
assert ctx["ownership"]["lead_agent"] == "Hermes"
def test_stale_owner_review_context_is_operator_visible(self):
candidate = {
"entry_id": "km-001",
"project_id": "awoooi",
"title": "Sentry checkout failure repair",
"stale_days": 35,
"priority_tier": "P0",
"priority_score": 265,
"recommended_action": "refresh_with_evidence",
"correlation_sources": ["incident", "playbook", "sentry", "signoz"],
"related_incident_id": "INC-20260513-79ED5E",
"related_playbook_id": "pb:auto-repair-canary",
"related_approval_id": "approval-001",
}
ctx = _build_stale_owner_review_decision_context(
governance_event_id="event-001",
entry_id="km-001",
candidate=candidate,
owner="operator_console",
owner_note="prioritize P0",
)
assert ctx["decision_path"] == "pending_owner_review"
assert ctx["next_action"] == "owner_review_stale_km_candidate"
assert ctx["workflow"]["work_kind"] == "km_stale_owner_review"
assert ctx["workflow"]["current_stage"] == "waiting_owner_review"
assert ctx["workflow"]["entry_id"] == "km-001"
assert ctx["workflow"]["writes_km"] is False
assert ctx["workflow"]["writes_km_without_approval"] is False
assert ctx["workflow"]["stage_by_dispatch_status"]["pending"] == "waiting_owner_review"
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(
governance_event_id="event-001",
canonical_entry_id="km-canonical",
requested_duplicate_entry_ids=["km-dup-1", "km-dup-2"],
archived_entry_ids=["km-dup-1", "km-dup-2"],
status="archived",
owner="operator_console",
owner_approved=True,
dry_run=False,
writes_km=True,
writes_governance_audit=True,
audit_dispatch_id="dispatch-audit-001",
stale_ratio_snapshot=KnowledgeReviewDraftStaleRatioSnapshot(
stale_count=120,
total_count=200,
stale_ratio=0.6,
threshold=0.2,
stale_days=7,
),
stale_ratio_recheck_status="completed",
stale_ratio_recheck_dispatch_id="dispatch-recheck-001",
generated_at=NOW,
)
captured: dict = {}
async def mock_archive(**kwargs):
captured.update(kwargs)
return fake
with patch(
"src.api.v1.ai_governance.archive_km_review_draft_duplicates",
new=mock_archive,
):
r = client.post(
"/api/v1/ai/governance/km-review-drafts/dedupe/event-001/archive-duplicates",
json={
"canonical_entry_id": "km-canonical",
"duplicate_entry_ids": ["km-dup-1", "km-dup-2"],
"owner": "operator_console",
"owner_approved": True,
"dry_run": False,
"dry_run_plan_fingerprint": "sha256:" + "a" * 64,
},
)
assert r.status_code == 200
assert captured["governance_event_id"] == "event-001"
assert captured["request"].owner_approved is True
assert captured["request"].dry_run_plan_fingerprint == "sha256:" + "a" * 64
data = r.json()
assert data["status"] == "archived"
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_status"] == "completed"
assert data["stale_ratio_recheck_dispatch_id"] == "dispatch-recheck-001"
assert data["stale_ratio_snapshot"]["stale_ratio"] == pytest.approx(0.6)
def test_archive_endpoint_maps_validation_error_to_http(self, client):
async def mock_archive(**kwargs):
raise KmReviewDraftArchiveError(409, "stale plan")
with patch(
"src.api.v1.ai_governance.archive_km_review_draft_duplicates",
new=mock_archive,
):
r = client.post(
"/api/v1/ai/governance/km-review-drafts/dedupe/event-001/archive-duplicates",
json={
"canonical_entry_id": "km-canonical",
"duplicate_entry_ids": ["km-dup-1"],
"owner_approved": True,
},
)
assert r.status_code == 409
assert r.json()["detail"] == "stale plan"
def test_archive_plan_validation_rejects_stale_duplicates(self):
group = KnowledgeReviewDraftDedupeGroup(
governance_event_id="event-001",
canonical_entry_id="km-canonical",
canonical_title="canonical",
preferred_source="dispatch_context",
duplicate_entry_ids=["km-dup-1", "km-dup-2"],
duplicate_count=2,
total_entries=3,
suggested_action="owner_review_canonical_then_archive_duplicates",
owner_action="review_canonical_and_archive_duplicate_drafts",
writes_on_read=False,
can_archive_without_owner_approval=False,
)
request = KnowledgeReviewDraftArchiveRequest(
canonical_entry_id="km-canonical",
duplicate_entry_ids=["km-dup-1"],
owner_approved=True,
)
with pytest.raises(KmReviewDraftArchiveError) as exc:
_validate_archive_request_against_plan(group, request, ["km-dup-1"])
assert exc.value.status_code == 409
assert "latest dedupe plan" in exc.value.detail
def test_archive_plan_fingerprint_is_required_before_write(self):
group = KnowledgeReviewDraftDedupeGroup(
governance_event_id="event-001",
canonical_entry_id="km-canonical",
canonical_title="canonical",
preferred_source="dispatch_context",
duplicate_entry_ids=["km-dup-2", "km-dup-1"],
duplicate_count=2,
total_entries=3,
suggested_action="owner_review_canonical_then_archive_duplicates",
owner_action="review_canonical_and_archive_duplicate_drafts",
writes_on_read=False,
can_archive_without_owner_approval=False,
)
fingerprint = _build_dry_run_plan_fingerprint("event-001", group)
assert fingerprint.startswith("sha256:")
assert fingerprint == _build_dry_run_plan_fingerprint("event-001", group)
_validate_dry_run_plan_fingerprint(fingerprint, fingerprint)
with pytest.raises(KmReviewDraftArchiveError) as missing:
_validate_dry_run_plan_fingerprint(None, fingerprint)
assert missing.value.status_code == 403
assert "dry_run_plan_fingerprint" in missing.value.detail
with pytest.raises(KmReviewDraftArchiveError) as mismatch:
_validate_dry_run_plan_fingerprint("sha256:" + "0" * 64, fingerprint)
assert mismatch.value.status_code == 409
assert "latest dedupe plan" in mismatch.value.detail
def test_stale_ratio_recheck_context_is_operator_visible(self):
snapshot = KnowledgeReviewDraftStaleRatioSnapshot(
stale_count=120,
total_count=200,
stale_ratio=0.6,
threshold=0.2,
stale_days=7,
)
request = KnowledgeReviewDraftArchiveRequest(
canonical_entry_id="km-canonical",
duplicate_entry_ids=["km-dup-1", "km-dup-2"],
owner="operator_console",
owner_approved=True,
dry_run_plan_fingerprint="sha256:" + "a" * 64,
)
ctx = _build_stale_ratio_recheck_context(
governance_event_id="event-001",
request=request,
archived_ids=["km-dup-1", "km-dup-2"],
stale_ratio_snapshot=snapshot,
)
assert ctx["next_action"] == "run_stale_ratio_recheck"
assert ctx["decision_path"] == "owner_approved_recheck_after_archive"
assert ctx["workflow"]["work_kind"] == "km_stale_ratio_recheck"
assert ctx["workflow"]["current_stage"] == "stale_ratio_recheck"
assert ctx["workflow"]["stage_by_dispatch_status"]["pending"] == "stale_ratio_recheck"
assert ctx["workflow"]["writes_km"] is False
assert ctx["workflow"]["dry_run_plan_fingerprint"] == "sha256:" + "a" * 64
assert ctx["workflow"]["stale_ratio_snapshot"]["stale_ratio"] == pytest.approx(0.6)
assert ctx["worker_result"]["status"] == "stale_ratio_rechecked"
assert ctx["worker_result"]["above_threshold"] is True
# =============================================================================
# 4. summary endpoint compliance_rate
# =============================================================================
class TestSummaryEndpoint:
def test_compliance_rate_normal(self, client):
"""有 unresolved 時計算 1 - unresolved/total."""
fake = GovernanceSummaryResponse(
compliance_rate=0.8,
total_events=10,
unresolved_count=2,
daily_counts=[],
)
with patch(
"src.api.v1.ai_governance.query_governance_summary",
new=AsyncMock(return_value=fake),
):
r = client.get("/api/v1/ai/governance/summary")
assert r.status_code == 200
data = r.json()
assert data["compliance_rate"] == pytest.approx(0.8)
assert data["total_events"] == 10
assert data["unresolved_count"] == 2
def test_compliance_rate_all_resolved(self, client):
"""全部已解決時 compliance_rate = 1.0."""
fake = GovernanceSummaryResponse(
compliance_rate=1.0,
total_events=5,
unresolved_count=0,
daily_counts=[],
)
with patch(
"src.api.v1.ai_governance.query_governance_summary",
new=AsyncMock(return_value=fake),
):
r = client.get("/api/v1/ai/governance/summary?days=7")
assert r.status_code == 200
assert r.json()["compliance_rate"] == pytest.approx(1.0)
def test_compliance_rate_total_zero(self, client):
"""total_events=0 時 compliance_rate = 1.0(邊界測試)."""
fake = GovernanceSummaryResponse(
compliance_rate=1.0,
total_events=0,
unresolved_count=0,
daily_counts=[],
)
with patch(
"src.api.v1.ai_governance.query_governance_summary",
new=AsyncMock(return_value=fake),
):
r = client.get("/api/v1/ai/governance/summary")
assert r.status_code == 200
data = r.json()
assert data["compliance_rate"] == pytest.approx(1.0)
assert data["total_events"] == 0
def test_days_max_boundary(self, client):
"""days=90 邊界值應被接受."""
fake = GovernanceSummaryResponse(
compliance_rate=1.0, total_events=0, unresolved_count=0, daily_counts=[],
)
with patch(
"src.api.v1.ai_governance.query_governance_summary",
new=AsyncMock(return_value=fake),
):
r = client.get("/api/v1/ai/governance/summary?days=90")
assert r.status_code == 200
def test_days_over_max_rejected(self, client):
"""days=91 應被拒絕422."""
r = client.get("/api/v1/ai/governance/summary?days=91")
assert r.status_code == 422
def test_daily_counts_structure(self, client):
"""daily_counts 結構正確."""
fake = GovernanceSummaryResponse(
compliance_rate=0.9,
total_events=10,
unresolved_count=1,
daily_counts=[
DailyCount(date="2026-05-01", total=3, by_type={"slo_violation": 2, "trust_drift": 1}),
DailyCount(date="2026-05-02", total=7, by_type={"slo_violation": 7}),
],
)
with patch(
"src.api.v1.ai_governance.query_governance_summary",
new=AsyncMock(return_value=fake),
):
r = client.get("/api/v1/ai/governance/summary")
assert r.status_code == 200
counts = r.json()["daily_counts"]
assert len(counts) == 2
assert counts[0]["date"] == "2026-05-01"
assert counts[0]["by_type"]["slo_violation"] == 2
# =============================================================================
# 5. service 層 compliance_rate 純函式測試(不經 HTTP
# =============================================================================
class TestComplianceRateCalculation:
"""直接測試 service 邏輯,不經 Router。"""
def test_formula_normal(self):
"""1 - 2/10 = 0.8"""
rate = round(1.0 - 2 / 10, 4)
assert rate == pytest.approx(0.8)
def test_formula_zero_total(self):
"""total=0 → 1.0"""
total = 0
rate = 1.0 if total == 0 else round(1.0 - 0 / total, 4)
assert rate == pytest.approx(1.0)
def test_formula_all_unresolved(self):
"""1 - 5/5 = 0.0"""
rate = round(1.0 - 5 / 5, 4)
assert rate == pytest.approx(0.0)