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
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:
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user