Files
awoooi/apps/api/src/services/pre_decision_investigator.py
Your Name 025a493f06
Some checks failed
run-migration / migrate (push) Failing after 12s
CD Pipeline / build-and-deploy (push) Has been cancelled
feat(p3.2+adr-100): Model Version Tracker + SLO 自治 + KB rot cleaner
Wave 8 P3.2 模型版本追蹤 + ADR-100 SLO 自我治理 + 配套:

P3.2 — Model Version Tracking:
- model_version_probe.py (268 行) — 探測 Ollama / OpenRouter 等 provider 的 model version
- model_version_tracker.py (101 行) — 對齊 PG provider_version_history 表
- migrations/p3_2_provider_version_history.sql + rollback — 25 行 schema
- db/models.py +32 行 — ProviderVersionHistory ORM

ADR-100 — AI 自主化 SLO:
- docs/adr/ADR-100-ai-autonomous-slo.md (167 行) — 飛輪 SLO 設計與閾值
- ops/monitoring/slo-rules.yml (254 行) — Prometheus SLO recording rules + alerts
- ops/monitoring/tests/test_slo_rules.yaml (242 行) — promtool unit tests

整合修改:
- main.py +72 行 — Lifespan 啟動 model_version_probe + KB rot cleaner schedule
- gitea_webhook.py +45 行 — webhook 接收 model 版本變化通知
- ci_auto_repair.py / evidence_snapshot.py / pre_decision_investigator.py — 配合接線

新測試:
- test_kb_rot_cleaner_schedule.py (120 行) — 9 tests pass
- test_slo_rules.yaml — promtool 驗收

Tests: 9 passed (test_kb_rot_cleaner_schedule)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Multiple Engineers (P3.2 + ADR-100) <noreply@anthropic.com>
2026-04-27 14:54:19 +08:00

647 lines
26 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.
"""
AWOOOI AIOps Phase 1 — 決策前情報調查員
==========================================
在 LLM 做出任何決策之前,主動呼叫 MCP 工具蒐集 8D 感官情報,
並將結果封裝為不可變的 EvidenceSnapshot。
設計原則:
1. 工具動態選擇(不 hardcode— 從 MCPToolRegistry.suggest_tools() 取清單
2. 並行蒐集asyncio.gather— 8D 感官同步展開P99 < 8s
3. 部分失敗不阻塞Graceful Degradation— 某感官失敗標 mcp_health[tool]=False繼續其他
4. Prompt Injection 防護Sanitization— 所有文字輸入先過 SanitizationService
5. Redis 快取30s 滑動窗口)— 防告警風暴重複打 K8s API
快取 Key 格式:
evidence:{sha256(alertname + namespace + pod_name + severity)[:12]}
P99 延遲目標:< 8000ms超時個別工具丟棄不阻塞主路徑
Token Budget單次 evidence_summary ≤ 32,000 chars≈ 8K tokens
ADR-081: PreDecisionInvestigator + EvidenceSnapshot
MASTER §3.1.3 (A)(B)(C)
2026-04-15 ogt + Claude Sonnet 4.6 (亞太): Phase 1 初始建立
"""
from __future__ import annotations
import asyncio
import hashlib
import json
import time
from typing import TYPE_CHECKING, Any
import structlog
from src.services.evidence_snapshot import EvidenceSnapshot
from src.services.mcp_tool_registry import RegisteredTool, SensorDimension, get_mcp_tool_registry
from src.services.sanitization_service import sanitize, sanitize_dict_values
if TYPE_CHECKING:
from src.models.incident import Incident
logger = structlog.get_logger(__name__)
# 單一 MCP 工具呼叫的超時(秒)— 超過則丟棄,不阻塞主路徑
MCP_TOOL_TIMEOUT_SEC = 5.0
# 全局 Investigator 超時P99 目標)
INVESTIGATOR_TIMEOUT_SEC = 8.0
# Redis 快取 TTL
CACHE_TTL_SEC = 30
class PreDecisionInvestigator:
"""
決策前情報調查員。
每個 Incident 在 LLM 推理前,先由此服務蒐集 8D 感官數據,
產出 EvidenceSnapshot 作為 LLM 的「眼睛」。
Usage:
investigator = PreDecisionInvestigator()
snapshot = await investigator.investigate(incident)
# snapshot.evidence_summary 可直接貼進 LLM prompt
"""
def __init__(self) -> None:
self._registry = get_mcp_tool_registry()
async def investigate(self, incident: "Incident") -> EvidenceSnapshot:
"""
主入口:為 Incident 蒐集 8D 感官情報。
流程:
1. 計算 fingerprint → 查 Redis cache
2. cache miss → 並行呼叫 suggest_tools() 回傳的工具
3. 每個工具結果過 SanitizationService
4. 組裝 EvidenceSnapshot → 寫 incident_evidence 表
5. 寫 Redis cache
Args:
incident: 目前處理中的 Incident
Returns:
EvidenceSnapshot: 含 evidence_summary 的完整快照
(即使所有 MCP 失敗也回傳空快照,不 raise
"""
start_ms = int(time.monotonic() * 1000)
incident_id = incident.incident_id if hasattr(incident, "incident_id") else str(incident.id)
# 1. 計算 fingerprint 並查 cache
fingerprint = _compute_fingerprint(incident)
cached = await _get_cache(fingerprint)
if cached is not None:
logger.debug("investigator_cache_hit", incident_id=incident_id, fingerprint=fingerprint)
return cached
# 2. 取工具清單
alertname = _get_alertname(incident)
labels = _get_labels(incident)
tools = self._registry.suggest_tools(
alertname=alertname,
incident_labels=labels,
)
snapshot = EvidenceSnapshot(incident_id=incident_id)
snapshot.sensors_attempted = len(tools)
# 告警基礎資訊sensors=0 時 AI 至少知道是什麼告警
# 2026-04-16 ogt + Claude Sonnet 4.6: 修復空 evidence → ABSTAIN 問題
sigs = getattr(incident, "signals", []) or []
sig0 = sigs[0] if sigs else None
snapshot.alert_info = {
"alert_name": alertname or getattr(incident, "alertname", "") or "",
"severity": str(getattr(incident, "severity", "")),
"affected_services": getattr(incident, "affected_services", []) or [],
"labels": labels,
"annotations": (
({k: v for k, v in (sig0.annotations or {}).items()} if sig0 else {})
),
"source": getattr(sig0, "source", "") if sig0 else "",
"incident_id": incident_id,
}
# 3. 並行蒐集(整體 INVESTIGATOR_TIMEOUT_SEC 保護)
try:
await asyncio.wait_for(
self._collect_all(snapshot, tools, incident),
timeout=INVESTIGATOR_TIMEOUT_SEC,
)
except asyncio.TimeoutError:
logger.warning(
"investigator_global_timeout",
incident_id=incident_id,
timeout_sec=INVESTIGATOR_TIMEOUT_SEC,
)
# 4. 記錄耗時
snapshot.collection_duration_ms = int(time.monotonic() * 1000) - start_ms
# 4.5 Phase 4 8D 感官增強:填入動態異常上下文(非阻塞,失敗不影響主路徑)
try:
await asyncio.wait_for(
self._collect_phase4_anomalies(snapshot),
timeout=2.0, # Phase 4 感官最多等 2s不能拖慢主路徑
)
except asyncio.TimeoutError:
logger.warning("phase4_anomaly_collect_timeout", incident_id=incident_id)
except Exception:
logger.exception("phase4_anomaly_collect_error", incident_id=incident_id)
# 4.6 P3.1-T2 by Claude 2026-04-27 — DiagnosisAggregator Pod 深診斷守門ENABLE_DIAGNOSIS_AGGREGATOR
# Conservative 策略:預設關閉,避免與 MCP sensor 重複收集 K8s+SignOz 資料。
# 待重疊分析完成確認互補性後,由統帥設定 ENABLE_DIAGNOSIS_AGGREGATOR=true 啟用。
try:
from src.core.config import settings as _settings
if _settings.ENABLE_DIAGNOSIS_AGGREGATOR:
await asyncio.wait_for(
self._collect_diagnosis_aggregator(snapshot, incident),
timeout=3.0,
)
except asyncio.TimeoutError:
logger.warning("diagnosis_aggregator_collect_timeout", incident_id=incident_id)
except Exception:
logger.warning("diagnosis_aggregator_collect_failed", incident_id=incident_id)
# 5. 組裝 summary
snapshot.evidence_summary = snapshot.build_summary()
# 6. 持久化fire-and-awaitPhase 3 學習閉環依賴此表)
try:
await snapshot.save()
except Exception:
logger.exception("investigator_save_failed", incident_id=incident_id)
# 不 raisesnapshot 仍可用於決策,存儲失敗不阻塞主路徑
# 7. 寫 cache
await _set_cache(fingerprint, snapshot)
logger.info(
"investigator_done",
incident_id=incident_id,
sensors_attempted=snapshot.sensors_attempted,
sensors_succeeded=snapshot.sensors_succeeded,
duration_ms=snapshot.collection_duration_ms,
)
return snapshot
async def _collect_diagnosis_aggregator(
self,
snapshot: EvidenceSnapshot,
incident: "Incident", # noqa: ARG002 — 路徑 A 從 snapshot 取 raw 資料,不需 incident labels
) -> None:
"""
2026-04-27 P3.1-T2-PathA by Claude — DiagAggregator 信號分類層補 PDI
路徑 A用 DA 的信號分類補 PDI raw 資料。
不重複收集 K8s/SignOz只取 raw 資料(來自 PDI 已收集的 D1/D2/D3
丟給 DA.classify_signals_from_raw() 做業務邏輯分類OOMKilled/CrashLoop/HighLatency 等)。
結果以結構化 dict 存入 snapshot.extra_diagnosis。
"""
from src.services.diagnosis_aggregator import get_diagnosis_aggregator
try:
aggregator = get_diagnosis_aggregator()
# 從 snapshot 取 PDI 已收集的 raw 資料(不打外部 API
signals = aggregator.classify_signals_from_raw(
k8s_data=snapshot.k8s_state,
logs_data=snapshot.recent_logs,
metrics_data=snapshot.metrics_snapshot,
)
result = {
"signal_count": len(signals),
"signals": [s.to_dict() if hasattr(s, "to_dict") else str(s) for s in signals],
}
snapshot.extra_diagnosis = result
logger.debug(
"diagnosis_aggregator_signal_classify_done",
incident_id=snapshot.incident_id,
signal_count=len(signals),
)
except Exception as e:
logger.warning("diagnosis_aggregator_signal_classify_failed", error=str(e))
async def _collect_phase4_anomalies(self, snapshot: EvidenceSnapshot) -> None:
"""
Phase 4 8D 感官增強:從 ProactiveInspector 快取 + LogAnomalyDetector
讀取動態異常上下文,填入 snapshot.anomaly_context。
設計原則:
- 只讀快取,不觸發新的 Prometheus 查詢(避免延遲)
- 失敗靜默降級(外層已包 try/except + timeout
- Phase 4 Shadow Mode 時資料仍填入(供 LLM 參考,不觸發 Alert
2026-04-15 ogt + Claude Sonnet 4.6(亞太): Phase 4 8D 升級
"""
from src.services.proactive_inspector import get_proactive_inspector
from src.services.log_anomaly_detector import get_log_anomaly_detector
context: dict[str, Any] = {}
# 1. 讀取最近一次巡檢報告ProactiveInspector 每 5 分鐘更新一次)
inspector = get_proactive_inspector()
last_report = inspector.get_last_report()
if last_report is not None:
context["last_inspection_at"] = last_report.finished_at
context["shadow_mode"] = last_report.shadow_mode
if last_report.baseline_anomalies > 0:
context["baseline_anomalies"] = [
{
"metric": a.metric_name,
"severity": a.severity,
"description": a.description,
"deviation_sigma": a.deviation_sigma,
}
for a in last_report.alerts
if a.alert_type == "dynamic_anomaly"
]
if last_report.trend_breaches > 0:
context["trend_breaches"] = [
{
"metric": a.metric_name,
"description": a.description,
"breach_in_hours": a.predicted_breach_hours,
}
for a in last_report.alerts
if a.alert_type == "trend_breach"
]
# 2. 讀取最近新 log pattern最多 5 個)
detector = get_log_anomaly_detector()
recent_patterns = await detector.get_recent_new_patterns(limit=5)
if recent_patterns:
context["recent_log_patterns"] = [
{
"template": p.get("template", "")[:200],
"cluster_id": p.get("cluster_id", ""),
"source": p.get("source", ""),
}
for p in recent_patterns
]
if context:
snapshot.anomaly_context = context
logger.debug(
"phase4_anomaly_context_collected",
has_baseline=bool(context.get("baseline_anomalies")),
has_trends=bool(context.get("trend_breaches")),
log_patterns=len(context.get("recent_log_patterns", [])),
)
async def _collect_all(
self,
snapshot: EvidenceSnapshot,
tools: list[RegisteredTool],
incident: "Incident",
) -> None:
"""並行呼叫所有工具,結果填入 snapshot。"""
params = _build_tool_params(incident)
tasks = [
self._collect_one(snapshot, reg, params)
for reg in tools
]
await asyncio.gather(*tasks, return_exceptions=True)
async def _collect_one(
self,
snapshot: EvidenceSnapshot,
reg: RegisteredTool,
params: dict[str, Any],
) -> None:
"""執行單一 MCP 工具呼叫,結果填入對應感官維度。"""
tool_name = reg.tool.name
snapshot.mcp_health[tool_name] = False # 預設失敗,成功後覆蓋
_started = asyncio.get_event_loop().time()
_mcp_status = "failed"
_mcp_error = None
try:
result = await asyncio.wait_for(
reg.provider.execute(tool_name, params),
timeout=MCP_TOOL_TIMEOUT_SEC,
)
if not result.success:
logger.warning(
"investigator_tool_failed",
tool=tool_name,
error=result.error,
)
_mcp_error = str(result.error)[:200] if result.error else "unknown"
return
snapshot.mcp_health[tool_name] = True
snapshot.sensors_succeeded += 1
_mcp_status = "success"
# 依感官維度填入對應欄位
raw = result.output
_fill_snapshot_dimension(snapshot, reg, raw)
except asyncio.TimeoutError:
logger.warning("investigator_tool_timeout", tool=tool_name, timeout=MCP_TOOL_TIMEOUT_SEC)
_mcp_status = "timeout"
_mcp_error = f"timeout {MCP_TOOL_TIMEOUT_SEC}s"
except Exception as _e:
logger.exception("investigator_tool_error", tool=tool_name)
_mcp_status = "error"
_mcp_error = str(_e)[:200]
finally:
# 2026-04-18 ADR-090-D: MCP 呼叫入 timeline_events(MASTER §7.1 #4 KPI)
try:
_duration_ms = int((asyncio.get_event_loop().time() - _started) * 1000)
asyncio.create_task(_log_mcp_call_to_timeline(
snapshot_incident_id=getattr(snapshot, "incident_id", None),
provider_name=reg.provider.name,
tool_name=tool_name,
status=_mcp_status,
error=_mcp_error,
duration_ms=_duration_ms,
))
except Exception:
pass
async def _log_mcp_call_to_timeline(
snapshot_incident_id: str | None,
provider_name: str,
tool_name: str,
status: str,
error: str | None,
duration_ms: int,
) -> None:
"""
2026-04-18 ADR-090-D: MCP 呼叫寫入 timeline_events,支援 MASTER §7.1 #4
"MCP 呼叫次數/24h > 0" KPI 量測。
"""
try:
from sqlalchemy import text as _sql
from src.db.base import get_db_context
import json as _json
_description = _json.dumps({
"provider": provider_name,
"tool": tool_name,
"status": status,
"error": error,
"duration_ms": duration_ms,
}, ensure_ascii=False)
async with get_db_context() as _db:
await _db.execute(
_sql("""
INSERT INTO timeline_events (
incident_id, event_type, status, title, description, actor,
actor_role, created_at
) VALUES (
:iid, 'mcp_call', :st, :tl, :desc, :actor,
'mcp', NOW()
)
"""),
{
"iid": snapshot_incident_id or "unknown",
"st": status,
"tl": f"MCP {provider_name}.{tool_name}"[:100],
"desc": _description[:500],
"actor": provider_name[:50],
},
)
except Exception:
# 靜默失敗,timeline_events 是稽核,不能反噬 MCP 主流程
pass
# ─────────────────────────────────────────────────────────────────────────────
# Snapshot dimension mapping
# ─────────────────────────────────────────────────────────────────────────────
def _fill_snapshot_dimension(
snapshot: EvidenceSnapshot,
reg: RegisteredTool,
raw: Any,
) -> None:
"""將工具輸出填入 EvidenceSnapshot 對應感官欄位。"""
if raw is None:
return
for dim in reg.dimensions:
if dim == SensorDimension.D1_K8S_STATE:
if isinstance(raw, dict):
snapshot.k8s_state = sanitize_dict_values(raw, "k8s_state")
else:
snapshot.k8s_state = {"raw": sanitize(str(raw), "k8s_state")}
elif dim == SensorDimension.D2_LOGS:
text = raw if isinstance(raw, str) else json.dumps(raw, ensure_ascii=False)
snapshot.recent_logs = sanitize(text, "recent_logs")
elif dim == SensorDimension.D3_METRICS:
if isinstance(raw, dict):
snapshot.metrics_snapshot = sanitize_dict_values(raw, "metrics")
else:
snapshot.metrics_snapshot = {"raw": str(raw)}
elif dim == SensorDimension.D4_CHANGES:
# Gate 1 fix: 過 sanitize_dict_valuesArgoCD diff / Git commit message 可含注入
if isinstance(raw, list):
snapshot.recent_deployments = [
sanitize_dict_values(item, "d4_changes") if isinstance(item, dict)
else {"raw": sanitize(str(item), "d4_changes")}
for item in raw
]
elif isinstance(raw, dict):
snapshot.recent_deployments = [sanitize_dict_values(raw, "d4_changes")]
elif dim == SensorDimension.D5_BUSINESS:
# Gate 1 fix: 業務指標可能含 Grafana annotation 等外部字串
if isinstance(raw, dict):
snapshot.business_metrics = sanitize_dict_values(raw, "d5_business")
elif dim == SensorDimension.D6_HISTORY:
text = raw if isinstance(raw, str) else json.dumps(raw, ensure_ascii=False)
snapshot.historical_context = sanitize(text, "historical_context")[:2000]
elif dim == SensorDimension.D7_PEERS:
# Gate 1 fix: Pod annotation / label 可含注入
if isinstance(raw, dict):
snapshot.peer_health = sanitize_dict_values(raw, "d7_peers")
elif dim == SensorDimension.D8_TOPOLOGY:
# Gate 1 fix: Istio / service mesh metadata 可含外部字串
if isinstance(raw, dict):
snapshot.dependency_topology = sanitize_dict_values(raw, "d8_topology")
# ─────────────────────────────────────────────────────────────────────────────
# Helpers
# ─────────────────────────────────────────────────────────────────────────────
def _get_alertname(incident: "Incident") -> str:
if incident.signals:
sig = incident.signals[0]
# alert_name 在 Signal 頂層欄位labels["alertname"] 是 Prometheus 慣例但可能為空
return (
getattr(sig, "alert_name", "")
or sig.labels.get("alertname", "")
or getattr(incident, "alertname", "")
or ""
)
return getattr(incident, "alertname", "") or ""
def _get_labels(incident: "Incident") -> dict[str, Any]:
if incident.signals:
sig = incident.signals[0]
labels = sig.labels or {}
# 若 labels 缺少 alertname補上頂層的 alert_name
if "alertname" not in labels and getattr(sig, "alert_name", ""):
labels = dict(labels)
labels["alertname"] = sig.alert_name
return labels
return {}
_SHORT_HOST_MAP: dict[str, str] = {
"110": "192.168.0.110",
"120": "192.168.0.120",
"121": "192.168.0.121",
"188": "192.168.0.188",
}
"""
Prometheus instance label 使用短主機名(如 "110:9100"
SSH_MCP_ALLOWED_HOSTS 使用完整 IP"192.168.0.110")。
此映射表做轉換,避免 SSH 工具 "Host 'X' not in SSH_MCP_ALLOWED_HOSTS" 失敗。
2026-04-16 ogt + Claude Sonnet 4.6: 修復 sensors 7/8 失敗根因
"""
def _build_prometheus_query(alertname: str, namespace: str, pod_name: str) -> str:
"""依告警類型生成 Prometheus PromQL 查詢(供 prometheus_query tool 使用)。
2026-04-24 ogt + Claude Sonnet 4.6: P0.4 fix — _build_tool_params 補 query 欄位"""
an = alertname.lower()
# CPU / 負載
if any(k in an for k in ("cpu", "load", "throttl")):
filter_pod = f',pod=~"{pod_name}.*"' if pod_name else ""
return f'avg(rate(container_cpu_usage_seconds_total{{namespace="{namespace}"{filter_pod}}}[5m]))'
# 記憶體
elif any(k in an for k in ("memory", "mem", "oom")):
filter_pod = f',pod=~"{pod_name}.*"' if pod_name else ""
return f'avg(container_memory_working_set_bytes{{namespace="{namespace}"{filter_pod}}}) / 1048576'
# CrashLoop / 重啟
elif any(k in an for k in ("crash", "restart", "oom", "backoff")):
return f'sum(increase(kube_pod_container_status_restarts_total{{namespace="{namespace}"}}[15m]))'
# 磁碟 / 儲存
elif any(k in an for k in ("disk", "storage", "pvc", "volume", "capacity")):
return 'sum(kubelet_volume_stats_used_bytes) by (persistentvolumeclaim)'
# HTTP / 可用性
elif any(k in an for k in ("http", "error", "5xx", "probe", "down", "unhealthy")):
return '1 - avg(probe_success)'
# Pod / Container 狀態
elif any(k in an for k in ("pod", "container", "deploy", "replicaset")):
return f'kube_pod_status_phase{{namespace="{namespace}"}}'
# 通用 fallback
else:
return f'up{{namespace="{namespace}"}}'
def _build_tool_params(incident: "Incident") -> dict[str, Any]:
"""從 Incident 提取 MCP 工具呼叫所需的公共參數。"""
labels = _get_labels(incident)
raw_host = labels.get("instance", "").split(":")[0] or labels.get("host", "")
host = _SHORT_HOST_MAP.get(raw_host, raw_host) # 短名 → 完整 IP
namespace = labels.get("namespace", "awoooi-prod")
pod_name = labels.get("pod", labels.get("name", ""))
alertname = labels.get("alertname", "")
return {
"namespace": namespace,
"pod_name": pod_name,
"deployment": labels.get("deployment", ""),
"host": host,
"container": labels.get("container", labels.get("name", "")),
"alertname": alertname,
# P0.4 fix 2026-04-24 ogt + Claude Sonnet 4.6: Prometheus tool 需要 query 欄位
# 原本缺少此欄位 → prometheus_query/range tool 傳入空 query → 回傳 error dict
"query": _build_prometheus_query(alertname, namespace, pod_name),
}
def _compute_fingerprint(incident: "Incident") -> str:
"""計算 cache key 用的 fingerprint。"""
labels = _get_labels(incident)
key = ":".join([
labels.get("alertname", ""),
labels.get("namespace", ""),
labels.get("pod", labels.get("name", "")),
labels.get("severity", ""),
])
return hashlib.sha256(key.encode()).hexdigest()[:16]
async def _get_cache(fingerprint: str) -> EvidenceSnapshot | None:
"""從 Redis 取快取的 EvidenceSnapshot若存在"""
try:
from src.core.redis_client import get_redis
redis = get_redis()
key = f"evidence:{fingerprint}"
raw = await redis.get(key)
if raw is None:
return None
data = json.loads(raw)
snap = EvidenceSnapshot(
incident_id=data.get("incident_id", ""),
snapshot_id=data.get("snapshot_id", ""),
)
snap.evidence_summary = data.get("evidence_summary", "")
snap.k8s_state = data.get("k8s_state")
snap.recent_logs = data.get("recent_logs")
snap.metrics_snapshot = data.get("metrics_snapshot")
snap.mcp_health = data.get("mcp_health", {})
snap.sensors_attempted = data.get("sensors_attempted", 0)
snap.sensors_succeeded = data.get("sensors_succeeded", 0)
return snap
except Exception:
return None
async def _set_cache(fingerprint: str, snapshot: EvidenceSnapshot) -> None:
"""將 EvidenceSnapshot 寫入 Redis cache。"""
try:
from src.core.redis_client import get_redis
redis = get_redis()
key = f"evidence:{fingerprint}"
payload = {
"incident_id": snapshot.incident_id,
"snapshot_id": snapshot.snapshot_id,
"evidence_summary": snapshot.evidence_summary,
"k8s_state": snapshot.k8s_state,
"recent_logs": snapshot.recent_logs,
"metrics_snapshot": snapshot.metrics_snapshot,
"mcp_health": snapshot.mcp_health,
"sensors_attempted": snapshot.sensors_attempted,
"sensors_succeeded": snapshot.sensors_succeeded,
}
await redis.set(key, json.dumps(payload, ensure_ascii=False), ex=CACHE_TTL_SEC)
except Exception:
pass # cache 失敗不影響主路徑
# ─────────────────────────────────────────────────────────────────────────────
# Singleton
# ─────────────────────────────────────────────────────────────────────────────
_investigator: PreDecisionInvestigator | None = None
def get_pre_decision_investigator() -> PreDecisionInvestigator:
"""取得 PreDecisionInvestigator Singleton。"""
global _investigator
if _investigator is None:
_investigator = PreDecisionInvestigator()
return _investigator