Files
ewoooc/services/pchome_growth_momo_backfill_service.py

311 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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],
},
}