2026-04-18 晚(台北時區)— ogt + Claude Opus 4.7 (1M) MASTER §7.1 15 個北極星 KPI 實測對標發現 5 個斷鏈: #3 fine-tune JSONL /week — finetune_exports 表不存在 #4 MCP 呼叫/24h — timeline_events 沒 mcp_call event_type #6 Declarative 修復使用率 — remediation_events 表不存在 #7 general 兜底 17.3% — classify_alert_early 漏 5 類 #10 notification_outcomes /week — 表不存在 本 commit 全修。 ## 1. Migration: adr090d_kpi_data_sources.sql (3 張表) - finetune_exports — P3 Fine-tune JSONL 追蹤 - remediation_events — P5 Declarative 修復追蹤 - notification_outcomes — 通知品質 + RLHF 語料 Idempotent (CREATE TABLE IF NOT EXISTS), 已 apply 進 prod。 ## 2. classify_alert_early 擴 4 類規則 (降 general 兜底) - test 攔截: Test*/FPTest/FingerprintTest/ADR089*Test/L4Closure*/*FreshUniq* → category='test', TYPE-1 純通知 - High*CPU/Memory/Disk/Load → host_resource - TLS*/SSL*/*ProbeFailure* → ssl_cert - PostgreSQL*/MySQL*/MongoDB*/*DiskGrowthRate → database 預期 general 17.3% → 3-5% (達標 <10%)。 ## 3. finetune_exporter DB 寫入 _run_export() 結尾寫 finetune_exports 一筆,含 checksum/size/record_count。 ## 4. declarative_remediation DB 寫入 evaluate() 後 fire-and-forget _log_remediation_event() 寫 remediation_events (status='pending', remediation_type 依 tier 自動判為 declarative/imperative/gitops_pr)。 ## 5. telegram_gateway DB 寫入 (send_approval_card) _send_request 成功返回 message_id 後寫 notification_outcomes 一筆, channel='telegram', delivery_status='delivered|failed'。未來人類按鈕時 update user_action → RLHF 訓料黃金。 ## 6. pre_decision_investigator MCP 呼叫追蹤 _call_single_tool() finally 寫 timeline_events event_type='mcp_call', 含 provider/tool/status/duration_ms/error。24h 內 MCP 呼叫可 SQL 量測。 ## 預期量化改善 | KPI | 修前 | 修後 24h 後應見 | |-----|------|----------------| | #3 fine-tune /week | 0 (表不存在) | >=10 (每週 cron 跑) | | #4 MCP 呼叫/24h | 0 | >0 (實測將寫 timeline) | | #6 declarative 使用率 | 表不存在 | 有資料 (pending/success/failed 分佈) | | #7 general 兜底 | 17.3% | <10% | | #10 notification_outcomes | 0 | 每次 approval card 寫一筆 | Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
282 lines
12 KiB
Python
282 lines
12 KiB
Python
"""
|
||
AWOOOI AIOps Phase 3 — Fine-tune JSONL 匯出器
|
||
=============================================
|
||
職責:每週將(EvidenceSnapshot × AgentSession × AutoRepairExecution)
|
||
組合成訓練對(instruction, input, output),匯出為 JSONL 檔案供 LLM 微調。
|
||
|
||
為什麼需要 fine-tune 管線?
|
||
EWMA Playbook trust 只調整「選哪個 Playbook」,
|
||
但 LLM 本身的推理模式(症狀識別、根因分析、行動描述格式)無法從 EWMA 學習。
|
||
Fine-tune 資料管線讓 AI 從真實成功案例中學習「如何推理」,
|
||
不只學習「信任哪個 Playbook」。
|
||
|
||
匯出策略:
|
||
- 查詢 incident_evidence 中 verification_result = 'success' 且有 evidence_summary 的記錄
|
||
- 聯結同 incident_id 的 AgentSession(coordinator turn)取得推理決策
|
||
- 聯結 auto_repair_executions 取得實際執行動作
|
||
- 組合成 JSONL 格式(Alpaca instruction-input-output 格式)
|
||
- 輸出到 FINETUNE_EXPORT_PATH(預設 /tmp/finetune/);MinIO 支援待設定
|
||
|
||
JSONL 格式(每行 1 個 JSON 物件):
|
||
{
|
||
"instruction": "根據 AIOps 情報摘要,分析告警根因並提出修復建議",
|
||
"input": "<evidence_summary>",
|
||
"output": "<coordinator 推理決策 + 執行動作>",
|
||
"metadata": {
|
||
"incident_id": "...",
|
||
"alertname": "...",
|
||
"verification_result": "success",
|
||
"collected_at": "...",
|
||
"schema_version": "v1"
|
||
}
|
||
}
|
||
|
||
設計原則:
|
||
1. 只匯出 verification_result = 'success' 的記錄(負向案例不入訓練集,避免強化錯誤模式)
|
||
2. 每次匯出加時間戳前綴(不覆蓋舊檔)
|
||
3. 每批最多 500 筆(大規模訓練集需分批)
|
||
4. 失敗只記錄 error,不影響主路徑
|
||
|
||
ADR-083 Phase 3: Fine-tune 管線(L7×D4)
|
||
2026-04-15 ogt + Claude Sonnet 4.6(亞太): Phase 3 初始建立
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import asyncio
|
||
import json
|
||
import os
|
||
from datetime import timedelta
|
||
from pathlib import Path
|
||
|
||
import structlog
|
||
from sqlalchemy import and_, select, text as sql_text
|
||
|
||
from src.db.base import get_session_factory
|
||
from src.db.models import AgentSession, AutoRepairExecution, IncidentEvidence
|
||
from src.utils.timezone import now_taipei
|
||
|
||
logger = structlog.get_logger(__name__)
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# 常數
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
FINETUNE_EXPORT_PATH = os.getenv("FINETUNE_EXPORT_PATH", "/tmp/finetune")
|
||
BATCH_LIMIT = 500
|
||
EXPORT_LOOKBACK_DAYS = 7 # 只匯出過去 N 天的資料
|
||
WEEKLY_INTERVAL_SEC = 7 * 86_400
|
||
|
||
INSTRUCTION = (
|
||
"根據以下 AIOps 情報摘要(EvidenceSnapshot),"
|
||
"分析告警根因並提出具體的修復建議,說明修復動作的理由。"
|
||
)
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# Fine-tune Exporter
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
class FineTuneExporter:
|
||
"""
|
||
Fine-tune JSONL 匯出器(每週執行)
|
||
|
||
Usage:
|
||
exporter = FineTuneExporter()
|
||
path, count = await exporter.export()
|
||
"""
|
||
|
||
async def export(self) -> tuple[str | None, int]:
|
||
"""
|
||
匯出訓練資料。
|
||
|
||
Returns:
|
||
(output_file_path, row_count)
|
||
若無資料或功能關閉,返回 (None, 0)
|
||
"""
|
||
from src.core.feature_flags import aiops_flags
|
||
if not aiops_flags.AIOPS_P3_FINETUNE_EXPORT:
|
||
logger.debug("finetune_exporter_skipped_feature_flag")
|
||
return None, 0
|
||
|
||
try:
|
||
return await self._run_export()
|
||
except Exception as e:
|
||
logger.error("finetune_exporter_error", error=str(e))
|
||
return None, 0
|
||
|
||
async def _run_export(self) -> tuple[str | None, int]:
|
||
cutoff = now_taipei() - timedelta(days=EXPORT_LOOKBACK_DAYS)
|
||
session_factory = get_session_factory()
|
||
|
||
async with session_factory() as db:
|
||
# 1. 取得成功驗證的 EvidenceSnapshot(有 evidence_summary + verification_result='success')
|
||
stmt = select(IncidentEvidence).where(
|
||
and_(
|
||
IncidentEvidence.verification_result == "success",
|
||
IncidentEvidence.evidence_summary.isnot(None),
|
||
IncidentEvidence.collected_at >= cutoff,
|
||
)
|
||
).limit(BATCH_LIMIT)
|
||
|
||
result = await db.execute(stmt)
|
||
evidences = result.scalars().all()
|
||
|
||
if not evidences:
|
||
logger.info("finetune_exporter_no_data", lookback_days=EXPORT_LOOKBACK_DAYS)
|
||
return None, 0
|
||
|
||
# 2. 為每筆 evidence 取對應的 coordinator AgentSession + AutoRepairExecution
|
||
rows: list[dict] = []
|
||
for ev in evidences:
|
||
row = await self._build_row(db, ev)
|
||
if row:
|
||
rows.append(row)
|
||
|
||
if not rows:
|
||
return None, 0
|
||
|
||
# 3. 寫出 JSONL
|
||
output_path = await self._write_jsonl(rows)
|
||
logger.info(
|
||
"finetune_export_done",
|
||
row_count=len(rows),
|
||
path=output_path,
|
||
)
|
||
|
||
# 2026-04-18 ADR-090-D: 寫入 finetune_exports 表(MASTER §7.1 #3 KPI 資料源)
|
||
try:
|
||
import hashlib, os
|
||
_size = os.path.getsize(output_path) if output_path and os.path.exists(output_path) else None
|
||
_checksum = None
|
||
if output_path and os.path.exists(output_path):
|
||
with open(output_path, 'rb') as _f:
|
||
_checksum = hashlib.sha256(_f.read()).hexdigest()
|
||
_ids = [str(ev.id) for ev in evidences]
|
||
async with session_factory() as _db:
|
||
await _db.execute(
|
||
sql_text("""
|
||
INSERT INTO finetune_exports (
|
||
export_type, source_table, source_ids,
|
||
file_path, record_count, size_bytes, checksum_sha256,
|
||
metadata
|
||
) VALUES (
|
||
'evidence_snapshot', 'incident_evidence', :ids,
|
||
:fp, :rc, :sz, :cs, CAST(:md AS jsonb)
|
||
)
|
||
"""),
|
||
{
|
||
"ids": _ids,
|
||
"fp": output_path,
|
||
"rc": len(rows),
|
||
"sz": _size,
|
||
"cs": _checksum,
|
||
"md": json.dumps({"lookback_days": EXPORT_LOOKBACK_DAYS}),
|
||
},
|
||
)
|
||
except Exception as _db_e:
|
||
logger.warning("finetune_exports_db_write_failed", error=str(_db_e))
|
||
|
||
return output_path, len(rows)
|
||
|
||
async def _build_row(self, db, ev: IncidentEvidence) -> dict | None:
|
||
"""組合單筆訓練對。"""
|
||
# 取 coordinator Agent turn(若有)
|
||
agent_stmt = select(AgentSession).where(
|
||
and_(
|
||
AgentSession.incident_id == ev.incident_id,
|
||
AgentSession.agent_role == "coordinator",
|
||
)
|
||
).order_by(AgentSession.created_at.desc()).limit(1)
|
||
agent_result = await db.execute(agent_stmt)
|
||
coordinator = agent_result.scalar_one_or_none()
|
||
|
||
# 取最新執行記錄
|
||
exec_stmt = select(AutoRepairExecution).where(
|
||
AutoRepairExecution.incident_id == ev.incident_id,
|
||
).order_by(AutoRepairExecution.created_at.desc()).limit(1)
|
||
exec_result = await db.execute(exec_stmt)
|
||
execution = exec_result.scalar_one_or_none()
|
||
|
||
# 組合 output 文字
|
||
output_parts: list[str] = []
|
||
if coordinator and coordinator.output_json:
|
||
coord_out = coordinator.output_json
|
||
if isinstance(coord_out, dict):
|
||
# 取 reasoning 或 decision 字段
|
||
reasoning = (
|
||
coord_out.get("reasoning")
|
||
or coord_out.get("decision")
|
||
or str(coord_out)[:500]
|
||
)
|
||
output_parts.append(f"[AI 決策]\n{reasoning}")
|
||
|
||
if execution:
|
||
action_desc = execution.playbook_name or "未知"
|
||
if execution.executed_steps:
|
||
steps = execution.executed_steps
|
||
if isinstance(steps, list) and steps:
|
||
first = steps[0]
|
||
if isinstance(first, dict):
|
||
action_desc = first.get("action") or first.get("step") or action_desc
|
||
output_parts.append(f"[執行動作]\n{action_desc}")
|
||
output_parts.append(
|
||
f"[執行結果] {'成功' if execution.success else '失敗'}"
|
||
)
|
||
|
||
if not output_parts:
|
||
return None # 無 output 資料,跳過
|
||
|
||
# 取 alertname(優先從 ev 關聯 incident 的 signal labels)
|
||
alertname = ev.incident_id # fallback to incident_id
|
||
|
||
return {
|
||
"instruction": INSTRUCTION,
|
||
"input": (ev.evidence_summary or "").strip(),
|
||
"output": "\n\n".join(output_parts),
|
||
"metadata": {
|
||
"incident_id": ev.incident_id,
|
||
"alertname": alertname,
|
||
"verification_result": ev.verification_result,
|
||
"collected_at": ev.collected_at.isoformat() if ev.collected_at else None,
|
||
"schema_version": ev.schema_version,
|
||
"matched_playbook_id": ev.matched_playbook_id,
|
||
},
|
||
}
|
||
|
||
async def _write_jsonl(self, rows: list[dict]) -> str:
|
||
"""寫出 JSONL 到 FINETUNE_EXPORT_PATH。"""
|
||
export_dir = Path(FINETUNE_EXPORT_PATH)
|
||
export_dir.mkdir(parents=True, exist_ok=True)
|
||
|
||
ts = now_taipei().strftime("%Y%m%d-%H%M%S")
|
||
filename = f"finetune-{ts}.jsonl"
|
||
output_path = export_dir / filename
|
||
|
||
with open(output_path, "w", encoding="utf-8") as f:
|
||
for row in rows:
|
||
f.write(json.dumps(row, ensure_ascii=False) + "\n")
|
||
|
||
return str(output_path)
|
||
|
||
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
# Loop(掛載到 main.py)
|
||
# ─────────────────────────────────────────────────────────────────────────────
|
||
|
||
async def run_finetune_export_loop() -> None:
|
||
"""
|
||
無限迴圈:每 7 天執行一次 fine-tune 資料匯出。
|
||
在 main.py startup 以 asyncio.create_task 掛載。
|
||
"""
|
||
exporter = FineTuneExporter()
|
||
while True:
|
||
try:
|
||
path, count = await exporter.export()
|
||
if count > 0:
|
||
logger.info("finetune_export_loop_tick", rows=count, path=path)
|
||
except Exception as e:
|
||
logger.error("finetune_export_loop_error", error=str(e))
|
||
|
||
await asyncio.sleep(WEEKLY_INTERVAL_SEC)
|