refactor(flywheel): 首席架構師審查修正 C1/C2/I1/I2/I3/I4/M1
Some checks are pending
CD Pipeline / build-and-deploy (push) Has started running

C1 — Repository 層修正 (積木化鐵律):
  新增 PlaybookEmbeddingRepository (pgvector UPSERT)
  playbook_embedding_service 改透過 Repository 存取 DB,不再直接 db.execute(text(...))

C2 — Router 層業務邏輯移入 Service 層:
  create_incident_for_approval + extract_affected_services (去掉底線前綴) 移入 incident_service.py
  webhooks.py 改從 incident_service import,自身不再含業務邏輯

I1 — _infra_jobs 提升為 module-level frozenset (_INFRA_JOB_NAMES),避免每次呼叫重建

I2 — _persist_embeddings_to_db 補齊 PlaybookRAGService / list[Playbook] 型別標注

I3 — embedding 格式顯式化: "[" + ",".join(str(float(x)) for x in embedding) + "]"
  防止 pgvector 因格式差異靜默解析失敗

I4 — import asyncio 移至 main.py 頂層,移除 try 區塊內重複 import

M1 — similarity.py: 移除死代碼 `if union > 0 else 0.0`
  union 在兩個集合都非空時不可能為 0

2026-04-10 Asia/Taipei — Claude Sonnet 4.6
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
OG T
2026-04-10 11:35:10 +08:00
parent 0cac128a64
commit 670cd5df86
6 changed files with 292 additions and 186 deletions

View File

@@ -14,11 +14,16 @@ Incident Service - Phase 6.2 雙層記憶寫入
統帥鐵律:
- 禁止硬編碼 IP 或密碼,嚴格讀取 .env
- 所有寫入操作都必須有結構化日誌
C2 修正 (首席架構師審查 2026-04-10 Claude Sonnet 4.6 Asia/Taipei):
create_incident_for_approval + _extract_affected_services 從 Router 層移入此 Service 層
原違規: 業務邏輯 (Severity 映射, Signal 建立, Incident 建立) 放在 api/v1/webhooks.py
"""
import json
from datetime import UTC, datetime
from typing import Any, Literal
from uuid import UUID
import structlog
@@ -31,9 +36,144 @@ from src.models.incident import (
Severity,
Signal,
)
from src.utils.timezone import now_taipei
logger = structlog.get_logger(__name__)
# =============================================================================
# C2 修正: 從 webhooks.py 遷入的業務邏輯
# 2026-04-10 Claude Sonnet 4.6 Asia/Taipei
# =============================================================================
# 風險等級 → 事件嚴重度映射 (原在 webhooks.py)
_RISK_TO_SEVERITY = {
"critical": Severity.P0,
"high": Severity.P1,
"medium": Severity.P2,
"low": Severity.P3,
}
# I1 修正: 提升為 module-level frozenset避免每次呼叫重建 (原在 webhooks.py 函數體內)
_INFRA_JOB_NAMES: frozenset[str] = frozenset(
j.lower().replace("-", "").replace("_", "")
for j in {"node", "node-exporter", "pushgateway", "blackbox",
"prometheus", "alertmanager", "cadvisor"}
)
def extract_affected_services(labels: dict, target_resource: str) -> list[str]:
"""
從告警 labels 提取真實服務名,防止 IP 或 alertname 污染 affected_services。
優先序:
1. component labelDocker-compose 層告警最可靠)
2. job label排除 node-exporter / pushgateway 等基礎設施 job
3. pod label取 deployment name去掉 hash suffix
4. target_resource不含冒號、不等於 alertname 時才採用)
5. 空列表(讓通用型 Playbook 透過空集合豁免規則匹配)
Phase 1 飛輪修復 — 2026-04-10 Claude Sonnet 4.6 Asia/Taipei
C2 修正: 從 api/v1/webhooks.py 移入 Service 層(純業務邏輯,無 I/O
"""
alertname = labels.get("alertname", "")
if comp := labels.get("component"):
return [comp]
if job := labels.get("job"):
normalized = job.lower().replace("-", "").replace("_", "")
if normalized not in _INFRA_JOB_NAMES:
return [job]
if pod := labels.get("pod"):
parts = pod.rsplit("-", 2)
if len(parts) >= 3 and len(parts[-1]) == 5 and len(parts[-2]) in (9, 10):
return [parts[0]]
elif len(parts) >= 2:
return ["-".join(parts[:-1])]
if (target_resource
and ":" not in target_resource
and target_resource != alertname
and not target_resource[0].isdigit()):
return [target_resource]
return []
async def create_incident_for_approval(
approval_id: str,
risk_level: str,
target_resource: str,
namespace: str,
alert_type: str,
message: str,
source: str = "alertmanager",
alertname: str | None = None,
alert_labels: dict | None = None,
) -> str:
"""
為 Approval 創建對應的 Incident (活躍事件同步)。
設計原則:
- Approval 和 Incident 必須同時存在
- Incident 存入 Redis (Working Memory) + PostgreSQL (Episodic Memory)
- 7 天 TTL 自動過期
C2 修正: 從 api/v1/webhooks.py 移入 Service 層(業務邏輯不屬 Router 層)
Returns:
str: Incident ID
"""
incident_service = get_incident_service()
severity = _RISK_TO_SEVERITY.get(risk_level.lower(), Severity.P2)
_labels: dict = {
"namespace": namespace,
"resource": target_resource,
"alertname": alertname or alert_type,
**(alert_labels or {}),
}
signal = Signal(
alert_name=alertname or alert_type,
severity=severity,
source=source,
fired_at=now_taipei(),
labels=_labels,
annotations={"message": message},
)
_affected_services = extract_affected_services(_labels, target_resource)
incident = Incident(
status=IncidentStatus.INVESTIGATING,
severity=severity,
signals=[signal],
affected_services=_affected_services,
proposal_ids=[UUID(approval_id)],
)
await incident_service.save_to_working_memory(incident)
try:
await incident_service.save_to_episodic_memory(incident)
except Exception as _pg_err:
logger.warning(
"incident_episodic_memory_failed",
incident_id=incident.incident_id,
error=str(_pg_err),
)
logger.info(
"incident_created_for_approval",
incident_id=incident.incident_id,
approval_id=approval_id,
severity=severity.value,
target=target_resource,
)
return incident.incident_id
# =============================================================================
# Legacy Value Normalization (方案 C - 代碼相容舊格式)