feat(growth): automate exact pack reconciliation
Some checks failed
CD Pipeline / deploy (push) Has been cancelled

This commit is contained in:
ogt
2026-07-15 09:37:54 +08:00
parent d7be63f71e
commit 05c7cd8421
6 changed files with 448 additions and 6 deletions

View File

@@ -127,6 +127,8 @@ GENERIC_TOKENS = {
"",
"",
"",
"",
"張入",
"",
"",
"",
@@ -854,8 +856,8 @@ PRODUCT_TYPES = {
}
COUNT_UNITS = {"", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "", "刀把", "刀片", "刀頭", ""}
COUNT_UNIT_PATTERN = r"(?:刀把|刀片|刀頭|入|組|瓶|支|條|盒|包|袋|片|顆|粒|錠|枚|件|罐|杯|本|蕊)"
PIECE_UNITS = {"", "", "", "", "", "", ""}
COUNT_UNIT_PATTERN = r"(?:刀把|刀片|刀頭|入|組|瓶|支|條|盒|包|袋|片|張|顆|粒|錠|枚|件|罐|杯|本|蕊)"
PIECE_UNITS = {"", "", "", "", "", "", "", ""}
CONTAINER_UNITS = {"", "", "", "", "", "", "", "", "", ""}
COUNT_UNIT_FAMILIES = {
"刀片": "blade",
@@ -3327,7 +3329,13 @@ def _search_spec_terms(identity: ProductIdentity) -> list[str]:
dosage = identity.dosages_mg[0]
specs.append(f"{dosage:g}mg")
if identity.total_piece_count:
specs.append(f"{identity.total_piece_count}")
piece_units = [
unit
for _, unit in identity.counts
if unit in PIECE_UNITS
]
unit = piece_units[0] if piece_units else ""
specs.append(f"{identity.total_piece_count}{unit}")
return specs

View File

@@ -0,0 +1,178 @@
"""Public-safe evidence shaping for same-item reconciliation receipts."""
from __future__ import annotations
from typing import Any
def _float(value: Any, default: float = 0.0) -> float:
try:
return float(value)
except (TypeError, ValueError):
return default
def _candidate_source_id(candidate: dict[str, Any]) -> str:
return str(
candidate.get("product_id")
or candidate.get("goodsCode")
or candidate.get("id")
or candidate.get("source_product_id")
or ""
).strip()
def _candidate_name(candidate: dict[str, Any]) -> str:
return str(
candidate.get("name")
or candidate.get("title")
or candidate.get("source_name")
or ""
).strip()
def safe_candidate_evidence(candidate: dict[str, Any]) -> dict[str, Any]:
reconciliation = candidate.get("same_item_reconciliation") or {}
return {
"target_pchome_product_id": str(
candidate.get("target_pchome_product_id") or ""
),
"source_product_id": _candidate_source_id(candidate),
"source_name": _candidate_name(candidate),
"source_price": _float(
candidate.get("price"),
_float(candidate.get("source_price")),
),
"search_term": str(
candidate.get("target_search_term")
or candidate.get("search_term")
or ""
),
"match_score": _float(
candidate.get("target_match_score"),
_float(candidate.get("match_score")),
),
"comparison_mode": str(
candidate.get("target_comparison_mode")
or candidate.get("comparison_mode")
or ""
),
"match_type": str(
candidate.get("target_match_type")
or candidate.get("match_type")
or ""
),
"price_basis": str(
candidate.get("target_price_basis")
or candidate.get("price_basis")
or ""
),
"alert_tier": str(
candidate.get("target_alert_tier")
or candidate.get("alert_tier")
or ""
),
"hard_veto": bool(
candidate.get("target_hard_veto")
if "target_hard_veto" in candidate
else candidate.get("hard_veto")
),
"reason_code": str(candidate.get("reason_code") or ""),
"reasons": list(
candidate.get("target_match_reasons")
or candidate.get("reasons")
or []
)[:8],
"auto_compare_type": str(candidate.get("auto_compare_type") or ""),
"candidate_fingerprint": str(
candidate.get("same_item_candidate_fingerprint")
or reconciliation.get("candidate_fingerprint")
or ""
),
"independent_verifier_passed": bool(
reconciliation.get("independent_verifier_passed")
),
}
def build_target_candidate_evidence(
target_id: str,
run_receipt: dict[str, Any],
) -> dict[str, Any]:
verification = run_receipt.get("candidate_verification") or {}
def matching(items: list[dict[str, Any]]) -> list[dict[str, Any]]:
return [
safe_candidate_evidence(item)
for item in items
if str(item.get("target_pchome_product_id") or "") == target_id
]
verified = matching(list(verification.get("verified_candidates") or []))
review = matching(list(verification.get("review_candidates") or []))
blocked = matching(list(verification.get("blocked_candidates") or []))
reason_codes = sorted({
item.get("reason_code")
for item in blocked
if item.get("reason_code")
})
verifier_reasons = sorted({
reason
for item in [*verified, *review, *blocked]
for reason in item.get("reasons") or []
if reason
})
return {
"verified_candidates": verified,
"review_candidates": review,
"blocked_candidates": blocked,
"verified_candidate_count": len(verified),
"review_candidate_count": len(review),
"blocked_candidate_count": len(blocked),
"reason_codes": reason_codes,
"verifier_reasons": verifier_reasons,
}
def select_next_machine_action(
decision: str,
candidate_evidence: dict[str, Any],
) -> str:
if decision == "promoted_verified":
return "continue_next_revenue_weighted_batch"
if decision == "apply_not_verified":
return "retry_exact_identity_readback_before_next_apply"
reason_codes = set(candidate_evidence.get("reason_codes") or [])
verifier_reasons = set(candidate_evidence.get("verifier_reasons") or [])
if decision == "no_candidate":
return "expand_search_terms_with_brand_pack_and_spec_anchors"
if "source_identity_missing" in reason_codes:
return "refresh_source_identity_then_retry"
if "positive_price_evidence_missing" in reason_codes:
return "refresh_positive_price_evidence_then_retry"
if verifier_reasons & {
"count_conflict",
"bundle_offer_conflict",
"multi_component_conflict",
"component_count_conflict",
"pack_quantity_difference",
}:
return "retry_search_with_exact_pack_quantity"
if verifier_reasons & {
"variant_descriptor_conflict",
"variant_option_conflict",
"variant_selection_review",
"makeup_catalog_selection_gap",
}:
return "retry_search_with_exact_variant_evidence"
if verifier_reasons & {"unit_comparable", "unit_price_review"}:
return "replay_unit_basis_before_promotion"
return "refresh_source_candidates_with_evidence_delta"
__all__ = (
"build_target_candidate_evidence",
"safe_candidate_evidence",
"select_next_machine_action",
)

View File

@@ -10,6 +10,11 @@ from typing import Any, Callable
from sqlalchemy import bindparam, inspect, text
from services.pchome_growth_same_item_receipt_evidence import (
build_target_candidate_evidence,
select_next_machine_action,
)
CONTRACT_VERSION = "pchome_same_item_reconciliation_v1"
WORK_ITEM_ID = "GROWTH-P0-001-B"
@@ -169,6 +174,9 @@ def verify_same_item_candidates(
blocked.append({
"target_pchome_product_id": target_id,
"source_product_id": source_id,
"source_name": source_name,
"source_price": _float(candidate.get("price")),
"search_term": str(candidate.get("target_search_term") or ""),
"reason_code": "target_not_in_bounded_batch",
})
continue
@@ -176,6 +184,9 @@ def verify_same_item_candidates(
blocked.append({
"target_pchome_product_id": target_id,
"source_product_id": source_id,
"source_name": source_name,
"source_price": _float(candidate.get("price")),
"search_term": str(candidate.get("target_search_term") or ""),
"reason_code": "source_identity_missing",
})
continue
@@ -186,6 +197,9 @@ def verify_same_item_candidates(
blocked.append({
"target_pchome_product_id": target_id,
"source_product_id": source_id,
"source_name": source_name,
"source_price": source_price,
"search_term": str(candidate.get("target_search_term") or ""),
"reason_code": "positive_price_evidence_missing",
})
continue
@@ -235,9 +249,18 @@ def verify_same_item_candidates(
blocked.append({
"target_pchome_product_id": target_id,
"source_product_id": source_id,
"source_name": source_name,
"source_price": source_price,
"search_term": str(candidate.get("target_search_term") or ""),
"reason_code": "same_item_verifier_rejected",
"match_score": _float(getattr(diagnostics, "score", 0.0)),
"hard_veto": bool(getattr(diagnostics, "hard_veto", False)),
"comparison_mode": str(
getattr(diagnostics, "comparison_mode", "") or ""
),
"match_type": str(getattr(diagnostics, "match_type", "") or ""),
"price_basis": str(getattr(diagnostics, "price_basis", "") or ""),
"alert_tier": str(getattr(diagnostics, "alert_tier", "") or ""),
"reasons": list(getattr(diagnostics, "reasons", ()) or ())[:8],
})
continue
@@ -720,12 +743,21 @@ def persist_reconciliation_receipts(
for target in receipt_targets:
target_id = str(target.get("product_id") or "__run__")
decision, apply_status = _target_receipt_decision(target, run_receipt)
candidate_evidence = build_target_candidate_evidence(target_id, run_receipt)
next_machine_action = select_next_machine_action(
decision,
candidate_evidence,
)
evidence = {
"contract_version": CONTRACT_VERSION,
"identity": identity,
"generated_at": generated_at,
"target": {
"pchome_product_id": target_id,
"name": target.get("name"),
"price": target.get("price"),
"sales_7d": target.get("sales_7d"),
"revenue_at_risk": target.get("revenue_at_risk"),
"target_state_before": target.get("target_state"),
"execution_rank": target.get("execution_rank"),
"execution_priority_score": target.get("execution_priority_score"),
@@ -734,6 +766,8 @@ def persist_reconciliation_receipts(
"source_receipt": run_receipt.get("source_receipt") or {},
"source_of_truth_diff": run_receipt.get("coverage_diff") or {},
"ai_decision": decision,
"candidate_evidence": candidate_evidence,
"next_machine_action": next_machine_action,
"risk_policy": run_receipt.get("risk_policy") or {},
"check_mode": run_receipt.get("check_mode") or {},
"execution": run_receipt.get("execution") or {},
@@ -840,6 +874,7 @@ def read_latest_reconciliation_receipts(engine, *, limit: int = 20) -> dict[str,
except json.JSONDecodeError:
evidence = {}
evidence = evidence if isinstance(evidence, dict) else {}
candidate_evidence = evidence.get("candidate_evidence") or {}
receipts.append({
"receipt_id": row.get("receipt_id"),
"pchome_product_id": row.get("pchome_product_id"),
@@ -852,6 +887,22 @@ def read_latest_reconciliation_receipts(engine, *, limit: int = 20) -> dict[str,
"run_id": (evidence.get("identity") or {}).get("run_id"),
"work_item_id": (evidence.get("identity") or {}).get("work_item_id"),
"post_verifier_status": (evidence.get("post_verifier") or {}).get("status"),
"next_machine_action": evidence.get("next_machine_action"),
"candidate_evidence_summary": {
"verified_candidate_count": int(
candidate_evidence.get("verified_candidate_count") or 0
),
"review_candidate_count": int(
candidate_evidence.get("review_candidate_count") or 0
),
"blocked_candidate_count": int(
candidate_evidence.get("blocked_candidate_count") or 0
),
"reason_codes": list(candidate_evidence.get("reason_codes") or []),
"verifier_reasons": list(
candidate_evidence.get("verifier_reasons") or []
)[:8],
},
})
return {
"contract_version": CONTRACT_VERSION,