feat(playbook): version generated playbooks
All checks were successful
CD Pipeline / tests (push) Successful in 1m34s
Code Review / ai-code-review (push) Successful in 28s
Type Sync Check / check-type-sync (push) Successful in 1m10s
CD Pipeline / build-and-deploy (push) Successful in 10m19s
CD Pipeline / post-deploy-checks (push) Successful in 3m1s

This commit is contained in:
Your Name
2026-04-30 23:59:39 +08:00
parent 474b913ac9
commit f154ac022e
13 changed files with 426 additions and 5 deletions

View File

@@ -207,9 +207,24 @@ async def init_db() -> None:
ADD COLUMN IF NOT EXISTS trust_score FLOAT NOT NULL DEFAULT 0.3,
ADD COLUMN IF NOT EXISTS requires_approval_level VARCHAR(20) NOT NULL DEFAULT 'auto',
ADD COLUMN IF NOT EXISTS stateful_targets JSONB NOT NULL DEFAULT '[]',
ADD COLUMN IF NOT EXISTS requires_pre_backup BOOLEAN NOT NULL DEFAULT FALSE;
ADD COLUMN IF NOT EXISTS requires_pre_backup BOOLEAN NOT NULL DEFAULT FALSE,
ADD COLUMN IF NOT EXISTS version INTEGER NOT NULL DEFAULT 1,
ADD COLUMN IF NOT EXISTS parent_playbook_id VARCHAR(36),
ADD COLUMN IF NOT EXISTS supersedes_playbook_id VARCHAR(36),
ADD COLUMN IF NOT EXISTS version_reason TEXT;
""")
)
await conn.execute(text(
"CREATE INDEX IF NOT EXISTS ix_playbook_lineage "
"ON playbooks(parent_playbook_id, version);"
))
await conn.execute(text(
"CREATE INDEX IF NOT EXISTS ix_playbook_supersedes "
"ON playbooks(supersedes_playbook_id) WHERE supersedes_playbook_id IS NOT NULL;"
))
await conn.execute(text(
"UPDATE playbooks SET parent_playbook_id = playbook_id WHERE parent_playbook_id IS NULL;"
))
# 2026-04-15 ogt + Claude Sonnet 4.6(亞太): Phase 4 8D 感官升級
# ADR-084: EvidenceSnapshot 加入 Phase 4 動態異常上下文anomaly_context

View File

@@ -1005,6 +1005,10 @@ class PlaybookRecord(Base):
# Source tracing
source_incident_ids: Mapped[list[str]] = mapped_column(JSON, default=list, nullable=False)
version: Mapped[int] = mapped_column(Integer, default=1, nullable=False)
parent_playbook_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
supersedes_playbook_id: Mapped[str | None] = mapped_column(String(36), nullable=True, index=True)
version_reason: Mapped[str | None] = mapped_column(Text, nullable=True)
ai_confidence: Mapped[float] = mapped_column(default=0.0, nullable=False)
# Stats — MUST be in PG (AI learning artifacts, cannot expire)
@@ -1048,6 +1052,7 @@ class PlaybookRecord(Base):
Index("ix_playbook_status", "status"),
Index("ix_playbook_trust_score", "trust_score"),
Index("ix_playbook_created_at", "created_at"),
Index("ix_playbook_lineage", "parent_playbook_id", "version"),
# W2 PR-L1: 快速查詢需要人工 review 的 Playbook預期數量少partial index 最省空間)
Index(
"ix_playbook_review_required",

View File

@@ -84,6 +84,12 @@ async def run_playbook_generation_governance_once(force: bool = False) -> Playbo
"notes": (playbook.notes or "") + "\n[Governance: REVIEW -> APPROVED]",
},
)
if playbook.supersedes_playbook_id:
await _deprecate_superseded(
service,
superseded_id=playbook.supersedes_playbook_id,
replacement_id=playbook.playbook_id,
)
report.approved_count += 1
_record_governance("approved")
else:
@@ -102,6 +108,24 @@ async def run_playbook_generation_governance_once(force: bool = False) -> Playbo
return report
async def _deprecate_superseded(service, superseded_id: str, replacement_id: str) -> None:
"""Deprecate an older Playbook version after replacement approval."""
superseded = await service.get_by_id(superseded_id)
if not superseded or superseded.status == PlaybookStatus.DEPRECATED:
return
await service.update_with_validation(
superseded_id,
{
"status": PlaybookStatus.DEPRECATED.value,
"notes": (
(superseded.notes or "")
+ f"\n[Governance: superseded by {replacement_id}]"
),
},
)
_record_governance("superseded")
async def run_playbook_generation_governance_loop() -> None:
"""Run governance forever at configured interval."""
while True:

View File

@@ -197,6 +197,10 @@ class Playbook(BaseModel):
default_factory=list,
description="萃取來源的 Incident ID",
)
version: int = Field(default=1, ge=1, description="Playbook lineage version")
parent_playbook_id: str | None = Field(None, description="Root Playbook ID for this lineage")
supersedes_playbook_id: str | None = Field(None, description="Previous Playbook version superseded by this one")
version_reason: str | None = Field(None, description="Why this version was created")
ai_confidence: float = Field(
default=0.0,
ge=0.0,
@@ -234,6 +238,10 @@ class Playbook(BaseModel):
default=False,
description="執行前是否需要 Pre-flight 備份檢查",
)
review_required: bool = Field(
default=False,
description="KM/治理累積觸發人工或 AI 複審信號",
)
# === 時間軸 ===
created_at: datetime = Field(default_factory=now_taipei)

View File

@@ -62,6 +62,10 @@ def _pydantic_to_orm(playbook: Playbook) -> PlaybookRecord:
repair_steps=[s.model_dump() for s in playbook.repair_steps],
estimated_duration_minutes=playbook.estimated_duration_minutes,
source_incident_ids=playbook.source_incident_ids,
version=playbook.version,
parent_playbook_id=playbook.parent_playbook_id,
supersedes_playbook_id=playbook.supersedes_playbook_id,
version_reason=playbook.version_reason,
ai_confidence=playbook.ai_confidence,
success_count=playbook.success_count,
failure_count=playbook.failure_count,
@@ -74,6 +78,7 @@ def _pydantic_to_orm(playbook: Playbook) -> PlaybookRecord:
requires_approval_level=playbook.requires_approval_level,
stateful_targets=playbook.stateful_targets,
requires_pre_backup=playbook.requires_pre_backup,
review_required=playbook.review_required,
created_at=playbook.created_at,
updated_at=playbook.updated_at,
)
@@ -107,6 +112,10 @@ def _orm_to_pydantic(record: PlaybookRecord) -> Playbook:
"repair_steps": normalized_steps,
"estimated_duration_minutes": record.estimated_duration_minutes,
"source_incident_ids": record.source_incident_ids,
"version": record.version,
"parent_playbook_id": record.parent_playbook_id,
"supersedes_playbook_id": record.supersedes_playbook_id,
"version_reason": record.version_reason,
"ai_confidence": float(record.ai_confidence),
"success_count": record.success_count,
"failure_count": record.failure_count,
@@ -119,6 +128,7 @@ def _orm_to_pydantic(record: PlaybookRecord) -> Playbook:
"requires_approval_level": record.requires_approval_level,
"stateful_targets": record.stateful_targets,
"requires_pre_backup": record.requires_pre_backup,
"review_required": record.review_required,
"created_at": record.created_at,
"updated_at": record.updated_at,
})
@@ -531,6 +541,10 @@ class PlaybookRepository:
repair_steps=[s.model_dump() for s in playbook.repair_steps],
estimated_duration_minutes=playbook.estimated_duration_minutes,
source_incident_ids=playbook.source_incident_ids,
version=playbook.version,
parent_playbook_id=playbook.parent_playbook_id,
supersedes_playbook_id=playbook.supersedes_playbook_id,
version_reason=playbook.version_reason,
ai_confidence=playbook.ai_confidence,
success_count=playbook.success_count,
failure_count=playbook.failure_count,
@@ -543,6 +557,7 @@ class PlaybookRepository:
requires_approval_level=playbook.requires_approval_level,
stateful_targets=playbook.stateful_targets,
requires_pre_backup=playbook.requires_pre_backup,
review_required=playbook.review_required,
created_at=playbook.created_at,
updated_at=playbook.updated_at,
).on_conflict_do_update(
@@ -556,6 +571,10 @@ class PlaybookRepository:
"repair_steps": [s.model_dump() for s in playbook.repair_steps],
"estimated_duration_minutes": playbook.estimated_duration_minutes,
"source_incident_ids": playbook.source_incident_ids,
"version": playbook.version,
"parent_playbook_id": playbook.parent_playbook_id,
"supersedes_playbook_id": playbook.supersedes_playbook_id,
"version_reason": playbook.version_reason,
"ai_confidence": playbook.ai_confidence,
"success_count": playbook.success_count,
"failure_count": playbook.failure_count,
@@ -568,6 +587,7 @@ class PlaybookRepository:
"requires_approval_level": playbook.requires_approval_level,
"stateful_targets": playbook.stateful_targets,
"requires_pre_backup": playbook.requires_pre_backup,
"review_required": playbook.review_required,
"updated_at": playbook.updated_at,
},
)

View File

@@ -179,10 +179,31 @@ class LLMPlaybookGenerator:
playbook.status = PlaybookStatus.DRAFT
if persist:
playbook = await self._service().create(playbook)
playbook = await self._persist_with_lineage(playbook)
return self._record(playbook, "success", provider, "")
async def _persist_with_lineage(self, playbook: Playbook) -> Playbook:
"""Create a new lineage version when a close approved Playbook exists."""
try:
recommendations = await self._service().get_recommendations(
symptoms=playbook.symptom_pattern,
top_k=1,
use_rag=False,
)
if recommendations and recommendations[0].similarity_score >= 0.85:
base = recommendations[0].playbook
created = await self._service().create_new_version(
base_playbook_id=base.playbook_id,
candidate=playbook,
reason="ADR-104 local LLM generated improved Playbook from successful incident",
)
if created is not None:
return created
except Exception as exc:
logger.warning("playbook_generation_lineage_fallback", error=str(exc))
return await self._service().create(playbook)
async def _call_local_llm(
self,
prompt: str,

View File

@@ -28,6 +28,7 @@ from src.models.playbook import (
RepairStep,
RiskLevel,
SymptomPattern,
generate_playbook_id,
)
from src.repositories.interfaces import IPlaybookRepository
from src.repositories.playbook_repository import get_playbook_repository
@@ -79,10 +80,32 @@ class IPlaybookService(Protocol):
self,
symptoms: SymptomPattern,
top_k: int = 3,
use_rag: bool = True,
) -> list[PlaybookRecommendation]:
"""取得 Playbook 推薦"""
...
async def get_by_id(self, playbook_id: str) -> Playbook | None:
"""取得 Playbook"""
...
async def create_new_version(
self,
base_playbook_id: str,
candidate: Playbook,
reason: str,
) -> Playbook | None:
"""從既有 Playbook 建立下一版"""
...
async def update_with_validation(
self,
playbook_id: str,
update_data: dict,
) -> Playbook | None:
"""驗證後更新 Playbook"""
...
async def approve(
self,
playbook_id: str,
@@ -463,6 +486,51 @@ class PlaybookService:
"""更新 Playbook"""
return await self._repository.update(playbook)
async def create_new_version(
self,
base_playbook_id: str,
candidate: Playbook,
reason: str,
) -> Playbook | None:
"""
從既有 Playbook 建立下一版。
ADR-104 T4: LLM 生成的改良方案不覆蓋舊 Playbook而是建立 lineage
root(parent_playbook_id) -> v2 -> v3。舊版在新版 APPROVED 前仍可用。
"""
base = await self._repository.get_by_id(base_playbook_id)
if not base:
logger.warning("playbook_version_base_missing", base_playbook_id=base_playbook_id)
return None
root_id = base.parent_playbook_id or base.playbook_id
candidate.playbook_id = generate_playbook_id()
candidate.version = base.version + 1
candidate.parent_playbook_id = root_id
candidate.supersedes_playbook_id = base.playbook_id
candidate.version_reason = reason[:500]
candidate.success_count = 0
candidate.failure_count = 0
candidate.last_used_at = None
candidate.approved_by = None
candidate.approved_at = None
if base.playbook_id not in candidate.source_incident_ids:
candidate.notes = (
(candidate.notes or "")
+ f"\n[Version lineage: v{candidate.version} supersedes {base.playbook_id}]"
).strip()
created = await self._repository.create(candidate)
logger.info(
"playbook_version_created",
playbook_id=created.playbook_id,
base_playbook_id=base.playbook_id,
root_playbook_id=root_id,
version=created.version,
reason=reason,
)
return created
async def update_with_validation(
self,
playbook_id: str,