diff --git a/apps/api/src/services/awooop_ansible_check_mode_service.py b/apps/api/src/services/awooop_ansible_check_mode_service.py index a67f0d8e..e045086e 100644 --- a/apps/api/src/services/awooop_ansible_check_mode_service.py +++ b/apps/api/src/services/awooop_ansible_check_mode_service.py @@ -528,7 +528,7 @@ def _claim_from_apply_operation_row(row: dict[str, Any]) -> tuple[AnsibleCheckMo input_payload = _json_loads(row.get("input")) output_payload = _json_loads(row.get("output")) dry_run_result = _json_loads(row.get("dry_run_result")) - catalog_id = str(input_payload.get("catalog_id") or "") + catalog_id = str(input_payload.get("catalog_id") or row.get("catalog_id") or "") if not catalog_id: return None catalog_item = get_ansible_catalog_item(catalog_id) or {} @@ -541,12 +541,14 @@ def _claim_from_apply_operation_row(row: dict[str, Any]) -> tuple[AnsibleCheckMo apply_playbook_path = str( input_payload.get("apply_playbook_path") or input_payload.get("playbook_path") + or row.get("playbook_path") or catalog_item.get("playbook_path") or "" ) check_mode_playbook_path = str( input_payload.get("check_mode_playbook_path") or input_payload.get("playbook_path") + or row.get("playbook_path") or apply_playbook_path ) if not apply_playbook_path: @@ -563,7 +565,12 @@ def _claim_from_apply_operation_row(row: dict[str, Any]) -> tuple[AnsibleCheckMo playbook_path=check_mode_playbook_path, apply_playbook_path=apply_playbook_path, inventory_hosts=tuple(str(host) for host in inventory_hosts), - risk_level=str(input_payload.get("risk_level") or catalog_item.get("risk_level") or ""), + risk_level=str( + input_payload.get("risk_level") + or row.get("risk_level") + or catalog_item.get("risk_level") + or "" + ), input_payload=input_payload, ) result = AnsibleRunResult( @@ -866,37 +873,54 @@ async def _record_post_apply_verifier_and_learning( from src.repositories.knowledge_repository import KnowledgeDBRepository async with get_db_context(project_id) as db: - repo = KnowledgeDBRepository(db) - await repo.create( - KnowledgeEntryCreate( - title=f"AI 自動修復沉澱:{claim.incident_id}", - content=( - "AI Agent 已完成 Ansible controlled apply 並寫入驗證摘要。\n\n" - f"- Incident: {claim.incident_id}\n" - f"- Catalog: {claim.catalog_id}\n" - f"- PlayBook: {claim.apply_playbook_path}\n" - f"- Apply operation: {apply_op_id}\n" - f"- Verification result: {verification_result}\n" - f"- Return code: {result.returncode}\n" - f"- Next step: {post_state['next_required_step']}\n" - ), - entry_type=EntryType.INCIDENT_CASE, - category="AI自動化/Ansible受控修復", - tags=[ - "ai_auto_repair", - "ansible_controlled_apply", - verification_result, - str(claim.catalog_id or ""), - ], - source=EntrySource.AI_EXTRACTED, - status=EntryStatus.REVIEW, - related_incident_id=claim.incident_id, - related_playbook_id=str(claim.catalog_id or "")[:36] or None, - path_type=_post_apply_km_path_type(apply_op_id), - created_by="ai_agent_ansible_worker", - ) + path_type = _post_apply_km_path_type(apply_op_id) + existing = await db.execute( + text(""" + SELECT id + FROM knowledge_entries + WHERE related_incident_id = :incident_id + AND path_type = :path_type + LIMIT 1 + """), + { + "incident_id": claim.incident_id, + "path_type": path_type, + }, ) - status["learning"] = True + if existing.scalar() is not None: + status["learning"] = True + else: + repo = KnowledgeDBRepository(db) + await repo.create( + KnowledgeEntryCreate( + title=f"AI 自動修復沉澱:{claim.incident_id}", + content=( + "AI Agent 已完成 Ansible controlled apply 並寫入驗證摘要。\n\n" + f"- Incident: {claim.incident_id}\n" + f"- Catalog: {claim.catalog_id}\n" + f"- PlayBook: {claim.apply_playbook_path}\n" + f"- Apply operation: {apply_op_id}\n" + f"- Verification result: {verification_result}\n" + f"- Return code: {result.returncode}\n" + f"- Next step: {post_state['next_required_step']}\n" + ), + entry_type=EntryType.INCIDENT_CASE, + category="AI自動化/Ansible受控修復", + tags=[ + "ai_auto_repair", + "ansible_controlled_apply", + verification_result, + str(claim.catalog_id or ""), + ], + source=EntrySource.AI_EXTRACTED, + status=EntryStatus.REVIEW, + related_incident_id=claim.incident_id, + related_playbook_id=str(claim.catalog_id or "")[:36] or None, + path_type=path_type, + created_by="ai_agent_ansible_worker", + ) + ) + status["learning"] = True except Exception as exc: logger.warning( "ansible_post_apply_learning_writeback_failed", @@ -983,6 +1007,9 @@ async def backfill_missing_auto_repair_execution_receipts_once( apply.error, apply.duration_ms, apply.status, + apply.catalog_id, + apply.playbook_path, + apply.risk_level, apply.created_at FROM automation_operation_log apply WHERE apply.operation_type = 'ansible_apply_executed' diff --git a/apps/api/tests/test_awooop_truth_chain_service.py b/apps/api/tests/test_awooop_truth_chain_service.py index e398509d..6c5a77df 100644 --- a/apps/api/tests/test_awooop_truth_chain_service.py +++ b/apps/api/tests/test_awooop_truth_chain_service.py @@ -1590,12 +1590,42 @@ def test_ansible_apply_operation_row_can_backfill_auto_repair_receipt() -> None: assert result.duration_ms == 456 +def test_ansible_apply_operation_row_reconstructs_from_columns_when_input_is_sparse() -> None: + reconstructed = _claim_from_apply_operation_row({ + "op_id": "apply-op-2", + "parent_op_id": "check-op-2", + "incident_id": "INC-20260629-231F8E", + "status": "success", + "catalog_id": "ansible:188-momo-backup-user", + "playbook_path": "infra/ansible/playbooks/188-momo-backup-user.yml", + "risk_level": "low", + "input": { + "incident_id": "INC-20260629-231F8E", + "source_candidate_op_id": "candidate-op-2", + "check_mode_op_id": "check-op-2", + }, + "output": {"returncode": 0, "stdout_tail": "ok"}, + "dry_run_result": {"apply_executed": True}, + "duration_ms": 7727, + }) + + assert reconstructed is not None + claim, result = reconstructed + assert claim.catalog_id == "ansible:188-momo-backup-user" + assert claim.inventory_hosts == ("host_188",) + assert claim.apply_playbook_path == "infra/ansible/playbooks/188-momo-backup-user.yml" + assert claim.risk_level == "low" + assert result.returncode == 0 + + def test_ansible_apply_receipt_backfill_queries_existing_apply_rows() -> None: source = inspect.getsource(backfill_missing_auto_repair_execution_receipts_once) assert "operation_type = 'ansible_apply_executed'" in source assert "auto_repair_executions existing" in source assert "executed_steps::text LIKE" in source + assert "apply.catalog_id" in source + assert "apply.playbook_path" in source def test_ansible_auto_repair_receipt_insert_casts_asyncpg_parameters() -> None: @@ -1628,6 +1658,19 @@ def test_ansible_learning_writeback_receipt_records_learning_service_call() -> N assert "stores_secret_values" in source +def test_ansible_post_apply_km_writeback_is_idempotent_for_learning_backfill() -> None: + from src.services.awooop_ansible_check_mode_service import ( + _record_post_apply_verifier_and_learning, + ) + + source = inspect.getsource(_record_post_apply_verifier_and_learning) + + assert "SELECT id" in source + assert "FROM knowledge_entries" in source + assert "path_type = :path_type" in source + assert "KnowledgeDBRepository" in source + + def test_ansible_live_controlled_apply_sends_telegram_receipt_but_backfill_does_not() -> None: live_source = inspect.getsource(run_controlled_apply_for_claim) backfill_source = inspect.getsource(backfill_missing_auto_repair_execution_receipts_once)