Files
awoooi/apps/api/src/services/governance_query_service.py
Your Name 855716b5b8
All checks were successful
Code Review / ai-code-review (push) Successful in 10s
Type Sync Check / check-type-sync (push) Successful in 33s
CD Pipeline / tests (push) Successful in 3m57s
CD Pipeline / build-and-deploy (push) Successful in 4m47s
CD Pipeline / post-deploy-checks (push) Successful in 2m3s
feat(awooop): surface km review draft dedupe
2026-05-19 23:27:33 +08:00

628 lines
21 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.
"""
Governance Query Service — /governance 頁面 DB 查詢邏輯
======================================================
封裝 3 個 governance endpoint 的資料庫查詢。
Router 層禁直接存取 DBleWOOOgo 積木化鐵律)。
函式清單:
query_governance_events(...) → GovernanceEventsResponse
query_governance_queue(...) → GovernanceQueueResponse
query_governance_summary(...) → GovernanceSummaryResponse
Graceful fallback 規則:
queue endpoint — governance_remediation_dispatch 表可能尚未建立Track D 進行中)。
捕捉 sqlalchemy.exc.ProgrammingError表不存在後回傳 table_pending=True 的空列表,
確保 API 在表建立前不拋 500。
2026-05-02 ogt + Claude Sonnet 4.6 Asia/Taipei
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from typing import Any
import structlog
from sqlalchemy import bindparam, func, select, text
from sqlalchemy.exc import ProgrammingError
from src.db.base import get_db_context
from src.db.models import AiGovernanceEvent
from src.models.governance import (
DailyCount,
DispatchItem,
GovernanceEvent,
GovernanceEventsResponse,
GovernanceQueueResponse,
GovernanceSummaryResponse,
map_severity,
)
from src.utils.timezone import now_taipei
logger = structlog.get_logger(__name__)
# =============================================================================
# 常數
# =============================================================================
_TAIPEI = timezone(timedelta(hours=8))
# =============================================================================
# helpers
# =============================================================================
def _extract_impact(details: dict) -> str:
"""
從 details 抽摘要字串≤80 字。
優先讀 details["impact"]dict取 status + 主要 metric 欄位。
fallback 到 details 頂層常見欄位。
"""
impact_block = details.get("impact")
if isinstance(impact_block, dict):
parts: list[str] = []
if "status" in impact_block:
parts.append(str(impact_block["status"]))
# 主要 metric 欄位優先順序
for key in ("metric", "value", "rate", "ratio", "score", "count"):
if key in impact_block:
parts.append(f"{key}={impact_block[key]}")
break
summary = " ".join(parts)
return summary[:80] if summary else ""
# fallback: 頂層常見欄位
for key in ("message", "reason", "summary", "description"):
val = details.get(key)
if isinstance(val, str) and val:
return val[:80]
# 最後 fallback: 把 details 第一個 string value 截取
for val in details.values():
if isinstance(val, str) and val:
return val[:80]
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,
*,
dispatch_ids: list[str] | None = None,
) -> GovernanceEvent:
details = row.details if isinstance(row.details, dict) else {}
return GovernanceEvent(
id=row.id,
event_type=row.event_type,
severity=map_severity(row.event_type),
triggered_at=row.triggered_at,
resolved=row.resolved,
resolved_at=row.resolved_at,
impact=_extract_impact(details),
details=details,
remediation=_extract_remediation(details),
dispatch_ids=_merge_dispatch_ids(dispatch_ids or [], details.get("dispatch_ids")),
)
def _merge_dispatch_ids(
db_dispatch_ids: list[str],
legacy_dispatch_ids: Any,
) -> list[str]:
"""合併 DB dispatch trail 與 legacy payload idsDB truth-first。"""
merged: list[str] = []
for raw in [*db_dispatch_ids, *(legacy_dispatch_ids if isinstance(legacy_dispatch_ids, list) else [])]:
if raw is None:
continue
value = str(raw)
if value and value not in merged:
merged.append(value)
return merged
async def _load_dispatch_ids_for_events(event_ids: list[str]) -> dict[str, list[str]]:
"""從 governance_remediation_dispatch 讀取事件對應 dispatch ids。
events endpoint 必須能在 dispatch 表尚未建立的環境 graceful fallback
因此這裡捕捉 ProgrammingError 並回空 dict。
"""
if not event_ids:
return {}
sql = text("""
SELECT
d.governance_event_id,
d.id
FROM governance_remediation_dispatch d
WHERE d.governance_event_id IN :event_ids
ORDER BY d.dispatched_at DESC
""").bindparams(bindparam("event_ids", expanding=True))
try:
async with get_db_context() as db:
result = await db.execute(sql, {"event_ids": event_ids})
rows = result.fetchall()
except ProgrammingError as exc:
logger.warning(
"governance_dispatch_ids_table_not_ready",
error=str(exc),
)
return {}
dispatch_ids_by_event: dict[str, list[str]] = {}
for row in rows:
dispatch_ids_by_event.setdefault(str(row.governance_event_id), []).append(str(row.id))
return dispatch_ids_by_event
# =============================================================================
# Endpoint 1: events
# =============================================================================
async def query_governance_events(
*,
event_types: list[str] | None = None,
from_dt: datetime | None = None,
to_dt: datetime | None = None,
status: str | None = None, # "resolved" | "unresolved"
severity: str | None = None, # "critical" | "warning" | "info"
page: int = 1,
size: int = 20,
) -> GovernanceEventsResponse:
"""
查詢 ai_governance_events 表,支援多維度過濾與分頁。
severity 過濾在 Python 層完成event_type 映射);
其他過濾在 SQL 層完成(效能優先)。
"""
async with get_db_context() as db:
stmt = select(AiGovernanceEvent)
if event_types:
stmt = stmt.where(AiGovernanceEvent.event_type.in_(event_types))
if from_dt is not None:
stmt = stmt.where(AiGovernanceEvent.triggered_at >= from_dt)
if to_dt is not None:
stmt = stmt.where(AiGovernanceEvent.triggered_at <= to_dt)
if status == "resolved":
stmt = stmt.where(AiGovernanceEvent.resolved.is_(True))
elif status == "unresolved":
stmt = stmt.where(AiGovernanceEvent.resolved.is_(False))
stmt = stmt.order_by(AiGovernanceEvent.triggered_at.desc())
# 取全部結果severity 在 Python 層過濾(避免 DB 不認識 mapping 邏輯)
result = await db.execute(stmt)
all_rows = result.scalars().all()
event_rows_by_id = {str(r.id): r for r in all_rows}
events = [_to_governance_event(r) for r in all_rows]
# severity 過濾Python 層)
if severity:
from src.models.governance import _CRITICAL_TYPES, _WARNING_TYPES
if severity == "critical":
events = [e for e in events if e.event_type in _CRITICAL_TYPES]
elif severity == "warning":
events = [e for e in events if e.event_type in _WARNING_TYPES]
elif severity == "info":
events = [
e for e in events
if e.event_type not in _CRITICAL_TYPES and e.event_type not in _WARNING_TYPES
]
total = len(events)
offset = (page - 1) * size
page_items = events[offset: offset + size]
dispatch_ids_by_event = await _load_dispatch_ids_for_events([e.id for e in page_items])
if dispatch_ids_by_event:
page_items = [
_to_governance_event(
event_rows_by_id[item.id],
dispatch_ids=dispatch_ids_by_event.get(item.id, []),
)
for item in page_items
]
return GovernanceEventsResponse(
items=page_items,
total=total,
page=page,
size=size,
)
# =============================================================================
# Endpoint 2: queue
# =============================================================================
async def query_governance_queue(
*,
dispatch_status: str = "pending",
event_types: list[str] | None = None,
page: int = 1,
size: int = 20,
) -> GovernanceQueueResponse:
"""
查詢 governance_remediation_dispatch 表。
Track D 進行中,表可能尚未建立。
捕捉 ProgrammingError → 回傳 table_pending=True 的空 response。
proposed_action 從 decision_context JSONB 抽取Track D 完成後可改為真實 join
"""
try:
return await _query_dispatch_table(
dispatch_status=dispatch_status,
event_types=event_types,
page=page,
size=size,
)
except ProgrammingError as exc:
logger.warning(
"governance_dispatch_table_not_ready",
error=str(exc),
)
return GovernanceQueueResponse(
items=[],
total=0,
page=page,
size=size,
table_pending=True,
)
except ImportError as exc:
logger.warning(
"governance_dispatch_model_not_ready",
error=str(exc),
)
return GovernanceQueueResponse(
items=[],
total=0,
page=page,
size=size,
table_pending=True,
)
async def _query_dispatch_table(
*,
dispatch_status: str,
event_types: list[str] | None,
page: int,
size: int,
) -> GovernanceQueueResponse:
"""實際查詢 governance_remediation_dispatch 表(不含 graceful fallback."""
# 動態 importTrack D 完成前 ORM class 可能不存在
# 使用 raw SQL 降低 ORM 模型缺失的耦合風險
status_filter = (
"TRUE"
if dispatch_status == "all"
else "d.dispatch_status = CAST(:dispatch_status AS governance_dispatch_status)"
)
event_type_filter = (
"TRUE"
if not event_types
else "e.event_type::text IN :event_types"
)
params: dict[str, Any] = {}
if dispatch_status != "all":
params["dispatch_status"] = dispatch_status
if event_types:
params["event_types"] = event_types
sql = text(f"""
SELECT
d.id,
d.governance_event_id,
e.event_type,
d.dispatch_status,
d.executor_type,
d.decision_context,
d.playbook_id,
d.dispatched_at AS created_at,
d.dispatched_at,
d.started_at,
d.completed_at,
NULL::text AS operator_note
FROM governance_remediation_dispatch d
JOIN ai_governance_events e ON e.id = d.governance_event_id
WHERE {status_filter}
AND {event_type_filter}
ORDER BY d.dispatched_at DESC
""")
count_sql = text(f"""
SELECT count(*) AS cnt
FROM governance_remediation_dispatch d
JOIN ai_governance_events e ON e.id = d.governance_event_id
WHERE {status_filter}
AND {event_type_filter}
""")
if event_types:
sql = sql.bindparams(bindparam("event_types", expanding=True))
count_sql = count_sql.bindparams(bindparam("event_types", expanding=True))
async with get_db_context() as db:
count_row = await db.execute(count_sql, params)
total = int(count_row.scalar_one_or_none() or 0)
rows = await db.execute(sql, params)
all_rows = rows.fetchall()
offset = (page - 1) * size
page_rows = all_rows[offset: offset + size]
items: list[DispatchItem] = []
for row in page_rows:
decision_ctx: dict = (row.decision_context or {}) if hasattr(row, "decision_context") else {}
proposed_action = _extract_proposed_action(decision_ctx)
# playbook_trust: Track D 完成後改為 JOIN playbooks 表取 trust_score
# 現階段從 decision_context 取 mock 值
playbook_trust_raw = decision_ctx.get("playbook_trust")
try:
playbook_trust = float(playbook_trust_raw) if playbook_trust_raw is not None else None
except (TypeError, ValueError):
playbook_trust = None
items.append(DispatchItem(
id=str(row.id),
governance_event_id=str(row.governance_event_id),
event_type=str(row.event_type),
dispatch_status=str(row.dispatch_status),
executor_type=str(row.executor_type) if row.executor_type else None,
proposed_action=proposed_action,
playbook_id=str(row.playbook_id) if row.playbook_id else None,
playbook_trust=playbook_trust,
created_at=row.created_at,
dispatched_at=row.dispatched_at,
started_at=row.started_at,
completed_at=row.completed_at,
operator_note=row.operator_note,
decision_path=_extract_decision_path(decision_ctx),
workflow_stage=_extract_workflow_stage(decision_ctx, str(row.dispatch_status)),
workflow_steps=_extract_workflow_steps(decision_ctx),
next_action=_extract_next_action(decision_ctx),
lead_agent=_extract_lead_agent(decision_ctx),
support_agents=_extract_support_agents(decision_ctx),
human_owner=_extract_human_owner(decision_ctx),
kb_draft_entry_id=_extract_kb_draft_entry_id(decision_ctx),
worker_status=_extract_worker_status(decision_ctx),
))
return GovernanceQueueResponse(
items=items,
total=total,
page=page,
size=size,
table_pending=False,
)
def _extract_proposed_action(decision_ctx: dict) -> str:
"""
從 decision_context JSONB 抽取 proposed_action≤120 字。
Track D 完成後此函式可改為從真實欄位讀取。
"""
for key in (
"proposed_action",
"suggested_action",
"next_action",
"action",
"suggestion",
"description",
"summary",
):
val = decision_ctx.get(key)
if isinstance(val, str) and val:
return val[:120]
return "(待補充)"
def _extract_decision_path(decision_ctx: dict) -> str | None:
val = decision_ctx.get("decision_path")
return val[:80] if isinstance(val, str) and val else None
def _extract_next_action(decision_ctx: dict) -> str | None:
for key in ("next_action", "suggested_action", "proposed_action"):
val = decision_ctx.get(key)
if isinstance(val, str) and val:
return val[:120]
workflow = decision_ctx.get("workflow")
if isinstance(workflow, dict):
val = workflow.get("next_action")
if isinstance(val, str) and val:
return val[:120]
return None
def _extract_workflow_stage(decision_ctx: dict, dispatch_status: str) -> str | None:
workflow = decision_ctx.get("workflow")
if isinstance(workflow, dict):
stages = workflow.get("stage_by_dispatch_status")
if isinstance(stages, dict):
stage = stages.get(dispatch_status)
if isinstance(stage, str) and stage:
return stage[:120]
current = workflow.get("current_stage")
if isinstance(current, str) and current:
return current[:120]
return {
"pending": "queued_for_review",
"dispatched": "dispatched",
"executing": "executing",
"succeeded": "completed",
"failed": "failed",
"skipped": "skipped",
"cancelled": "cancelled",
}.get(dispatch_status)
def _extract_workflow_steps(decision_ctx: dict) -> list[str]:
workflow = decision_ctx.get("workflow")
if not isinstance(workflow, dict):
return []
steps = workflow.get("steps")
if not isinstance(steps, list):
return []
return [str(step)[:120] for step in steps if step is not None][:8]
def _extract_ownership(decision_ctx: dict) -> dict:
ownership = decision_ctx.get("ownership")
if isinstance(ownership, dict):
return ownership
extra = decision_ctx.get("extra")
if isinstance(extra, dict) and isinstance(extra.get("ownership"), dict):
return extra["ownership"]
return {}
def _extract_lead_agent(decision_ctx: dict) -> str | None:
val = _extract_ownership(decision_ctx).get("lead_agent")
return val[:80] if isinstance(val, str) and val else None
def _extract_support_agents(decision_ctx: dict) -> list[str]:
raw = _extract_ownership(decision_ctx).get("support_agents")
if not isinstance(raw, list):
return []
return [str(item)[:160] for item in raw if item is not None][:6]
def _extract_human_owner(decision_ctx: dict) -> str | None:
val = _extract_ownership(decision_ctx).get("human_owner")
return val[:120] if isinstance(val, str) and val else None
def _extract_kb_draft_entry_id(decision_ctx: dict) -> str | None:
"""Expose Hermes KM review draft id for Work Items owner review."""
workflow = decision_ctx.get("workflow")
if isinstance(workflow, dict):
val = workflow.get("kb_draft_entry_id")
if isinstance(val, str) and val:
return val[:120]
worker_result = decision_ctx.get("worker_result")
if isinstance(worker_result, dict):
val = worker_result.get("km_draft_entry_id")
if isinstance(val, str) and val:
return val[:120]
return None
def _extract_worker_status(decision_ctx: dict) -> str | None:
worker_result = decision_ctx.get("worker_result")
if not isinstance(worker_result, dict):
return None
val = worker_result.get("status")
return val[:80] if isinstance(val, str) and val else None
# =============================================================================
# Endpoint 3: summary
# =============================================================================
async def query_governance_summary(*, days: int = 30) -> GovernanceSummaryResponse:
"""
過去 N 天 SLO 違反時序統計 + compliance_rate。
compliance_rate = 1 - unresolved / totaltotal=0 時回 1.0
"""
since = now_taipei() - timedelta(days=days)
async with get_db_context() as db:
# 總數 & 未解決數
count_stmt = select(
func.count().label("total"),
func.count().filter(AiGovernanceEvent.resolved.is_(False)).label("unresolved"),
).where(AiGovernanceEvent.triggered_at >= since)
count_row = await db.execute(count_stmt)
counts = count_row.one()
total_events = int(counts.total)
unresolved_count = int(counts.unresolved)
# 每日計數DATE_TRUNC 在 Postgres 端執行)
daily_sql = text("""
SELECT
DATE_TRUNC('day', triggered_at AT TIME ZONE 'Asia/Taipei')::date AS day,
event_type,
count(*) AS cnt
FROM ai_governance_events
WHERE triggered_at >= :since
GROUP BY day, event_type
ORDER BY day ASC
""")
daily_result = await db.execute(daily_sql, {"since": since})
daily_rows = daily_result.fetchall()
# 彙整每日資料
daily_map: dict[str, dict[str, int]] = {}
for row in daily_rows:
day_str = row.day.strftime("%Y-%m-%d") if hasattr(row.day, "strftime") else str(row.day)
if day_str not in daily_map:
daily_map[day_str] = {}
daily_map[day_str][row.event_type] = int(row.cnt)
daily_counts = [
DailyCount(
date=day_str,
total=sum(by_type.values()),
by_type=by_type,
)
for day_str, by_type in sorted(daily_map.items())
]
if total_events == 0:
compliance_rate = 1.0
else:
compliance_rate = round(1.0 - unresolved_count / total_events, 4)
return GovernanceSummaryResponse(
compliance_rate=compliance_rate,
total_events=total_events,
unresolved_count=unresolved_count,
daily_counts=daily_counts,
)