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>
This commit is contained in:
@@ -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_type:workflow 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:狀態 + 資源 + 連結
|
||||
|
||||
@@ -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"),
|
||||
)
|
||||
|
||||
@@ -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 Provider(ollama/ollama_188/gemini/claude/openclaw_nemo)版本
|
||||
# 寫入 ai_provider_version_history;版本變更時 log warning,P3.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
|
||||
|
||||
@@ -175,7 +175,8 @@ class CIAutoRepairService:
|
||||
)
|
||||
|
||||
# 2. 意圖分類
|
||||
intent_result = self._intent_classifier.classify(analysis_text)
|
||||
# 2026-04-27 P3.1-T3 by Claude — 修復缺失 await(classify 是 async method)
|
||||
intent_result = await self._intent_classifier.classify(analysis_text)
|
||||
|
||||
# 3. 複雜度評估
|
||||
complexity_result = self._complexity_scorer.score(
|
||||
|
||||
@@ -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]
|
||||
|
||||
268
apps/api/src/services/model_version_probe.py
Normal file
268
apps/api/src/services/model_version_probe.py
Normal 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 或 tag(Ollama 用 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:
|
||||
"""探測 Ollama(111 或 188):GET /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:
|
||||
"""Claude:model 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 是 8088(OpenClaw API),Ollama 通常在 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 name,digest=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
|
||||
101
apps/api/src/services/model_version_tracker.py
Normal file
101
apps/api/src/services/model_version_tracker.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""
|
||||
AI Provider 版本追蹤器 — 每小時探測 5 Provider 並寫入 DB,偵測版本變更
|
||||
|
||||
職責:
|
||||
- 排程呼叫 probe_all_providers()
|
||||
- 與 DB 最後一筆比對,判斷 changed 旗標
|
||||
- 寫入 AIProviderVersionHistory
|
||||
- 若有 changed → 記錄 warning log(P3.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
|
||||
@@ -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 sensors(D1_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
|
||||
|
||||
Reference in New Issue
Block a user