Files
awoooi/apps/api/src/services/governance_dispatcher.py
Your Name 8342cfa460
All checks were successful
Code Review / ai-code-review (push) Successful in 11s
CD Pipeline / tests (push) Successful in 2m1s
CD Pipeline / build-and-deploy (push) Successful in 4m45s
CD Pipeline / post-deploy-checks (push) Successful in 1m46s
fix(governance): stop km healthcheck requeue
2026-05-19 23:01:03 +08:00

614 lines
23 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.
"""
GovernanceDispatcher — 治理事件 → 修復派遣
============================================
Poll 模式:每 30s 掃 ai_governance_events 中 resolved=False 且
無活躍 dispatch 的事件,呼叫 DecisionFusionAdapter 三維融合後
寫入 governance_remediation_dispatch 表。
職責:
1. Poll unresolved 治理事件(不直接修改 ai_governance_events 表)
2. 呼叫 DecisionFusionAdapter.fuse_decision → FusedDecision
3. 依 decision_path 決定是否寫入 dispatch
4. 不執行 remediation實際執行由 approval_execution / auto_repair 消費 dispatch 表)
Tier 3 鐵線(絕不觸碰):
- decision_manager.py / learning_service.py / trust_engine.py
- 本模組透過 DecisionFusionAdapterwrapper間接使用這些能力
2026-05-03 ogt + Claude Sonnet 4.6(亞太): GovernanceDispatcher Wave 2E 實作
2026-05-04 ogt + Claude Sonnet 4.6(亞太): skip 路徑無限迴圈修復
- skip 決策後設 Redis 90min 冷卻,避免重複 LLM 呼叫
- 超過 2 小時的 stale skip 事件標記 resolved=True新事件若問題持續會重新產生
"""
from __future__ import annotations
import asyncio
from datetime import datetime, timezone
from typing import Any
import structlog
from sqlalchemy import exists, select, update
from src.db.base import get_db_context
from src.db.models import AiGovernanceEvent, GovernanceRemediationDispatch
from src.repositories.governance_remediation_dispatch_repo import (
ACTIVE_STATUSES,
DispatchAlreadyActive,
create_dispatch,
get_active_for_event,
list_by_event,
transition_status,
)
from src.services.decision_fusion_adapter import FusedDecision, get_decision_fusion_adapter
logger = structlog.get_logger(__name__)
# =============================================================================
# 常數
# TODO: 移到 settingsADR-P2E-FUTURE目前暫時 hardcode
# =============================================================================
# Poll 間隔(秒)
# TODO: 移到 settings允許運維不重啟調整 poll 間隔
_DISPATCHER_INTERVAL_SEC: int = 30
# Skip 冷卻時間skip 決策後 90 分鐘內不重新評估同一事件
# 原因skip = 信心度不足,短期內 playbook trust / MCP 指標不會驟變
_SKIP_COOLDOWN_SEC: int = 5400 # 90 分鐘
# Stale 事件閾值(秒):超過此時間的 skip 事件直接標 resolved
# 原因:持久問題會由 governance_agent 重新產生新事件;舊事件繼續留著只是積壓
_STALE_EVENT_SEC: int = 7200 # 2 小時
# 每輪最多處理幾個事件(避免單輪阻塞過長)
_MAX_EVENTS_PER_CYCLE: int = 10
# 允許建立 dispatch 的 event_type對齊 governance_event_type enum
_DISPATCHABLE_EVENT_TYPES: frozenset[str] = frozenset({
"trust_drift",
"knowledge_degradation",
"llm_hallucination",
"execution_blast_radius",
"governance_slo_data_gap",
})
# =============================================================================
# Redis 冷卻 helpers防止 skip 事件無限重評迴圈)
# =============================================================================
async def _is_skip_cooldown(event_id: str) -> bool:
"""確認事件是否在 skip 冷卻期內90 分鐘)。"""
try:
from src.core.redis_client import get_redis
redis = get_redis()
return bool(await redis.exists(f"governance:skip:{event_id}"))
except Exception:
return False
async def _set_skip_cooldown(event_id: str) -> None:
"""設置 skip 冷卻期90 分鐘),防止重複 LLM 呼叫。"""
try:
from src.core.redis_client import get_redis
redis = get_redis()
await redis.setex(f"governance:skip:{event_id}", _SKIP_COOLDOWN_SEC, "1")
except Exception as exc:
logger.warning("governance_skip_cooldown_set_failed", event_id=event_id, error=str(exc))
async def _mark_event_resolved(event_id: str, reason: str) -> None:
"""將 stale skip 事件標記為 resolved持久問題會由 governance_agent 重新產生新事件)。
對齊模型設計resolved=True 由「下次計算時補填」,
dispatcher skip = 系統判斷當前無法自動修復,等同一次計算完成。
"""
try:
from src.utils.timezone import now_taipei
async with get_db_context() as db:
await db.execute(
update(AiGovernanceEvent)
.where(AiGovernanceEvent.id == event_id)
.where(AiGovernanceEvent.resolved.is_(False))
.values(resolved=True, resolved_at=now_taipei())
)
logger.info(
"governance_event_stale_resolved",
event_id=event_id,
reason=reason,
)
except Exception as exc:
logger.warning("governance_event_resolve_failed", event_id=event_id, error=str(exc))
# =============================================================================
# 核心函數
# =============================================================================
async def dispatch_governance_event(event: AiGovernanceEvent) -> str | None:
"""處理單一治理事件:決策融合 → 寫 dispatch 記錄。
Args:
event: AiGovernanceEvent ORM 物件(唯讀,不修改)
Returns:
建立的 dispatch_idstr或 Noneskip / 已有活躍 dispatch
"""
event_id = event.id
event_type = event.event_type
# Step 0: 檢查是否已有活躍 dispatch冪等保護
# 先查 DB 再看 skip cooldown避免曾經被 skip 的 knowledge_degradation
# 無法補建 Hermes KB healthcheck intake work item。
existing = await get_active_for_event(event_id)
if existing is not None:
logger.debug(
"governance_dispatch_skipped_already_active",
event_id=event_id,
event_type=event_type,
existing_dispatch_id=existing.id,
existing_status=existing.dispatch_status,
)
return None
# knowledge_degradation 的 run_kb_growth_healthcheck 是治理工作項 intake
# 不是自動修復執行;先落 pending dispatch讓既有 unresolved 事件也能在 AwoooP 追蹤。
if _is_kb_growth_healthcheck_event(event):
if await _has_kb_growth_review_draft(event_id):
logger.debug(
"governance_kb_healthcheck_dispatch_skipped_waiting_review",
event_id=event_id,
event_type=event_type,
)
return None
return await _record_kb_growth_healthcheck_dispatch(event)
# Step 1: Redis skip 冷卻檢查(防止一般 skip 事件每 30s 重新做 LLM 呼叫)。
if await _is_skip_cooldown(event_id):
logger.debug(
"governance_dispatch_skip_cooldown",
event_id=event_id,
event_type=event_type,
)
return None
# Step 2: 決策融合三維LLM × Playbook × MCP
adapter = get_decision_fusion_adapter()
try:
decision: FusedDecision = await adapter.fuse_decision(event)
except Exception as exc:
logger.warning(
"governance_fusion_failed",
event_id=event_id,
event_type=event_type,
error=str(exc),
)
# LLM 失敗 fallbackskip + log不寫 dispatch
logger.info(
"governance_dispatch_fallback_skip",
event_id=event_id,
reason="fusion_exception",
)
return None
# Step 3: 依 decision_path 決定要不要寫 dispatch
if decision.decision_path == "skip":
# 2026-05-04 ogt: 修復無限迴圈根因
# skip 決策後設 90min Redis 冷卻,避免每 30s 重新呼叫 LLM
# 超過 2h 的 stale 事件直接標 resolved持久問題由 governance_agent 重新產生新事件)
await _set_skip_cooldown(event_id)
triggered_at_aware = event.triggered_at
if triggered_at_aware is not None and triggered_at_aware.tzinfo is None:
triggered_at_aware = triggered_at_aware.replace(tzinfo=timezone.utc)
event_age_sec = (
(datetime.now(timezone.utc) - triggered_at_aware).total_seconds()
if triggered_at_aware is not None else 0
)
logger.info(
"governance_dispatch_path_skip",
event_id=event_id,
event_type=event_type,
confidence=round(decision.confidence, 4),
event_age_sec=int(event_age_sec),
stale=event_age_sec > _STALE_EVENT_SEC,
)
await _record_skipped_dispatch(event, decision)
if event_age_sec > _STALE_EVENT_SEC:
await _mark_event_resolved(event_id, reason=f"skip_stale_{int(event_age_sec)}s")
return None
# Step 4: 決定 executor_type 與 dispatch_status
# auto_dispatch → dispatched下游 auto_repair 消費)
# pending_approval → pending等人工審核
if decision.decision_path == "auto_dispatch":
executor_type = "playbook_executor"
else: # pending_approval
executor_type = "manual"
# Step 5: 建構 decision_context JSONB完整三維快照
decision_context = _build_decision_context(event, decision)
# Step 6: 寫入 governance_remediation_dispatch用 repo 函數)
try:
dispatch_row = await create_dispatch(
event_id=event_id,
event_type=event_type,
executor_type=executor_type,
playbook_id=decision.matched_playbook_id,
decision_context=decision_context,
created_by="governance_dispatcher",
)
except DispatchAlreadyActive:
# 並行 race condition另一個 worker 先建立了 dispatch
logger.info(
"governance_dispatch_race_condition",
event_id=event_id,
event_type=event_type,
)
return None
except Exception as exc:
logger.warning(
"governance_dispatch_create_failed",
event_id=event_id,
event_type=event_type,
error=str(exc),
)
return None
logger.info(
"governance_dispatched",
dispatch_id=dispatch_row.id,
event_id=event_id,
event_type=event_type,
decision_path=decision.decision_path,
confidence=round(decision.confidence, 4),
executor_type=executor_type,
playbook_id=decision.matched_playbook_id,
)
return dispatch_row.id
async def _record_kb_growth_healthcheck_dispatch(event: AiGovernanceEvent) -> str | None:
"""將 knowledge_degradation intake 成 Hermes KB healthcheck work item."""
try:
from src.services.governance_agent import (
_build_knowledge_degradation_dispatch_context,
)
dispatch_row = await create_dispatch(
event_id=event.id,
event_type=event.event_type,
executor_type="hermes_kb_growth_healthcheck",
decision_context=_build_knowledge_degradation_dispatch_context(
event.id,
event.details if isinstance(event.details, dict) else {},
),
max_attempts=1,
created_by="governance_dispatcher_intake",
)
except DispatchAlreadyActive:
logger.info(
"governance_kb_healthcheck_dispatch_race_condition",
event_id=event.id,
event_type=event.event_type,
)
return None
except Exception as exc:
logger.warning(
"governance_kb_healthcheck_dispatch_failed",
event_id=event.id,
event_type=event.event_type,
error=str(exc),
)
return None
logger.info(
"governance_kb_healthcheck_dispatched",
dispatch_id=dispatch_row.id,
event_id=event.id,
event_type=event.event_type,
)
return dispatch_row.id
async def _record_skipped_dispatch(event: AiGovernanceEvent, decision: FusedDecision) -> None:
"""留下 skipped 派遣 trail讓治理事件的 AI 判斷不再只存在 log 裡。
skip 不代表「事情解決」,而是 DecisionFusion 判斷目前不能自動派遣。
這筆 terminal dispatch row 讓 AwoooP / Work Items 可顯示:
AI 已判斷、為何沒有自動處理、下一步需要誰接手。
"""
try:
row = await create_dispatch(
event_id=event.id,
event_type=event.event_type,
executor_type="manual",
playbook_id=decision.matched_playbook_id,
decision_context=_build_decision_context(event, decision),
created_by="governance_dispatcher_skip",
)
await transition_status(row.id, "pending", "skipped")
except DispatchAlreadyActive:
logger.info(
"governance_skip_dispatch_race_condition",
event_id=event.id,
event_type=event.event_type,
)
except Exception as exc:
logger.warning(
"governance_skip_dispatch_record_failed",
event_id=event.id,
event_type=event.event_type,
error=str(exc),
)
async def _poll_unresolved_events() -> list[AiGovernanceEvent]:
"""查詢 unresolved 且 event_type 在 dispatchable 範圍內的治理事件。
Returns:
最多 _MAX_EVENTS_PER_CYCLE 筆 AiGovernanceEvent ORM 物件列表
"""
async with get_db_context() as db:
active_dispatch_exists = exists().where(
GovernanceRemediationDispatch.governance_event_id == AiGovernanceEvent.id,
GovernanceRemediationDispatch.dispatch_status.in_(list(ACTIVE_STATUSES)),
)
result = await db.execute(
select(AiGovernanceEvent)
.where(AiGovernanceEvent.resolved.is_(False))
.where(AiGovernanceEvent.event_type.in_(list(_DISPATCHABLE_EVENT_TYPES)))
.where(~active_dispatch_exists)
.order_by(AiGovernanceEvent.triggered_at.asc())
.limit(_MAX_EVENTS_PER_CYCLE)
)
rows = result.scalars().all()
return list(rows)
def _build_decision_context(
event: AiGovernanceEvent,
decision: FusedDecision,
) -> dict[str, Any]:
"""建構 decision_context JSONB完整三維輸入快照
規格對齊 DecisionContextV1models/governance_dispatch.py
但直接建 dict 不依賴 Pydantic model避免引入額外依賴
Fields:
version: schema 版本v1
trigger_source: 觸發來源
suggested_action: AI 建議的修復動作摘要
fusion_scores: 三維分數詳情
llm_reasoning: LLM 原始輸出摘要
mcp_snapshot: MCP 情報快照
decision_path: 決策分支
confidence: 最終融合信心度
"""
details = event.details if isinstance(event.details, dict) else {}
next_action = _extract_event_next_action(details, decision.recommended_action)
workflow = _build_event_workflow(event, details, decision, next_action)
ownership = details.get("ownership") if isinstance(details.get("ownership"), dict) else {}
return {
"version": "v1",
"trigger_source": "governance_dispatcher",
"triggered_metric": event.event_type,
"metric_value": decision.confidence,
"threshold": 0.85, # TODO: 移到 settings
"suggested_action": decision.recommended_action,
"next_action": next_action,
"workflow": workflow,
"ownership": ownership,
"fusion_scores": {
"llm_score": round(decision.llm_score, 4),
"playbook_score": round(decision.playbook_score, 4),
"mcp_score": round(decision.mcp_score, 4),
"confidence": round(decision.confidence, 4),
"weights": {"llm": 0.4, "playbook": 0.3, "mcp": 0.3}, # TODO: 移到 settings
},
"llm_reasoning": decision.llm_reasoning,
"mcp_snapshot": decision.mcp_snapshot,
"decision_path": decision.decision_path,
"matched_playbook_id": decision.matched_playbook_id,
"playbook_trust": decision.playbook_trust,
"affected_resources": [event.event_type],
"extra": {
"event_id": event.id,
"event_details_keys": list(details.keys()),
"ownership": ownership,
},
}
def _is_kb_growth_healthcheck_event(event: AiGovernanceEvent) -> bool:
if event.event_type != "knowledge_degradation":
return False
details = event.details if isinstance(event.details, dict) else {}
remediation = details.get("remediation")
if isinstance(remediation, dict) and remediation.get("next_action") == "run_kb_growth_healthcheck":
return True
return details.get("next_action") == "run_kb_growth_healthcheck"
async def _has_kb_growth_review_draft(event_id: str) -> bool:
"""同一治理事件已有 Hermes review draft 時,不再重複建立 pending dispatch。"""
try:
rows = await list_by_event(event_id)
except Exception as exc:
logger.warning(
"governance_kb_healthcheck_history_lookup_failed",
event_id=event_id,
error=str(exc),
)
return False
for row in rows:
if row.executor_type != "hermes_kb_growth_healthcheck":
continue
if row.dispatch_status != "succeeded":
continue
context = row.decision_context if isinstance(row.decision_context, dict) else {}
workflow = context.get("workflow") if isinstance(context.get("workflow"), dict) else {}
worker_result = (
context.get("worker_result")
if isinstance(context.get("worker_result"), dict)
else {}
)
if workflow.get("current_stage") == "waiting_owner_review":
return True
if worker_result.get("status") == "draft_created":
return True
return False
def _extract_event_next_action(details: dict[str, Any], fallback: str) -> str:
remediation = details.get("remediation")
if isinstance(remediation, dict):
next_action = remediation.get("next_action")
if isinstance(next_action, str) and next_action:
return next_action[:160]
next_action = details.get("next_action")
if isinstance(next_action, str) and next_action:
return next_action[:160]
return fallback[:160]
def _build_event_workflow(
event: AiGovernanceEvent,
details: dict[str, Any],
decision: FusedDecision,
next_action: str,
) -> dict[str, Any]:
"""把治理事件轉成 AwoooP 可讀 workflow metadata。
這不是自動修復決策;最終是否執行仍由 DecisionFusion 與 dispatch state 決定。
"""
base_steps = ["detected", "ai_analyzed"]
if next_action == "run_kb_growth_healthcheck":
steps = [
*base_steps,
"queued_kb_healthcheck",
"draft_km_updates",
"waiting_owner_review",
"km_writeback_after_approval",
"stale_ratio_recheck",
]
stage_by_status = {
"pending": "queued_kb_healthcheck",
"dispatched": "queued_kb_healthcheck",
"executing": "draft_km_updates",
"succeeded": "stale_ratio_recheck",
"failed": "needs_manual_km_triage",
"skipped": "waiting_owner_review",
"cancelled": "cancelled",
}
current_stage = (
"waiting_owner_review"
if decision.decision_path == "skip"
else "queued_kb_healthcheck"
)
work_kind = "kb_growth_healthcheck"
else:
steps = [*base_steps, "dispatch_decision", "operator_review"]
stage_by_status = {
"pending": "queued_for_review",
"dispatched": "dispatched",
"executing": "executing",
"succeeded": "completed",
"failed": "failed",
"skipped": "skipped",
"cancelled": "cancelled",
}
current_stage = stage_by_status.get("pending", "queued_for_review")
work_kind = "governance_remediation"
return {
"work_item_id": f"governance:{event.event_type}:{event.id}",
"work_kind": work_kind,
"current_stage": current_stage,
"steps": steps,
"stage_by_dispatch_status": stage_by_status,
"next_action": next_action,
"needs_human_review": decision.decision_path != "auto_dispatch",
"writes_km_without_approval": False,
"impact": details.get("impact") if isinstance(details.get("impact"), dict) else {},
}
# =============================================================================
# 排程迴圈(仿 run_governance_loop 模式)
# =============================================================================
async def run_governance_dispatcher_loop(
interval_seconds: int = _DISPATCHER_INTERVAL_SEC,
) -> None:
"""每 30s 掃 unresolved 事件 → dispatch。
仿照 governance_agent.run_governance_loop 模式:
- while True → try/except → sleep
- 任一事件失敗不阻塞其他事件(獨立 try/except
- CancelledError 向上傳播(允許 graceful shutdown
2026-05-03 ogt + Claude Sonnet 4.6(亞太): Wave 2E 實作
"""
logger.info(
"governance_dispatcher_loop_started",
interval_seconds=interval_seconds,
max_events_per_cycle=_MAX_EVENTS_PER_CYCLE,
)
while True:
try:
events = await _poll_unresolved_events()
if events:
logger.info(
"governance_dispatcher_cycle_start",
event_count=len(events),
)
dispatched = 0
skipped = 0
for event in events:
try:
result = await dispatch_governance_event(event)
if result is not None:
dispatched += 1
else:
skipped += 1
except asyncio.CancelledError:
raise
except Exception as exc:
logger.warning(
"governance_dispatcher_event_error",
event_id=event.id,
event_type=event.event_type,
error=str(exc),
)
skipped += 1
logger.info(
"governance_dispatcher_cycle_done",
total=len(events),
dispatched=dispatched,
skipped=skipped,
)
else:
logger.debug("governance_dispatcher_no_events")
except asyncio.CancelledError:
logger.info("governance_dispatcher_loop_cancelled")
raise
except Exception as exc:
logger.warning("governance_dispatcher_loop_error", error=str(exc))
await asyncio.sleep(interval_seconds)