Files
ewoooc/services/pchome_growth_momo_backfill_service.py
ogt c091c466c2
Some checks failed
CD Pipeline / deploy (push) Has been cancelled
fix(growth): partition Yahoo promotion candidates
2026-07-15 13:15:33 +08:00

439 lines
17 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,
readback_external_offer_candidates,
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,
readback_func: Callable[[Any, list[dict[str, Any]]], dict[str, Any]] | None = None,
source_profile: dict[str, Any] | None = None,
source_activation_func: Callable[..., dict[str, Any]] | None = None,
source_rollback_func: Callable[..., 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
readback_candidates = readback_func or readback_external_offer_candidates
profile = {
"display_name": "MOMO",
"receipt_source": "momo_targeted_search",
"work_item_id": "GROWTH-P0-001-B",
**(source_profile or {}),
}
source_label = str(profile.get("display_name") or "外部平台")
formal_compare_types = {
str(item)
for item in (
profile.get("formal_compare_types") or ("total_price", "unit_price")
)
}
identity = new_reconciliation_identity(
str(profile.get("work_item_id") or "GROWTH-P0-001-B")
)
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 [])
promotion_policy_deferred = []
for candidate in [*exact_candidates, *unit_candidates]:
compare_type = candidate_auto_compare_type(candidate)
if compare_type in formal_compare_types:
continue
deferred = dict(candidate)
deferred["promotion_policy_status"] = "candidate_only"
deferred["promotion_policy_reason"] = (
"source_profile_compare_type_not_allowed"
)
promotion_policy_deferred.append(deferred)
review_candidates.extend(promotion_policy_deferred)
exact_candidates = [
item for item in exact_candidates if "total_price" in formal_compare_types
]
unit_candidates = [
item for item in unit_candidates if "unit_price" in formal_compare_types
]
auto_candidates = [*exact_candidates, *unit_candidates]
external_offer_sync = {
"success": True,
"status": "not_found",
"written_count": 0,
"message": f"已搜尋 {source_label},但尚未找到可自動寫入的同款或單位價候選。",
}
if auto_candidates:
external_offer_sync = sync_candidates(engine, auto_candidates)
external_offer_sync["independent_readback_required"] = True
external_offer_sync["independent_readback"] = readback_candidates(
engine,
auto_candidates,
)
source_activation = {
"success": True,
"status": "not_requested",
"applied": False,
"writes_database_count": 0,
}
if source_activation_func and auto_candidates:
source_activation = source_activation_func(
engine,
candidates=auto_candidates,
independent_readback=external_offer_sync.get("independent_readback") or {},
identity=identity,
)
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)
activation_status = str(source_activation.get("status") or "")
activation_failed = bool(
source_activation_func
and auto_candidates
and not source_activation.get("applied")
and activation_status != "canary_threshold_not_met"
)
source_rollback = {
"success": True,
"status": "not_required",
"writes_database_count": 0,
}
if (
(not post_verifier.get("all_checks_passed") or activation_failed)
and source_rollback_func
and (written_count > 0 or source_activation.get("applied"))
):
source_rollback = source_rollback_func(
engine,
candidates=auto_candidates,
identity=identity,
activation_receipt=source_activation,
)
external_offer_sync["independent_readback"] = readback_candidates(
engine,
auto_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,
)
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 source_activation_func and auto_candidates and not source_activation.get(
"applied"
):
terminal_status = (
"partial_source_activation_pending"
if activation_status == "canary_threshold_not_met"
else "partial_source_activation_failed"
)
next_machine_action = (
"continue_bounded_source_canary"
if activation_status == "canary_threshold_not_met"
else "retry_source_activation_after_rollback_diagnosis"
)
elif written_count > 0:
terminal_status = (
"verified_with_coverage_gain"
if max(
float(post_verifier.get("count_coverage_delta") or 0),
float(post_verifier.get("revenue_coverage_delta") or 0),
) > 0
else "verified_write_readback"
)
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": profile.get("receipt_source") or "external_marketplace_search",
"source_label": source_label,
"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,
"source_activation_requires_canary_readback": bool(source_activation_func),
"formal_compare_types": sorted(formal_compare_types),
},
"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),
"promotion_policy_deferred_count": len(promotion_policy_deferred),
},
"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"),
},
"source_activation": source_activation,
"source_rollback": source_rollback,
"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,
}
activation_contract_passed = bool(
not source_activation_func
or not auto_candidates
or source_activation.get("applied")
)
success = bool(
post_verifier.get("all_checks_passed")
and receipt_persistence.get("success")
and activation_contract_passed
)
message = (
f"已依業績權重掃描 {len(targets)} 個商品並搜尋 {source_label},找到 {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 搜尋,但本輪沒有安全候選。"
elif not activation_contract_passed:
message += " 來源尚未通過 activation canary報價維持候選狀態。"
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),
"promotion_policy_deferred_count": len(promotion_policy_deferred),
"external_offer_sync": external_offer_sync,
"review_candidate_sync": review_candidate_sync,
"source_activation": source_activation,
"source_rollback": source_rollback,
"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],
},
}