diff --git a/apps/api/src/jobs/agent99_controlled_dispatch_reconciler_job.py b/apps/api/src/jobs/agent99_controlled_dispatch_reconciler_job.py index c1176ccc8..0a1f4d9ac 100644 --- a/apps/api/src/jobs/agent99_controlled_dispatch_reconciler_job.py +++ b/apps/api/src/jobs/agent99_controlled_dispatch_reconciler_job.py @@ -300,6 +300,60 @@ def _playbook_id(identity: Agent99DispatchIdentity) -> str: return f"PB-A99-{digest}" +def _learning_profile( + identity: Agent99DispatchIdentity, + *, + mode: str, +) -> dict[str, Any]: + if "backup_health" in identity.route_id: + return { + "description": ( + "Verified Agent99 read-only BackupCheck with independent DR " + "evidence readback; no backup or restore write was performed." + ), + "affected_services": ["backup_restore"], + "keywords": [ + "agent99", "backup", "restore", "escrow", "offsite", mode, + ], + "action_type": "agent99_readonly_check", + "expected_result": "independent backup/DR verifier passes", + "rollback_command": "not_applicable_no_write", + "tags": ["agent99", "controlled_dispatch", "backup_restore"], + "notes": ( + "Backup/restore/delete/retention/escrow writes and raw secret " + "values are not permitted or persisted." + ), + "km_title": f"Agent99 verified backup readback {identity.incident_id}", + "km_content": ( + f"Run {identity.run_id} on route {identity.route_id} completed " + f"read-only Agent99 {mode or 'BackupCheck'} with independent " + "backup/DR verifier success. No backup, restore, delete, " + "retention or escrow write was performed; LLM had no runtime authority." + ), + "km_category": "資料保護/DR Scorecard", + } + return { + "description": ( + "Verified Agent99 controlled route with independent outcome " + "readback and same-run learning receipts." + ), + "affected_services": ["cold-start-gate"], + "keywords": ["agent99", "cold-start", mode], + "action_type": "agent99_mode", + "expected_result": "independent verifier passes", + "rollback_command": "Agent99 Status (no-write)", + "tags": ["agent99", "controlled_dispatch"], + "notes": "No raw logs or secret values are persisted.", + "km_title": f"Agent99 verified recovery {identity.incident_id}", + "km_content": ( + f"Run {identity.run_id} on route {identity.route_id} completed " + f"Agent99 mode {mode or 'Status'} with independent verifier success. " + "Raw logs and secret values are not stored." + ), + "km_category": "AI自動化/Agent99受控修復", + } + + async def _ensure_playbook_trust( identity: Agent99DispatchIdentity, *, @@ -307,6 +361,7 @@ async def _ensure_playbook_trust( ) -> str | None: playbook_id = _playbook_id(identity) run_tag = f"agent99_run:{identity.run_id}" + profile = _learning_profile(identity, mode=mode) now = now_taipei() try: async with get_db_context(identity.project_id) as db: @@ -324,25 +379,22 @@ async def _ensure_playbook_trust( playbook_id=playbook_id, project_id=identity.project_id, name=f"Agent99 {mode or 'Status'}: {identity.route_id}", - description=( - "Verified Agent99 controlled route with independent " - "outcome readback and same-run learning receipts." - ), + description=profile["description"], status="approved", source="ai_extracted", symptom_pattern={ "alert_names": [], - "affected_services": ["cold-start-gate"], + "affected_services": profile["affected_services"], "severity_range": ["P0", "P1", "P2"], "label_patterns": {}, - "keywords": ["agent99", "cold-start", mode], + "keywords": profile["keywords"], }, repair_steps=[{ "step_number": 1, - "action_type": "agent99_mode", + "action_type": profile["action_type"], "command": mode, - "expected_result": "independent verifier passes", - "rollback_command": "Agent99 Status (no-write)", + "expected_result": profile["expected_result"], + "rollback_command": profile["rollback_command"], "requires_approval": False, "risk_level": "LOW", }], @@ -356,8 +408,8 @@ async def _ensure_playbook_trust( trust_score=0.37, approved_by="agent99_independent_verifier", approved_at=now, - tags=["agent99", "controlled_dispatch", run_tag], - notes="No raw logs or secret values are persisted.", + tags=[*profile["tags"], run_tag], + notes=profile["notes"], requires_approval_level="auto", stateful_targets=[], requires_pre_backup=False, @@ -401,20 +453,16 @@ async def _ensure_km_writeback( mode: str, ) -> str | None: knowledge = get_knowledge_service() + profile = _learning_profile(identity, mode=mode) try: entry = await knowledge.create_entry( KnowledgeEntryCreate( - title=f"Agent99 verified recovery {identity.incident_id}", - content=( - f"Run {identity.run_id} on route {identity.route_id} " - f"completed Agent99 mode {mode or 'Status'} with independent " - "verifier success. Raw logs and secret values are not stored." - ), + title=profile["km_title"], + content=profile["km_content"], entry_type=EntryType.INCIDENT_CASE, - category="AI自動化/Agent99受控修復", + category=profile["km_category"], tags=[ - "agent99", - "controlled_dispatch", + *profile["tags"], f"automation_run_id:{identity.run_id}", ], source=EntrySource.AI_EXTRACTED, diff --git a/apps/api/tests/test_agent99_controlled_dispatch_reconciler_job.py b/apps/api/tests/test_agent99_controlled_dispatch_reconciler_job.py index 1e608bce5..cca55b282 100644 --- a/apps/api/tests/test_agent99_controlled_dispatch_reconciler_job.py +++ b/apps/api/tests/test_agent99_controlled_dispatch_reconciler_job.py @@ -51,6 +51,26 @@ def test_agent99_playbook_identity_is_project_scoped() -> None: assert job._playbook_id(IDENTITY) != job._playbook_id(other) +def test_backup_learning_profile_is_typed_and_no_write() -> None: + identity = build_agent99_dispatch_identity( + project_id="awoooi", + incident_id="INC-20260719-BACKUP-LEARNING", + source_fingerprint="backup-learning-fingerprint", + route_id="agent99:backup_health:BackupCheck", + ) + + profile = job._learning_profile(identity, mode="BackupCheck") + + assert profile["affected_services"] == ["backup_restore"] + assert profile["action_type"] == "agent99_readonly_check" + assert profile["rollback_command"] == "not_applicable_no_write" + assert profile["km_category"] == "資料保護/DR Scorecard" + assert "verified backup readback" in profile["km_title"] + assert "LLM had no runtime authority" in profile["km_content"] + assert "cold-start" not in str(profile) + assert "verified recovery" not in profile["km_title"] + + class _Context: def __init__(self, db) -> None: # type: ignore[no-untyped-def] self.db = db