diff --git a/config.py b/config.py index 7533862..a7eda11 100644 --- a/config.py +++ b/config.py @@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.590" +SYSTEM_VERSION = "V10.591" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/routes/dashboard_routes.py b/routes/dashboard_routes.py index 16da718..1be3118 100644 --- a/routes/dashboard_routes.py +++ b/routes/dashboard_routes.py @@ -68,10 +68,14 @@ REVIEW_STATUS_OPTIONS = [ 'label': '需單位價', 'statuses': ('unit_comparable', 'refresh_unit_comparable'), }, - {'key': 'catalog_comparable', 'label': '型錄可比', 'statuses': ('true_low_confidence',)}, - {'key': 'catalog_variant_review', 'label': '選項待核', 'statuses': ('true_low_confidence',)}, - {'key': 'catalog_unit_review', 'label': '入數待核', 'statuses': ('true_low_confidence',)}, - {'key': 'catalog_identity_review', 'label': '身份待核', 'statuses': ('true_low_confidence',)}, + { + 'key': 'catalog_comparable', + 'label': '型錄可比', + 'statuses': ('true_low_confidence', 'catalog_variant_review', 'catalog_unit_review', 'catalog_identity_review'), + }, + {'key': 'catalog_variant_review', 'label': '選項待核', 'statuses': ('true_low_confidence', 'catalog_variant_review')}, + {'key': 'catalog_unit_review', 'label': '入數待核', 'statuses': ('true_low_confidence', 'catalog_unit_review')}, + {'key': 'catalog_identity_review', 'label': '身份待核', 'statuses': ('true_low_confidence', 'catalog_identity_review')}, {'key': 'identity_veto', 'label': '已排除', 'statuses': ('identity_veto',)}, {'key': 'recoverable_low_score', 'label': '近門檻可救', 'statuses': ('recoverable_low_score',)}, {'key': 'true_low_confidence', 'label': '證據不足', 'statuses': ('true_low_confidence',)}, @@ -693,6 +697,11 @@ def _merge_competitor_review_context(overview, review_context): coverage = review_context.get('coverage') or {} review_queue = review_context.get('review_queue') or [] attempt_status = coverage.get('attempt_status') or {} + explicit_catalog_count = sum( + int(attempt_status.get(status) or 0) + for status in ('catalog_variant_review', 'catalog_unit_review', 'catalog_identity_review') + ) + legacy_catalog_count = max(int(coverage.get('catalog_comparable_count') or 0) - explicit_catalog_count, 0) review_status_counts = {} for option in REVIEW_STATUS_OPTIONS: if option['key'] == 'catalog_comparable': @@ -706,7 +715,7 @@ def _merge_competitor_review_context(overview, review_context): elif option['key'] == 'true_low_confidence': review_status_counts[option['key']] = max( int(attempt_status.get('true_low_confidence') or 0) - - int(coverage.get('catalog_comparable_count') or 0), + - legacy_catalog_count, 0, ) else: diff --git a/services/competitor_intel_repository.py b/services/competitor_intel_repository.py index 2db0405..9fba7d1 100644 --- a/services/competitor_intel_repository.py +++ b/services/competitor_intel_repository.py @@ -42,6 +42,11 @@ CATALOG_UNIT_REVIEW_REASONS = { "commercial_condition_gap", "catalog_count_omission", } +CATALOG_REVIEW_ATTEMPT_STATUSES = { + "catalog_variant_review", + "catalog_unit_review", + "catalog_identity_review", +} CATALOG_COMPARABLE_IDENTITY_REASONS = { "strong_product_line_match", "strong_exact_spec_match", @@ -87,6 +92,9 @@ ACTIONABLE_ATTEMPT_STATUSES = { "refresh_low_score", "recoverable_low_score", "true_low_confidence", + "catalog_variant_review", + "catalog_unit_review", + "catalog_identity_review", "protected_existing_match", "expired_match", "refresh_no_result", @@ -96,10 +104,10 @@ REVIEW_QUEUE_ATTEMPT_STATUSES = ACTIONABLE_ATTEMPT_STATUSES | MANUAL_CLOSED_ATTE REVIEW_STATUS_FILTER_GROUPS = { "rescore_accepted": ("rescore_accepted_current",), "unit_comparable": ("unit_comparable", "refresh_unit_comparable"), - "catalog_comparable": ("true_low_confidence",), - "catalog_variant_review": ("true_low_confidence",), - "catalog_unit_review": ("true_low_confidence",), - "catalog_identity_review": ("true_low_confidence",), + "catalog_comparable": ("true_low_confidence", "catalog_variant_review", "catalog_unit_review", "catalog_identity_review"), + "catalog_variant_review": ("true_low_confidence", "catalog_variant_review"), + "catalog_unit_review": ("true_low_confidence", "catalog_unit_review"), + "catalog_identity_review": ("true_low_confidence", "catalog_identity_review"), "identity_veto": ("identity_veto",), "low_score": ("low_score", "refresh_low_score", "recoverable_low_score", "true_low_confidence"), "recoverable_low_score": ("recoverable_low_score",), @@ -119,6 +127,9 @@ ATTEMPT_STATUS_LABELS = { "refresh_low_score": "刷新後仍低信心舊候選", "recoverable_low_score": "近門檻可救回", "true_low_confidence": "證據不足待觀察", + "catalog_variant_review": "選項 / 色號待核", + "catalog_unit_review": "單位價 / 入數待核", + "catalog_identity_review": "身份採用待核", "protected_existing_match": "既有較強配對保護中", "expired_match": "價格過期待刷新", "refresh_no_result": "刷新找不到商品", @@ -138,6 +149,9 @@ ATTEMPT_ACTION_LABELS = { "refresh_low_score": "刷新後仍低分,需補搜尋詞或等待新證據", "recoverable_low_score": "優先回放這批近門檻同品線候選", "true_low_confidence": "保守保留,等待更明確的身份證據", + "catalog_variant_review": "確認 MOMO 選項、色號、香味或款式是否涵蓋 PChome 候選", + "catalog_unit_review": "確認入數、贈品、檔期或商業條件後決定單位價或採用", + "catalog_identity_review": "身份證據完整,人工確認後可採用同款", "protected_existing_match": "比較新舊候選證據,避免覆蓋較強正式配對", "expired_match": "重新刷新 PChome 價格", "refresh_no_result": "調整搜尋詞後重抓", @@ -340,7 +354,7 @@ def _jsonb_any_array_predicate(jsonb_expr: str, values: set[str]) -> str: return f"(COALESCE({jsonb_expr}, '[]'::jsonb) ?| ARRAY[{value_sql}])" -def _catalog_comparable_sql(alias: str = "la") -> str: +def _legacy_catalog_comparable_sql(alias: str = "la") -> str: diagnostic_codes = f"{alias}.diagnostic_codes" signal_sql = _jsonb_any_array_predicate(diagnostic_codes, CATALOG_COMPARABLE_SIGNAL_REASONS) identity_sql = _jsonb_any_array_predicate(diagnostic_codes, CATALOG_COMPARABLE_IDENTITY_REASONS) @@ -360,18 +374,32 @@ def _catalog_comparable_sql(alias: str = "la") -> str: )""" +def _explicit_catalog_status_sql(alias: str = "la", lane: str = "") -> str: + if lane in CATALOG_REVIEW_ATTEMPT_STATUSES: + return f"({alias}.attempt_status = '{lane}' AND COALESCE({alias}.hard_veto, false) = false)" + status_sql = ", ".join(f"'{status}'" for status in sorted(CATALOG_REVIEW_ATTEMPT_STATUSES)) + return f"({alias}.attempt_status IN ({status_sql}) AND COALESCE({alias}.hard_veto, false) = false)" + + +def _catalog_comparable_sql(alias: str = "la") -> str: + return f"""( + {_explicit_catalog_status_sql(alias)} + OR {_legacy_catalog_comparable_sql(alias)} + )""" + + def _catalog_review_lane_sql(alias: str = "la", lane: str = "catalog_comparable") -> str: diagnostic_codes = f"{alias}.diagnostic_codes" - catalog_sql = _catalog_comparable_sql(alias) + legacy_catalog_sql = _legacy_catalog_comparable_sql(alias) variant_sql = _jsonb_any_array_predicate(diagnostic_codes, CATALOG_VARIANT_REVIEW_REASONS) unit_sql = _jsonb_any_array_predicate(diagnostic_codes, CATALOG_UNIT_REVIEW_REASONS) if lane == "catalog_variant_review": - return f"({catalog_sql} AND {variant_sql})" + return f"({_explicit_catalog_status_sql(alias, lane)} OR ({legacy_catalog_sql} AND {variant_sql}))" if lane == "catalog_unit_review": - return f"({catalog_sql} AND NOT {variant_sql} AND {unit_sql})" + return f"({_explicit_catalog_status_sql(alias, lane)} OR ({legacy_catalog_sql} AND NOT {variant_sql} AND {unit_sql}))" if lane == "catalog_identity_review": - return f"({catalog_sql} AND NOT {variant_sql} AND NOT {unit_sql})" - return catalog_sql + return f"({_explicit_catalog_status_sql(alias, lane)} OR ({legacy_catalog_sql} AND NOT {variant_sql} AND NOT {unit_sql}))" + return _catalog_comparable_sql(alias) def _catalog_review_lane_case_sql(alias: str = "la") -> str: diff --git a/services/competitor_price_feeder.py b/services/competitor_price_feeder.py index 82d7dd9..833342b 100644 --- a/services/competitor_price_feeder.py +++ b/services/competitor_price_feeder.py @@ -131,6 +131,45 @@ RECOVERABLE_DIAGNOSTIC_REASONS = { "shared_model_token", "spec_name_alignment", } +CATALOG_REVIEW_VARIANT_REASONS = { + "variant_selection_review", + "makeup_catalog_selection_gap", +} +CATALOG_REVIEW_UNIT_REASONS = { + "commercial_condition_gap", + "catalog_count_omission", +} +CATALOG_REVIEW_IDENTITY_REASONS = { + "strong_product_line_match", + "strong_exact_spec_match", + "shared_identity_anchor_exact_line", + "shared_identity_anchor_core_line", + "shared_identity_anchor_variant_safe", + "spec_name_alignment", + "shared_model_token", +} +CATALOG_REVIEW_BLOCK_REASONS = { + "brand_conflict", + "type_conflict", + "product_line_conflict", + "core_ingredient_line_conflict", + "variant_option_conflict", + "variant_descriptor_conflict", + "aroma_scent_variant_conflict", + "bath_additive_variant_gap", + "makeup_finish_conflict", + "makeup_usage_conflict", + "romand_lip_line_conflict", + "count_conflict", + "component_count_conflict", + "multi_component_conflict", + "multi_component_count_conflict", + "bundle_offer_conflict", + "refill_pack_conflict", + "accessory_case_conflict", + "named_component_quantity_conflict", + "price_ratio_extreme", +} RECOVERABLE_SQL_REASON_LIST = ", ".join(f"'{reason}'" for reason in sorted(RECOVERABLE_DIAGNOSTIC_REASONS)) FOCUSED_TOTAL_PRICE_IDENTITY_DIAGNOSTIC_REASONS = { @@ -304,9 +343,40 @@ def _is_auto_price_write_safe(diagnostics) -> bool: return True +def _classify_catalog_review_attempt(score: float, diagnostics) -> Optional[str]: + """Split high-confidence manual gates into operational review lanes.""" + if getattr(diagnostics, "hard_veto", False): + return None + if score < MIN_MATCH_SCORE: + return None + + reasons = set(getattr(diagnostics, "reasons", ()) or ()) + if reasons & CATALOG_REVIEW_BLOCK_REASONS: + return None + + has_identity_signal = bool(reasons & CATALOG_REVIEW_IDENTITY_REASONS) or score >= 0.95 + if not has_identity_signal: + return None + if reasons & CATALOG_REVIEW_VARIANT_REASONS: + return "catalog_variant_review" + if reasons & CATALOG_REVIEW_UNIT_REASONS: + return "catalog_unit_review" + if ( + getattr(diagnostics, "comparison_mode", "") == "exact_identity" + and getattr(diagnostics, "match_type", "") in {"exact", "comparable"} + and getattr(diagnostics, "price_basis", "") in {"manual_review", "total_price"} + and getattr(diagnostics, "alert_tier", "") in {"identity_review", "price_alert_exact"} + ): + return "catalog_identity_review" + return None + + def _classify_auto_write_block_attempt(score: float, diagnostics) -> str: if getattr(diagnostics, "hard_veto", False): return "identity_veto" + catalog_status = _classify_catalog_review_attempt(score, diagnostics) + if catalog_status: + return catalog_status if score >= MIN_MATCH_SCORE: return "true_low_confidence" return _classify_low_score_attempt(score, diagnostics) diff --git a/tests/test_competitor_intel_cache.py b/tests/test_competitor_intel_cache.py index 99dbf05..3e7042e 100644 --- a/tests/test_competitor_intel_cache.py +++ b/tests/test_competitor_intel_cache.py @@ -216,10 +216,24 @@ def test_competitor_review_queue_is_canonical_unit_price_handoff(): 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",) - assert REVIEW_STATUS_FILTER_GROUPS["catalog_variant_review"] == ("true_low_confidence",) - assert REVIEW_STATUS_FILTER_GROUPS["catalog_unit_review"] == ("true_low_confidence",) - assert REVIEW_STATUS_FILTER_GROUPS["catalog_identity_review"] == ("true_low_confidence",) + 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") diff --git a/tests/test_competitor_match_attempts_persistence.py b/tests/test_competitor_match_attempts_persistence.py index 6db41e3..d8ed924 100644 --- a/tests/test_competitor_match_attempts_persistence.py +++ b/tests/test_competitor_match_attempts_persistence.py @@ -379,7 +379,23 @@ def test_competitor_feeder_blocks_identity_review_from_auto_price_write(): match_type="comparable", price_basis="manual_review", alert_tier="identity_review", - reasons=("variant_selection_review",), + reasons=("variant_selection_review", "strong_product_line_match"), + ) + unit_review = SimpleNamespace( + hard_veto=False, + comparison_mode="exact_identity", + match_type="exact", + price_basis="manual_review", + alert_tier="identity_review", + reasons=("commercial_condition_gap", "strong_exact_spec_match"), + ) + catalog_identity_review = SimpleNamespace( + hard_veto=False, + comparison_mode="exact_identity", + match_type="exact", + price_basis="manual_review", + alert_tier="identity_review", + reasons=("strong_exact_spec_match", "shared_identity_anchor_variant_safe"), ) exact_price = SimpleNamespace( hard_veto=False, @@ -391,7 +407,9 @@ def test_competitor_feeder_blocks_identity_review_from_auto_price_write(): ) assert _is_auto_price_write_safe(identity_review) is False - assert _classify_auto_write_block_attempt(0.783, identity_review) == "true_low_confidence" + assert _classify_auto_write_block_attempt(0.783, identity_review) == "catalog_variant_review" + assert _classify_auto_write_block_attempt(1.0, unit_review) == "catalog_unit_review" + assert _classify_auto_write_block_attempt(1.0, catalog_identity_review) == "catalog_identity_review" assert _is_auto_price_write_safe(exact_price) is True