311 lines
12 KiB
Python
311 lines
12 KiB
Python
#!/usr/bin/env python3
|
||
# -*- coding: utf-8 -*-
|
||
"""PChome 高業績商品主動反查 MOMO 候選,寫入外部報價層。"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import os
|
||
from datetime import datetime, timezone
|
||
from typing import Any, Callable
|
||
|
||
from services.pchome_growth_same_item_reconciliation import (
|
||
CONTRACT_VERSION,
|
||
build_coverage_post_verifier,
|
||
build_revenue_weighted_targets,
|
||
ensure_evidence_receipt_table,
|
||
new_reconciliation_identity,
|
||
persist_reconciliation_receipts,
|
||
verify_same_item_candidates,
|
||
)
|
||
|
||
|
||
def _int_env(name: str, default: int, *, minimum: int, maximum: int) -> int:
|
||
try:
|
||
value = int(os.getenv(name, str(default)))
|
||
except (TypeError, ValueError):
|
||
value = default
|
||
return max(minimum, min(value, maximum))
|
||
|
||
|
||
def _float_env(name: str, default: float, *, minimum: float, maximum: float) -> float:
|
||
try:
|
||
value = float(os.getenv(name, str(default)))
|
||
except (TypeError, ValueError):
|
||
value = default
|
||
return max(minimum, min(value, maximum))
|
||
|
||
|
||
def candidate_auto_compare_type(candidate: dict[str, Any]) -> str:
|
||
auto_type = str(candidate.get("auto_compare_type") or "").strip()
|
||
if auto_type in {"total_price", "unit_price"}:
|
||
return auto_type
|
||
if candidate.get("can_auto_compare") is True:
|
||
return "total_price"
|
||
return "manual_review"
|
||
|
||
|
||
def build_momo_backfill_targets(payload: dict[str, Any], limit: int) -> list[dict[str, Any]]:
|
||
return build_revenue_weighted_targets(payload, limit)
|
||
|
||
|
||
def _default_build_payload(engine, limit: int) -> dict[str, Any]:
|
||
from services.pchome_revenue_growth_service import build_pchome_growth_opportunities
|
||
|
||
return build_pchome_growth_opportunities(engine, limit=limit)
|
||
|
||
|
||
def _default_search_candidates(targets: list[dict[str, Any]], limit: int):
|
||
from services.momo_crawler import search_momo_products_for_pchome_products
|
||
|
||
return search_momo_products_for_pchome_products(
|
||
targets,
|
||
max_products=limit,
|
||
limit_per_product=_int_env(
|
||
"PCHOME_GROWTH_MOMO_BACKFILL_LIMIT_PER_TERM",
|
||
8,
|
||
minimum=3,
|
||
maximum=12,
|
||
),
|
||
max_terms_per_product=_int_env(
|
||
"PCHOME_GROWTH_MOMO_BACKFILL_MAX_TERMS",
|
||
8,
|
||
minimum=3,
|
||
maximum=10,
|
||
),
|
||
min_score=_float_env(
|
||
"PCHOME_GROWTH_MOMO_BACKFILL_MIN_SCORE",
|
||
0.45,
|
||
minimum=0.35,
|
||
maximum=0.8,
|
||
),
|
||
)
|
||
|
||
|
||
def _default_sync_candidates(engine, candidates: list[dict[str, Any]]) -> dict[str, Any]:
|
||
from services.external_market_offer_service import sync_targeted_momo_candidates_to_external_offers
|
||
|
||
return sync_targeted_momo_candidates_to_external_offers(engine, candidates, dry_run=False)
|
||
|
||
|
||
def _default_sync_review_candidates(engine, candidates: list[dict[str, Any]]) -> dict[str, Any]:
|
||
from services.external_market_offer_service import sync_targeted_momo_review_candidates_to_external_offers
|
||
|
||
return sync_targeted_momo_review_candidates_to_external_offers(engine, candidates, dry_run=False)
|
||
|
||
|
||
def run_pchome_growth_momo_backfill(
|
||
engine,
|
||
*,
|
||
limit: int = 12,
|
||
build_payload_func: Callable[[Any, int], dict[str, Any]] | None = None,
|
||
search_func: Callable[[list[dict[str, Any]], int], tuple[bool, str, list[dict[str, Any]]]] | None = None,
|
||
sync_func: Callable[[Any, list[dict[str, Any]]], dict[str, Any]] | None = None,
|
||
sync_review_func: Callable[[Any, list[dict[str, Any]]], dict[str, Any]] | None = None,
|
||
) -> dict[str, Any]:
|
||
"""Run a bounded search, independent verifier, apply, and readback loop."""
|
||
limit = max(1, min(int(limit or 12), 20))
|
||
build_payload = build_payload_func or _default_build_payload
|
||
search_candidates = search_func or _default_search_candidates
|
||
sync_candidates = sync_func or _default_sync_candidates
|
||
sync_review_candidates = sync_review_func or _default_sync_review_candidates
|
||
identity = new_reconciliation_identity()
|
||
generated_at = datetime.now(timezone.utc).isoformat()
|
||
|
||
# The command center contract is top-50. Ranking a smaller pre-sorted slice
|
||
# can hide the highest-revenue unresolved products.
|
||
before_payload = build_payload(engine, 50)
|
||
targets = build_momo_backfill_targets(before_payload, limit)
|
||
schema_receipt = ensure_evidence_receipt_table(engine)
|
||
if not schema_receipt.get("success"):
|
||
return {
|
||
"success": False,
|
||
"message": "同商品收據資料層尚未就緒,本輪已安全停止且沒有寫入價格。",
|
||
"data": {
|
||
"contract_version": CONTRACT_VERSION,
|
||
"identity": identity,
|
||
"terminal_status": "blocked_receipt_schema_unavailable",
|
||
"target_count": len(targets),
|
||
"scanned_products": 0,
|
||
"candidate_count": 0,
|
||
"auto_compare_count": 0,
|
||
"review_count": 0,
|
||
"external_offer_sync": {"status": "not_executed", "written_count": 0},
|
||
"review_candidate_sync": {"status": "not_executed", "written_count": 0},
|
||
"receipt_schema": schema_receipt,
|
||
"before_stats": before_payload.get("stats") or {},
|
||
"after_stats": before_payload.get("stats") or {},
|
||
"targets": targets,
|
||
"review_candidates": [],
|
||
},
|
||
}
|
||
|
||
if targets:
|
||
search_success, search_message, candidates = search_candidates(targets, limit)
|
||
else:
|
||
search_success, search_message, candidates = (
|
||
True,
|
||
"目前 TOP 50 沒有未解決的同商品對應。",
|
||
[],
|
||
)
|
||
candidates = list(candidates or [])
|
||
candidate_verification = verify_same_item_candidates(
|
||
targets,
|
||
candidates,
|
||
identity=identity,
|
||
)
|
||
auto_candidates = list(candidate_verification.get("verified_candidates") or [])
|
||
exact_candidates = [item for item in auto_candidates if candidate_auto_compare_type(item) == "total_price"]
|
||
unit_candidates = [item for item in auto_candidates if candidate_auto_compare_type(item) == "unit_price"]
|
||
review_candidates = list(candidate_verification.get("review_candidates") or [])
|
||
auto_candidates = [*exact_candidates, *unit_candidates]
|
||
external_offer_sync = {
|
||
"success": True,
|
||
"status": "not_found",
|
||
"written_count": 0,
|
||
"message": "已搜尋 MOMO,但尚未找到可自動寫入的同款或單位價候選。",
|
||
}
|
||
if auto_candidates:
|
||
external_offer_sync = sync_candidates(engine, auto_candidates)
|
||
review_candidate_sync = {
|
||
"success": True,
|
||
"status": "not_found",
|
||
"written_count": 0,
|
||
"message": "沒有需要保存的待確認候選。",
|
||
}
|
||
if review_candidates:
|
||
review_candidate_sync = sync_review_candidates(engine, review_candidates)
|
||
|
||
after_payload = build_payload(engine, 50)
|
||
post_verifier = build_coverage_post_verifier(
|
||
before_payload=before_payload,
|
||
after_payload=after_payload,
|
||
verified_candidates=auto_candidates,
|
||
sync_result=external_offer_sync,
|
||
)
|
||
written_count = int(external_offer_sync.get("written_count") or 0)
|
||
after_snapshot = post_verifier.get("after") or {}
|
||
unresolved_after = int(after_snapshot.get("candidate_validation_count") or 0) + int(
|
||
after_snapshot.get("unmatched_count") or 0
|
||
)
|
||
if not post_verifier.get("all_checks_passed"):
|
||
terminal_status = "partial_post_verifier_failed"
|
||
next_machine_action = "retry_bounded_batch_after_drift_diagnosis"
|
||
elif written_count > 0:
|
||
terminal_status = "verified_with_coverage_gain"
|
||
next_machine_action = (
|
||
"continue_next_revenue_weighted_batch"
|
||
if unresolved_after
|
||
else "maintain_same_item_coverage"
|
||
)
|
||
elif unresolved_after:
|
||
terminal_status = "degraded_no_safe_candidate"
|
||
next_machine_action = "retry_unresolved_batch_with_fresh_source_evidence"
|
||
else:
|
||
terminal_status = "verified_no_work_remaining"
|
||
next_machine_action = "maintain_same_item_coverage"
|
||
|
||
run_receipt = {
|
||
"contract_version": CONTRACT_VERSION,
|
||
"generated_at": generated_at,
|
||
"identity": identity,
|
||
"source_receipt": {
|
||
"source": "momo_targeted_search",
|
||
"search_success": bool(search_success),
|
||
"search_message": search_message,
|
||
"target_count": len(targets),
|
||
"candidate_count": len(candidates),
|
||
},
|
||
"normalization": {
|
||
"target_count": len(targets),
|
||
"candidate_count": len(candidates),
|
||
"candidate_fingerprint_count": len({
|
||
str(item.get("same_item_candidate_fingerprint") or "")
|
||
for item in auto_candidates
|
||
if item.get("same_item_candidate_fingerprint")
|
||
}),
|
||
},
|
||
"candidate_verification": candidate_verification,
|
||
"coverage_diff": {
|
||
"comparison_ready_delta": post_verifier.get("comparison_ready_delta"),
|
||
"count_coverage_delta": post_verifier.get("count_coverage_delta"),
|
||
"revenue_coverage_delta": post_verifier.get("revenue_coverage_delta"),
|
||
},
|
||
"risk_policy": {
|
||
"risk": "medium",
|
||
"bounded_target_limit": limit,
|
||
"formal_price_write_requires_independent_verifier": True,
|
||
"pixelrag_direct_price_write_allowed": False,
|
||
},
|
||
"check_mode": {
|
||
"receipt_schema_ready": bool(schema_receipt.get("success")),
|
||
"verified_candidate_count": len(auto_candidates),
|
||
"blocked_candidate_count": int(candidate_verification.get("blocked_candidate_count") or 0),
|
||
},
|
||
"execution": {
|
||
"idempotent_bounded_execution": True,
|
||
"written_count": written_count,
|
||
"review_candidate_written_count": int(review_candidate_sync.get("written_count") or 0),
|
||
"sync_status": external_offer_sync.get("status"),
|
||
},
|
||
"post_verifier": post_verifier,
|
||
"terminal": {
|
||
"status": terminal_status,
|
||
"next_machine_action": next_machine_action,
|
||
"unresolved_after": unresolved_after,
|
||
},
|
||
}
|
||
receipt_persistence = persist_reconciliation_receipts(
|
||
engine,
|
||
targets=targets,
|
||
run_receipt=run_receipt,
|
||
)
|
||
if not receipt_persistence.get("success"):
|
||
terminal_status = "partial_receipt_persistence_failed"
|
||
next_machine_action = "retry_receipt_persistence_before_next_apply"
|
||
run_receipt["terminal"] = {
|
||
"status": terminal_status,
|
||
"next_machine_action": next_machine_action,
|
||
"unresolved_after": unresolved_after,
|
||
}
|
||
|
||
success = bool(
|
||
post_verifier.get("all_checks_passed")
|
||
and receipt_persistence.get("success")
|
||
)
|
||
message = (
|
||
f"已依業績權重掃描 {len(targets)} 個商品,找到 {len(candidates)} 筆候選,"
|
||
f"獨立驗證 {len(auto_candidates)} 筆,正式寫入並回讀 {post_verifier.get('readback_pass_count', 0)} 筆。"
|
||
)
|
||
if not search_success and not candidates:
|
||
message = search_message or "已完成 bounded 搜尋,但本輪沒有安全候選。"
|
||
|
||
return {
|
||
"success": success,
|
||
"message": message,
|
||
"data": {
|
||
"contract_version": CONTRACT_VERSION,
|
||
"identity": identity,
|
||
"terminal_status": terminal_status,
|
||
"next_machine_action": next_machine_action,
|
||
"search_success": bool(search_success),
|
||
"search_message": search_message,
|
||
"scanned_products": len(targets),
|
||
"target_count": len(targets),
|
||
"candidate_count": len(candidates),
|
||
"exact_compare_count": len(exact_candidates),
|
||
"unit_compare_count": len(unit_candidates),
|
||
"auto_compare_count": len(auto_candidates),
|
||
"review_count": len(review_candidates),
|
||
"external_offer_sync": external_offer_sync,
|
||
"review_candidate_sync": review_candidate_sync,
|
||
"before_stats": before_payload.get("stats") or {},
|
||
"after_stats": after_payload.get("stats") or {},
|
||
"post_verifier": post_verifier,
|
||
"receipt_schema": schema_receipt,
|
||
"receipt_persistence": receipt_persistence,
|
||
"reconciliation_receipt": run_receipt,
|
||
"targets": targets,
|
||
"review_candidates": review_candidates[:8],
|
||
},
|
||
}
|