feat(flywheel): W1 PR-P1 + ADR-091 T1 — 飛輪 80→90 第一波

依 onboarder 端到端閉環審計挖出的 10 條斷鏈 + critic 鐵律違反全景,
W1 第一波修復飛輪鐵證 1 + 2 的核心斷鏈 C1。

## W1 PR-P1 — matched_playbook_id 四斷點守門 (C1 修復)
fullstack 探勘發現 4 斷點之前 session 已修,本 PR 補:
- ENABLE_PLAYBOOK_MATCHING feature flag (default=true)
  rollback: kubectl set env deployment/awoooi-api ENABLE_PLAYBOOK_MATCHING=false
- proposal_service._try_playbook_match_id 入口加 flag check
- 7 個 e2e 測試補上保護網(之前無測試覆蓋)

斷鏈 C1 證據鏈:proposal_service.generate_proposal() → matched_playbook_id
→ approval_db → approval_repository → learning_service._update_playbook_stats
24h 後 playbooks.trust_score 應有真實 EWMA 更新。

## ADR-091 T1 — auto_generate_rule 雙寫 DB (鐵證 1 第一步)
飛輪鐵證 1:alert_rule_catalog.source='ai_generated' 全 codebase 0 筆。
auto_generate_rule() 寫 alert_rules.yaml 但不寫 DB → AI 自學成果與 catalog 雙軌脫鉤。

修法(依 ADR-091 §1 D1):
- 新增 _insert_catalog_ai_generated():YAML 寫入成功後雙寫
  source='ai_generated', confidence=0.5, review_status='draft', created_by_agent
- 新增 _parse_for_to_seconds() helper("30s"/"5m"/"2h" → seconds)
- ON CONFLICT (rule_name) DO NOTHING 冪等保證
- transaction 策略:YAML + DB 不在同一 transaction(YAML 已成 SoT,DB 失敗只 log)
- ENABLE_AI_RULE_CATALOG_WRITE feature flag (default=true)
  rollback: kubectl set env deployment/awoooi-api ENABLE_AI_RULE_CATALOG_WRITE=false

13 個測試覆蓋:parse helper 8 + 業務邏輯 5(success/db_fail/idempotent/flag/SQL_lit)

## 驗證
1572 unit tests 全綠(+20 新增:PR-P1 7 + ADR-091 T1 13)

## 期望影響
飛輪自主化評分:42 → 65(+23 = C1 +3 + 鐵證 1 +20)

## 已知債(critic PR review 揭示,下一個 commit 處理)
- KMWriter 統一契約 3 條 caller 路徑被旁路(C1/M1/M2)
- KMWriter 冪等聲明與實作不符(M3 缺 ON CONFLICT)
- Alertmanager equal:[] 爆炸抑制 + 版本未驗(M4/M5)
- drift checker regex 脆弱(M7 應改 AST)
- governance health score skipped 失真(M6)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-04-29 10:09:56 +08:00
parent dc18b0ebd6
commit 6878e62af7
5 changed files with 838 additions and 0 deletions

View File

@@ -62,6 +62,16 @@ class Settings(BaseSettings):
description="Phase 24: True=新 AIRouter 路由, False=舊 openclaw.py fallback chain",
)
# ==========================================================================
# W1 PR-P1: Playbook 匹配 Feature Flag (2026-04-28 ogt + Claude Sonnet 4.6)
# 修復飛輪斷鏈 C1 — proposal_service 填 matched_playbook_id → EWMA 更新
# 回滾指令: kubectl set env deployment/awoooi-api ENABLE_PLAYBOOK_MATCHING=false
# ==========================================================================
ENABLE_PLAYBOOK_MATCHING: bool = Field(
default=True,
description="W1 PR-P1: True=generate_proposal 時執行 Playbook RAG 匹配並填 matched_playbook_id, False=行為與修復前完全相同(回滾用)",
)
# ==========================================================================
# P1-1: KMWriter 統一契約 (2026-04-28 ogt + Claude Sonnet 4.6)
# KM_WRITE_AWAIT=true → 強制 await asyncio.wait_for(timeout=KM_WRITE_TIMEOUT_SECONDS)
@@ -530,6 +540,16 @@ class Settings(BaseSettings):
default=False,
description="ADR-095: 啟用 12-Agent ConsensusEngine weights預設關閉",
)
# ==========================================================================
# ADR-091 Task T1: AI 自學規則雙寫 alert_rule_catalog (2026-04-28 ogt + Claude Sonnet 4.6)
# True=auto_generate_rule() 成功後同步寫入 DB source='ai_generated'
# False=回滾開關,只寫 YAML不寫 DB
# 回滾指令: kubectl set env deployment/awoooi-api ENABLE_AI_RULE_CATALOG_WRITE=false
# ==========================================================================
ENABLE_AI_RULE_CATALOG_WRITE: bool = Field(
default=True,
description="ADR-091 T1: True=AI 自學規則雙寫 alert_rule_catalog DB, False=僅 YAML回滾用",
)
# 2026-04-27 P3.1-T2-PathA by Claude — DiagAggregator 信號分類層補 PDI
# 路徑 A 已啟用DA 只取 PDI 已收集的 raw 資料做業務邏輯分類OOMKilled/CrashLoop 等),
# 不重複呼叫 K8s/SignOz API純邏輯分類不打外部服務

View File

@@ -581,6 +581,118 @@ def _append_rule_to_yaml(rule_yaml: str, alertname: str) -> bool:
return False
def _parse_for_to_seconds(for_str: str) -> int | None:
"""將 Prometheus 'for' 字串(如 '5m', '30s', '1h')轉換為整數秒數。
無法解析時回傳 None。
"""
if not for_str:
return None
for_str = for_str.strip()
mapping = {"s": 1, "m": 60, "h": 3600, "d": 86400}
m = re.fullmatch(r"(\d+)([smhd]?)", for_str)
if not m:
return None
value = int(m.group(1))
unit = m.group(2) or "s"
return value * mapping.get(unit, 1)
async def _insert_catalog_ai_generated(
rule_dict: dict,
llm_source: str,
rule_id: str,
alertname_safe: str,
) -> None:
"""寫入 alert_rule_catalog source='ai_generated'
冪等rule_name 唯一索引alert_rule_catalog_rule_name_key已存在
使用 INSERT ON CONFLICT (rule_name) DO NOTHING
transaction 策略YAML + DB 不在同一 transaction
YAML 已成功 → DB 失敗只 log warning不回滾 YAML
review_status='draft':對應 DB CHECK ('draft','approved','deprecated','retired')
confidence=0.50:新規則未驗證,保守初始值
ADR-091 Task T1 — 2026-04-28 ogt + Claude Sonnet 4.6 (Asia/Taipei)
"""
from src.core.config import settings as _settings
if not _settings.ENABLE_AI_RULE_CATALOG_WRITE:
logger.debug(
"ai_rule_catalog_write_skipped_flag_disabled",
alertname=alertname_safe,
)
return
from sqlalchemy import text as _sql
import src.db.base as _db_base
import json as _json
# 從 rule_dict 提取欄位
# 'expr' 在 OpenClaw YAML 規則中不存在(非 PromQL
# 使用 alertname 作為語意佔位(與 yaml_hardcoded 同等策略)
expr = rule_dict.get("expr") or f'alertname="{alertname_safe}"'
for_str = rule_dict.get("for", "0s")
duration_seconds = _parse_for_to_seconds(str(for_str))
labels = rule_dict.get("labels", {})
annotations = rule_dict.get("annotations", {})
# 若 LLM 有產出 incident_type注入 annotations 方便後續 T3 查詢
incident_type = rule_dict.get("incident_type")
if incident_type and "incident_type" not in annotations:
annotations = {**annotations, "incident_type": str(incident_type)}
# severity 從 labels 取Prometheus 慣例),兜底空字串
severity = (labels.get("severity", "") or "").strip()[:50] or None
try:
async with _db_base.get_db_context() as db:
await db.execute(
_sql("""
INSERT INTO alert_rule_catalog (
rule_name, source, expr, duration_seconds,
severity, labels, annotations,
created_by_agent, confidence, review_status,
created_at, updated_at
) VALUES (
:rule_name, 'ai_generated', :expr, :duration_seconds,
:severity,
CAST(:labels AS jsonb),
CAST(:annotations AS jsonb),
:created_by_agent, 0.50, 'draft',
NOW(), NOW()
)
ON CONFLICT (rule_name) DO NOTHING
"""),
{
"rule_name": alertname_safe[:200],
"expr": expr[:4000],
"duration_seconds": duration_seconds,
"severity": severity,
"labels": _json.dumps(labels, ensure_ascii=False),
"annotations": _json.dumps(annotations, ensure_ascii=False),
"created_by_agent": llm_source,
},
)
logger.info(
"ai_rule_catalog_insert_success",
alertname=alertname_safe,
rule_id=rule_id,
llm_source=llm_source,
outcome="success",
)
except Exception as db_err:
# DB 失敗只 warning不影響已成功的 YAML 寫入
logger.warning(
"ai_rule_catalog_insert_failed",
alertname=alertname_safe,
rule_id=rule_id,
error=str(db_err),
outcome="failed",
)
async def _call_ollama(prompt: str, ollama_url: str, model: str) -> str | None:
"""呼叫 Ollama 生成規則 YAML"""
try:
@@ -749,6 +861,25 @@ async def auto_generate_rule(
# 立即為新規則建立 APPROVED Playbook不等下次重啟
from src.services.playbook_seed_service import seed_playbooks_from_rules
_asyncio.create_task(seed_playbooks_from_rules())
# ADR-091 T1: 雙寫 alert_rule_catalog source='ai_generated'
# 獨立 try/except — DB 失敗不回滾已成功的 YAML 寫入
try:
parsed_rules = yaml.safe_load(yaml_block)
rule_dict = parsed_rules[0] if isinstance(parsed_rules, list) and parsed_rules else {}
_asyncio.create_task(
_insert_catalog_ai_generated(
rule_dict=rule_dict,
llm_source=llm_source or "unknown",
rule_id=rule_id,
alertname_safe=alertname_safe,
)
)
except Exception as _catalog_err:
logger.warning(
"ai_rule_catalog_task_create_failed",
alertname=alertname_safe,
error=str(_catalog_err),
)
else:
logger.warning(
"auto_rule_auto_generate_failed",

View File

@@ -22,6 +22,7 @@ from datetime import UTC, datetime
import structlog
from src.core.config import get_settings
from src.db.base import get_db_context
from src.db.models import IncidentRecord
from src.models.approval import (
@@ -373,7 +374,17 @@ class ProposalService:
讓學習服務 EWMA 能在人工審核後更新 Playbook trust score。
邏輯與 decision_manager._try_playbook_match 相同,但只回傳 ID 不改 action。
失敗時靜默返回 None不阻塞主流程
W1 PR-P1 Feature Flag (2026-04-28 ogt + Claude Sonnet 4.6):
ENABLE_PLAYBOOK_MATCHING=false → 回傳 None行為與修復前完全相同回滾用
"""
if not get_settings().ENABLE_PLAYBOOK_MATCHING:
logger.debug(
"playbook_matching_disabled",
incident_id=getattr(incident, "incident_id", "?"),
)
return None
PLAYBOOK_SIMILARITY_THRESHOLD = 0.85
try:
from src.models.playbook import SymptomPattern