diff --git a/apps/api/src/services/operator_outcome.py b/apps/api/src/services/operator_outcome.py index f331adfb..cbf1e071 100644 --- a/apps/api/src/services/operator_outcome.py +++ b/apps/api/src/services/operator_outcome.py @@ -144,6 +144,14 @@ def _build_execution_result( failure_status = "no_command_failed" summary = "已執行成功,但缺少修復驗證結果" terminal = False + elif state == "apply_candidate_owner_review_ready": + approval_status = "owner_review_required" + completion_status = "dry_run_passed_apply_candidate_ready" + command_status = "check_mode_succeeded" + repair_status = "not_executed" + failure_status = "not_applicable" + summary = "AI 已完成安全乾跑並產生 apply candidate;等待 owner review 後才可執行" + terminal = False elif state == "dry_run_only_owner_review_required": approval_status = "owner_review_required" completion_status = "dry_run_completed_no_apply" @@ -313,12 +321,12 @@ def build_operator_outcome( summary = "已執行但驗證退化,需人工確認" reason = first_blocker or f"verification={verification}" elif verdict == "ansible_check_mode_only" or ansible_dry_run_only: - state = "dry_run_only_owner_review_required" + state = "apply_candidate_owner_review_ready" severity = "warning" needs_human = True - next_action = "owner_review_apply_gate_or_create_verifier_plan" - summary = "只完成 Ansible check-mode 乾跑,尚未執行修復" - reason = first_blocker or "ansible_check_mode_without_apply" + next_action = "open_apply_gate_work_item_review_verifier_and_km" + summary = "AI 已完成 Ansible check-mode 並產生 apply candidate,等待 owner review;尚未執行修復" + reason = first_blocker or "apply_candidate_requires_owner_review" elif verdict == "execution_unverified" or ( has_repair_execution and verification == "missing" ): diff --git a/apps/api/src/services/platform_operator_service.py b/apps/api/src/services/platform_operator_service.py index 3203c4e1..04bdef62 100644 --- a/apps/api/src/services/platform_operator_service.py +++ b/apps/api/src/services/platform_operator_service.py @@ -4132,7 +4132,7 @@ def _status_chain_ansible_apply_gate_handoff( "runtime_execution_authorized": False, "writes_runtime_state": False, "owner_review_gate": "required_before_apply", - "next_action": "owner_review_apply_gate_or_create_verifier_plan", + "next_action": "open_apply_gate_work_item_review_verifier_and_km", "asset_ids": { "dry_run": f"ansible-check-mode:{catalog_id}", "apply_candidate": f"ansible-apply-candidate:{catalog_id}", @@ -5018,7 +5018,7 @@ def _build_awooop_status_chain( if ansible_dry_run_only: verdict = "ansible_check_mode_only" repair_state = "ansible_check_mode_only" - next_step = "owner_review_apply_gate_or_create_verifier_plan" + next_step = "open_apply_gate_work_item_review_verifier_and_km" needs_human = True source_section = _status_chain_source_section(truth_chain) if source_correlation is not None: diff --git a/apps/api/tests/test_awooop_operator_timeline_labels.py b/apps/api/tests/test_awooop_operator_timeline_labels.py index adb19ed0..ed199caa 100644 --- a/apps/api/tests/test_awooop_operator_timeline_labels.py +++ b/apps/api/tests/test_awooop_operator_timeline_labels.py @@ -1795,18 +1795,22 @@ def test_awooop_status_chain_does_not_treat_ansible_check_mode_as_repair() -> No assert chain["verdict"] == "ansible_check_mode_only" assert chain["repair_state"] == "ansible_check_mode_only" - assert chain["next_step"] == "owner_review_apply_gate_or_create_verifier_plan" + assert chain["next_step"] == "open_apply_gate_work_item_review_verifier_and_km" assert chain["needs_human"] is True assert chain["evidence"]["ansible_dry_run_only"] is True - assert chain["operator_outcome"]["state"] == "dry_run_only_owner_review_required" + assert chain["operator_outcome"]["state"] == "apply_candidate_owner_review_ready" + assert "apply candidate" in chain["operator_outcome"]["summary_zh"] assert ( chain["operator_outcome"]["execution_result"]["completion_status"] - == "dry_run_completed_no_apply" + == "dry_run_passed_apply_candidate_ready" ) assert "verification_missing" in chain["blockers"] assert "verification_recorded" not in chain["blockers"] assert chain["automation_handoff"]["kind"] == "ansible_check_mode_apply_gate" assert chain["automation_handoff"]["status"] == "owner_review_required" + assert chain["automation_handoff"]["next_action"] == ( + "open_apply_gate_work_item_review_verifier_and_km" + ) assert chain["automation_handoff"]["runtime_execution_authorized"] is False assert chain["automation_handoff"]["work_item_id"].startswith( "ansible-apply-gate:awoooi:INC-20260625-977E5F" diff --git a/apps/api/tests/test_operator_outcome.py b/apps/api/tests/test_operator_outcome.py index 88cef670..bebf3c28 100644 --- a/apps/api/tests/test_operator_outcome.py +++ b/apps/api/tests/test_operator_outcome.py @@ -98,7 +98,7 @@ def test_operator_outcome_marks_unverified_execution_as_human_review() -> None: assert outcome["next_action"] == "run_or_review_post_execution_verification" -def test_operator_outcome_marks_ansible_check_mode_as_dry_run_only() -> None: +def test_operator_outcome_marks_ansible_check_mode_as_apply_candidate_ready() -> None: outcome = build_operator_outcome( truth_status={ "current_stage": "execution_succeeded", @@ -119,10 +119,12 @@ def test_operator_outcome_marks_ansible_check_mode_as_dry_run_only() -> None: }, ) - assert outcome["state"] == "dry_run_only_owner_review_required" + assert outcome["state"] == "apply_candidate_owner_review_ready" assert outcome["needs_human"] is True - assert outcome["next_action"] == "owner_review_apply_gate_or_create_verifier_plan" - assert outcome["execution_result"]["completion_status"] == "dry_run_completed_no_apply" + assert outcome["next_action"] == "open_apply_gate_work_item_review_verifier_and_km" + assert outcome["execution_result"]["completion_status"] == ( + "dry_run_passed_apply_candidate_ready" + ) assert outcome["execution_result"]["command_status"] == "check_mode_succeeded" assert outcome["execution_result"]["repair_status"] == "not_executed" @@ -230,6 +232,40 @@ def test_execution_result_message_includes_operator_outcome_and_human_channels() assert "manual_review_no_action_decision" in text +def test_operator_outcome_marks_ansible_check_mode_as_apply_candidate_ready() -> None: + outcome = build_operator_outcome( + truth_status={ + "current_stage": "execution_succeeded", + "stage_status": "success", + "needs_human": True, + "blockers": ["incident_open_after_successful_execution"], + }, + automation_quality={ + "verdict": "ansible_check_mode_only", + "facts": { + "ansible_check_mode_total": 1, + "ansible_apply_total": 0, + "auto_repair_execution_records": 0, + "effective_execution_records": 1, + "automation_operation_records": 2, + "verification_result": None, + "knowledge_entries": 0, + }, + "blockers": ["auto_repair_recorded", "verification_recorded", "learning_recorded"], + }, + ) + + assert outcome["state"] == "apply_candidate_owner_review_ready" + assert outcome["next_action"] == "open_apply_gate_work_item_review_verifier_and_km" + assert "apply candidate" in outcome["summary_zh"] + assert outcome["execution_result"]["completion_status"] == ( + "dry_run_passed_apply_candidate_ready" + ) + assert "auto_repair_missing" in outcome["blockers"] + assert "verification_missing" in outcome["blockers"] + assert "learning_missing" in outcome["blockers"] + + def test_execution_result_message_does_not_call_diagnostic_success_repair_done() -> None: service = ApprovalExecutionService() approval = SimpleNamespace( diff --git a/apps/api/tests/test_telegram_message_templates.py b/apps/api/tests/test_telegram_message_templates.py index 65211d42..0a5083b2 100644 --- a/apps/api/tests/test_telegram_message_templates.py +++ b/apps/api/tests/test_telegram_message_templates.py @@ -768,10 +768,10 @@ def test_awooop_status_chain_lines_mark_ansible_check_mode_as_dry_run_only() -> joined = "\n".join(lines) assert "ansible_check_mode_only" in joined - assert "owner_review_apply_gate_or_create_verifier_plan" in joined - assert "dry_run_only_owner_review_required" in joined - assert "dry_run_completed_no_apply" in joined - assert "只完成 Ansible check-mode 乾跑,尚未執行修復" in joined + assert "open_apply_gate_work_item_review_verifier_and_km" in joined + assert "apply_candidate_owner_review_ready" in joined + assert "dry_run_passed_apply_candidate_ready" in joined + assert "AI 已完成 Ansible check-mode 並產生 apply candidate" in joined assert "executed_pending_verification" not in joined assert "run_or_review_post_execution_verification" not in joined diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 9992eaaa..93bf559a 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -10062,6 +10062,16 @@ "writes": "寫入旗標", "verdict": "判定" }, + "nextActions": { + "openApplyGateWorkItem": "開啟 apply gate 工作項,審查 Verifier 與 KM 回寫", + "manualReviewNoActionDecision": "人工判斷是否接手或關閉事件", + "ownerReviewRepairCandidateDraft": "審查修復候選草案、rollback 與 verifier", + "collectRepairEvidence": "補齊修復證據或建立專屬 PlayBook", + "manualInvestigation": "人工調查卡點並補證據", + "reviewVerifier": "執行或審查修復後 verifier", + "monitorRegression": "持續觀察是否復發", + "collectEvidenceOrWait": "補收證據或等待下一筆 recurrence" + }, "evidence": { "autoRepair": "Auto-repair", "ops": "Ops", @@ -10515,6 +10525,11 @@ "verifier": "Verifier", "nextStep": "下一步" }, + "nextActions": { + "openApplyGateWorkItem": "開啟 apply gate 工作項,審查 Verifier 與 KM 回寫", + "ownerReviewRepairCandidateDraft": "審查修復候選草案、rollback 與 verifier", + "manualReviewNoActionDecision": "人工判斷是否接手或關閉事件" + }, "items": { "km": "KM", "playbook": "PlayBook", diff --git a/apps/web/messages/zh-TW.json b/apps/web/messages/zh-TW.json index 9992eaaa..93bf559a 100644 --- a/apps/web/messages/zh-TW.json +++ b/apps/web/messages/zh-TW.json @@ -10062,6 +10062,16 @@ "writes": "寫入旗標", "verdict": "判定" }, + "nextActions": { + "openApplyGateWorkItem": "開啟 apply gate 工作項,審查 Verifier 與 KM 回寫", + "manualReviewNoActionDecision": "人工判斷是否接手或關閉事件", + "ownerReviewRepairCandidateDraft": "審查修復候選草案、rollback 與 verifier", + "collectRepairEvidence": "補齊修復證據或建立專屬 PlayBook", + "manualInvestigation": "人工調查卡點並補證據", + "reviewVerifier": "執行或審查修復後 verifier", + "monitorRegression": "持續觀察是否復發", + "collectEvidenceOrWait": "補收證據或等待下一筆 recurrence" + }, "evidence": { "autoRepair": "Auto-repair", "ops": "Ops", @@ -10515,6 +10525,11 @@ "verifier": "Verifier", "nextStep": "下一步" }, + "nextActions": { + "openApplyGateWorkItem": "開啟 apply gate 工作項,審查 Verifier 與 KM 回寫", + "ownerReviewRepairCandidateDraft": "審查修復候選草案、rollback 與 verifier", + "manualReviewNoActionDecision": "人工判斷是否接手或關閉事件" + }, "items": { "km": "KM", "playbook": "PlayBook", diff --git a/apps/web/src/components/awooop/automation-asset-ledger.tsx b/apps/web/src/components/awooop/automation-asset-ledger.tsx index 1071d8b6..5de48f0f 100644 --- a/apps/web/src/components/awooop/automation-asset-ledger.tsx +++ b/apps/web/src/components/awooop/automation-asset-ledger.tsx @@ -26,6 +26,11 @@ function valueLabel(value: number, fallback: string) { return value > 0 ? String(value) : fallback; } +function nextActionLabel(value: unknown, labels: Record, fallback: string) { + const key = String(value ?? ""); + return labels[key] ?? (value ? String(value) : fallback); +} + export function AwoooPAutomationAssetLedger({ chain, remediationSummary, @@ -38,6 +43,12 @@ export function AwoooPAutomationAssetLedger({ className?: string; }) { const t = useTranslations("awooop.automationAssetLedger"); + const nextActionLabels: Record = { + open_apply_gate_work_item_review_verifier_and_km: t("nextActions.openApplyGateWorkItem"), + owner_review_apply_gate_or_create_verifier_plan: t("nextActions.openApplyGateWorkItem"), + owner_review_repair_candidate_draft: t("nextActions.ownerReviewRepairCandidateDraft"), + manual_review_no_action_decision: t("nextActions.manualReviewNoActionDecision"), + }; const handoff = chain?.automation_handoff; const handoffAssets = handoff?.asset_ids; const kmCount = chain?.evidence?.knowledge_entries ?? 0; @@ -116,7 +127,10 @@ export function AwoooPAutomationAssetLedger({ { key: "dryRun", value: handoffAssets?.dry_run }, { key: "apply", value: handoffAssets?.apply_candidate }, { key: "verifier", value: handoffAssets?.verifier }, - { key: "nextStep", value: handoff?.next_action ?? chain?.next_step }, + { + key: "nextStep", + value: nextActionLabel(handoff?.next_action ?? chain?.next_step, nextActionLabels, "--"), + }, ].filter((item) => Boolean(item.value)); return ( diff --git a/apps/web/src/components/awooop/status-chain.tsx b/apps/web/src/components/awooop/status-chain.tsx index 8c9103a8..85297c6f 100644 --- a/apps/web/src/components/awooop/status-chain.tsx +++ b/apps/web/src/components/awooop/status-chain.tsx @@ -274,6 +274,21 @@ export function AwoooPStatusChainPanel({ const automationHandoff = chain?.automation_handoff; const blockers = chain?.blockers ?? []; const sourceCorrelation = chain?.source_refs?.correlation; + const nextActionLabel = (value: unknown) => { + const key = String(value ?? ""); + const labels: Record = { + open_apply_gate_work_item_review_verifier_and_km: t("nextActions.openApplyGateWorkItem"), + owner_review_apply_gate_or_create_verifier_plan: t("nextActions.openApplyGateWorkItem"), + manual_review_no_action_decision: t("nextActions.manualReviewNoActionDecision"), + owner_review_repair_candidate_draft: t("nextActions.ownerReviewRepairCandidateDraft"), + manual_review_or_collect_repair_evidence: t("nextActions.collectRepairEvidence"), + manual_investigation: t("nextActions.manualInvestigation"), + run_or_review_post_execution_verification: t("nextActions.reviewVerifier"), + monitor_for_regression: t("nextActions.monitorRegression"), + collect_evidence_or_wait: t("nextActions.collectEvidenceOrWait"), + }; + return labels[key] ?? valueOrEmpty(value, emptyLabel); + }; if (!chain) { return ( @@ -295,7 +310,7 @@ export function AwoooPStatusChainPanel({ { label: t("fields.stage"), value: `${valueOrEmpty(chain.current_stage, emptyLabel)} / ${valueOrEmpty(chain.stage_status, emptyLabel)}` }, { label: t("fields.repair"), value: valueOrEmpty(chain.repair_state, emptyLabel) }, { label: t("fields.verification"), value: valueOrEmpty(chain.verification, emptyLabel) }, - { label: t("fields.nextStep"), value: valueOrEmpty(outcome?.next_action ?? chain.next_step, emptyLabel) }, + { label: t("fields.nextStep"), value: nextActionLabel(outcome?.next_action ?? chain.next_step) }, ]; const evidenceMetrics = [ { label: t("evidence.autoRepair"), value: evidence.auto_repair_records ?? 0 }, @@ -516,7 +531,7 @@ export function AwoooPStatusChainPanel({ }), detail: t("toolchain.learningDetail", { verification: valueOrEmpty(chain.verification, emptyLabel), - nextStep: valueOrEmpty(chain.next_step, emptyLabel), + nextStep: nextActionLabel(chain.next_step), }), }, ]; @@ -633,7 +648,7 @@ export function AwoooPStatusChainPanel({ }), detail: t("drilldown.learningDetail", { verification: valueOrEmpty(chain.verification, emptyLabel), - nextStep: valueOrEmpty(chain.next_step, emptyLabel), + nextStep: nextActionLabel(chain.next_step), }), }, { @@ -644,7 +659,7 @@ export function AwoooPStatusChainPanel({ value: chain.needs_human ? t("human.yes") : t("human.no"), detail: t("drilldown.handoffDetail", { reason: valueOrEmpty(outcome?.human_action_reason, emptyLabel), - nextAction: valueOrEmpty(outcome?.next_action ?? chain.next_step, emptyLabel), + nextAction: nextActionLabel(outcome?.next_action ?? chain.next_step), }), }, ];