fix(governance): normalize event and dispatch queries
This commit is contained in:
@@ -85,8 +85,38 @@ def _extract_impact(details: dict) -> str:
|
||||
return ""
|
||||
|
||||
|
||||
def _extract_remediation(details: dict) -> str | None:
|
||||
"""
|
||||
將治理事件 details.remediation 正規化為前端可顯示的短字串。
|
||||
|
||||
Production 事件已出現 dict 形態(例如 {"items": [...]}),API response
|
||||
schema 則是字串。這裡做 read-side normalization,避免歷史資料讓
|
||||
/governance events 變成 500。
|
||||
"""
|
||||
remediation = details.get("remediation")
|
||||
if remediation is None:
|
||||
return None
|
||||
if isinstance(remediation, str):
|
||||
return remediation[:160]
|
||||
if isinstance(remediation, dict):
|
||||
for key in ("summary", "message", "reason", "action"):
|
||||
value = remediation.get(key)
|
||||
if isinstance(value, str) and value:
|
||||
return value[:160]
|
||||
items = remediation.get("items")
|
||||
if isinstance(items, list):
|
||||
normalized = [str(item) for item in items if item is not None]
|
||||
if normalized:
|
||||
return ";".join(normalized[:3])[:160]
|
||||
return str(remediation)[:160]
|
||||
if isinstance(remediation, list):
|
||||
normalized = [str(item) for item in remediation if item is not None]
|
||||
return ";".join(normalized[:3])[:160] if normalized else None
|
||||
return str(remediation)[:160]
|
||||
|
||||
|
||||
def _to_governance_event(row: AiGovernanceEvent) -> GovernanceEvent:
|
||||
details = row.details or {}
|
||||
details = row.details if isinstance(row.details, dict) else {}
|
||||
return GovernanceEvent(
|
||||
id=row.id,
|
||||
event_type=row.event_type,
|
||||
@@ -96,7 +126,7 @@ def _to_governance_event(row: AiGovernanceEvent) -> GovernanceEvent:
|
||||
resolved_at=row.resolved_at,
|
||||
impact=_extract_impact(details),
|
||||
details=details,
|
||||
remediation=details.get("remediation"),
|
||||
remediation=_extract_remediation(details),
|
||||
dispatch_ids=details.get("dispatch_ids", []),
|
||||
)
|
||||
|
||||
@@ -239,14 +269,14 @@ async def _query_dispatch_table(
|
||||
d.dispatch_status,
|
||||
d.decision_context,
|
||||
d.playbook_id,
|
||||
d.created_at,
|
||||
d.dispatched_at AS created_at,
|
||||
d.dispatched_at,
|
||||
d.completed_at,
|
||||
d.operator_note
|
||||
NULL::text AS operator_note
|
||||
FROM governance_remediation_dispatch d
|
||||
JOIN ai_governance_events e ON e.id = d.governance_event_id
|
||||
WHERE d.dispatch_status = :dispatch_status
|
||||
ORDER BY d.created_at DESC
|
||||
ORDER BY d.dispatched_at DESC
|
||||
""")
|
||||
|
||||
count_sql = text("""
|
||||
|
||||
@@ -31,6 +31,11 @@ from src.models.governance import (
|
||||
GovernanceSummaryResponse,
|
||||
map_severity,
|
||||
)
|
||||
from src.services.governance_query_service import (
|
||||
_extract_remediation,
|
||||
_query_dispatch_table,
|
||||
_to_governance_event,
|
||||
)
|
||||
|
||||
TAIPEI = timezone(timedelta(hours=8))
|
||||
NOW = datetime(2026, 5, 2, 12, 0, tzinfo=TAIPEI)
|
||||
@@ -183,6 +188,40 @@ class TestEventsEndpoint:
|
||||
assert items[2]["severity"] == "info"
|
||||
|
||||
|
||||
class TestEventsReadSideNormalization:
|
||||
def test_remediation_dict_is_normalized_to_string(self):
|
||||
"""production details.remediation 可能是 dict,response 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"
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 3. queue endpoint graceful fallback
|
||||
# =============================================================================
|
||||
@@ -240,6 +279,18 @@ class TestQueueEndpoint:
|
||||
r = client.get("/api/v1/ai/governance/queue?dispatch_status=unknown")
|
||||
assert r.status_code == 422
|
||||
|
||||
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 "d.created_at" not in source
|
||||
assert "d.operator_note" not in source
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# 4. summary endpoint compliance_rate
|
||||
|
||||
Reference in New Issue
Block a user