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

@@ -3441,6 +3441,36 @@ def test_marketplace_search_terms_prefer_readable_brand_core_spec():
assert not any(term.endswith(" l") for term in terms)
def test_marketplace_sheet_count_is_structured_for_search_and_identity():
from services.marketplace_product_matcher import (
build_search_terms,
parse_product_identity,
score_marketplace_match,
)
pchome_name = "GATSBY 潔面濕紙巾超值包42張入"
same_pack = "【GATSBY】潔面濕紙巾超值包42張入(2款任選)"
different_pack = "【GATSBY】潔面濕紙巾15張入(3款任選)"
identity = parse_product_identity(pchome_name)
terms = build_search_terms(pchome_name, max_terms=5)
exact = score_marketplace_match(same_pack, pchome_name)
rejected = score_marketplace_match(different_pack, pchome_name)
assert (42, "") in identity.counts
assert identity.total_piece_count == 42
assert terms[0] == "gatsby 潔面濕紙巾 42張"
assert not any(term in {"gatsby 張入", "張入"} for term in terms)
assert exact.hard_veto is False
assert exact.match_type == "exact"
assert exact.price_basis == "total_price"
assert exact.alert_tier == "price_alert_exact"
assert exact.spec_score == 1.0
assert "spec_name_alignment" in exact.reasons
assert rejected.hard_veto is True
assert "count_conflict" in rejected.reasons
def test_marketplace_search_terms_prioritize_identity_phrase_over_ambiguous_copy():
from services.marketplace_product_matcher import build_search_terms
@@ -3556,7 +3586,7 @@ def test_marketplace_search_terms_prefer_exact_identity_for_nail_foam_and_foot_m
assert "小銀蓋" not in " ".join(opi_terms[:3])
assert arau_terms[0] == "愛樂寶 溫和洗手慕斯 300ml"
assert "溫和不乾澀" not in " ".join(arau_terms[:3])
assert kameria_terms[0] == "凱蜜菈 足足稱奇足膜 17ml 2"
assert kameria_terms[0] == "凱蜜菈 足足稱奇足膜 17ml 2"
assert "枚入" not in " ".join(kameria_terms[:3])

View File

@@ -90,6 +90,7 @@ def test_independent_verifier_promotes_exact_and_unit_candidates_but_rejects_con
"name": "蘭蔻 玫瑰霜 60ml",
"price": 5000,
"target_pchome_product_id": "P-BLOCK",
"target_search_term": "巴黎萊雅 膠原輕盈乳霜 60ml",
"auto_compare_type": "total_price",
},
]
@@ -110,6 +111,63 @@ def test_independent_verifier_promotes_exact_and_unit_candidates_but_rejects_con
assert selected["P-UNIT"]["target_unit_price_comparison"]["comparable"] is True
assert selected["P-EXACT"]["same_item_candidate_fingerprint"]
assert result["blocked_candidates"][0]["reason_code"] == "same_item_verifier_rejected"
assert result["blocked_candidates"][0]["source_name"] == "蘭蔻 玫瑰霜 60ml"
assert result["blocked_candidates"][0]["search_term"] == "巴黎萊雅 膠原輕盈乳霜 60ml"
assert result["blocked_candidates"][0]["comparison_mode"]
assert result["blocked_candidates"][0]["reasons"]
def test_independent_verifier_promotes_exact_sheet_pack_and_blocks_wrong_count():
from services.pchome_growth_same_item_reconciliation import (
verify_same_item_candidates,
)
target = _target(
"P-GATSBY-42",
"GATSBY 潔面濕紙巾超值包42張入",
199,
1,
)
candidates = [
{
"product_id": "M-GATSBY-42",
"name": "【GATSBY】潔面濕紙巾超值包42張入(2款任選)",
"price": 179,
"target_pchome_product_id": "P-GATSBY-42",
"target_search_term": "gatsby 潔面濕紙巾 42張",
"auto_compare_type": "manual_review",
},
{
"product_id": "M-GATSBY-15",
"name": "【GATSBY】潔面濕紙巾15張入(3款任選)",
"price": 89,
"target_pchome_product_id": "P-GATSBY-42",
"target_search_term": "gatsby 潔面濕紙巾 42張",
"auto_compare_type": "manual_review",
},
]
result = verify_same_item_candidates(
[target],
candidates,
identity={
"trace_id": "trace-gatsby-pack",
"run_id": "run-gatsby-pack",
"work_item_id": "GROWTH-P0-001-B",
},
)
assert result["selected_candidate_count"] == 1
assert result["verified_candidates"][0]["product_id"] == "M-GATSBY-42"
assert result["verified_candidates"][0]["auto_compare_type"] == "total_price"
rejected = next(
item
for item in result["blocked_candidates"]
if item["source_product_id"] == "M-GATSBY-15"
)
assert rejected["hard_veto"] is True
assert rejected["search_term"] == "gatsby 潔面濕紙巾 42張"
assert "count_conflict" in rejected["reasons"]
def test_coverage_post_verifier_requires_exact_source_readback_and_reports_revenue_delta():
@@ -247,6 +305,7 @@ def test_receipt_schema_apply_persistence_and_public_safe_readback():
engine = create_engine("sqlite:///:memory:")
schema = ensure_evidence_receipt_table(engine)
target = _target("P-1", "商品", 100, 1)
target.update({"sales_7d": 8000, "revenue_at_risk": 8000})
run_receipt = {
"generated_at": "2026-07-14T12:00:00+00:00",
"identity": {
@@ -256,7 +315,24 @@ def test_receipt_schema_apply_persistence_and_public_safe_readback():
},
"source_receipt": {"candidate_count": 1},
"candidate_verification": {
"verified_candidates": [{"target_pchome_product_id": "P-1"}],
"verified_candidates": [{
"target_pchome_product_id": "P-1",
"product_id": "M-1",
"name": "商品 100ml",
"price": 90,
"target_search_term": "商品 100ml",
"target_match_score": 0.99,
"target_comparison_mode": "total_price",
"target_match_type": "exact",
"target_price_basis": "total_price",
"target_alert_tier": "price_alert_exact",
"auto_compare_type": "total_price",
"same_item_candidate_fingerprint": "fingerprint-1",
"same_item_reconciliation": {
"independent_verifier_passed": True,
},
}],
"review_candidates": [],
"blocked_candidates": [],
},
"coverage_diff": {"comparison_ready_delta": 1},
@@ -276,6 +352,11 @@ def test_receipt_schema_apply_persistence_and_public_safe_readback():
run_receipt=run_receipt,
)
readback = read_latest_reconciliation_receipts(engine)
with engine.connect() as conn:
evidence = conn.execute(text(
"SELECT evidence_delta_json FROM external_offer_evidence_receipts"
)).scalar_one()
evidence = json.loads(evidence)
assert schema["status"] == "created_and_verified"
assert persistence["status"] == "persisted_and_verified"
@@ -284,6 +365,100 @@ def test_receipt_schema_apply_persistence_and_public_safe_readback():
assert readback["verified_count"] == 1
assert readback["latest_run_id"] == "run-1"
assert readback["receipts"][0]["work_item_id"] == "GROWTH-P0-001-B"
assert evidence["target"]["sales_7d"] == 8000
assert evidence["candidate_evidence"]["verified_candidate_count"] == 1
assert evidence["candidate_evidence"]["verified_candidates"][0]["source_product_id"] == "M-1"
assert evidence["candidate_evidence"]["verified_candidates"][0]["search_term"] == "商品 100ml"
assert evidence["next_machine_action"] == "continue_next_revenue_weighted_batch"
assert readback["receipts"][0]["next_machine_action"] == (
"continue_next_revenue_weighted_batch"
)
assert readback["receipts"][0]["candidate_evidence_summary"] == {
"verified_candidate_count": 1,
"review_candidate_count": 0,
"blocked_candidate_count": 0,
"reason_codes": [],
"verifier_reasons": [],
}
def test_rejected_pack_candidate_persists_autonomous_retry_action():
from services.pchome_growth_same_item_reconciliation import (
ensure_evidence_receipt_table,
persist_reconciliation_receipts,
read_latest_reconciliation_receipts,
)
engine = create_engine("sqlite:///:memory:")
ensure_evidence_receipt_table(engine)
target = _target(
"P-GATSBY-42",
"GATSBY 潔面濕紙巾超值包42張入",
199,
1,
)
run_receipt = {
"generated_at": "2026-07-15T00:30:00+00:00",
"identity": {
"trace_id": "trace-pack-retry",
"run_id": "run-pack-retry",
"work_item_id": "GROWTH-P0-001-B",
},
"candidate_verification": {
"verified_candidates": [],
"review_candidates": [],
"blocked_candidates": [{
"target_pchome_product_id": "P-GATSBY-42",
"source_product_id": "M-GATSBY-15",
"source_name": "GATSBY 潔面濕紙巾15張入",
"source_price": 89,
"search_term": "gatsby 潔面濕紙巾 42張",
"reason_code": "same_item_verifier_rejected",
"match_score": 0.92,
"hard_veto": True,
"comparison_mode": "blocked",
"match_type": "mismatch",
"price_basis": "none",
"alert_tier": "none",
"reasons": ["count_conflict"],
}],
},
"post_verifier": {"status": "no_write_verified", "readback": []},
"terminal": {"status": "verified_no_write"},
}
persistence = persist_reconciliation_receipts(
engine,
targets=[target],
run_receipt=run_receipt,
)
readback = read_latest_reconciliation_receipts(engine)
with engine.connect() as conn:
evidence = conn.execute(text(
"SELECT evidence_delta_json FROM external_offer_evidence_receipts"
)).scalar_one()
evidence = json.loads(evidence)
assert persistence["status"] == "persisted_and_verified"
assert evidence["ai_decision"] == "candidate_rejected"
assert evidence["candidate_evidence"]["reason_codes"] == [
"same_item_verifier_rejected"
]
assert evidence["candidate_evidence"]["verifier_reasons"] == [
"count_conflict"
]
assert evidence["next_machine_action"] == (
"retry_search_with_exact_pack_quantity"
)
receipt = readback["receipts"][0]
assert receipt["apply_status"] == "no_write"
assert receipt["next_machine_action"] == (
"retry_search_with_exact_pack_quantity"
)
assert receipt["candidate_evidence_summary"]["blocked_candidate_count"] == 1
assert receipt["candidate_evidence_summary"]["verifier_reasons"] == [
"count_conflict"
]
def test_targeted_momo_search_keeps_same_source_product_per_pchome_target():