feat(p3.2+adr-100): Model Version Tracker + SLO 自治 + KB rot cleaner
Some checks failed
run-migration / migrate (push) Failing after 12s
CD Pipeline / build-and-deploy (push) Has been cancelled

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>
This commit is contained in:
Your Name
2026-04-27 14:54:19 +08:00
parent 9908fdf50d
commit 025a493f06
14 changed files with 1370 additions and 36 deletions

View File

@@ -667,6 +667,51 @@ async def handle_workflow_run(
background_tasks.add_task(_create_ci_incident)
# 2026-04-27 P3.1-T3 by Claude — CI auto-repair 評估(孤立服務整合)
# 與 incident 路徑並行exception 全隔離不影響主流程
async def _evaluate_ci_repair() -> None:
try:
from src.services.ci_auto_repair import get_ci_auto_repair_service
ci_svc = get_ci_auto_repair_service()
# 推斷 error_typeworkflow name 含 deploy → deploy否則從 name 推斷
wf_lower = wf.name.lower()
if "deploy" in wf_lower:
error_type = "deploy"
elif "test" in wf_lower:
error_type = "test"
elif "lint" in wf_lower:
error_type = "lint"
elif "build" in wf_lower:
error_type = "build"
else:
error_type = "unknown"
decision = await ci_svc.evaluate_repair(
error_type=error_type,
workflow_name=wf.name,
repo=repo,
failure_context={
"branch": branch,
"sha": sha_short,
"run_url": run_url,
"status": wf.status,
"conclusion": wf.conclusion,
},
)
logger.info(
"ci_auto_repair_evaluated",
repo=repo,
workflow=wf.name,
error_type=error_type,
should_repair=decision.should_repair,
execution_decision=decision.execution_decision.value,
risk_level=decision.risk_level.value,
)
except Exception:
logger.exception("ci_auto_repair_evaluation_failed", repo=repo, workflow=wf.name)
background_tasks.add_task(_evaluate_ci_repair)
# 新增路徑:直接 Telegram 通知 (Task C 2026-04-25 ogt + Claude Sonnet 4.6)
# workflow name 含 deploy 關鍵字 → 部署失敗;否則 → 構建失敗
# 格式遵循 feedback_telegram_alert_format.md狀態 + 資源 + 連結

View File

@@ -17,6 +17,7 @@ from uuid import uuid4
from sqlalchemy import (
JSON,
BigInteger,
Boolean,
CheckConstraint,
DateTime,
Float,
@@ -1297,3 +1298,34 @@ class TrustRecordDB(Base):
Index("ix_trust_records_score", "score"),
Index("ix_trust_records_updated", "updated_at"),
)
# =============================================================================
# AIProviderVersionHistory - AI Provider 版本歷史
# 2026-04-27 P3.2.2 by Claude
# =============================================================================
class AIProviderVersionHistory(Base):
"""AI Provider 版本探測歷史記錄
每次 ModelVersionTracker.run_probe_cycle() 寫入一筆。
changed=True 表示本次探測到版本或 digest 與上一筆不同。
Migration: apps/api/migrations/p3_2_provider_version_history.sql
"""
__tablename__ = "ai_provider_version_history"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
provider: Mapped[str] = mapped_column(String(40), nullable=False, index=True)
model: Mapped[str] = mapped_column(String(100), nullable=False)
version: Mapped[str | None] = mapped_column(String(200), nullable=True)
digest: Mapped[str | None] = mapped_column(String(80), nullable=True)
captured_at: Mapped[datetime] = mapped_column(
DateTime(timezone=True), nullable=False, default=taipei_now,
)
prev_version: Mapped[str | None] = mapped_column(String(200), nullable=True)
changed: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
__table_args__ = (
Index("ix_provider_version_captured", "provider", "captured_at"),
)

View File

@@ -509,6 +509,51 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
except Exception as e:
logger.warning("knowledge_decay_loop_schedule_failed", error=str(e))
# ADR-087 Phase 6: KB 腐爛清理(月度)— 每月 1 號 03:00 台北時間
# 掃描 knowledge_entries 中腐爛條目(廢棄 K8s API / Prometheus pattern / 180d 未引用)
# 2026-04-27 P3.1-T3 by Claude
try:
from src.utils.timezone import now_taipei
from datetime import datetime as _dt
async def _run_kb_rot_cleaner_loop() -> None:
from src.jobs.kb_rot_cleaner import get_kb_rot_cleaner
import asyncio as _asyncio
while True:
try:
now = now_taipei()
# 計算下次月初 3 點(台北時間)
if now.day == 1 and now.hour < 3:
next_run = now.replace(hour=3, minute=0, second=0, microsecond=0)
elif now.month == 12:
next_run = now.replace(
year=now.year + 1, month=1, day=1,
hour=3, minute=0, second=0, microsecond=0,
)
else:
next_run = now.replace(
month=now.month + 1, day=1,
hour=3, minute=0, second=0, microsecond=0,
)
sleep_sec = (next_run - now).total_seconds()
logger.info("kb_rot_cleaner_next_run", next_run=next_run.isoformat(), sleep_sec=int(sleep_sec))
await _asyncio.sleep(sleep_sec)
try:
result = await get_kb_rot_cleaner().run()
logger.info("kb_rot_cleaner_completed", stale_count=result.stale_count, total=result.total_scanned)
except Exception as _e:
logger.exception("kb_rot_cleaner_failed", error=str(_e))
except _asyncio.CancelledError:
break
except Exception as _e:
logger.exception("kb_rot_cleaner_loop_error", error=str(_e))
await _asyncio.sleep(3600) # 1h 後重試
asyncio.create_task(_run_kb_rot_cleaner_loop())
logger.info("kb_rot_cleaner_loop_scheduled", trigger="monthly_day1_03h_taipei")
except Exception as e:
logger.warning("kb_rot_cleaner_loop_schedule_failed", error=str(e))
# ADR-083 Phase 3: Fine-tune JSONL 匯出(每週)— EvidenceSnapshot × AgentSession → JSONL
# 2026-04-15 ogt + Claude Sonnet 4.6(亞太): Phase 3 初始建立
try:
@@ -590,6 +635,33 @@ async def lifespan(_app: FastAPI) -> AsyncGenerator[None, None]:
except Exception as e:
logger.warning("ollama_failover_system_start_failed", error=str(e))
# 2026-04-27 P3.2.2 by Claude — AI Provider 版本追蹤(每 1 小時)
# 探測 5 Providerollama/ollama_188/gemini/claude/openclaw_nemo版本
# 寫入 ai_provider_version_history版本變更時 log warningP3.2.3 alerter 後續整合
try:
async def _run_model_version_tracker_loop() -> None:
from src.services.model_version_tracker import get_model_version_tracker
tracker = get_model_version_tracker()
while True:
try:
await asyncio.sleep(3600) # 每 1 小時
result = await tracker.run_probe_cycle()
logger.info(
"model_version_probe_cycle_done",
probed=result["probed"],
changed=result["changed"],
)
except asyncio.CancelledError:
break
except Exception as _loop_err:
logger.exception("model_version_tracker_loop_error", error=str(_loop_err))
await asyncio.sleep(60) # 錯誤後 1 分鐘重試
asyncio.create_task(_run_model_version_tracker_loop())
logger.info("model_version_tracker_scheduled", interval_sec=3600)
except Exception as e:
logger.warning("model_version_tracker_schedule_failed", error=str(e))
yield
# Shutdown

View File

@@ -175,7 +175,8 @@ class CIAutoRepairService:
)
# 2. 意圖分類
intent_result = self._intent_classifier.classify(analysis_text)
# 2026-04-27 P3.1-T3 by Claude — 修復缺失 awaitclassify 是 async method
intent_result = await self._intent_classifier.classify(analysis_text)
# 3. 複雜度評估
complexity_result = self._complexity_scorer.score(

View File

@@ -92,8 +92,9 @@ class EvidenceSnapshot:
# Phase 4 ADR-084: 動態異常感官DynamicBaseline + LogAnomaly + TrendPredictor
# 2026-04-15 ogt + Claude Sonnet 4.6(亞太): Phase 4 8D 升級
anomaly_context: dict[str, Any] | None = None # Phase 4 動態異常上下文
# 2026-04-27 P3.1-T2 by Claude — DiagnosisAggregator Pod 深診斷補充in-memory only不持久化
extra_diagnosis: str | None = None
# 2026-04-27 P3.1-T2-PathA by Claude — DiagAggregator 信號分類層in-memory only不持久化
# {"signal_count": int, "signals": [{"source", "signal_type", "severity", "message", ...}]}
extra_diagnosis: dict | None = None
# 感官品質
mcp_health: dict[str, bool] = field(default_factory=dict)
@@ -164,9 +165,12 @@ class EvidenceSnapshot:
parts.append(f"[依賴拓撲] {self.dependency_topology}")
if self.anomaly_context:
parts.append(f"[動態異常偵測]\n{self.anomaly_context}")
# 2026-04-27 P3.1-T2 by Claude — DiagnosisAggregator Pod 深診斷ENABLE_DIAGNOSIS_AGGREGATOR=true 時填入
if self.extra_diagnosis:
parts.append(f"[Pod深診斷]\n{self.extra_diagnosis}")
# 2026-04-27 P3.1-T2-PathA by Claude — DiagAggregator 信號分類層(結構化 dict
if self.extra_diagnosis and self.extra_diagnosis.get("signals"):
signals_str = ", ".join(
s.get("signal_type", "?") for s in self.extra_diagnosis["signals"][:5]
)
parts.append(f"[Signal Classification] {signals_str}")
# 感官品質報告
failed_tools = [t for t, ok in self.mcp_health.items() if not ok]

View File

@@ -0,0 +1,268 @@
"""
AI Provider 版本探測 — 為每個 Provider 提供 get_version()
每個 probe 函數獨立運作,失敗只影響該 provider不 crash 整批。
Provider:
- ollama : 192.168.0.111 Ollama (primary)
- ollama_188 : 192.168.0.188 Ollama (fallback)
- gemini : Google Gemini API (版本 = model name)
- claude : Anthropic Claude (版本 = model name)
- openclaw_nemo : OpenClaw NemoTron (版本 = OPENCLAW_DEFAULT_MODEL)
# 2026-04-27 P3.2.1 by Claude
"""
from __future__ import annotations
import asyncio
from dataclasses import dataclass, field
from datetime import datetime, timedelta, timezone
import structlog
logger = structlog.get_logger(__name__)
TAIPEI_TZ = timezone(timedelta(hours=8))
@dataclass
class ProviderVersionInfo:
"""AI Provider 版本快照"""
provider: str # "ollama" / "ollama_188" / "gemini" / "claude" / "openclaw_nemo"
model: str
version: str # version string 或 tagOllama 用 modified_at其他用 model name
digest: str | None = None # SHA256 digest僅 Ollama 有)
captured_at: datetime = field(default_factory=lambda: datetime.now(TAIPEI_TZ))
# =============================================================================
# Ollama Probe
# =============================================================================
async def probe_ollama_version(url: str, model: str) -> ProviderVersionInfo:
"""探測 Ollama111 或 188GET /api/tags 取 model digest + modified_at
Args:
url: Ollama base URL例如 "http://192.168.0.111:11434"
model: model name例如 "qwen2.5:7b-instruct"
Returns:
ProviderVersionInfo — provider 依 URL 自動判斷111=ollama, 否則=ollama_188
Raises:
ValueError: model 不在清單
httpx.HTTPError: 連線失敗
"""
import httpx
provider_name = "ollama" if "192.168.0.111" in url else "ollama_188"
async with httpx.AsyncClient(timeout=5.0) as client:
resp = await client.get(f"{url}/api/tags")
resp.raise_for_status()
models = resp.json().get("models", [])
for m in models:
if m.get("name") == model:
return ProviderVersionInfo(
provider=provider_name,
model=model,
version=m.get("modified_at", ""),
digest=m.get("digest"),
)
raise ValueError(f"Model {model!r} not found at {url}; available: {[m.get('name') for m in models]}")
# =============================================================================
# Gemini Probe
# =============================================================================
async def probe_gemini_version() -> ProviderVersionInfo:
"""探測 Gemini以設定的 model name 作為版本字串
Gemini model name 本身即版本識別碼e.g. "gemini-1.5-flash"
不需要額外 API 呼叫。若 GEMINI_API_KEY 存在則視為可用。
Returns:
ProviderVersionInfo — version = model name (e.g. "gemini-1.5-flash")
Raises:
RuntimeError: GEMINI_API_KEY 未設定
"""
from src.core.config import settings
api_key = settings.GEMINI_API_KEY
if not api_key:
raise RuntimeError("GEMINI_API_KEY not configured")
# Gemini 以 AI_FALLBACK_ORDER 中 "gemini" 的設定決定 model
# 實際 model name 在 ai_router 層,此處以已知預設值作為版本
# 透過 list models API 取得最新版本資訊
import httpx
async with httpx.AsyncClient(timeout=8.0) as client:
resp = await client.get(
"https://generativelanguage.googleapis.com/v1beta/models",
params={"key": api_key, "pageSize": 50},
)
resp.raise_for_status()
data = resp.json()
# 找第一個 GENERATE_CONTENT 功能的 gemini 模型版本
models = data.get("models", [])
gemini_model = None
for m in models:
name = m.get("name", "")
if "gemini" in name and "generateContent" in m.get("supportedGenerationMethods", []):
gemini_model = name.replace("models/", "")
break
if not gemini_model:
gemini_model = "gemini-unknown"
return ProviderVersionInfo(
provider="gemini",
model=gemini_model,
version=gemini_model,
digest=None,
)
# =============================================================================
# Claude Probe
# =============================================================================
async def probe_claude_version() -> ProviderVersionInfo:
"""Claudemodel name 即版本識別(例如 "claude-sonnet-4-6"
Anthropic 沒有 list models endpoint截至 2026-04
以設定中的 claude model name 作為版本字串。
若 CLAUDE_API_KEY 存在則視為可用。
Returns:
ProviderVersionInfo — version = model name來自設定或預設
Raises:
RuntimeError: CLAUDE_API_KEY 未設定
"""
from src.core.config import settings
api_key = settings.CLAUDE_API_KEY
if not api_key:
raise RuntimeError("CLAUDE_API_KEY not configured")
# Claude model name 從 AI_FALLBACK_ORDER 的 claude provider 取
# 直接使用已知 model name 作為版本Claude 不提供公開版本 API
model_name = "claude-sonnet-4-6" # 與 settings 中 ai_router 的 claude model 對齊
return ProviderVersionInfo(
provider="claude",
model=model_name,
version=model_name,
digest=None,
)
# =============================================================================
# OpenClaw NemoTron Probe
# =============================================================================
async def probe_openclaw_nemo_version() -> ProviderVersionInfo:
"""OpenClaw NemoTron版本字串從 settings.OPENCLAW_DEFAULT_MODEL 讀取
NemoTron 運行在 OpenClaw 188 節點(使用 Ollama 推理),
透過 OPENCLAW_URL /api/tags 探測,模型名稱即版本識別。
Returns:
ProviderVersionInfo — version = model tag (e.g. "deepseek-r1:14b")
Raises:
RuntimeError: OPENCLAW_DEFAULT_MODEL 未設定
httpx.HTTPError: 連線失敗
"""
from src.core.config import settings
model = settings.OPENCLAW_DEFAULT_MODEL
if not model:
raise RuntimeError("OPENCLAW_DEFAULT_MODEL not configured")
# OpenClaw 底層是 Ollama使用 OPENCLAW_URL 的 host:port 加上 Ollama port
# OPENCLAW_URL 是 8088OpenClaw APIOllama 通常在 11434
# 188 的 Ollama URL 若有設定則直接用 OLLAMA_FALLBACK_URL
ollama_188_url = settings.OLLAMA_FALLBACK_URL
if not ollama_188_url:
# fallback從 OPENCLAW_URL host 構建 Ollama URL
from urllib.parse import urlparse
parsed = urlparse(settings.OPENCLAW_URL)
ollama_188_url = f"{parsed.scheme}://{parsed.hostname}:11434"
import httpx
async with httpx.AsyncClient(timeout=5.0) as client:
resp = await client.get(f"{ollama_188_url}/api/tags")
resp.raise_for_status()
models = resp.json().get("models", [])
for m in models:
if m.get("name") == model:
return ProviderVersionInfo(
provider="openclaw_nemo",
model=model,
version=m.get("modified_at", model),
digest=m.get("digest"),
)
# model 不在清單時version 用 model namedigest=None
logger.warning("openclaw_nemo_model_not_in_tags", model=model, url=ollama_188_url)
return ProviderVersionInfo(
provider="openclaw_nemo",
model=model,
version=model,
digest=None,
)
# =============================================================================
# Probe All
# =============================================================================
async def probe_all_providers() -> list[ProviderVersionInfo]:
"""並行探測所有 5 個 AI Provider失敗的 provider 以 exception 跳過
Returns:
成功探測的 ProviderVersionInfo 列表(長度 0~5
Notes:
- 使用 return_exceptions=True 確保任一 provider 失敗不影響其他
- 每個 exception 都有對應的 log warning
"""
from src.core.config import settings
tasks = [
probe_ollama_version(settings.OLLAMA_URL, settings.OLLAMA_HEALTH_CHECK_MODEL),
probe_ollama_version(
settings.OLLAMA_FALLBACK_URL or settings.OLLAMA_URL,
settings.OLLAMA_HEALTH_CHECK_MODEL,
),
probe_gemini_version(),
probe_claude_version(),
probe_openclaw_nemo_version(),
]
raw = await asyncio.gather(*tasks, return_exceptions=True)
results: list[ProviderVersionInfo] = []
provider_labels = ["ollama", "ollama_188", "gemini", "claude", "openclaw_nemo"]
for label, outcome in zip(provider_labels, raw):
if isinstance(outcome, ProviderVersionInfo):
results.append(outcome)
else:
logger.warning(
"provider_probe_failed",
provider=label,
error=str(outcome),
)
return results

View File

@@ -0,0 +1,101 @@
"""
AI Provider 版本追蹤器 — 每小時探測 5 Provider 並寫入 DB偵測版本變更
職責:
- 排程呼叫 probe_all_providers()
- 與 DB 最後一筆比對,判斷 changed 旗標
- 寫入 AIProviderVersionHistory
- 若有 changed → 記錄 warning logP3.2.3 alerter 後續整合)
# 2026-04-27 P3.2.2 by Claude
"""
from __future__ import annotations
import asyncio
import structlog
logger = structlog.get_logger(__name__)
class ModelVersionTracker:
"""每小時探測所有 AI Provider 版本並寫入 DB"""
async def run_probe_cycle(self) -> dict:
"""執行一輪探測probe → 比對上一筆 → 寫入 DB
Returns:
dict with keys:
- probed : int — 成功探測的 provider 數
- changed : list[str] — 版本有變更的 provider names
"""
from src.db.base import get_db_context
from src.db.models import AIProviderVersionHistory
from src.services.model_version_probe import probe_all_providers
from sqlalchemy import desc, select
results = await probe_all_providers()
changed_providers: list[str] = []
async with get_db_context() as db:
for info in results:
# 取最近一筆比對
stmt = (
select(AIProviderVersionHistory)
.where(AIProviderVersionHistory.provider == info.provider)
.order_by(desc(AIProviderVersionHistory.captured_at))
.limit(1)
)
last = (await db.execute(stmt)).scalar_one_or_none()
changed = (
last is None
or last.version != info.version
or last.digest != info.digest
)
if changed:
changed_providers.append(info.provider)
db.add(
AIProviderVersionHistory(
provider=info.provider,
model=info.model,
version=info.version,
digest=info.digest,
captured_at=info.captured_at,
prev_version=last.version if last else None,
changed=changed,
)
)
await db.commit()
if changed_providers:
logger.warning(
"provider_version_changed",
changed=changed_providers,
total_probed=len(results),
)
else:
logger.info(
"provider_version_stable",
total_probed=len(results),
)
return {"probed": len(results), "changed": changed_providers}
# =============================================================================
# Singleton
# =============================================================================
_tracker: ModelVersionTracker | None = None
def get_model_version_tracker() -> ModelVersionTracker:
"""取得 ModelVersionTracker singleton"""
global _tracker
if _tracker is None:
_tracker = ModelVersionTracker()
return _tracker

View File

@@ -189,45 +189,42 @@ class PreDecisionInvestigator:
async def _collect_diagnosis_aggregator(
self,
snapshot: EvidenceSnapshot,
incident: "Incident",
incident: "Incident", # noqa: ARG002 — 路徑 A 從 snapshot 取 raw 資料,不需 incident labels
) -> None:
"""
P3.1-T2 by Claude 2026-04-27 — DiagnosisAggregator Pod 深診斷整合
2026-04-27 P3.1-T2-PathA by Claude — DiagAggregator 信號分類層補 PDI
僅在 ENABLE_DIAGNOSIS_AGGREGATOR=true 時呼叫(外層已守門)
從 incident labels 取 pod_name + namespace呼叫 DiagnosisAggregator
收集 K8s events + SignOz metrics結果存入 snapshot.extra_diagnosis
Conservative 策略說明:
DiagnosisAggregator 與 MCP sensorsD1_K8S_STATE / D3_METRICS存在資料重疊
本方法透過 feature flag 隔離,不影響主路徑。資料僅作補充,不覆蓋 MCP 結果。
路徑 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
labels = _get_labels(incident)
pod_name = labels.get("pod", labels.get("name", ""))
namespace = labels.get("namespace", "awoooi-prod")
try:
aggregator = get_diagnosis_aggregator()
if not pod_name:
logger.debug("diagnosis_aggregator_skip_no_pod", incident_id=snapshot.incident_id)
return
aggregator = get_diagnosis_aggregator()
ctx = await aggregator.collect_pod_diagnosis(
pod_name=pod_name,
namespace=namespace,
)
prompt_ctx = ctx.get_llm_prompt_context()
if prompt_ctx:
snapshot.extra_diagnosis = prompt_ctx[:4000] # 限 4K chars不壓縮主 evidence_summary
logger.debug(
"diagnosis_aggregator_collected",
incident_id=snapshot.incident_id,
pod=pod_name,
signals=len(ctx.signals),
highest_severity=ctx.highest_severity.value,
# 從 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