feat(flywheel): W2 三件 + KMWriter critic 修法(1635 tests 全綠)
Some checks failed
CD Pipeline / build-and-deploy (push) Failing after 1m38s

W2 (onboarder 4 週飛輪 80→90 路徑第二週) + critic PR review 5 個 critical/major
全部修完,default flag=false 安全無爆炸風險。

## W2 三件 PR

### PR-R2 — AOL → catalog confidence EWMA 回灌(修飛輪斷鏈 C2)
- 新檔 `apps/api/src/jobs/aol_to_catalog_writeback_job.py`
- 邏輯:每小時掃 AOL 計算 EWMA confidence (alpha=0.3) 回灌 alert_rule_catalog
- 失敗閾值 N=5 連續低成功率 → review_status='draft'
- Hermes _fetch_noisy_rules SQL 加 OR review_status='draft'
- ENABLE_AOL_WRITEBACK_JOB=false (default)
- 8 個測試(mock path 修正:lazy import → patch src.db.base.get_db_context)

### PR-V1 — self_healing_validator 串接 (修飛輪斷鏈 C6)
- 新檔 `apps/api/src/services/self_healing_validator.py`(純函數 assess_self_healing)
- post_execution_verifier.py step 5 串接(feature flag gate)
- evidence_snapshot.py 加 self_healing_score / self_healing_detail 欄位
- db/models.py + base.py ALTER IF NOT EXISTS
- score < 0.5 → 觸發 rollback 提案 Telegram alert(不自動執行)
- ENABLE_SELF_HEALING_VALIDATOR=false (default)
- 7 個測試

### PR-L1 — KM ↔ Playbook 雙向回路 (修飛輪斷鏈 C3+C4)
- learning_service.py 三條新邏輯:
  1. _write_playbook_evolution_km:promote/demote 寫 KM 演化條目
  2. _check_and_mark_playbook_review:N=5 累積觸發 review_required
  3. _demote_alert_rule_catalog_confidence:DEPRECATED → confidence×=0.5
- PlaybookRecord 加 review_required 欄位(schema migration via base.py)
- ENABLE_KM_PLAYBOOK_FEEDBACK_LOOP=false (default)
- KM_PLAYBOOK_REVIEW_THRESHOLD=5 可調
- 6 個測試

## KMWriter Critic 5 個 Critical/Major 修復(之前 critic PR review 發現)
之前 push commit c5753e1c 已修,本 commit 補回 stash 中的對應檔案:
- C1 km_writer.py:194 backfill 自打臉(已修:同步 await + DLQ)
- C2 km_writer.py:391 KM_WRITE_AWAIT=false 路徑收緊
- M1 decision_manager.py:2178/2203 移除 _fire_and_forget
- M2 incident_service.py:1099 自製 path 加 retry+DLQ
- M3 km_writer.py:166 冪等聲明對齊(UPSERT + partial unique index)

## 驗證
- 1635 unit tests 全綠(+27 from 1608)
- 與 fb0c72db (推翻 A2 Ollama primary) 共存無衝突
- 所有新 Job/Service default flag=false(不爆炸)

## 期望影響
飛輪斷鏈 C2 + C3 + C4 + C6 全修
飛輪自主化評分:65 → 85 預估(W2 完成後)

啟用順序(待 prod fb0c72db 驗證 OLLAMA primary 跑得起來後):
1. ENABLE_AOL_WRITEBACK_JOB=true
2. ENABLE_KM_PLAYBOOK_FEEDBACK_LOOP=true
3. ENABLE_SELF_HEALING_VALIDATOR=true

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Your Name
2026-04-29 19:44:04 +08:00
parent fb0c72db42
commit 3668d49f2f
13 changed files with 2294 additions and 6 deletions

View File

@@ -389,13 +389,40 @@ class LearningService:
playbook_id: str,
success: bool,
) -> None:
"""更新 Playbook 統計"""
"""
更新 Playbook 統計
W2 PR-L1: 統計更新後,取 Playbook 的 symptom_pattern hash 觸發邏輯 2
KM 累積門檻檢查 → review_required 標記)。
"""
try:
from src.services.playbook_service import get_playbook_service
service = get_playbook_service()
await service.record_execution(playbook_id, success)
# W2 PR-L1 邏輯 2: 取得 Playbook symptom_pattern hash觸發 KM 累積檢查
from src.core.config import settings
if settings.ENABLE_KM_PLAYBOOK_FEEDBACK_LOOP:
try:
from src.repositories.playbook_repository import get_playbook_repository
from src.models.playbook import SymptomPattern
repo = get_playbook_repository()
playbook = await repo.get_by_id(playbook_id)
if playbook and playbook.symptom_pattern:
sp = playbook.symptom_pattern
# symptom_pattern 可能是 Pydantic model 或 dictORM 載入)
if isinstance(sp, dict):
sp = SymptomPattern.model_validate(sp)
symptoms_hash = sp.compute_hash()
await self._check_and_mark_playbook_review(symptoms_hash)
except Exception as inner_e:
logger.warning(
"playbook_review_check_failed",
playbook_id=playbook_id,
error=str(inner_e),
)
except Exception as e:
logger.warning(
"playbook_stats_update_error",
@@ -459,6 +486,7 @@ class LearningService:
- 尋找 source_incident_ids 包含此 incident_id 的 Playbooks
- 提升 ai_confidence +0.1 (上限 1.0)
- 若信心度 >= 0.9 且 status == DRAFT → 自動升級為 APPROVED
- W2 PR-L1: 寫 KM 演化條目ENABLE_KM_PLAYBOOK_FEEDBACK_LOOP 開啟時)
"""
try:
from src.repositories.playbook_repository import get_playbook_repository
@@ -478,6 +506,7 @@ class LearningService:
updated_count = 0
for playbook in playbooks:
previous_trust = playbook.trust_score
result = await repo.adjust_confidence(
playbook_id=playbook.playbook_id,
delta=CONFIDENCE_BOOST,
@@ -485,6 +514,13 @@ class LearningService:
)
if result:
updated_count += 1
# W2 PR-L1: promote 觸發 → 寫 KM 演化條目
await self._write_playbook_evolution_km(
playbook=playbook,
previous_trust=previous_trust,
evolution_type="promote",
incident_id=incident_id,
)
logger.info(
"playbook_promoted",
@@ -513,6 +549,7 @@ class LearningService:
- 尋找 source_incident_ids 包含此 incident_id 的 Playbooks
- 降低 ai_confidence -0.15 (下限 0.0)
- 若信心度 < 0.3 且 failure_rate > 50% → 自動降級為 DEPRECATED
- W2 PR-L1: 寫 KM 演化條目DEPRECATED 時回灌 alert_rule_catalog飛輪 C4 修復)
"""
try:
from src.repositories.playbook_repository import get_playbook_repository
@@ -532,6 +569,7 @@ class LearningService:
updated_count = 0
for playbook in playbooks:
previous_trust = playbook.trust_score
result = await repo.adjust_confidence(
playbook_id=playbook.playbook_id,
delta=CONFIDENCE_PENALTY,
@@ -539,6 +577,17 @@ class LearningService:
)
if result:
updated_count += 1
# W2 PR-L1: demote 觸發 → 寫 KM 演化條目
await self._write_playbook_evolution_km(
playbook=playbook,
previous_trust=previous_trust,
evolution_type="demote",
incident_id=incident_id,
)
# W2 PR-L1 邏輯 3: DEPRECATED 時回灌 alert_rule_catalog飛輪 C4 修復)
from src.models.playbook import PlaybookStatus
if playbook.status == PlaybookStatus.DEPRECATED:
await self._demote_alert_rule_catalog_confidence(playbook)
logger.info(
"playbook_demoted",
@@ -557,6 +606,241 @@ class LearningService:
)
return False
# =========================================================================
# W2 PR-L1: KM → Playbook 互饋回路私有方法
# 飛輪斷鏈 C3 + C4 修復
# 2026-04-28 ogt + Claude Sonnet 4.6
# =========================================================================
async def _write_playbook_evolution_km(
self,
playbook: Any,
previous_trust: float,
evolution_type: str,
incident_id: str,
) -> None:
"""
邏輯 1: promote/demote 觸發 → 寫 KM 演化條目(飛輪 C3
KM 條目 metadata 含playbook_id, previous_trust, new_trust,
success_count, failure_count, decision_chain
path_type='playbook_evolution',供冪等 key 使用
(incident_id, path_type) = (incident_id, 'playbook_evolution') 可能重複,
但 playbook_id 不同的演化各自獨立,所以 path_type 加 playbook_id 作為識別。
"""
from src.core.config import settings
if not settings.ENABLE_KM_PLAYBOOK_FEEDBACK_LOOP:
return
try:
import json
from src.services.km_writer import KMWritePayload, km_write_with_flag
from src.utils.timezone import now_taipei
new_trust = getattr(playbook, "trust_score", previous_trust)
success_count = getattr(playbook, "success_count", 0)
failure_count = getattr(playbook, "failure_count", 0)
path_type = f"playbook_evolution:{playbook.playbook_id}"
payload = KMWritePayload(
path_type=path_type,
incident_id=incident_id,
entry_create_kwargs={
"title": f"Playbook {evolution_type}: {playbook.name} [{playbook.playbook_id}]",
"content": (
f"Playbook {evolution_type} 事件記錄\n"
f"Playbook ID: {playbook.playbook_id}\n"
f"名稱: {playbook.name}\n"
f"trust_score 變化: {previous_trust:.3f}{new_trust:.3f}\n"
f"成功次數: {success_count} / 失敗次數: {failure_count}\n"
f"觸發來源: incident {incident_id}\n"
f"記錄時間: {now_taipei().isoformat()}"
),
"entry_type": "best_practice",
"category": "AI系統",
"tags": ["playbook_evolution", evolution_type, playbook.playbook_id],
"source": "ai_extracted",
"related_playbook_id": playbook.playbook_id,
"related_incident_id": incident_id,
"path_type": path_type,
},
metadata={
"playbook_id": playbook.playbook_id,
"previous_trust": previous_trust,
"new_trust": new_trust,
"success_count": success_count,
"failure_count": failure_count,
"evolution_type": evolution_type,
},
)
await km_write_with_flag(payload)
logger.info(
"playbook_evolution_km_written",
playbook_id=playbook.playbook_id,
evolution_type=evolution_type,
trust_change=f"{previous_trust:.3f}{new_trust:.3f}",
)
except Exception as e:
logger.warning(
"playbook_evolution_km_write_failed",
playbook_id=getattr(playbook, "playbook_id", "unknown"),
evolution_type=evolution_type,
error=str(e),
)
async def _check_and_mark_playbook_review(self, symptoms_hash: str) -> None:
"""
邏輯 2: KM 累積 N=5 條同 symptom_pattern_hash → 觸發 Playbook review_required 標記(飛輪 C3
每次 KM 寫入後由 _update_playbook_stats 呼叫端觸發此檢查。
若同 symptoms_hash 在 knowledge_entries 已有 >= threshold 條,
則 UPDATE playbooks SET review_required=true WHERE 症狀 hash 相符。
比對策略:從 KnowledgeEntry 讀 symptoms_hash 計數,
再透過 playbook.symptom_pattern 的 hash 比對 Playbook。
"""
from src.core.config import settings
if not settings.ENABLE_KM_PLAYBOOK_FEEDBACK_LOOP:
return
if not symptoms_hash:
return
try:
from sqlalchemy import text as sa_text
from src.db.base import get_db_context
async with get_db_context() as db:
# 計算同 symptoms_hash 的 KM 條目數
count_result = await db.execute(
sa_text(
"SELECT COUNT(*) FROM knowledge_entries "
"WHERE symptoms_hash = :hash"
),
{"hash": symptoms_hash},
)
count = count_result.scalar() or 0
if count < settings.KM_PLAYBOOK_REVIEW_THRESHOLD:
return
# 累積達到門檻 → 標記相關 Playbook 需要 review
# Playbook 的 symptom_pattern 存為 JSONB無直接 hash 欄位
# 透過 knowledge_entries.related_playbook_id 關聯找到要標記的 Playbook
updated = await db.execute(
sa_text(
"UPDATE playbooks pb "
"SET review_required = true, updated_at = NOW() "
"FROM knowledge_entries ke "
"WHERE ke.symptoms_hash = :hash "
" AND ke.related_playbook_id = pb.playbook_id "
" AND pb.review_required = false "
"RETURNING pb.playbook_id"
),
{"hash": symptoms_hash},
)
marked_ids = [row[0] for row in updated.fetchall()]
await db.commit()
if marked_ids:
logger.info(
"playbook_review_required_marked",
symptoms_hash=symptoms_hash,
km_count=count,
threshold=settings.KM_PLAYBOOK_REVIEW_THRESHOLD,
playbook_ids=marked_ids,
)
except Exception as e:
logger.warning(
"playbook_review_mark_failed",
symptoms_hash=symptoms_hash,
error=str(e),
)
async def _demote_alert_rule_catalog_confidence(self, playbook: Any) -> None:
"""
邏輯 3: Playbook DEPRECATED 時回灌 alert_rule_catalog飛輪 C4 修復)
UPDATE alert_rule_catalog
SET confidence = confidence * 0.5,
review_status = 'draft' -- CHECK constraint 允許 draft/approved/deprecated/retired
WHERE rule_name LIKE pattern(symptom_pattern.alert_names)
注意alert_rule_catalog.review_status CHECK 限制只允許:
draft | approved | deprecated | retired
任務描述的 'needs_review' 不合法,改用 'draft'(語意等效:需要人工審核)
失敗容忍:不影響 demote 主流程。
"""
from src.core.config import settings
if not settings.ENABLE_KM_PLAYBOOK_FEEDBACK_LOOP:
return
try:
import json
from sqlalchemy import text as sa_text
from src.db.base import get_db_context
# 從 playbook symptom_pattern 取出 alert_names 作為比對鍵
symptom = getattr(playbook, "symptom_pattern", None)
if symptom is None:
return
# symptom_pattern 可能是 Pydantic model 或 dict從 ORM 載入為 dict
if hasattr(symptom, "alert_names"):
alert_names: list[str] = symptom.alert_names or []
elif isinstance(symptom, dict):
alert_names = symptom.get("alert_names") or []
else:
return
if not alert_names:
logger.debug(
"playbook_demote_no_alert_names",
playbook_id=playbook.playbook_id,
)
return
async with get_db_context() as db:
updated_count = 0
for alert_name in alert_names:
# rule_name 完全匹配或前綴匹配(去掉 * suffix
match_name = alert_name.rstrip("*")
result = await db.execute(
sa_text(
"UPDATE alert_rule_catalog "
"SET confidence = CASE "
" WHEN confidence IS NOT NULL "
" THEN GREATEST(0.01, confidence * 0.5) "
" ELSE 0.5 "
" END, "
" review_status = 'draft', "
" updated_at = NOW() "
"WHERE rule_name LIKE :pattern "
" AND (review_status IS NULL OR review_status NOT IN "
" ('deprecated', 'retired')) "
"RETURNING rule_id"
),
{"pattern": f"{match_name}%"},
)
affected = result.rowcount or 0
updated_count += affected
await db.commit()
if updated_count > 0:
logger.info(
"alert_rule_catalog_confidence_demoted",
playbook_id=playbook.playbook_id,
alert_names=alert_names,
rules_updated=updated_count,
)
except Exception as e:
logger.warning(
"alert_rule_catalog_demote_failed",
playbook_id=getattr(playbook, "playbook_id", "unknown"),
error=str(e),
)
# =========================================================================
# 🆕 Phase D-G P0 修正: 新增方法
# =========================================================================