From b421e2b26dd6b66a1d2edbe5b2fd3f4575c2cd58 Mon Sep 17 00:00:00 2001 From: ogt Date: Wed, 1 Jul 2026 18:37:48 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A3=9C=E9=BD=8A=20PChome=20AI=20=E4=BE=8B?= =?UTF-8?q?=E5=A4=96=E5=80=99=E9=81=B8=E6=94=B6=E6=93=9A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- services/pchome_mapping_backlog_service.py | 66 ++++++++++++++++++++- tests/test_pchome_mapping_backlog_report.py | 12 ++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/services/pchome_mapping_backlog_service.py b/services/pchome_mapping_backlog_service.py index 9f976ba..3754ffd 100644 --- a/services/pchome_mapping_backlog_service.py +++ b/services/pchome_mapping_backlog_service.py @@ -1402,6 +1402,59 @@ def _build_candidate_decision_envelope(candidate: dict[str, Any], min_score: flo } +def _next_machine_actions_for_candidate_exception(failure_reasons: list[str]) -> list[str]: + actions: list[str] = [] + if "auto_compare_type_not_receipt_ready" in failure_reasons: + actions.extend([ + "run_variant_bundle_discriminator", + "build_named_candidate_evidence_delta", + ]) + if "target_hard_veto_true" in failure_reasons: + actions.extend([ + "keep_candidate_out_of_no_write_receipt", + "expand_search_terms_with_unit_basis", + ]) + if "target_match_score_below_min_score" in failure_reasons: + actions.append("expand_search_terms_with_brand_spec_anchors") + if "missing_momo_product_id" in failure_reasons or "missing_target_pchome_product_id" in failure_reasons: + actions.append("drop_incomplete_candidate_and_retry_search") + if not actions: + actions.append("build_machine_review_exception_receipt") + return list(dict.fromkeys(actions)) + + +def _build_candidate_exception_receipt(decision: dict[str, Any]) -> dict[str, Any]: + failure_reasons = list(decision.get("failure_reasons") or []) + receipt_basis = { + "decision_id": decision.get("decision_id"), + "failure_reasons": failure_reasons, + "subject": decision.get("subject") or {}, + } + receipt_hash = hashlib.sha256( + json.dumps(receipt_basis, ensure_ascii=False, sort_keys=True, default=str).encode("utf-8") + ).hexdigest() + return { + "receipt_id": f"pchome-direct-mapping-exception-{receipt_hash[:16]}", + "source_decision_id": decision.get("decision_id"), + "stage": "P2_machine_verifiable_exception_receipt", + "subject": decision.get("subject") or {}, + "failure_reasons": failure_reasons, + "next_machine_actions": _next_machine_actions_for_candidate_exception(failure_reasons), + "data_quality": decision.get("data_quality") or "needs_machine_review", + "confidence": decision.get("confidence"), + "expected_resolution": "machine_verifiable_auto_resolution", + "guardrails": { + "machine_actionable": True, + "can_auto_execute": False, + "writes_database": False, + "persists_candidate": False, + "requires_retry_or_evidence_delta": True, + "requires_verifier_before_persistence": True, + "manual_review_mode": "exception_only", + }, + } + + def build_pchome_direct_mapping_auto_search_package( payload: dict[str, Any], batch_size: int = 5, @@ -1586,6 +1639,10 @@ def build_pchome_direct_mapping_candidate_decision_package( for envelope in decision_envelopes if envelope.get("decision") == "route_to_machine_review_decision" ] + machine_review_exception_receipts = [ + _build_candidate_exception_receipt(envelope) + for envelope in machine_review_decisions + ] if not int((search_package.get("summary") or {}).get("selected_direct_mapping_count") or 0): result = "NO_DIRECT_MAPPING_TARGETS" @@ -1611,6 +1668,7 @@ def build_pchome_direct_mapping_candidate_decision_package( "candidate_decision_count": len(decision_envelopes), "auto_compare_decision_count": len(auto_compare_decisions), "machine_review_decision_count": len(machine_review_decisions), + "machine_review_exception_receipt_count": len(machine_review_exception_receipts), "can_auto_persist_now_count": 0, "writes_database_count": 0, "persists_candidate_count": 0, @@ -1619,6 +1677,7 @@ def build_pchome_direct_mapping_candidate_decision_package( "stage": "P2_machine_verifiable_candidate_decision", "execute_search": bool(execute_search), "candidate_decisions": decision_envelopes, + "machine_review_exception_receipts": machine_review_exception_receipts, "manual_review_mode": "exception_only", }, "decision_acceptance_policy": { @@ -1635,7 +1694,7 @@ def build_pchome_direct_mapping_candidate_decision_package( "next_actions": [ "Run controlled read-only search first when candidate_decision_count is zero.", "Send auto-compare decisions to no-write receipt generation before any persistence.", - "Keep machine-review decisions as exception receipts with named failure reasons.", + "Route machine-review decisions through exception receipts with named failure reasons and next machine actions.", ], "safety": { "read_only_preview": True, @@ -2268,6 +2327,9 @@ def build_pchome_growth_ai_automation_readiness( selected_search_targets = int(search_summary.get("selected_direct_mapping_count") or 0) planned_search_terms = int(search_summary.get("planned_search_term_count") or 0) candidate_decision_count = int(decision_summary.get("candidate_decision_count") or 0) + exception_receipt_count = int( + decision_summary.get("machine_review_exception_receipt_count") or 0 + ) waiting_candidate_count = selected_search_targets if not candidate_decision_count else 0 receipt_count = int(receipt_summary.get("receipt_count") or 0) ready_receipt_count = int(receipt_summary.get("ready_for_auto_persistence_count") or 0) @@ -2278,6 +2340,7 @@ def build_pchome_growth_ai_automation_readiness( "mode": AI_EXCEPTION_MODE_MACHINE_VERIFIABLE, PRIMARY_HUMAN_GATE_COUNT_KEY: 0, "ai_exception_count": exception_count, + "exception_receipt_count": exception_receipt_count, "routes": [ { "source": "candidate_decision_package", @@ -2360,6 +2423,7 @@ def build_pchome_growth_ai_automation_readiness( "waiting_candidate_count": waiting_candidate_count, "auto_compare_decision_count": int(decision_summary.get("auto_compare_decision_count") or 0), "machine_review_decision_count": int(decision_summary.get("machine_review_decision_count") or 0), + "machine_review_exception_receipt_count": exception_receipt_count, "receipt_count": receipt_count, "ready_receipt_count": ready_receipt_count, "exception_count": exception_count, diff --git a/tests/test_pchome_mapping_backlog_report.py b/tests/test_pchome_mapping_backlog_report.py index bd87a53..b059157 100644 --- a/tests/test_pchome_mapping_backlog_report.py +++ b/tests/test_pchome_mapping_backlog_report.py @@ -378,11 +378,13 @@ def test_direct_mapping_candidate_decision_package_routes_candidates_to_machine_ ) decisions = package["decision_package"]["candidate_decisions"] + exception_receipts = package["decision_package"]["machine_review_exception_receipts"] assert package["result"] == "DIRECT_MAPPING_CANDIDATE_DECISION_PACKAGE_READY" assert package["summary"]["candidates_found_count"] == 2 assert package["summary"]["candidate_decision_count"] == 2 assert package["summary"]["auto_compare_decision_count"] == 1 assert package["summary"]["machine_review_decision_count"] == 1 + assert package["summary"]["machine_review_exception_receipt_count"] == 1 assert package["summary"]["can_auto_persist_now_count"] == 0 assert decisions[0]["decision_id"].startswith("pchome-direct-mapping-candidate-") assert decisions[0]["decision"] == "route_to_no_write_auto_compare_receipt" @@ -394,6 +396,14 @@ def test_direct_mapping_candidate_decision_package_routes_candidates_to_machine_ assert decisions[0]["guardrails"]["manual_review_mode"] == "exception_only" assert decisions[1]["decision"] == "route_to_machine_review_decision" assert decisions[1]["failure_reasons"] == ["auto_compare_type_not_receipt_ready"] + assert exception_receipts[0]["receipt_id"].startswith("pchome-direct-mapping-exception-") + assert exception_receipts[0]["source_decision_id"] == decisions[1]["decision_id"] + assert exception_receipts[0]["failure_reasons"] == ["auto_compare_type_not_receipt_ready"] + assert exception_receipts[0]["next_machine_actions"] == [ + "run_variant_bundle_discriminator", + "build_named_candidate_evidence_delta", + ] + assert exception_receipts[0]["guardrails"]["writes_database"] is False assert package["safety"]["executes_search"] is True assert package["safety"]["writes_database"] is False assert package["safety"]["persists_candidate"] is False @@ -411,6 +421,7 @@ def test_ai_automation_readiness_makes_automation_visible_without_manual_primary assert readiness["summary"]["waiting_candidate_count"] == 1 assert readiness["summary"]["primary_human_gate_count"] == 0 assert readiness["summary"]["ai_exception_count"] == 0 + assert readiness["summary"]["machine_review_exception_receipt_count"] == 0 assert readiness["summary"]["manual_required_as_primary_flow_count"] == 0 assert readiness["automation_policy"]["primary_flow"] == "ai_controlled" assert readiness["automation_policy"]["human_primary_flow"] is False @@ -457,6 +468,7 @@ def test_ai_automation_readiness_reports_candidate_decisions_after_controlled_se assert readiness["summary"]["waiting_candidate_count"] == 0 assert readiness["summary"]["auto_compare_decision_count"] == 1 assert readiness["summary"]["machine_review_decision_count"] == 0 + assert readiness["summary"]["machine_review_exception_receipt_count"] == 0 assert readiness["summary"]["external_network_execute_count"] == 1 assert lanes["candidate_decision_package"]["status"] == "ready" assert readiness["safety"]["executes_search"] is True