From f1795e555d58fcfa7e9399643fcfaa329dc75796 Mon Sep 17 00:00:00 2001 From: ogt Date: Thu, 2 Jul 2026 13:28:40 +0800 Subject: [PATCH] =?UTF-8?q?=E6=96=B0=E5=A2=9E=20PChome=20compact=20control?= =?UTF-8?q?led=20apply=20readback?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/AI_INTELLIGENCE_MODULE_SOT.md | 1 + .../pchome_ai_automation_priority_backlog.md | 12 +- routes/ai_routes.py | 34 ++ services/pchome_mapping_backlog_service.py | 293 ++++++++++++++++++ tests/test_pchome_mapping_backlog_report.py | 40 +++ 5 files changed, 376 insertions(+), 4 deletions(-) diff --git a/docs/AI_INTELLIGENCE_MODULE_SOT.md b/docs/AI_INTELLIGENCE_MODULE_SOT.md index 15410b9..4946d3e 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -86,6 +86,7 @@ - 2026-07-02 起 PChome AI 自動化主線工作順序以 `docs/guides/pchome_ai_automation_priority_backlog.md` 為可執行 backlog;使用者中途插入的 production truth、版本不得錯、GitHub freeze、推版到 Gitea/正式環境、AI 自動化取代人工主流程、外部專業 benchmark、主流專業產品網站、實作結果與完整優先順序要求,都必須列入 backlog 並依 P0/P1/P2/P3/P4 推進。未在 backlog 的支線不得蓋過 P0 runtime truth / controlled apply closure。 - 2026-07-02 起 AI automation smoke 必須例行執行 PChome controlled-apply drift monitor;`PChome 受控落地 drift monitor` 會以 read-only 方式重放 receipt replay + drift verifier,將 drift detected 或 verifier write-risk 升為 `critical`,並在 `/api/ai-automation/smoke` 與每日 smoke 摘要中回報 selector/readback/drift/artifact hash 狀態。 - 2026-07-02 起 PChome controlled-apply drift 必須提供 read-only rollback / re-apply recommendation package;`/api/ai/pchome-growth/mapping-backlog/direct-mapping-retry-candidate-exception-controlled-apply-drift-recovery-package` 會輸出 drift recovery actions、controlled re-apply SQL shape、rollback SQL shape、selector bindings、acceptance gates 與 artifact hash verifier。此 package 不執行 SQL、不寫 DB,0 drift 時必須產生 no-op evidence,drift detected 時才輸出 ready_for_controlled_reapply actions。 +- 2026-07-02 起 PChome controlled-apply 必須提供 compact latest readback endpoint;`/api/ai/pchome-growth/mapping-backlog/direct-mapping-retry-candidate-exception-controlled-apply-compact-readback-package` 會收斂 apply、receipt replay、drift verifier、drift recovery 四段 receipt,輸出 product status、next machine action、selector readback、drift count、recovery action count 與 artifact hash 狀態。此 endpoint 是後續產品 UI 的主要資料來源,不執行 SQL、不寫 DB。 - V10.644 起 `/ai_intelligence` 的商品明細列不得只用句子描述比價;每列必須顯示 PChome 價格、MOMO 參考價、差距、可信度四格價格證據,並保留下一步按鈕。單位價候選需顯示單位價與單位,候選待確認或缺資料則以「待補 / 候選待確認」呈現,不得捏造價格。 - V10.645 起 `/ai_intelligence` 的商品明細分流切換後,必須顯示「這類商品怎麼處理」的行動摘要,包含件數、近 7 天業績、平均可信度、最大價差、代表商品與主按鈕;使用者不得只能看到商品列表而不知道下一步。 - V10.646 起 `/ai_intelligence` 的商品明細必須提供搜尋與排序;搜尋至少涵蓋商品、分類、商品編號與 MOMO 候選資訊,排序至少支援優先級、近 7 天業績、價差、下滑幅度與可信度。搜尋/排序後的行動摘要與明細列表必須使用同一批結果。 diff --git a/docs/guides/pchome_ai_automation_priority_backlog.md b/docs/guides/pchome_ai_automation_priority_backlog.md index 397bc48..90cbb08 100644 --- a/docs/guides/pchome_ai_automation_priority_backlog.md +++ b/docs/guides/pchome_ai_automation_priority_backlog.md @@ -70,6 +70,10 @@ - `direct-mapping-retry-candidate-exception-controlled-apply-drift-recovery-package` 可輸出 read-only recovery package - drift detected 時會產生 controlled re-apply SQL shape、rollback SQL shape、selector bindings 與 acceptance gates - 0 drift 時會產生 no-op evidence,避免把正常狀態誤報成人工審核 +- Compact latest apply / replay / drift / recovery readback endpoint 已完成: + - `direct-mapping-retry-candidate-exception-controlled-apply-compact-readback-package` 會回傳 apply、replay、drift、recovery 四段 compact receipt + - 產品面可直接讀取 `product_status`、`next_machine_action`、selector readback、drift count 與 artifact hash 狀態 + - compact readback 自身也可 materialize artifact 並驗證 hash - AI debt scanner 顯示產品面清空: - `PRODUCT_SURFACE_CLEAR` - `finding_count=0` @@ -77,8 +81,7 @@ 進行中 / 下一步,必須照順序: -1. 建立正式環境 compact readback endpoint,回傳最新 apply / replay / drift / recovery receipts。 -2. 建立 PChome controlled-apply artifacts retention policy,讓 evidence 可追蹤但不無限制膨脹。 +1. 建立 PChome controlled-apply artifacts retention policy,讓 evidence 可追蹤但不無限制膨脹。 完成標準: @@ -192,8 +195,9 @@ | P0.6 | Drift verifier artifact | 已完成 | drift artifact hash match `1` | 增加 latest compact readback | | P0.7 | Automated drift monitor | 已完成 | smoke check `PChome 受控落地 drift monitor` | 納入每日 smoke 與 runtime readback | | P0.8 | Drift rollback / re-apply package | 已完成 | drift recovery package route + focused tests | 接入 compact readback | -| P0.9 | Compact latest apply / replay / drift / recovery readback endpoint | 未開始 | apply/replay/drift/recovery packages exist | 下一個實作 | -| P1.1 | Dashboard AI automation first-viewport surface | 未開始 | API readiness exists | P0 compact readback 後實作 | +| P0.9 | Compact latest apply / replay / drift / recovery readback endpoint | 已完成 | compact readback route + focused tests | 接入 product dashboard first viewport | +| P0.10 | Controlled-apply artifact retention policy | 未開始 | compact artifacts exist | 下一個實作 | +| P1.1 | Dashboard AI automation first-viewport surface | 未開始 | API readiness + compact readback exist | P0 retention policy 後實作 | | P1.2 | UI wording guard for no raw engineering terms | 未開始 | existing guardrails only | 為新 automation surface 補 tests | | P2.1 | External benchmark encoded into requirements | 未開始 | benchmark guide exists | 更新 guardrails / tests | | P3.1 | Extend receipt / replay / drift pattern to more lanes | 未開始 | current retry lane complete | P1 後選下一條 safe lane | diff --git a/routes/ai_routes.py b/routes/ai_routes.py index d065e83..712acc0 100644 --- a/routes/ai_routes.py +++ b/routes/ai_routes.py @@ -2583,6 +2583,40 @@ def api_pchome_growth_direct_mapping_retry_candidate_exception_controlled_apply_ }), 500 +@ai_bp.route('/api/ai/pchome-growth/mapping-backlog/direct-mapping-retry-candidate-exception-controlled-apply-compact-readback-package') +@login_required +def api_pchome_growth_direct_mapping_retry_candidate_exception_controlled_apply_compact_readback_package(): + """P2 compact readback for controlled apply / replay / drift / recovery receipts.""" + try: + from config import DATABASE_PATH + from services.pchome_mapping_backlog_service import ( + build_pchome_direct_mapping_retry_candidate_exception_controlled_apply_compact_readback_package, + ) + + run_id = str(request.args.get('run_id') or '').strip() or None + materialize_artifacts = str(request.args.get('materialize_artifacts') or '').strip().lower() in {'1', 'true', 'yes'} + + engine = _create_icaim_dashboard_engine(DATABASE_PATH) + try: + package = build_pchome_direct_mapping_retry_candidate_exception_controlled_apply_compact_readback_package( + run_id=run_id, + materialize_artifacts=materialize_artifacts, + engine=engine, + ) + finally: + engine.dispose() + package["source_endpoint"] = ( + "/api/ai/pchome-growth/mapping-backlog/direct-mapping-retry-candidate-exception-controlled-apply-drift-recovery-package" + ) + return jsonify(package) + except Exception as exc: + logger.error("[PChomeGrowth] direct mapping retry candidate exception controlled apply compact readback 讀取失敗: %s", exc, exc_info=True) + return jsonify({ + "success": False, + "error": "PChome 商品對應 retry 例外 controlled apply compact readback 暫時無法讀取,請稍後再試。", + }), 500 + + @ai_bp.route('/api/ai/pchome-growth/ai-automation-readiness') @login_required def api_pchome_growth_ai_automation_readiness(): diff --git a/services/pchome_mapping_backlog_service.py b/services/pchome_mapping_backlog_service.py index 43928d0..50dfa32 100644 --- a/services/pchome_mapping_backlog_service.py +++ b/services/pchome_mapping_backlog_service.py @@ -80,6 +80,9 @@ DIRECT_MAPPING_RETRY_CANDIDATE_EXCEPTION_CONTROLLED_APPLY_DRIFT_VERIFIER_POLICY DIRECT_MAPPING_RETRY_CANDIDATE_EXCEPTION_CONTROLLED_APPLY_DRIFT_RECOVERY_POLICY = ( "ai_controlled_pchome_growth_direct_mapping_retry_candidate_exception_controlled_apply_drift_recovery" ) +DIRECT_MAPPING_RETRY_CANDIDATE_EXCEPTION_CONTROLLED_APPLY_COMPACT_READBACK_POLICY = ( + "read_only_pchome_growth_direct_mapping_retry_candidate_exception_controlled_apply_compact_readback" +) AI_AUTOMATION_READINESS_POLICY = "read_only_pchome_growth_ai_automation_readiness" EVIDENCE_ENRICHMENT_PREVIEW_POLICY = "read_only_pchome_growth_evidence_enrichment_preview" EVIDENCE_SOURCE_PREVIEW_POLICY = "read_only_pchome_growth_evidence_source_preview" @@ -4982,6 +4985,296 @@ def build_pchome_direct_mapping_retry_candidate_exception_controlled_apply_drift } +def _compact_retry_exception_artifact_readback( + root: Path, + subdir: str, + *, + artifact_key: str, + run_id: str | None = None, +) -> dict[str, Any]: + path = _find_retry_exception_artifact_file(root, subdir, run_id) + payload = _load_retry_exception_json_artifact(path) + actual_sha = hashlib.sha256(path.read_bytes()).hexdigest() if path and path.exists() else "" + return { + "artifact_key": artifact_key, + "exists": bool(path and path.exists()), + "relative_path": str(path.relative_to(root)) if path and path.exists() else None, + "absolute_path": str(path) if path and path.exists() else None, + "payload_sha256": actual_sha, + "byte_count": path.stat().st_size if path and path.exists() else 0, + "result": payload.get("result"), + "run_id": payload.get("run_id"), + "summary": payload.get("summary") or {}, + "writes_database": False, + } + + +def _compact_package_artifact_readback(package: dict[str, Any], artifact_key: str) -> dict[str, Any]: + artifact = ( + package.get("executor_receipt_artifact") + or package.get("drift_verifier_artifact") + or package.get("recovery_artifact") + or {} + ) + verifier = ( + package.get("post_executor_receipt_verifier") + or package.get("post_drift_verifier_artifact_verifier") + or package.get("post_recovery_artifact_verifier") + or {} + ) + return { + "artifact_key": artifact_key, + "exists": bool(verifier.get("actual_sha256")), + "relative_path": artifact.get("relative_path"), + "payload_sha256": verifier.get("actual_sha256") or artifact.get("payload_sha256") or "", + "expected_sha256": verifier.get("expected_sha256") or artifact.get("payload_sha256") or "", + "hash_match": bool(verifier.get("hash_match")), + "byte_count": artifact.get("byte_count") or 0, + "result": package.get("result"), + "summary": package.get("summary") or {}, + "writes_database": False, + } + + +def build_pchome_direct_mapping_retry_candidate_exception_controlled_apply_compact_readback_package( + *, + artifact_root: str | Path | None = None, + run_id: str | None = None, + engine: Any = None, + source_receipt_replay: dict[str, Any] | None = None, + source_drift_verifier: dict[str, Any] | None = None, + source_drift_recovery: dict[str, Any] | None = None, + materialize_artifacts: bool = False, +) -> dict[str, Any]: + """Build a compact product-facing readback for apply / replay / drift / recovery receipts.""" + root = Path(artifact_root) if artifact_root is not None else Path.cwd() / "data" + apply_receipt = _compact_retry_exception_artifact_readback( + root, + "controlled_apply_executor", + artifact_key="retry_exception_controlled_apply_executor_receipt", + run_id=run_id, + ) + replay = source_receipt_replay or build_pchome_direct_mapping_retry_candidate_exception_controlled_apply_receipt_replay_package( + artifact_root=root, + run_id=run_id, + materialize_artifacts=False, + engine=engine, + ) + drift = source_drift_verifier or build_pchome_direct_mapping_retry_candidate_exception_controlled_apply_drift_verifier_package( + artifact_root=root, + run_id=run_id, + engine=engine, + source_receipt_replay=replay, + materialize_artifacts=False, + ) + recovery = source_drift_recovery or build_pchome_direct_mapping_retry_candidate_exception_controlled_apply_drift_recovery_package( + artifact_root=root, + run_id=run_id, + engine=engine, + source_receipt_replay=replay, + source_drift_verifier=drift, + materialize_artifacts=False, + ) + replay_summary = replay.get("summary") or {} + drift_summary = drift.get("summary") or {} + recovery_summary = recovery.get("summary") or {} + selector_count = int(drift_summary.get("target_selector_count") or replay_summary.get("target_selector_count") or 0) + readback_pass_count = int( + drift_summary.get("post_apply_readback_pass_count") + or replay_summary.get("post_apply_readback_pass_count") + or 0 + ) + drift_count = int(drift_summary.get("drift_count") or 0) + recovery_action_count = int(recovery_summary.get("drift_recovery_action_count") or 0) + replay_hash_match_count = int(replay_summary.get("executor_receipt_hash_match_count") or 0) + drift_hash_match_count = int(drift_summary.get("drift_verifier_artifact_hash_match_count") or 0) + recovery_hash_match_count = int(recovery_summary.get("recovery_artifact_hash_match_count") or 0) + apply_hash_match_count = 1 if apply_receipt.get("exists") and len(str(apply_receipt.get("payload_sha256") or "")) == 64 else 0 + + if drift_count: + result = "DIRECT_MAPPING_RETRY_EXCEPTION_CONTROLLED_APPLY_COMPACT_READBACK_DRIFT_REQUIRES_RECOVERY" + next_machine_action = "run_controlled_reapply_check_mode" + product_status = "blocked" + elif ( + selector_count + and readback_pass_count == selector_count + and replay_hash_match_count + and drift.get("result") == "DIRECT_MAPPING_RETRY_EXCEPTION_CONTROLLED_APPLY_DRIFT_VERIFIED" + and recovery.get("result") == "DIRECT_MAPPING_RETRY_EXCEPTION_CONTROLLED_APPLY_DRIFT_RECOVERY_NOT_REQUIRED" + ): + result = "DIRECT_MAPPING_RETRY_EXCEPTION_CONTROLLED_APPLY_COMPACT_READBACK_VERIFIED" + next_machine_action = "keep_monitoring_drift" + product_status = "completed" + elif replay.get("missing_artifacts"): + result = "WAITING_FOR_RETRY_EXCEPTION_CONTROLLED_APPLY_COMPACT_READBACK_ARTIFACTS" + next_machine_action = "restore_or_materialize_source_receipts" + product_status = "waiting" + else: + result = "WAITING_FOR_RETRY_EXCEPTION_CONTROLLED_APPLY_COMPACT_READBACK_BASELINE" + next_machine_action = "run_receipt_replay_and_drift_verifier" + product_status = "waiting" + + summary = { + "target_selector_count": selector_count, + "post_apply_readback_pass_count": readback_pass_count, + "post_apply_readback_fail_count": max(selector_count - readback_pass_count, 0), + "drift_count": drift_count, + "drift_recovery_action_count": recovery_action_count, + "drift_reapply_ready_count": int(recovery_summary.get("drift_reapply_ready_count") or 0), + "apply_receipt_hash_match_count": apply_hash_match_count, + "replay_receipt_hash_match_count": replay_hash_match_count, + "drift_verifier_artifact_hash_match_count": drift_hash_match_count, + "recovery_artifact_hash_match_count": recovery_hash_match_count, + "compact_readback_artifact_materialized_count": 0, + "compact_readback_artifact_hash_match_count": 0, + "writes_database_count": 0, + } + receipts = { + "apply": apply_receipt, + "replay": _compact_package_artifact_readback( + replay, + "retry_exception_controlled_apply_executor_replay_receipt", + ), + "drift": _compact_package_artifact_readback( + drift, + "retry_exception_controlled_apply_drift_verifier_receipt", + ), + "recovery": _compact_package_artifact_readback( + recovery, + "retry_exception_controlled_apply_drift_recovery_receipt", + ), + } + safety = { + "ai_controlled_apply": True, + "compact_readback": True, + "reads_artifact_files": True, + "reads_database": engine is not None, + "writes_database": False, + "writes_database_count": 0, + "writes_artifact_count": 0, + "syncs_external_offers": False, + "dispatches_telegram": False, + "gemini_allowed": False, + "requires_production_version_truth": True, + } + compact_id_payload = { + "run_id": run_id or (replay.get("receipt_replay") or {}).get("run_id") or "", + "result": result, + "summary": summary, + "receipts": receipts, + } + compact_id = ( + "pchome-retry-exception-controlled-apply-compact-readback-" + + hashlib.sha256( + json.dumps(compact_id_payload, ensure_ascii=False, sort_keys=True, default=str).encode("utf-8") + ).hexdigest()[:16] + ) + checks = [ + { + "check": "apply_receipt_loaded_if_available", + "passed": bool(apply_receipt.get("exists")) or replay_hash_match_count > 0, + }, + {"check": "receipt_replay_loaded", "passed": bool(replay)}, + {"check": "drift_verifier_loaded", "passed": bool(drift)}, + {"check": "drift_recovery_loaded", "passed": bool(recovery)}, + {"check": "post_apply_readback_matches_selector_count", "passed": (not selector_count) or readback_pass_count == selector_count}, + {"check": "compact_readback_does_not_write_database", "passed": True}, + ] + artifact_payload = { + "artifact_key": "retry_exception_controlled_apply_compact_readback_receipt", + "compact_readback_id": compact_id, + "source_policy": DIRECT_MAPPING_RETRY_CANDIDATE_EXCEPTION_CONTROLLED_APPLY_COMPACT_READBACK_POLICY, + "result": result, + "product_status": product_status, + "next_machine_action": next_machine_action, + "summary": summary, + "receipts": receipts, + "checks": checks, + "safety": safety, + } + artifact_bytes = _canonical_retry_exception_artifact_bytes(artifact_payload) + artifact_relative_path = ( + f"artifacts/pchome_growth/retry_exception_closeout/" + f"controlled_apply_compact_readback/{compact_id}.json" + ) + compact_artifact = { + "key": "retry_exception_controlled_apply_compact_readback_receipt", + "artifact_type": "controlled_apply_compact_readback_receipt", + "relative_path": artifact_relative_path, + "payload_sha256": hashlib.sha256(artifact_bytes).hexdigest(), + "byte_count": len(artifact_bytes), + "payload": artifact_payload, + "materialized": False, + "writes_database": False, + } + materialized_compact_artifacts: list[dict[str, Any]] = [] + if materialize_artifacts and selector_count: + target_path = _resolve_retry_exception_artifact_path(root, artifact_relative_path) + target_path.parent.mkdir(parents=True, exist_ok=True) + target_path.write_bytes(artifact_bytes) + materialized_compact_artifacts.append({ + "key": compact_artifact["key"], + "relative_path": artifact_relative_path, + "absolute_path": str(target_path), + "payload_sha256": compact_artifact["payload_sha256"], + "written_byte_count": target_path.stat().st_size, + "writes_database": False, + }) + compact_artifact["materialized"] = True + compact_artifact["absolute_path"] = str(target_path) + artifact_path = _resolve_retry_exception_artifact_path(root, artifact_relative_path) + artifact_sha = hashlib.sha256(artifact_path.read_bytes()).hexdigest() if artifact_path.exists() else "" + artifact_hash_match = bool(artifact_sha) and artifact_sha == compact_artifact["payload_sha256"] + summary["compact_readback_artifact_materialized_count"] = len(materialized_compact_artifacts) or (1 if artifact_hash_match else 0) + summary["compact_readback_artifact_hash_match_count"] = 1 if artifact_hash_match else 0 + safety["writes_artifact_count"] = len(materialized_compact_artifacts) + checks.extend([ + { + "check": "compact_artifact_materialized_when_requested", + "passed": (not materialize_artifacts) or (selector_count > 0 and artifact_path.exists()), + }, + { + "check": "compact_artifact_hash_matches_expected", + "passed": (not materialize_artifacts) or artifact_hash_match, + }, + ]) + return { + "policy": DIRECT_MAPPING_RETRY_CANDIDATE_EXCEPTION_CONTROLLED_APPLY_COMPACT_READBACK_POLICY, + "result": result, + "success": result in { + "DIRECT_MAPPING_RETRY_EXCEPTION_CONTROLLED_APPLY_COMPACT_READBACK_VERIFIED", + "DIRECT_MAPPING_RETRY_EXCEPTION_CONTROLLED_APPLY_COMPACT_READBACK_DRIFT_REQUIRES_RECOVERY", + }, + "summary": summary, + "compact_readback": { + "compact_readback_id": compact_id, + "stage": "P2_retry_exception_controlled_apply_compact_readback", + "status": product_status, + "next_machine_action": next_machine_action, + "materialize_artifacts": bool(materialize_artifacts), + "requires_production_version_truth": True, + }, + "receipts": receipts, + "compact_artifact": compact_artifact, + "materialized_compact_artifacts": materialized_compact_artifacts, + "post_compact_artifact_verifier": { + "expected_sha256": compact_artifact["payload_sha256"], + "actual_sha256": artifact_sha, + "hash_match": artifact_hash_match, + "writes_database": False, + }, + "checks": checks, + "check_count": len(checks), + "all_checks_passed": all(check.get("passed") is True for check in checks), + "next_actions": [ + "Use this compact readback as the product/UI source for controlled apply status.", + "If drift_count is zero, continue automated drift monitoring.", + "If drift_count is positive, execute the recovery package through controlled check-mode first.", + ], + "safety": safety, + } + + def build_pchome_evidence_enrichment_preview(payload: dict[str, Any], batch_size: int = 5) -> dict[str, Any]: """Build a read-only evidence enrichment package for mapping targets.""" operator_preview = build_pchome_mapping_operator_preview(payload, batch_size=batch_size) diff --git a/tests/test_pchome_mapping_backlog_report.py b/tests/test_pchome_mapping_backlog_report.py index 998319a..00dc027 100644 --- a/tests/test_pchome_mapping_backlog_report.py +++ b/tests/test_pchome_mapping_backlog_report.py @@ -76,6 +76,7 @@ from services.pchome_mapping_backlog_service import ( build_pchome_direct_mapping_candidate_exception_resolution_closeout_package, build_pchome_direct_mapping_retry_candidate_decision_package, build_pchome_direct_mapping_retry_candidate_exception_auto_resolution_package, + build_pchome_direct_mapping_retry_candidate_exception_controlled_apply_compact_readback_package, build_pchome_direct_mapping_retry_candidate_exception_controlled_apply_drift_recovery_package, build_pchome_direct_mapping_retry_candidate_exception_controlled_apply_drift_verifier_package, build_pchome_direct_mapping_retry_candidate_exception_controlled_apply_executor_package, @@ -1446,6 +1447,29 @@ def test_direct_mapping_retry_candidate_exception_controlled_apply_receipt_repla assert no_drift_recovery["summary"]["recovery_artifact_materialized_count"] == 1 assert no_drift_recovery["post_recovery_artifact_verifier"]["hash_match"] is True assert no_drift_recovery["safety"]["writes_database"] is False + compact = build_pchome_direct_mapping_retry_candidate_exception_controlled_apply_compact_readback_package( + artifact_root=tmp_path, + run_id=run_id, + engine=engine, + source_receipt_replay=read_only_package, + source_drift_verifier=verifier, + source_drift_recovery=no_drift_recovery, + materialize_artifacts=True, + ) + assert compact["result"] == "DIRECT_MAPPING_RETRY_EXCEPTION_CONTROLLED_APPLY_COMPACT_READBACK_VERIFIED" + assert compact["summary"]["target_selector_count"] == 2 + assert compact["summary"]["post_apply_readback_pass_count"] == 2 + assert compact["summary"]["drift_count"] == 0 + assert compact["summary"]["compact_readback_artifact_materialized_count"] == 1 + assert compact["summary"]["compact_readback_artifact_hash_match_count"] == 1 + assert compact["compact_readback"]["status"] == "completed" + assert compact["compact_readback"]["next_machine_action"] == "keep_monitoring_drift" + assert compact["receipts"]["apply"]["artifact_key"] == "retry_exception_controlled_apply_executor_receipt" + assert compact["receipts"]["replay"]["hash_match"] is True + assert compact["receipts"]["drift"]["hash_match"] is True + assert compact["receipts"]["recovery"]["hash_match"] is True + assert compact["post_compact_artifact_verifier"]["hash_match"] is True + assert compact["safety"]["writes_database"] is False with engine.begin() as conn: conn.execute(text(""" UPDATE pchome_product_matches @@ -1487,6 +1511,22 @@ def test_direct_mapping_retry_candidate_exception_controlled_apply_receipt_repla assert recovery["post_recovery_artifact_verifier"]["hash_match"] is True assert recovery["safety"]["writes_database"] is False assert recovery["safety"]["writes_artifact_count"] == 1 + drift_compact = build_pchome_direct_mapping_retry_candidate_exception_controlled_apply_compact_readback_package( + artifact_root=tmp_path, + run_id=run_id, + engine=engine, + source_drift_verifier=drift_package, + source_drift_recovery=recovery, + materialize_artifacts=True, + ) + assert drift_compact["result"] == "DIRECT_MAPPING_RETRY_EXCEPTION_CONTROLLED_APPLY_COMPACT_READBACK_DRIFT_REQUIRES_RECOVERY" + assert drift_compact["summary"]["drift_count"] == 1 + assert drift_compact["summary"]["drift_recovery_action_count"] == 1 + assert drift_compact["compact_readback"]["status"] == "blocked" + assert drift_compact["compact_readback"]["next_machine_action"] == "run_controlled_reapply_check_mode" + assert drift_compact["summary"]["compact_readback_artifact_materialized_count"] == 1 + assert drift_compact["post_compact_artifact_verifier"]["hash_match"] is True + assert drift_compact["safety"]["writes_database"] is False assert call_count["search"] == 2