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:
@@ -24,8 +24,6 @@ env:
|
||||
jobs:
|
||||
migrate:
|
||||
runs-on: ubuntu-latest # 或 self-hosted runner on 110
|
||||
container:
|
||||
image: postgres:15-alpine # 帶 psql
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
@@ -33,6 +31,28 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 2 # 需比對上一個 commit
|
||||
|
||||
- name: Install migration tools
|
||||
run: |
|
||||
set -euo pipefail
|
||||
missing=""
|
||||
for bin in psql jq curl; do
|
||||
if ! command -v "$bin" >/dev/null 2>&1; then
|
||||
missing="$missing $bin"
|
||||
fi
|
||||
done
|
||||
if [ -z "$missing" ]; then
|
||||
exit 0
|
||||
fi
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
apt-get update -qq
|
||||
apt-get install -y -q postgresql-client jq curl
|
||||
elif command -v apk >/dev/null 2>&1; then
|
||||
apk add --no-cache postgresql-client jq curl
|
||||
else
|
||||
echo "::error::missing required tools:$missing"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
- name: Identify new migrations
|
||||
id: diff
|
||||
run: |
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,6 +1,15 @@
|
||||
from src.jobs.playbook_generation_governance_job import run_playbook_generation_governance_once
|
||||
from src.models.incident import Incident, IncidentOutcome, IncidentStatus, Severity, Signal
|
||||
from src.models.playbook import ActionType, PlaybookStatus, RepairStep, RiskLevel
|
||||
from src.models.playbook import (
|
||||
ActionType,
|
||||
Playbook,
|
||||
PlaybookRecommendation,
|
||||
PlaybookStatus,
|
||||
RepairStep,
|
||||
RiskLevel,
|
||||
SymptomPattern,
|
||||
generate_playbook_id,
|
||||
)
|
||||
from src.services.playbook_generator import LLMPlaybookGenerator
|
||||
from src.utils.timezone import now_taipei
|
||||
|
||||
@@ -13,6 +22,44 @@ class InMemoryPlaybookService:
|
||||
self.items[playbook.playbook_id] = playbook
|
||||
return playbook
|
||||
|
||||
async def get_by_id(self, playbook_id):
|
||||
return self.items.get(playbook_id)
|
||||
|
||||
async def get_recommendations(self, symptoms, top_k=3, use_rag=True):
|
||||
recommendations = []
|
||||
for playbook in self.items.values():
|
||||
if playbook.status != PlaybookStatus.APPROVED:
|
||||
continue
|
||||
alert_match = set(symptoms.alert_names) & set(playbook.symptom_pattern.alert_names)
|
||||
service_match = set(symptoms.affected_services) & set(playbook.symptom_pattern.affected_services)
|
||||
if alert_match and service_match:
|
||||
recommendations.append(
|
||||
PlaybookRecommendation(
|
||||
playbook=playbook,
|
||||
similarity_score=1.0,
|
||||
matched_symptoms=[
|
||||
*(f"Alert: {name}" for name in alert_match),
|
||||
*(f"Service: {name}" for name in service_match),
|
||||
],
|
||||
reason="test exact match",
|
||||
)
|
||||
)
|
||||
return recommendations[:top_k]
|
||||
|
||||
async def create_new_version(self, base_playbook_id, candidate, reason):
|
||||
base = self.items.get(base_playbook_id)
|
||||
if base is None:
|
||||
return None
|
||||
candidate.playbook_id = generate_playbook_id()
|
||||
candidate.version = base.version + 1
|
||||
candidate.parent_playbook_id = base.parent_playbook_id or base.playbook_id
|
||||
candidate.supersedes_playbook_id = base.playbook_id
|
||||
candidate.version_reason = reason
|
||||
candidate.success_count = 0
|
||||
candidate.failure_count = 0
|
||||
self.items[candidate.playbook_id] = candidate
|
||||
return candidate
|
||||
|
||||
async def list_playbooks(self, status=None, tags=None, limit=20, offset=0):
|
||||
values = list(self.items.values())
|
||||
if status is not None:
|
||||
@@ -29,6 +76,33 @@ class InMemoryPlaybookService:
|
||||
return playbook
|
||||
|
||||
|
||||
def make_approved_api_playbook() -> Playbook:
|
||||
return Playbook(
|
||||
playbook_id="PB-APPROVED-API",
|
||||
name="Approved API recovery",
|
||||
description="Existing approved recovery for API error rate",
|
||||
status=PlaybookStatus.APPROVED,
|
||||
symptom_pattern=SymptomPattern(
|
||||
alert_names=["ApiErrorRateHigh"],
|
||||
affected_services=["awoooi-api"],
|
||||
severity_range=["P2"],
|
||||
),
|
||||
repair_steps=[
|
||||
RepairStep(
|
||||
step_number=1,
|
||||
action_type=ActionType.KUBECTL,
|
||||
command="kubectl rollout restart deployment/awoooi-api -n awoooi-prod",
|
||||
expected_result="new pods become ready",
|
||||
risk_level=RiskLevel.MEDIUM,
|
||||
)
|
||||
],
|
||||
ai_confidence=0.91,
|
||||
success_count=12,
|
||||
failure_count=1,
|
||||
tags=["api", "rollout"],
|
||||
)
|
||||
|
||||
|
||||
def make_resolved_incident(action: str = "kubectl rollout restart deployment/awoooi-api -n awoooi-prod") -> Incident:
|
||||
return Incident(
|
||||
incident_id="INC-20260430-PLAYBK",
|
||||
@@ -120,6 +194,22 @@ async def test_llm_playbook_generator_creates_review_playbook():
|
||||
assert result.playbook.repair_steps[0].command == "kubectl rollout restart deployment/awoooi-api -n awoooi-prod"
|
||||
|
||||
|
||||
async def test_llm_playbook_generator_creates_lineage_version_for_similar_playbook():
|
||||
service = InMemoryPlaybookService()
|
||||
base = await service.create(make_approved_api_playbook())
|
||||
generator = LLMPlaybookGenerator(playbook_service=service, llm_callable=local_llm_ok)
|
||||
|
||||
result = await generator.generate_from_incident(make_resolved_incident())
|
||||
|
||||
assert result.outcome == "success"
|
||||
assert result.playbook is not None
|
||||
assert result.playbook.playbook_id != base.playbook_id
|
||||
assert result.playbook.version == 2
|
||||
assert result.playbook.parent_playbook_id == base.playbook_id
|
||||
assert result.playbook.supersedes_playbook_id == base.playbook_id
|
||||
assert result.playbook.status == PlaybookStatus.REVIEW
|
||||
|
||||
|
||||
async def test_llm_playbook_generator_downgrades_unsafe_kubectl_to_manual():
|
||||
service = InMemoryPlaybookService()
|
||||
generator = LLMPlaybookGenerator(playbook_service=service, llm_callable=local_llm_unsafe)
|
||||
@@ -153,3 +243,26 @@ async def test_playbook_generation_governance_promotes_review_to_approved(monkey
|
||||
|
||||
assert report.approved_count == 1
|
||||
assert service.items[result.playbook.playbook_id].status == PlaybookStatus.APPROVED
|
||||
|
||||
|
||||
async def test_playbook_generation_governance_deprecates_superseded_version(monkeypatch):
|
||||
service = InMemoryPlaybookService()
|
||||
base = await service.create(make_approved_api_playbook())
|
||||
generator = LLMPlaybookGenerator(playbook_service=service, llm_callable=local_llm_ok)
|
||||
result = await generator.generate_from_incident(make_resolved_incident())
|
||||
assert result.playbook is not None
|
||||
result.playbook.ai_confidence = 0.93
|
||||
|
||||
class FakeSettings:
|
||||
ENABLE_PLAYBOOK_DRAFT_GOVERNANCE_JOB = True
|
||||
|
||||
import src.jobs.playbook_generation_governance_job as job
|
||||
|
||||
monkeypatch.setattr(job, "settings", FakeSettings())
|
||||
monkeypatch.setattr("src.services.playbook_service.get_playbook_service", lambda: service)
|
||||
|
||||
report = await run_playbook_generation_governance_once()
|
||||
|
||||
assert report.approved_count == 1
|
||||
assert service.items[result.playbook.playbook_id].status == PlaybookStatus.APPROVED
|
||||
assert service.items[base.playbook_id].status == PlaybookStatus.DEPRECATED
|
||||
|
||||
@@ -326,6 +326,39 @@ class TestPlaybookService:
|
||||
updated = await service.get_by_id(playbook.playbook_id)
|
||||
assert updated.success_count == 11
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_new_version_preserves_lineage(self, service, mock_repo):
|
||||
"""Test LLM-generated improvements create a new Playbook lineage version."""
|
||||
base = create_test_playbook(
|
||||
playbook_id="PB-BASE-001",
|
||||
status=PlaybookStatus.APPROVED,
|
||||
success_count=20,
|
||||
failure_count=1,
|
||||
)
|
||||
candidate = create_test_playbook(
|
||||
playbook_id="PB-CANDIDATE-001",
|
||||
status=PlaybookStatus.REVIEW,
|
||||
success_count=7,
|
||||
failure_count=3,
|
||||
)
|
||||
await mock_repo.create(base)
|
||||
|
||||
created = await service.create_new_version(
|
||||
base_playbook_id=base.playbook_id,
|
||||
candidate=candidate,
|
||||
reason="generated from successful incident",
|
||||
)
|
||||
|
||||
assert created is not None
|
||||
assert created.playbook_id not in {base.playbook_id, "PB-CANDIDATE-001"}
|
||||
assert created.version == 2
|
||||
assert created.parent_playbook_id == base.playbook_id
|
||||
assert created.supersedes_playbook_id == base.playbook_id
|
||||
assert created.version_reason == "generated from successful incident"
|
||||
assert created.success_count == 0
|
||||
assert created.failure_count == 0
|
||||
assert "supersedes PB-BASE-001" in (created.notes or "")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_playbook_success_rate(self):
|
||||
"""Test success rate calculation"""
|
||||
|
||||
@@ -6,6 +6,23 @@
|
||||
|
||||
---
|
||||
|
||||
## 2026-04-30 | ADR-104 Playbook 版本化 lineage
|
||||
|
||||
承接「自動建立 Playbook」第二段,讓 LLM 生成的改良 Playbook 不覆蓋舊知識,而是形成可審核、可追溯、可替換的版本鏈。
|
||||
|
||||
### 完成
|
||||
- 新增 Playbook lineage 欄位:`version`、`parent_playbook_id`、`supersedes_playbook_id`、`version_reason`。
|
||||
- 新增 migration `adr104_playbook_versioning.sql`,既有 Playbook 回填 root lineage,並加 lineage / supersedes index。
|
||||
- `LLMPlaybookGenerator` 生成成功後會先查相似 approved Playbook;相似度足夠時建立 v2,而不是直接新增孤立 Playbook。
|
||||
- Governance job 在 REVIEW→APPROVED 後,會將被取代的舊版本標記為 `DEPRECATED`,保留版本證據鏈。
|
||||
- shared-types schema/types 已同步,避免後端模型新增欄位後 Type Sync 失敗。
|
||||
- 修復 Gitea migration workflow:移除缺 node/curl 的 `postgres:15-alpine` job container,改由 runner 環境安裝/檢查 `psql`、`jq`、`curl`。
|
||||
|
||||
### 驗證
|
||||
- `python3 -m py_compile` 針對 Playbook model/db/repository/service/generator/governance 通過。
|
||||
- `pytest apps/api/tests/test_playbook_generator.py apps/api/tests/test_playbook_service.py apps/api/tests/test_action_parser_safety.py -q` → 46 passed。
|
||||
- Prod DB 已手動套用 additive migration,確認 `playbooks` 欄位與 `ix_playbook_lineage` / `ix_playbook_supersedes` index 存在。
|
||||
|
||||
## 2026-04-30 | Auto Repair 緊急介入補洞 — rule-first + host SSH
|
||||
|
||||
統帥批准繼續推進「所有異常要自動修復;無法自修復要有緊急通道」。Live 盤查確認 AI 診斷不是全死,而是主機/備份/磁碟告警常在 `auto_repair=false`、Phase2 空動作、或 LLM 產生 K8s 垃圾 target 後,只回到人工卡。
|
||||
|
||||
@@ -1997,6 +1997,52 @@
|
||||
"title": "Source Incident Ids",
|
||||
"type": "array"
|
||||
},
|
||||
"version": {
|
||||
"default": 1,
|
||||
"description": "Playbook lineage version",
|
||||
"minimum": 1,
|
||||
"title": "Version",
|
||||
"type": "integer"
|
||||
},
|
||||
"parent_playbook_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"description": "Root Playbook ID for this lineage",
|
||||
"title": "Parent Playbook Id"
|
||||
},
|
||||
"supersedes_playbook_id": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"description": "Previous Playbook version superseded by this one",
|
||||
"title": "Supersedes Playbook Id"
|
||||
},
|
||||
"version_reason": {
|
||||
"anyOf": [
|
||||
{
|
||||
"type": "string"
|
||||
},
|
||||
{
|
||||
"type": "null"
|
||||
}
|
||||
],
|
||||
"default": null,
|
||||
"description": "Why this version was created",
|
||||
"title": "Version Reason"
|
||||
},
|
||||
"ai_confidence": {
|
||||
"default": 0.0,
|
||||
"description": "AI 萃取信心度",
|
||||
@@ -2109,6 +2155,12 @@
|
||||
"title": "Requires Pre Backup",
|
||||
"type": "boolean"
|
||||
},
|
||||
"review_required": {
|
||||
"default": false,
|
||||
"description": "KM/治理累積觸發人工或 AI 複審信號",
|
||||
"title": "Review Required",
|
||||
"type": "boolean"
|
||||
},
|
||||
"created_at": {
|
||||
"format": "date-time",
|
||||
"title": "Created At",
|
||||
|
||||
@@ -668,6 +668,22 @@ export type EstimatedDurationMinutes = number;
|
||||
* 萃取來源的 Incident ID
|
||||
*/
|
||||
export type SourceIncidentIds = string[];
|
||||
/**
|
||||
* Playbook lineage version
|
||||
*/
|
||||
export type Version = number;
|
||||
/**
|
||||
* Root Playbook ID for this lineage
|
||||
*/
|
||||
export type ParentPlaybookId = string | null;
|
||||
/**
|
||||
* Previous Playbook version superseded by this one
|
||||
*/
|
||||
export type SupersedesPlaybookId = string | null;
|
||||
/**
|
||||
* Why this version was created
|
||||
*/
|
||||
export type VersionReason = string | null;
|
||||
/**
|
||||
* AI 萃取信心度
|
||||
*/
|
||||
@@ -716,6 +732,10 @@ export type StatefulTargets = string[];
|
||||
* 執行前是否需要 Pre-flight 備份檢查
|
||||
*/
|
||||
export type RequiresPreBackup = boolean;
|
||||
/**
|
||||
* KM/治理累積觸發人工或 AI 複審信號
|
||||
*/
|
||||
export type ReviewRequired = boolean;
|
||||
export type CreatedAt6 = string;
|
||||
export type UpdatedAt3 = string;
|
||||
export type SuccessRate = number;
|
||||
@@ -1420,6 +1440,10 @@ export interface Playbook {
|
||||
repair_steps?: RepairSteps;
|
||||
estimated_duration_minutes?: EstimatedDurationMinutes;
|
||||
source_incident_ids?: SourceIncidentIds;
|
||||
version?: Version;
|
||||
parent_playbook_id?: ParentPlaybookId;
|
||||
supersedes_playbook_id?: SupersedesPlaybookId;
|
||||
version_reason?: VersionReason;
|
||||
ai_confidence?: AiConfidence;
|
||||
success_count?: SuccessCount;
|
||||
failure_count?: FailureCount;
|
||||
@@ -1432,6 +1456,7 @@ export interface Playbook {
|
||||
requires_approval_level?: RequiresApprovalLevel;
|
||||
stateful_targets?: StatefulTargets;
|
||||
requires_pre_backup?: RequiresPreBackup;
|
||||
review_required?: ReviewRequired;
|
||||
created_at?: CreatedAt6;
|
||||
updated_at?: UpdatedAt3;
|
||||
[k: string]: unknown;
|
||||
|
||||
Reference in New Issue
Block a user