diff --git a/.gitea/workflows/run-migration.yml b/.gitea/workflows/run-migration.yml index d78cf91d..13b17fff 100644 --- a/.gitea/workflows/run-migration.yml +++ b/.gitea/workflows/run-migration.yml @@ -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: | diff --git a/apps/api/src/db/base.py b/apps/api/src/db/base.py index e629b588..1c92ceeb 100644 --- a/apps/api/src/db/base.py +++ b/apps/api/src/db/base.py @@ -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) diff --git a/apps/api/src/db/models.py b/apps/api/src/db/models.py index 65fec351..396c82a6 100644 --- a/apps/api/src/db/models.py +++ b/apps/api/src/db/models.py @@ -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", diff --git a/apps/api/src/jobs/playbook_generation_governance_job.py b/apps/api/src/jobs/playbook_generation_governance_job.py index 6ddff81b..80ee79a4 100644 --- a/apps/api/src/jobs/playbook_generation_governance_job.py +++ b/apps/api/src/jobs/playbook_generation_governance_job.py @@ -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: diff --git a/apps/api/src/models/playbook.py b/apps/api/src/models/playbook.py index ddf9eb49..7adcd01d 100644 --- a/apps/api/src/models/playbook.py +++ b/apps/api/src/models/playbook.py @@ -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) diff --git a/apps/api/src/repositories/playbook_repository.py b/apps/api/src/repositories/playbook_repository.py index 40d9c7de..b30727c1 100644 --- a/apps/api/src/repositories/playbook_repository.py +++ b/apps/api/src/repositories/playbook_repository.py @@ -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, }, ) diff --git a/apps/api/src/services/playbook_generator.py b/apps/api/src/services/playbook_generator.py index ca1e455a..7fceba57 100644 --- a/apps/api/src/services/playbook_generator.py +++ b/apps/api/src/services/playbook_generator.py @@ -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, diff --git a/apps/api/src/services/playbook_service.py b/apps/api/src/services/playbook_service.py index fb1029e1..9d4299b9 100644 --- a/apps/api/src/services/playbook_service.py +++ b/apps/api/src/services/playbook_service.py @@ -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, diff --git a/apps/api/tests/test_playbook_generator.py b/apps/api/tests/test_playbook_generator.py index 31ec041d..23a9f612 100644 --- a/apps/api/tests/test_playbook_generator.py +++ b/apps/api/tests/test_playbook_generator.py @@ -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 diff --git a/apps/api/tests/test_playbook_service.py b/apps/api/tests/test_playbook_service.py index 8a0e5bc3..d7d8ff78 100644 --- a/apps/api/tests/test_playbook_service.py +++ b/apps/api/tests/test_playbook_service.py @@ -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""" diff --git a/docs/LOGBOOK.md b/docs/LOGBOOK.md index 0fe34581..4833d81a 100644 --- a/docs/LOGBOOK.md +++ b/docs/LOGBOOK.md @@ -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 後,只回到人工卡。 diff --git a/packages/shared-types/schemas/api-types.json b/packages/shared-types/schemas/api-types.json index 051c76c9..c0de214a 100644 --- a/packages/shared-types/schemas/api-types.json +++ b/packages/shared-types/schemas/api-types.json @@ -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", diff --git a/packages/shared-types/src/api-types.ts b/packages/shared-types/src/api-types.ts index d5d51047..53d7eb64 100644 --- a/packages/shared-types/src/api-types.ts +++ b/packages/shared-types/src/api-types.ts @@ -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;