549 lines
26 KiB
Python
549 lines
26 KiB
Python
from pathlib import Path
|
||
|
||
|
||
ROOT = Path(__file__).resolve().parents[1]
|
||
|
||
|
||
def test_competitor_intel_cache_reuses_memory_and_shared_file(tmp_path, monkeypatch):
|
||
from services import competitor_intel_repository as repo
|
||
|
||
monkeypatch.setattr(repo, "_CACHE_FILE", Path(tmp_path) / "competitor_intel_cache.pkl")
|
||
repo._MEM_CACHE.clear()
|
||
|
||
calls = {"count": 0}
|
||
|
||
def producer():
|
||
calls["count"] += 1
|
||
return {"valid_matches": 7, "match_rate": 0.1}
|
||
|
||
first = repo._cached_payload("coverage:test", producer, ttl_seconds=60)
|
||
second = repo._cached_payload("coverage:test", producer, ttl_seconds=60)
|
||
repo._MEM_CACHE.clear()
|
||
third = repo._cached_payload("coverage:test", producer, ttl_seconds=60)
|
||
|
||
assert first == second == third == {"valid_matches": 7, "match_rate": 0.1}
|
||
assert calls["count"] == 1
|
||
|
||
|
||
def test_clear_competitor_intel_cache_removes_shared_file(tmp_path, monkeypatch):
|
||
from services import competitor_intel_repository as repo
|
||
|
||
cache_file = Path(tmp_path) / "competitor_intel_cache.pkl"
|
||
monkeypatch.setattr(repo, "_CACHE_FILE", cache_file)
|
||
repo._MEM_CACHE["x"] = {"time": 1, "value": {"ok": True}}
|
||
cache_file.write_bytes(b"stale")
|
||
|
||
repo.clear_competitor_intel_cache()
|
||
|
||
assert repo._MEM_CACHE == {}
|
||
assert not cache_file.exists()
|
||
|
||
|
||
def test_competitor_ppt_results_keep_pending_diagnostics_in_export():
|
||
source = (ROOT / "services" / "competitor_intel_repository.py").read_text(encoding="utf-8")
|
||
|
||
assert "LEFT JOIN valid_competitor" in source
|
||
assert "\"found\": found" in source
|
||
assert "\"match_status\"" in source
|
||
assert "\"candidate_count\"" in source
|
||
assert "\"unit_comparison\"" in source
|
||
assert "build_unit_price_comparison" in source
|
||
assert "(vc.pchome_price IS NULL)" in source
|
||
assert "(lm.momo_price - vc.pchome_price) AS price_diff" in source
|
||
assert "((lm.momo_price - vc.pchome_price) / vc.pchome_price * 100) AS price_diff_pct" in source
|
||
|
||
|
||
def test_competitor_ppt_results_use_history_for_dated_reports():
|
||
source = (ROOT / "services" / "competitor_intel_repository.py").read_text(encoding="utf-8")
|
||
route_source = (ROOT / "routes" / "openclaw_bot_routes.py").read_text(encoding="utf-8")
|
||
comparison_source = source.split("def fetch_competitor_comparison_results", 1)[1].split(
|
||
"def build_competitor_intel_payload", 1
|
||
)[0]
|
||
|
||
assert "requested_historical_prices" in source
|
||
assert "use_history_prices" in source
|
||
assert "FROM competitor_price_history cph" in source
|
||
assert "cph.crawled_at >= DATE(:start_date)" in source
|
||
assert "cph.crawled_at < DATE(:end_date) + INTERVAL '1 day'" in source
|
||
assert "pr.timestamp < DATE(:end_date) + INTERVAL '1 day'" in source
|
||
assert "FROM products p\n JOIN LATERAL" in comparison_source
|
||
assert "WHERE pr.product_id = p.id" in comparison_source
|
||
assert "ORDER BY pr.timestamp DESC, pr.id DESC" in comparison_source
|
||
assert "ROW_NUMBER() OVER (PARTITION BY p.id" not in comparison_source
|
||
assert "WHERE lm.rn = 1" not in comparison_source
|
||
assert "'competitor_price_history' AS competitor_source" in source
|
||
assert "\"competitor_source\"" in source
|
||
assert "\"pc_crawled_at\"" in source
|
||
assert "指定期間 competitor_price_history" in route_source
|
||
|
||
|
||
def test_competitor_coverage_counts_only_active_product_intersection():
|
||
source = (ROOT / "services" / "competitor_intel_repository.py").read_text(encoding="utf-8")
|
||
coverage_source = source.split("def _fetch_competitor_coverage_uncached", 1)[1].split(
|
||
"def _fetch_manual_review_summary", 1
|
||
)[0]
|
||
|
||
assert "coverage:v14" in source
|
||
assert "CATALOG_COMPARABLE_SCORE_FLOOR" in source
|
||
assert "CATALOG_IDENTITY_REVIEW_SCORE_FLOOR" in source
|
||
assert "rescore_accepted_count" in coverage_source
|
||
assert "(SELECT COUNT(*) FROM valid_competitor) AS valid_matches" not in coverage_source
|
||
assert "identity_competitor AS" in coverage_source
|
||
assert "fresh_competitor AS" in coverage_source
|
||
assert "unknown_freshness_competitor AS" in coverage_source
|
||
assert "WHERE expires_at > CURRENT_TIMESTAMP" in coverage_source
|
||
assert "WHERE expires_at IS NULL" in coverage_source
|
||
assert "FROM latest_momo lm\n JOIN identity_competitor ic ON ic.sku = lm.sku" in coverage_source
|
||
assert "LEFT JOIN fresh_competitor fc ON fc.sku = lm.sku" in coverage_source
|
||
assert "WHERE fc.sku IS NULL" in coverage_source
|
||
assert "\"fresh_matches\": fresh" in coverage_source
|
||
assert "\"stale_matches\": stale" in coverage_source
|
||
assert "\"unknown_freshness_matches\": unknown_freshness" in coverage_source
|
||
assert "\"not_decision_ready_count\": pending + stale + unknown_freshness" in coverage_source
|
||
assert "\"decision_ready_matches\": fresh" in coverage_source
|
||
assert "\"decision_ready_rate\": round(fresh / max(active, 1) * 100, 1)" in coverage_source
|
||
assert "\"decision_support_count\": decision_support_count" in coverage_source
|
||
assert "\"decision_support_rate\": round(decision_support_count / max(active, 1) * 100, 1)" in coverage_source
|
||
assert "\"catalog_comparable_count\": catalog_comparable_count" in coverage_source
|
||
assert "_catalog_comparable_sql(\"la\")" in coverage_source
|
||
assert "_catalog_review_lane_sql(\"la\", \"catalog_variant_review\")" in coverage_source
|
||
assert "_catalog_review_lane_sql(\"la\", \"catalog_unit_review\")" in coverage_source
|
||
assert "_catalog_review_lane_sql(\"la\", \"catalog_identity_review\")" in coverage_source
|
||
assert "\"catalog_review_plan\": {" in coverage_source
|
||
assert "CATALOG_COMPARABLE_SIGNAL_REASONS" in source
|
||
assert "CATALOG_VARIANT_REVIEW_REASONS" in source
|
||
assert "CATALOG_UNIT_REVIEW_REASONS" in source
|
||
assert "CATALOG_COMPARABLE_IDENTITY_REASONS" in source
|
||
assert "\"catalog_identity_review_score_floor\": CATALOG_IDENTITY_REVIEW_SCORE_FLOOR" in coverage_source
|
||
|
||
|
||
def test_catalog_comparable_sql_includes_high_confidence_identity_review_lane():
|
||
from services.competitor_intel_repository import (
|
||
CATALOG_COMPARABLE_SCORE_FLOOR,
|
||
CATALOG_IDENTITY_REVIEW_SCORE_FLOOR,
|
||
_catalog_comparable_sql,
|
||
)
|
||
|
||
source = (ROOT / "services" / "competitor_intel_repository.py").read_text(encoding="utf-8")
|
||
coverage_source = source.split("def _fetch_competitor_coverage_uncached", 1)[1].split(
|
||
"def _fetch_manual_review_summary", 1
|
||
)[0]
|
||
sql = _catalog_comparable_sql("la")
|
||
|
||
assert f">= {CATALOG_COMPARABLE_SCORE_FLOOR}" in sql
|
||
assert f">= {CATALOG_IDENTITY_REVIEW_SCORE_FLOOR}" in sql
|
||
assert "CATALOG_IDENTITY_REVIEW_SCORE_FLOOR" not in sql
|
||
assert "OR COALESCE(la.best_match_score, 0)" in sql
|
||
assert "CATALOG_COMPARABLE_SIGNAL_REASONS" not in sql
|
||
assert "CATALOG_COMPARABLE_BLOCK_REASONS" in source
|
||
assert "\"identity_coverage_matches\": valid" in coverage_source
|
||
assert "\"manual_closed_count\": manual_closed_count" in coverage_source
|
||
assert "\"last_decision_ready_crawled_at\": last_decision_ready_crawled_at" in coverage_source
|
||
assert "FROM products p\n JOIN LATERAL" in coverage_source
|
||
assert "WHERE p.status = 'ACTIVE'" in coverage_source
|
||
|
||
|
||
def test_competitor_decision_consumers_require_explicit_freshness():
|
||
source = (ROOT / "services" / "competitor_intel_repository.py").read_text(encoding="utf-8")
|
||
|
||
assert "(cp.expires_at IS NULL OR cp.expires_at > CURRENT_TIMESTAMP)" not in source
|
||
assert source.count("AND cp.expires_at > CURRENT_TIMESTAMP") >= 4
|
||
|
||
|
||
def test_competitor_ppt_and_ai_use_momo_minus_pchome_gap_direction():
|
||
ppt_source = (ROOT / "services" / "ppt_generator.py").read_text(encoding="utf-8")
|
||
route_source = (ROOT / "routes" / "openclaw_bot_routes.py").read_text(encoding="utf-8")
|
||
|
||
assert 'pc_wins = [r for r in found if r.get("price_diff", 0) > 10]' in ppt_source
|
||
assert 'mo_wins = [r for r in found if r.get("price_diff", 0) < -10]' in ppt_source
|
||
assert "平均價差 {avg_pct:+.1f}%(momo - PChome)" in ppt_source
|
||
assert "price_diff = MOMO售價 − PChome售價" in route_source
|
||
assert "正值=MOMO較貴、PChome低價壓力" in route_source
|
||
|
||
|
||
def test_competitor_review_queue_is_canonical_unit_price_handoff():
|
||
source = (ROOT / "services" / "competitor_intel_repository.py").read_text(encoding="utf-8")
|
||
daily_template = (ROOT / "templates" / "daily_sales.html").read_text(encoding="utf-8")
|
||
growth_template = (ROOT / "templates" / "growth_analysis.html").read_text(encoding="utf-8")
|
||
|
||
assert "def fetch_competitor_review_queue" in source
|
||
assert "review_queue = fetch_competitor_review_queue" in source
|
||
assert "\"review_queue\": review_queue" in source
|
||
assert "\"unit_comparable_count\"" in source
|
||
assert "\"rescore_accepted_count\"" in source
|
||
assert "manual_review_summary" in source
|
||
assert "manual_accept_count" in source
|
||
assert "manual_reject_count" in source
|
||
assert "manual_unit_price_count" in source
|
||
assert "manual_rejected" in source
|
||
assert "manual_unit_price_required" in source
|
||
assert "manual_needs_research" in source
|
||
assert "manual_closed" in source
|
||
assert "competitor_match_reviews" in source
|
||
assert "\"status_label\"" in source
|
||
assert "\"action_label\"" in source
|
||
assert "build_unit_price_comparison" in source
|
||
assert "需單位價覆核" in daily_template
|
||
assert "人工採用" in daily_template
|
||
assert "人工否決" in daily_template
|
||
assert "人工單位價" in daily_template
|
||
assert "competitor_intel.review_queue" in daily_template
|
||
assert "coverage.fresh_matches" in growth_template
|
||
assert "coverage.fresh_match_rate" in growth_template
|
||
assert "coverage.decision_ready_rate" in growth_template
|
||
assert "coverage.decision_support_rate" in growth_template
|
||
assert "coverage.catalog_comparable_count" in growth_template
|
||
assert "型錄/任選可比" in growth_template
|
||
assert "coverage.stale_matches" in growth_template
|
||
assert "coverage.unknown_freshness_matches" in growth_template
|
||
assert "未形成有效身份配對" in growth_template
|
||
assert "coverage.unit_comparable_count" in growth_template
|
||
assert "coverage.rescore_accepted_count" in growth_template
|
||
assert "重算待人工覆核" in growth_template
|
||
assert "coverage.manual_accept_count" in growth_template
|
||
assert "coverage.manual_reject_count" in growth_template
|
||
assert "coverage.manual_unit_price_count" in growth_template
|
||
assert "comp_coverage.rescore_accepted_count" in daily_template
|
||
assert "重算待人工覆核" in daily_template
|
||
assert "comp_coverage.decision_support_rate" in daily_template
|
||
assert "comp_coverage.catalog_comparable_count" in daily_template
|
||
assert "精準可告警覆蓋" in daily_template
|
||
assert "comp_coverage.stale_matches" in daily_template
|
||
assert "comp_coverage.unknown_freshness_matches" in daily_template
|
||
assert "comp_coverage.decision_ready_rate" in daily_template
|
||
|
||
|
||
def test_competitor_review_filters_split_low_score_operational_buckets():
|
||
from services.competitor_intel_repository import REVIEW_STATUS_FILTER_GROUPS
|
||
|
||
assert REVIEW_STATUS_FILTER_GROUPS["catalog_comparable"] == (
|
||
"true_low_confidence",
|
||
"catalog_variant_review",
|
||
"catalog_unit_review",
|
||
"catalog_identity_review",
|
||
)
|
||
assert REVIEW_STATUS_FILTER_GROUPS["catalog_variant_review"] == (
|
||
"true_low_confidence",
|
||
"catalog_variant_review",
|
||
)
|
||
assert REVIEW_STATUS_FILTER_GROUPS["catalog_unit_review"] == (
|
||
"true_low_confidence",
|
||
"catalog_unit_review",
|
||
)
|
||
assert REVIEW_STATUS_FILTER_GROUPS["catalog_identity_review"] == (
|
||
"true_low_confidence",
|
||
"catalog_identity_review",
|
||
)
|
||
assert REVIEW_STATUS_FILTER_GROUPS["recoverable_low_score"] == ("recoverable_low_score",)
|
||
assert REVIEW_STATUS_FILTER_GROUPS["true_low_confidence"] == ("true_low_confidence",)
|
||
assert REVIEW_STATUS_FILTER_GROUPS["legacy_low_score"] == ("low_score", "refresh_low_score")
|
||
assert set(REVIEW_STATUS_FILTER_GROUPS["low_score"]) == {
|
||
"low_score",
|
||
"refresh_low_score",
|
||
"recoverable_low_score",
|
||
"true_low_confidence",
|
||
}
|
||
|
||
|
||
def test_catalog_comparable_review_item_keeps_exact_match_guardrail():
|
||
from services.competitor_intel_repository import _format_competitor_review_item
|
||
|
||
item = _format_competitor_review_item({
|
||
"sku": "CAT-001",
|
||
"name": "DASHING DIVA Gloss Gel 美甲片 月影柔霧",
|
||
"momo_price": 699,
|
||
"attempt_status": "true_low_confidence",
|
||
"catalog_comparable": True,
|
||
"candidate_count": 3,
|
||
"best_competitor_product_id": "DABC-CATALOG",
|
||
"best_competitor_product_name": "DASHING DIVA Gloss Gel 美甲片 月影柔霧 任選",
|
||
"best_competitor_price": 599,
|
||
"best_match_score": 0.912,
|
||
"match_diagnostic_json": {
|
||
"match_type": "comparable",
|
||
"price_basis": "manual_review",
|
||
"alert_tier": "identity_review",
|
||
"reasons": [
|
||
"strong_product_line_match",
|
||
"variant_selection_review",
|
||
],
|
||
},
|
||
})
|
||
|
||
assert item["review_bucket"] == "catalog_variant_review"
|
||
assert item["status_label"] == "選項 / 色號待核"
|
||
assert item["catalog_review_guidance"]["primary_review_action"] == "needs_research"
|
||
assert "選項、色號" in item["action_label"]
|
||
envelope = item["decision_envelope"]
|
||
assert envelope["recommended_action"]["action"] == "needs_research"
|
||
assert envelope["guardrails"]["can_auto_execute"] is False
|
||
assert envelope["guardrails"]["catalog_comparable"] is True
|
||
assert envelope["guardrails"]["catalog_review_lane"] == "catalog_variant_review"
|
||
assert envelope["expected_impact"]["catalog_review_guidance"]["lane"] == "catalog_variant_review"
|
||
assert any(
|
||
evidence["metric"] == "catalog_comparable"
|
||
and evidence["value"] == "catalog_variant_review"
|
||
for evidence in envelope["evidence"]
|
||
)
|
||
assert any(evidence["metric"] == "catalog_comparable" for evidence in envelope["evidence"])
|
||
|
||
|
||
def test_competitor_review_reasons_prefer_json_payload_labels():
|
||
from services.competitor_intel_repository import _format_competitor_review_item
|
||
|
||
item = _format_competitor_review_item({
|
||
"sku": "SKU-1",
|
||
"name": "M.A.C Macximal 柔霧唇膏",
|
||
"momo_price": 990,
|
||
"attempt_status": "identity_veto",
|
||
"candidate_count": 1,
|
||
"best_competitor_product_id": "DABC123",
|
||
"best_competitor_product_name": "MAC Macximal 緞光唇膏",
|
||
"best_competitor_price": 880,
|
||
"best_match_score": 0.32,
|
||
"match_diagnostic_json": {
|
||
"match_type": "no_match",
|
||
"price_basis": "none",
|
||
"alert_tier": "suppress",
|
||
"identity_evidence": {
|
||
"version": "identity_evidence_v1",
|
||
"brand": {"momo": ["mac"], "competitor": ["mac"], "shared": ["mac"]},
|
||
"product_type": {"momo": "唇膏", "competitor": "唇膏", "matched": True},
|
||
"identity_anchor": "macximal 柔霧唇膏",
|
||
"shared_model_tokens": [],
|
||
"specs": {
|
||
"momo": {"volumes_ml": [], "weights_g": [], "dosages_mg": [], "counts": [], "total_piece_count": None},
|
||
"competitor": {"volumes_ml": [], "weights_g": [], "dosages_mg": [], "counts": [], "total_piece_count": None},
|
||
"mismatches": [],
|
||
},
|
||
"variant_guardrails": {
|
||
"hard_veto": True,
|
||
"conflict_reasons": ["makeup_finish_conflict"],
|
||
"catalog_count_omission": False,
|
||
},
|
||
},
|
||
"offer_evidence": {
|
||
"version": "offer_evidence_v1",
|
||
"price_basis": "none",
|
||
"alert_tier": "suppress",
|
||
"momo_price": 990,
|
||
"competitor_price": 880,
|
||
"gap_pct": 12.5,
|
||
"price_is_identity_evidence": False,
|
||
},
|
||
"reasons": [
|
||
"makeup_finish_conflict",
|
||
"nail_tool_function_conflict",
|
||
"schick_razor_line_conflict",
|
||
],
|
||
},
|
||
"error_message": "",
|
||
})
|
||
|
||
assert item["match_type_label"] == "非同款"
|
||
assert item["price_basis_label"] == "不可比"
|
||
assert item["alert_tier_label"] == "不告警"
|
||
assert item["diagnostic_reason_text"] == "妝效質地不同、工具功能不同、除毛刀品線不同"
|
||
assert item["identity_evidence_summary"].startswith("品牌 mac")
|
||
assert item["offer_evidence"]["price_is_identity_evidence"] is False
|
||
assert item["difference_highlights"][0]["dimension"] == "妝效/質地不同"
|
||
assert [reason["code"] for reason in item["diagnostic_reasons"]] == [
|
||
"makeup_finish_conflict",
|
||
"nail_tool_function_conflict",
|
||
"schick_razor_line_conflict",
|
||
]
|
||
envelope = item["decision_envelope"]
|
||
assert envelope["decision_type"] == "pchome_match_review"
|
||
assert envelope["subject"]["sku"] == "SKU-1"
|
||
assert envelope["subject"]["competitor_product_id"] == "DABC123"
|
||
assert envelope["guardrails"]["can_auto_execute"] is False
|
||
assert envelope["guardrails"]["data_quality"] == "partial"
|
||
assert envelope["guardrails"]["match_type"] == "no_match"
|
||
assert envelope["guardrails"]["identity_evidence_version"] == "identity_evidence_v1"
|
||
assert envelope["guardrails"]["price_is_identity_evidence"] is False
|
||
assert envelope["identity_evidence"]["brand"]["shared"] == ["mac"]
|
||
assert envelope["offer_evidence"]["gap_pct"] == 12.5
|
||
assert envelope["difference_highlights"][0]["dimension"] == "妝效/質地不同"
|
||
assert envelope["recommended_action"]["requires_hitl"] is True
|
||
assert envelope["recommended_action"]["action"] == "verify_or_reject_identity"
|
||
assert any(evidence["metric"] == "reasons" for evidence in envelope["evidence"])
|
||
assert any(evidence["metric"] == "identity_evidence" for evidence in envelope["evidence"])
|
||
assert any(evidence["metric"] == "offer_evidence" for evidence in envelope["evidence"])
|
||
assert any(evidence["metric"] == "difference_highlights" for evidence in envelope["evidence"])
|
||
|
||
|
||
def test_rescore_accepted_review_item_has_actionable_decision_envelope():
|
||
from services.competitor_intel_repository import _format_competitor_review_item
|
||
|
||
item = _format_competitor_review_item({
|
||
"sku": "10922465",
|
||
"name": "【Herbacin 德國小甘菊】小甘菊1號護手霜20ml",
|
||
"momo_price": 99,
|
||
"attempt_status": "rescore_accepted_current",
|
||
"candidate_count": 1,
|
||
"best_competitor_product_id": "DDAO4C-A79050612",
|
||
"best_competitor_product_name": "小甘菊經典護手霜20ml",
|
||
"best_competitor_price": 89,
|
||
"best_match_score": 0.872,
|
||
"match_diagnostic_json": {
|
||
"match_type": "exact",
|
||
"price_basis": "total_price",
|
||
"alert_tier": "identity_review",
|
||
"reasons": ["focused_exact_identity_herbacin_classic_hand_cream_20ml_brandless"],
|
||
},
|
||
})
|
||
|
||
envelope = item["decision_envelope"]
|
||
assert envelope["severity"] in {"P1", "P2"}
|
||
assert envelope["subject"]["momo_price"] == 99
|
||
assert envelope["subject"]["competitor_price"] == 89
|
||
assert envelope["recommended_action"]["action"] == "review_accept_identity"
|
||
assert envelope["guardrails"]["data_quality"] == "complete"
|
||
assert envelope["expected_impact"]["gap_amount"] == 10
|
||
assert envelope["expected_impact"]["candidate_gap_pct"] == 11.2
|
||
assert any(evidence["metric"] == "candidate_gap_pct" for evidence in envelope["evidence"])
|
||
|
||
|
||
def test_manual_unit_price_review_item_keeps_business_insight():
|
||
from services.competitor_intel_repository import (
|
||
_format_competitor_review_item,
|
||
summarize_review_decision_envelopes,
|
||
)
|
||
|
||
item = _format_competitor_review_item({
|
||
"sku": "11223344",
|
||
"name": "理膚寶水 B5 全面修復霜 40ml x2 超值組",
|
||
"momo_price": 1199,
|
||
"attempt_status": "manual_unit_price_required",
|
||
"candidate_count": 1,
|
||
"best_competitor_product_id": "DABC01-B5",
|
||
"best_competitor_product_name": "理膚寶水 全面修復霜 B5 40ml",
|
||
"best_competitor_price": 679,
|
||
"best_match_score": 0.742,
|
||
"match_diagnostic_json": {
|
||
"match_type": "same_product_different_pack",
|
||
"price_basis": "unit_price",
|
||
"alert_tier": "unit_price_review",
|
||
"reasons": ["unit_comparable"],
|
||
},
|
||
})
|
||
|
||
assert item["unit_comparison"]["comparable"] is True
|
||
assert item["unit_price_insight"]["direction"] == "momo_cheaper"
|
||
assert "MOMO 單位價低" in item["unit_price_insight"]["summary"]
|
||
envelope = item["decision_envelope"]
|
||
assert envelope["recommended_action"]["action"] == "unit_price_required"
|
||
assert envelope["expected_impact"]["unit_price_insight"]["unit_gap_pct"] == -11.71
|
||
assert any(evidence["metric"] == "unit_price_gap_pct" for evidence in envelope["evidence"])
|
||
|
||
brief = summarize_review_decision_envelopes([item], limit=5)
|
||
assert "單位價差 -11.7%" in brief["text"]
|
||
assert brief["items"][0]["unit_price_gap_pct"] == -11.71
|
||
|
||
|
||
def test_protected_existing_match_envelope_explains_candidate_conflict():
|
||
from services.competitor_intel_repository import _format_competitor_review_item
|
||
|
||
item = _format_competitor_review_item({
|
||
"sku": "14338675",
|
||
"name": "【Relove】胺基酸私密潔淨精華凝露120ml",
|
||
"momo_price": 399,
|
||
"attempt_status": "protected_existing_match",
|
||
"candidate_count": 2,
|
||
"best_competitor_product_id": "QEAE1O-A900A6DRS",
|
||
"best_competitor_product_name": "RELOVE胺基酸私密清潔凝露120ml",
|
||
"best_competitor_price": 329,
|
||
"best_match_score": 0.817,
|
||
"match_diagnostic_json": {
|
||
"match_type": "exact",
|
||
"price_basis": "total_price",
|
||
"alert_tier": "identity_review",
|
||
"reasons": ["strong_exact_spec_match", "spec_name_alignment"],
|
||
},
|
||
"error_message": (
|
||
"existing_match_conflict;existing_id=QEAE1O-A900A6DNN;"
|
||
"incoming_id=QEAE1O-A900A6DRS;existing_score=0.766;incoming_score=0.817"
|
||
),
|
||
})
|
||
|
||
envelope = item["decision_envelope"]
|
||
conflict = envelope["expected_impact"]["existing_match_conflict"]
|
||
assert item["existing_match_conflict"]["score_delta"] == 0.051
|
||
assert envelope["recommended_action"]["action"] == "compare_existing_identity"
|
||
assert envelope["severity"] == "P2"
|
||
assert envelope["guardrails"]["existing_match_protected"] is True
|
||
assert conflict["existing_product_id"] == "QEAE1O-A900A6DNN"
|
||
assert conflict["incoming_product_id"] == "QEAE1O-A900A6DRS"
|
||
assert any(evidence["metric"] == "existing_match_conflict" for evidence in envelope["evidence"])
|
||
|
||
|
||
def test_review_decision_brief_is_shared_by_openclaw_and_ppt():
|
||
from services.competitor_intel_repository import (
|
||
_format_competitor_review_item,
|
||
summarize_review_decision_envelopes,
|
||
)
|
||
|
||
item = _format_competitor_review_item({
|
||
"sku": "10922465",
|
||
"name": "【Herbacin 德國小甘菊】小甘菊1號護手霜20ml",
|
||
"momo_price": 99,
|
||
"attempt_status": "rescore_accepted_current",
|
||
"candidate_count": 1,
|
||
"best_competitor_product_id": "DDAO4C-A79050612",
|
||
"best_competitor_product_name": "小甘菊經典護手霜20ml",
|
||
"best_competitor_price": 89,
|
||
"best_match_score": 0.872,
|
||
"match_diagnostic_json": {
|
||
"match_type": "exact",
|
||
"price_basis": "total_price",
|
||
"alert_tier": "identity_review",
|
||
},
|
||
})
|
||
|
||
brief = summarize_review_decision_envelopes([item], limit=5)
|
||
|
||
assert brief["hitl_count"] == 1
|
||
assert brief["auto_execute_blocked_count"] == 1
|
||
assert brief["severity_counts"]
|
||
assert brief["data_quality_counts"] == {"complete": 1}
|
||
assert "人工確認身份後採用同款" in brief["text"]
|
||
assert "HITL" in brief["text"]
|
||
assert "DDAO4C-A79050612" in brief["text"]
|
||
|
||
repo_source = (ROOT / "services" / "competitor_intel_repository.py").read_text(encoding="utf-8")
|
||
openclaw_source = (ROOT / "services" / "openclaw_strategist_service.py").read_text(encoding="utf-8")
|
||
ppt_source = (ROOT / "services" / "ppt_generator.py").read_text(encoding="utf-8")
|
||
bot_source = (ROOT / "routes" / "openclaw_bot_routes.py").read_text(encoding="utf-8")
|
||
|
||
assert "\"review_decision_brief\": summarize_review_decision_envelopes" in repo_source
|
||
assert "summarize_review_decision_envelopes" in openclaw_source
|
||
assert "review_decision_text" in openclaw_source
|
||
assert "PChome 覆核決策信封(HITL,不可自動寫正式價差)" in openclaw_source
|
||
assert "SUM(CASE WHEN attempt_status IN ('unit_comparable'" not in openclaw_source
|
||
assert "review_decision_brief" in bot_source
|
||
assert "覆核決策信封(HITL,不可自動寫正式價差)" in bot_source
|
||
assert "覆核決策信封(HITL)" in ppt_source
|
||
|
||
|
||
def test_competitor_ppt_prompt_uses_neutral_ewooc_viewpoint():
|
||
source = (ROOT / "routes" / "openclaw_bot_routes.py").read_text(encoding="utf-8")
|
||
|
||
assert "EwoooC 商品營運視角" in source
|
||
assert "待補資料不可當成成功配對" in source
|
||
assert "高信心比對" in source
|
||
assert "待補身份/價格" in source
|
||
assert "需單位價比較" in source
|
||
assert "單位價覆核樣本" in source
|
||
assert "我方 = PChome" not in source
|
||
assert "請以 PChome 視角" not in source
|
||
|
||
|
||
def test_top_competitor_risks_reads_latest_momo_price_after_valid_competitor_filter():
|
||
source = (ROOT / "services" / "competitor_intel_repository.py").read_text(encoding="utf-8")
|
||
risk_source = source.split("def _fetch_top_competitor_risks_uncached", 1)[1].split("def fetch_competitor_review_queue", 1)[0]
|
||
|
||
assert "FROM valid_competitor vc" in risk_source
|
||
assert "JOIN LATERAL" in risk_source
|
||
assert "WHERE pr.product_id = p.id" in risk_source
|
||
assert "ROW_NUMBER() OVER (PARTITION BY p.id" not in risk_source
|