diff --git a/TODO_NEXT_STEPS.txt b/TODO_NEXT_STEPS.txt index c7b03d3..d1cc56c 100644 --- a/TODO_NEXT_STEPS.txt +++ b/TODO_NEXT_STEPS.txt @@ -4,6 +4,7 @@ ================================================================================ 【已完成】 + - V10.575 拆分 PChome 型錄可比覆核 lane:`catalog_comparable` 不再只是一個總數,正式拆成 `catalog_variant_review`(選項/色號/款式待核)、`catalog_unit_review`(入數/檔期/商業條件待核)與 `catalog_identity_review`(身份採用待核)。Coverage、review queue filter、Dashboard 分段、decision envelope、Webcrumbs host data 都共用同一套 SQL helper 與 metadata;仍維持 HITL、不自動寫正式價差,讓營運可批次清理最有機會轉成單位價或正式身份的候選。 - V10.574 接上 PChome 型錄/任選可比覆核隊列:沿用 V10.572 的 `catalog_comparable_count` 安全口徑,將高分、無 hard veto、具同品線身份證據但仍有任選/型錄/商業條件待確認的 `true_low_confidence` 候選,拆成獨立 `catalog_comparable` 篩選與 decision envelope。此隊列仍維持 HITL,不寫入正式 `competitor_prices`、不算 exact matched,並把「型錄可比」與真正「證據不足」分開,讓營運可以先批次處理最有機會轉成單位價或正式身份的候選。 - V10.574 新增市場情報 Source Governance → Fetch Target bridge:新增 `/api/market_intel/mcp_fetch_target_source_governance_review`、市場情報頁 bridge panel 與 deployment readiness smoke target,交叉審核 Professional Source Governance 與 MCP Fetch Target Review,要求每個 target `platform_code/source_key` 都能對上已通過治理的公開 source contract;仍不抓外站、不讀 robots/sitemap、不開 DB、不寫檔、不執行 CLI、不掛 scheduler。 - V10.572 新增 PChome 決策支援覆蓋率:不放寬 `matched` / `decision_ready` 的 exact identity 門檻,另外把高分、無 hard veto、具同品線與規格證據,但因「任選 / 色號 / 型錄 / 即期」仍需覆核的候選,納入 `catalog_comparable_count` 與 `decision_support_rate`。Dashboard、當日業績、成長分析與 backfill 狀態摘要同步顯示「決策支援覆蓋率 / 精準可告警覆蓋 / 型錄可比 / 單位價」,讓覆蓋率提升建立在可解釋情報分層上,而不是把非 exact 商品硬寫成正式同款。 diff --git a/config.py b/config.py index dccc9e5..74706d5 100644 --- a/config.py +++ b/config.py @@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.574" +SYSTEM_VERSION = "V10.575" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/docs/AI_INTELLIGENCE_MODULE_SOT.md b/docs/AI_INTELLIGENCE_MODULE_SOT.md index ed8e2c4..125ce4c 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -58,7 +58,7 @@ - PChome 覆核隊列本身也必須輸出 `decision_envelope`:`fetch_competitor_review_queue()`、`fetch_competitor_review_queue_page()` 與 `/api/pchome-review/queue` 的每筆候選需帶相同的 `subject`、`evidence`、`recommended_action`、`expected_impact` 與 `guardrails`,供 Dashboard、Agent、Telegram 與 PPT 共用;任何下游不得另寫一套比價狀態翻譯或繞過 HITL guardrails。 - Dashboard 覆核卡與 `/api/export/excel/pchome-review` 也必須顯示/匯出 `decision_envelope` 的等級、資料品質、建議代碼、HITL、trace 與 `can_auto_execute=false` 邊界;操作員離開系統畫面或下載 Excel 後,仍要看得到「不可自動寫正式價差」的 guardrails。 - OpenClaw 週報/日報/月報與 competitor PPT 不得再各自重算或翻譯 PChome 覆核狀態;必須透過 `competitor_intel_repository.summarize_review_decision_envelopes()` 讀取同一份 `decision_envelope` 摘要,並在 prompt / data_summary / KPI slide 保留 HITL 與 `can_auto_execute=false` 邊界。 -- Webcrumbs / Shared UI host data 也必須透過 `summarize_review_decision_envelopes()` 輸出 `reviewDecisionBrief`,並在 metadata 保留 review queue、HITL 與 auto-execute-blocked 數量;不得另寫一套 PChome 覆核摘要或在前端 runtime 重新推論價格行動。 +- Webcrumbs / Shared UI host data 也必須透過 `summarize_review_decision_envelopes()` 輸出 `reviewDecisionBrief`,並在 metadata 保留 review queue、HITL、auto-execute-blocked、`decision_support_rate`、`catalog_comparable_count` 與 catalog review lane counts;不得另寫一套 PChome 覆核摘要或在前端 runtime 重新推論價格行動。 - ElephantAlpha 的 `resource_optimization` 與低信心 `ea_escalation` 也必須輸出 `decision_envelope`:資源壓力信封只能使用 `action_plans`、CPU 實測、hygiene 結果與 insight/action trace,不得加入 LLM 預測效益;`triaged_alert()` 對 `ea_escalation` 亦需渲染信封並以 `decision_id` 作為 callback 追蹤 ID。 ## 一、四 AI Agent 路由架構 diff --git a/docs/memory/current_execution_queue_20260524.md b/docs/memory/current_execution_queue_20260524.md index f588265..0103c23 100644 --- a/docs/memory/current_execution_queue_20260524.md +++ b/docs/memory/current_execution_queue_20260524.md @@ -104,6 +104,7 @@ - 2026-05-31 起,`V10.506` 新增市場情報 MCP Fetch Candidate Queue Writer Review Decision Approval gate:在 review decision 通過後只審核 operator human approval 摘要,要求 decision linkage、approval identity、target table、row count、dedupe keys、`approved_for_writer_preflight` approval result、decision/approval evidence refs、artifact paths、matched row exact-identity/variant/overwrite guard 與 operator confirmation 對齊;仍不讀 token、不執行 CLI、不開 DB、不寫 approval record、不寫 decision record、不更新 review_state、不寫 match result、不補 queue、不掛 scheduler,只放行到後續 writer preflight 設計。 - 2026-05-31 起,`V10.509` 新增市場情報 MCP Fetch Candidate Queue Writer Review Decision Approval Writer Preflight gate:在 human approval 通過後只審核 operator writer preflight 摘要,要求 approval linkage、writer_preflight_id、target operation、row count、dedupe keys、approved decision 到 target review_state 的逐列映射、decision/approval/preflight evidence refs、matched row exact-identity/variant/overwrite guard 與 operator boundary;仍不讀 token、不執行 CLI、不開 DB、不寫 preflight/approval/decision/match、不更新 review_state、不補 queue、不掛 scheduler,只放行到後續 CLI review / run package 設計。 - 2026-06-01 起,`V10.566` 新增市場情報 Professional Source Governance gate:將 robots/REP、sitemap/lastmod、JSON-LD / schema.org structured data、canonical URL、rate limit、公開資料邊界、provenance、snapshot hash 與 idempotency key 納入 source contract,並接上 `/api/market_intel/mcp_professional_source_governance`、UI preview panel、deployment readiness check 與 production smoke target;仍不抓外站、不讀 robots/sitemap、不開 DB、不寫檔、不掛 scheduler。 +- 2026-06-04 起,`V10.575` 拆分 PChome 型錄可比覆核 lane:`catalog_comparable` 會依 diagnostic evidence 分成選項/色號、單位/入數與身份採用三條人工處理路徑,Dashboard、decision envelope、coverage 與 Webcrumbs host data 使用同一套統計與 HITL guardrail。 - 2026-06-03 起,`V10.574` 新增市場情報 Source Governance → Fetch Target bridge:`/api/market_intel/mcp_fetch_target_source_governance_review` 交叉審核 Professional Source Governance 與 MCP Fetch Target Review,要求 target `platform_code/source_key` 全部命中已治理 source contract;仍不抓外站、不讀 robots/sitemap、不開 DB、不寫檔、不執行 CLI、不掛 scheduler,只放行到後續人工 fetch run package review。 - 2026-06-02 起,`V10.567` 將 MCP 市場洞察 fallback 收斂為 GCP-A / GCP-B only,不再讓 111 承接非即時市場分析長任務;預設 timeout 25 秒、`num_predict` 500,GCP 不可用時直接保守降級,避免 Elephant Alpha 60 秒 timeout 與 111 負載尖峰。 - 2026-06-02 起,`V10.568` 將價格類 `decision_envelope` 的 Telegram 直送訊息改為專業 brief:標的、價格證據、比對證據、人工下一步四段式;review queue 信封 subject 同步帶 `momo_price` / `competitor_price`,讓 Telegram、PPT、Webcrumbs 與 AI 摘要共用價格證據。 diff --git a/docs/memory/history_logs.md b/docs/memory/history_logs.md index 5db94fb..556bdce 100644 --- a/docs/memory/history_logs.md +++ b/docs/memory/history_logs.md @@ -13,6 +13,7 @@ ## 📅 詳細更新日誌 (考古存檔) ### 2026-06-01:PChome 比價新鮮度操作閉環 +- **V10.575 PChome 型錄可比覆核 lane 分流**: `catalog_comparable` 進一步拆成 `catalog_variant_review`、`catalog_unit_review` 與 `catalog_identity_review`。Coverage SQL、review queue filter、Dashboard 分段、decision envelope 與 Webcrumbs host data 共用同一套 helper,將選項/色號/款式、入數/商業條件、身份採用三種人工閉環路徑分開統計與瀏覽;仍維持 HITL,不自動寫正式價差。 - **V10.574 PChome 型錄/任選可比覆核隊列**: 將 V10.572 的 `catalog_comparable_count` 派生口徑正式接進 PChome review queue。高分、無 hard veto、具同品線身份證據但仍有任選/型錄/商業條件待確認的 `true_low_confidence` 會進獨立 `catalog_comparable` 篩選、狀態標籤與 decision envelope;真正 `true_low_confidence` 會排除這批候選,避免重複出現在「證據不足」。此變更不放寬 `MIN_MATCH_SCORE`、不寫正式 `competitor_prices`、不算 exact matched,只把最有機會人工批次確認的候選變成可操作隊列。 - **V10.574 市場情報 Source Governance → Fetch Target bridge**: 新增 `/api/market_intel/mcp_fetch_target_source_governance_review`、preview service 與市場情報頁 bridge panel,交叉審核 Professional Source Governance 與 MCP Fetch Target Review。此 gate 要求每個 target `platform_code/source_key` 都能對上已通過治理的公開 source contract,並同步納入 deployment readiness preview-safe check 與 production smoke target;API/UI 仍不抓外站、不讀 robots/sitemap、不開 DB、不寫檔、不執行 CLI、不掛 scheduler。 - **V10.572 PChome 決策支援覆蓋率分層**: 覆蓋率不再只有 exact `decision_ready_rate`。`fetch_competitor_coverage()` cache 升到 v11,新增 `catalog_comparable_count`、`decision_support_count`、`decision_support_rate` 與非 exact 支援數;只納入高分、無 hard veto、同時具型錄/任選/商業條件訊號與強身份證據,且排除品類、品線、入數、香味、型號、價格極端等硬衝突的候選。Dashboard、daily、growth 與 backfill JS 同步顯示「決策支援覆蓋率 / 精準可告警覆蓋 / 型錄可比 / 單位價」,提升可用情報覆蓋但不污染正式 `matched`。 diff --git a/routes/dashboard_routes.py b/routes/dashboard_routes.py index 4462f99..16da718 100644 --- a/routes/dashboard_routes.py +++ b/routes/dashboard_routes.py @@ -69,6 +69,9 @@ REVIEW_STATUS_OPTIONS = [ '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': 'identity_veto', 'label': '已排除', 'statuses': ('identity_veto',)}, {'key': 'recoverable_low_score', 'label': '近門檻可救', 'statuses': ('recoverable_low_score',)}, {'key': 'true_low_confidence', 'label': '證據不足', 'statuses': ('true_low_confidence',)}, @@ -694,6 +697,12 @@ def _merge_competitor_review_context(overview, review_context): for option in REVIEW_STATUS_OPTIONS: if option['key'] == 'catalog_comparable': review_status_counts[option['key']] = int(coverage.get('catalog_comparable_count') or 0) + elif option['key'] == 'catalog_variant_review': + review_status_counts[option['key']] = int(coverage.get('catalog_variant_review_count') or 0) + elif option['key'] == 'catalog_unit_review': + review_status_counts[option['key']] = int(coverage.get('catalog_unit_review_count') or 0) + elif option['key'] == 'catalog_identity_review': + review_status_counts[option['key']] = int(coverage.get('catalog_identity_review_count') or 0) elif option['key'] == 'true_low_confidence': review_status_counts[option['key']] = max( int(attempt_status.get('true_low_confidence') or 0) @@ -728,6 +737,10 @@ def _merge_competitor_review_context(overview, review_context): 'decision_support_non_exact_count': int(coverage.get('decision_support_non_exact_count') or 0), 'catalog_comparable_count': int(coverage.get('catalog_comparable_count') or 0), 'catalog_comparable_rate': coverage.get('catalog_comparable_rate', 0), + 'catalog_variant_review_count': int(coverage.get('catalog_variant_review_count') or 0), + 'catalog_unit_review_count': int(coverage.get('catalog_unit_review_count') or 0), + 'catalog_identity_review_count': int(coverage.get('catalog_identity_review_count') or 0), + 'catalog_review_plan': coverage.get('catalog_review_plan') or {}, 'stale_match_count': int(coverage.get('stale_matches') or 0), 'unknown_freshness_count': int(coverage.get('unknown_freshness_matches') or 0), 'pending_match_count': int(coverage.get('pending') or overview.get('pending_match_count') or 0), @@ -883,6 +896,10 @@ def _load_competitor_decision_overview(session, latest_items=None): 'decision_support_non_exact_count': 0, 'catalog_comparable_count': 0, 'catalog_comparable_rate': 0, + 'catalog_variant_review_count': 0, + 'catalog_unit_review_count': 0, + 'catalog_identity_review_count': 0, + 'catalog_review_plan': {}, 'stale_match_count': 0, 'unknown_freshness_count': 0, 'pchome_advantage_count': 0, @@ -1748,6 +1765,10 @@ def _load_cached_competitor_overview_for_review(now_taipei, review_queue, review 'decision_support_non_exact_count': 0, 'catalog_comparable_count': 0, 'catalog_comparable_rate': 0, + 'catalog_variant_review_count': 0, + 'catalog_unit_review_count': 0, + 'catalog_identity_review_count': 0, + 'catalog_review_plan': {}, 'unknown_freshness_count': 0, 'pchome_advantage_count': 0, 'momo_threat_count': 0, @@ -1782,6 +1803,10 @@ def _load_cached_competitor_overview_for_review(now_taipei, review_queue, review overview.setdefault('decision_support_non_exact_count', 0) overview.setdefault('catalog_comparable_count', 0) overview.setdefault('catalog_comparable_rate', 0) + overview.setdefault('catalog_variant_review_count', 0) + overview.setdefault('catalog_unit_review_count', 0) + overview.setdefault('catalog_identity_review_count', 0) + overview.setdefault('catalog_review_plan', {}) return overview diff --git a/services/competitor_intel_repository.py b/services/competitor_intel_repository.py index 15e2f01..b49ed88 100644 --- a/services/competitor_intel_repository.py +++ b/services/competitor_intel_repository.py @@ -33,6 +33,14 @@ CATALOG_COMPARABLE_SIGNAL_REASONS = { "commercial_condition_gap", "catalog_count_omission", } +CATALOG_VARIANT_REVIEW_REASONS = { + "variant_selection_review", + "makeup_catalog_selection_gap", +} +CATALOG_UNIT_REVIEW_REASONS = { + "commercial_condition_gap", + "catalog_count_omission", +} CATALOG_COMPARABLE_IDENTITY_REASONS = { "strong_product_line_match", "strong_exact_spec_match", @@ -88,6 +96,9 @@ 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",), "identity_veto": ("identity_veto",), "low_score": ("low_score", "refresh_low_score", "recoverable_low_score", "true_low_confidence"), "recoverable_low_score": ("recoverable_low_score",), @@ -152,6 +163,21 @@ DECISION_ACTION_LABELS = { "refresh_or_compare_identity": "刷新價格或比較候選", "human_review": "人工覆核", } +CATALOG_REVIEW_LANE_LABELS = { + "catalog_variant_review": "選項 / 色號待核", + "catalog_unit_review": "單位價 / 入數待核", + "catalog_identity_review": "身份採用待核", +} +CATALOG_REVIEW_LANE_ACTION_HINTS = { + "catalog_variant_review": "先確認 MOMO 選項、色號、香味或款式是否涵蓋 PChome 候選;一致才採用,無法對齊就補搜尋或否決", + "catalog_unit_review": "先確認入數、贈品、檔期或商業條件;總價不可直比時標記單位價", + "catalog_identity_review": "身份證據已完整但仍留在 HITL;人工確認後可採用同款", +} +CATALOG_REVIEW_LANE_PRIMARY_ACTIONS = { + "catalog_variant_review": "needs_research", + "catalog_unit_review": "unit_price_required", + "catalog_identity_review": "accept_identity", +} DATA_QUALITY_LABELS = { "complete": "證據完整", "partial": "證據部分完整", @@ -322,6 +348,29 @@ def _catalog_comparable_sql(alias: str = "la") -> str: )""" +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) + 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})" + if lane == "catalog_unit_review": + return f"({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 + + +def _catalog_review_lane_case_sql(alias: str = "la") -> str: + return f"""CASE + WHEN {_catalog_review_lane_sql(alias, "catalog_variant_review")} THEN 'catalog_variant_review' + WHEN {_catalog_review_lane_sql(alias, "catalog_unit_review")} THEN 'catalog_unit_review' + WHEN {_catalog_review_lane_sql(alias, "catalog_identity_review")} THEN 'catalog_identity_review' + ELSE '' + END""" + + def _tag_suffix(tags: list[str], prefix: str) -> str: marker = f"{prefix}_" for tag in tags: @@ -495,6 +544,43 @@ def _build_review_difference_highlights( return highlights +def _build_catalog_review_guidance( + catalog_comparable: bool, + catalog_review_lane: str, + diagnostic_reasons: list[dict[str, str]], +) -> dict[str, Any]: + if not catalog_comparable: + return {} + reason_codes = { + str(reason.get("code") or "") + for reason in diagnostic_reasons or [] + if reason.get("code") + } + if catalog_review_lane in CATALOG_REVIEW_LANE_LABELS: + lane = catalog_review_lane + elif reason_codes & CATALOG_VARIANT_REVIEW_REASONS: + lane = "catalog_variant_review" + elif reason_codes & CATALOG_UNIT_REVIEW_REASONS: + lane = "catalog_unit_review" + else: + lane = "catalog_identity_review" + reason_labels = [ + str(reason.get("label") or reason.get("code") or "") + for reason in diagnostic_reasons or [] + if reason.get("label") or reason.get("code") + ] + primary_action = CATALOG_REVIEW_LANE_PRIMARY_ACTIONS.get(lane, "accept_identity") + return { + "lane": lane, + "lane_label": CATALOG_REVIEW_LANE_LABELS.get(lane, "型錄可比待核"), + "action_hint": CATALOG_REVIEW_LANE_ACTION_HINTS.get(lane, "人工確認型錄條件後再決定採用或否決"), + "primary_review_action": primary_action, + "primary_review_action_label": MANUAL_REVIEW_ACTION_LABELS.get(primary_action, "人工覆核"), + "reason_labels": reason_labels[:5], + "can_auto_execute": False, + } + + def _build_unit_comparison_for_attempt(row: dict[str, Any]) -> Optional[dict[str, Any]]: status = str(row.get("attempt_status") or "") if status not in UNIT_PRICE_DECISION_STATUSES: @@ -633,11 +719,20 @@ def _parse_existing_match_conflict(error_message: Any) -> dict[str, Any]: def _build_review_decision_envelope(item: dict[str, Any]) -> dict[str, Any]: """Build the shared evidence contract for an operator review queue item.""" attempt_status = str(item.get("attempt_status") or "") - action_code = ( - "review_catalog_comparable" - if item.get("catalog_comparable") - else _review_action_code(attempt_status) + catalog_guidance = ( + item.get("catalog_review_guidance") + if isinstance(item.get("catalog_review_guidance"), dict) + else {} ) + action_code = _review_action_code(attempt_status) + if item.get("catalog_comparable"): + primary_action = str(catalog_guidance.get("primary_review_action") or "") + if primary_action == "accept_identity": + action_code = "review_accept_identity" + elif primary_action in {"needs_research", "unit_price_required"}: + action_code = primary_action + else: + action_code = "review_catalog_comparable" momo_price = _num(item.get("momo_price")) candidate_price = _num(item.get("candidate_pc_price")) gap_amount = None @@ -680,8 +775,12 @@ def _build_review_decision_envelope(item: dict[str, Any]) -> dict[str, Any]: evidence.append({ "type": "review_bucket", "metric": "catalog_comparable", - "value": "true", - "basis": "true_low_confidence + high score + identity anchor + catalog/variant review signal + no hard veto", + "value": item.get("catalog_review_lane") or catalog_guidance.get("lane") or "catalog_comparable", + "basis": ( + catalog_guidance.get("lane_label") + if catalog_guidance.get("lane_label") + else "true_low_confidence + high score + identity anchor + catalog/variant review signal + no hard veto" + ), }) identity_evidence = item.get("identity_evidence") identity_summary = _build_identity_evidence_summary(identity_evidence) @@ -776,6 +875,7 @@ def _build_review_decision_envelope(item: dict[str, Any]) -> dict[str, Any]: "candidate_gap_pct": gap_pct, "unit_price_insight": unit_price_insight if isinstance(unit_price_insight, dict) else {}, "existing_match_conflict": existing_conflict if isinstance(existing_conflict, dict) else {}, + "catalog_review_guidance": catalog_guidance, "risk_reduction": "medium" if attempt_status in {"rescore_accepted_current", "recoverable_low_score"} else "watch", }, "confidence": round(_num(item.get("best_match_score")), 3), @@ -794,6 +894,7 @@ def _build_review_decision_envelope(item: dict[str, Any]) -> dict[str, Any]: else "" ), "catalog_comparable": bool(item.get("catalog_comparable")), + "catalog_review_lane": item.get("catalog_review_lane") or catalog_guidance.get("lane") or "", "price_is_identity_evidence": False, }, "trace": { @@ -830,6 +931,7 @@ def summarize_review_decision_envelopes( guardrails = envelope.get("guardrails") if isinstance(envelope.get("guardrails"), dict) else {} action = envelope.get("recommended_action") if isinstance(envelope.get("recommended_action"), dict) else {} expected = envelope.get("expected_impact") if isinstance(envelope.get("expected_impact"), dict) else {} + catalog_guidance = expected.get("catalog_review_guidance") if isinstance(expected.get("catalog_review_guidance"), dict) else {} evidence = envelope.get("evidence") if isinstance(envelope.get("evidence"), list) else [] severity = str(envelope.get("severity") or "P4") @@ -888,6 +990,8 @@ def summarize_review_decision_envelopes( line_parts.append(unit_text) if diff_text: line_parts.append(diff_text) + if catalog_guidance.get("lane_label"): + line_parts.append(str(catalog_guidance.get("lane_label"))) if evidence_basis: line_parts.append(evidence_basis) line = " | ".join(part for part in line_parts if part) @@ -907,6 +1011,7 @@ def summarize_review_decision_envelopes( "candidate_gap_pct": gap_pct, "unit_price_gap_pct": unit_gap_pct, "difference_highlights": difference_highlights if isinstance(difference_highlights, list) else [], + "catalog_review_guidance": catalog_guidance, "line": line, }) @@ -946,13 +1051,24 @@ def _format_competitor_review_item(row: dict[str, Any]) -> dict[str, Any]: difference_highlights = _build_review_difference_highlights(diagnostic_reasons, identity_evidence) existing_match_conflict = _parse_existing_match_conflict(match_diagnostic) catalog_comparable = bool(item.get("catalog_comparable")) + catalog_review_lane = str(item.get("catalog_review_lane") or "") + catalog_review_guidance = _build_catalog_review_guidance( + catalog_comparable, + catalog_review_lane, + diagnostic_reasons, + ) + resolved_catalog_review_lane = ( + str(catalog_review_guidance.get("lane") or "") + if catalog_comparable + else catalog_review_lane + ) status_label = _attempt_status_label(item.get("attempt_status")) action_label = _attempt_action_label(item.get("attempt_status")) review_bucket = str(item.get("attempt_status") or "") if catalog_comparable: - status_label = "型錄/任選可比" - action_label = "人工確認型錄、任選與規格條件後,再轉單位價或採用身份" - review_bucket = "catalog_comparable" + status_label = catalog_review_guidance.get("lane_label") or "型錄/任選可比" + action_label = catalog_review_guidance.get("action_hint") or "人工確認型錄、任選與規格條件後,再轉單位價或採用身份" + review_bucket = catalog_review_guidance.get("lane") or "catalog_comparable" formatted = { "sku": str(item.get("sku") or ""), "name": item.get("name") or "", @@ -963,6 +1079,8 @@ def _format_competitor_review_item(row: dict[str, Any]) -> dict[str, Any]: "status_label": status_label, "action_label": action_label, "catalog_comparable": catalog_comparable, + "catalog_review_lane": resolved_catalog_review_lane, + "catalog_review_guidance": catalog_review_guidance, "candidate_count": int(item.get("candidate_count") or 0), "candidate_pc_id": item.get("best_competitor_product_id"), "candidate_pc_name": item.get("best_competitor_product_name") or "", @@ -1060,7 +1178,7 @@ def _cached_payload(cache_key: str, producer, ttl_seconds: int = COMPETITOR_INTE def fetch_competitor_coverage(engine) -> dict: return _cached_payload( - f"coverage:v12:floor={PCHOME_MATCH_SCORE_FLOOR}:catalog_floor={CATALOG_COMPARABLE_SCORE_FLOOR}:manual_reviews=1:rescore=1:review_no_fresh=1:decision_ready=1:open_queue=1:unknown_freshness=1:decision_support=1", + f"coverage:v13:floor={PCHOME_MATCH_SCORE_FLOOR}:catalog_floor={CATALOG_COMPARABLE_SCORE_FLOOR}:manual_reviews=1:rescore=1:review_no_fresh=1:decision_ready=1:open_queue=1:unknown_freshness=1:decision_support=1:catalog_plan=1", lambda: _fetch_competitor_coverage_uncached(engine), ) @@ -1085,6 +1203,15 @@ def _fetch_competitor_coverage_uncached(engine) -> dict: "decision_support_non_exact_count": 0, "catalog_comparable_count": 0, "catalog_comparable_rate": 0, + "catalog_variant_review_count": 0, + "catalog_unit_review_count": 0, + "catalog_identity_review_count": 0, + "catalog_review_plan": { + "variant_review": 0, + "unit_review": 0, + "identity_review": 0, + "total": 0, + }, "identity_coverage_matches": 0, "identity_coverage_rate": 0, "pending_identity_count": 0, @@ -1209,6 +1336,27 @@ def _fetch_competitor_coverage_uncached(engine) -> dict: WHERE fc.sku IS NULL AND {_catalog_comparable_sql("la")} ) AS catalog_comparable_count, + (SELECT COUNT(*) + FROM latest_momo lm + LEFT JOIN fresh_competitor fc ON fc.sku = lm.sku + JOIN latest_attempt la ON la.sku = lm.sku + WHERE fc.sku IS NULL + AND {_catalog_review_lane_sql("la", "catalog_variant_review")} + ) AS catalog_variant_review_count, + (SELECT COUNT(*) + FROM latest_momo lm + LEFT JOIN fresh_competitor fc ON fc.sku = lm.sku + JOIN latest_attempt la ON la.sku = lm.sku + WHERE fc.sku IS NULL + AND {_catalog_review_lane_sql("la", "catalog_unit_review")} + ) AS catalog_unit_review_count, + (SELECT COUNT(*) + FROM latest_momo lm + LEFT JOIN fresh_competitor fc ON fc.sku = lm.sku + JOIN latest_attempt la ON la.sku = lm.sku + WHERE fc.sku IS NULL + AND {_catalog_review_lane_sql("la", "catalog_identity_review")} + ) AS catalog_identity_review_count, COALESCE(la.attempt_status, 'never_attempted') AS attempt_status, COUNT(*) AS status_count FROM latest_momo lm @@ -1232,6 +1380,9 @@ def _fetch_competitor_coverage_uncached(engine) -> dict: } unit_count = sum(statuses.get(status, 0) for status in UNIT_COMPARABLE_STATUSES) catalog_comparable_count = int(rows[0].get("catalog_comparable_count") or 0) if rows else 0 + catalog_variant_review_count = int(rows[0].get("catalog_variant_review_count") or 0) if rows else 0 + catalog_unit_review_count = int(rows[0].get("catalog_unit_review_count") or 0) if rows else 0 + catalog_identity_review_count = int(rows[0].get("catalog_identity_review_count") or 0) if rows else 0 decision_support_non_exact_count = unit_count + catalog_comparable_count decision_support_count = fresh + decision_support_non_exact_count rescore_accepted_count = int(statuses.get("rescore_accepted_current") or 0) @@ -1251,6 +1402,15 @@ def _fetch_competitor_coverage_uncached(engine) -> dict: "decision_support_non_exact_count": decision_support_non_exact_count, "catalog_comparable_count": catalog_comparable_count, "catalog_comparable_rate": round(catalog_comparable_count / max(active, 1) * 100, 1), + "catalog_variant_review_count": catalog_variant_review_count, + "catalog_unit_review_count": catalog_unit_review_count, + "catalog_identity_review_count": catalog_identity_review_count, + "catalog_review_plan": { + "variant_review": catalog_variant_review_count, + "unit_review": catalog_unit_review_count, + "identity_review": catalog_identity_review_count, + "total": catalog_comparable_count, + }, "identity_coverage_matches": valid, "identity_coverage_rate": round(valid / max(active, 1) * 100, 1), "pending_identity_count": pending, @@ -1534,7 +1694,7 @@ def fetch_competitor_review_queue(engine, limit: int = 12) -> list[dict]: """可行動的 PChome 比對覆核隊列,供 Dashboard / AI / PPT 共用。""" limit = max(1, min(int(limit or 12), 50)) return _cached_payload( - f"review_queue:v4:limit={limit}:floor={PCHOME_MATCH_SCORE_FLOOR}:catalog=1", + f"review_queue:v5:limit={limit}:floor={PCHOME_MATCH_SCORE_FLOOR}:catalog=1:lanes=1", lambda: _fetch_competitor_review_queue_uncached(engine, limit=limit), ) @@ -1557,7 +1717,7 @@ def fetch_competitor_review_queue_page( if status_filter not in REVIEW_STATUS_FILTER_GROUPS: status_filter = "" cache_key = ( - "review_queue_page:v4:" + "review_queue_page:v5:" f"page={page}:per={per_page}:q={search_query.lower()}:cat={category}:" f"status={status_filter}:" f"count={int(bool(count_total))}:" @@ -1588,6 +1748,7 @@ def _review_queue_cte_and_filter( status_values = REVIEW_STATUS_FILTER_GROUPS.get(status_filter) or tuple(ACTIONABLE_ATTEMPT_STATUSES) status_sql = ", ".join(f"'{status}'" for status in status_values) catalog_comparable_expr = _catalog_comparable_sql("la") + catalog_lane_expr = _catalog_review_lane_case_sql("la") filters = [ f"la.attempt_status IN ({status_sql})", f"""NOT EXISTS ( @@ -1604,6 +1765,12 @@ def _review_queue_cte_and_filter( ] if status_filter == "catalog_comparable": filters.append(catalog_comparable_expr) + elif status_filter in { + "catalog_variant_review", + "catalog_unit_review", + "catalog_identity_review", + }: + filters.append(_catalog_review_lane_sql("la", status_filter)) elif status_filter == "true_low_confidence": filters.append(f"NOT {catalog_comparable_expr}") if search_query: @@ -1651,6 +1818,7 @@ def _review_queue_cte_and_filter( la.error_message, la.attempted_at, {catalog_comparable_expr} AS catalog_comparable, + {catalog_lane_expr} AS catalog_review_lane, CASE WHEN la.attempt_status = 'rescore_accepted_current' THEN 0 WHEN la.attempt_status IN ('unit_comparable', 'refresh_unit_comparable') THEN 1 @@ -1836,7 +2004,8 @@ def _fetch_competitor_review_queue_uncached(engine, limit: int = 12) -> list[dic la.match_diagnostic_json, la.error_message, la.attempted_at, - {_catalog_comparable_sql("la")} AS catalog_comparable + {_catalog_comparable_sql("la")} AS catalog_comparable, + {_catalog_review_lane_case_sql("la")} AS catalog_review_lane FROM latest_momo lm JOIN latest_attempt la ON la.sku = lm.sku LEFT JOIN valid_competitor vc ON vc.sku = lm.sku diff --git a/services/webcrumbs_host_data_service.py b/services/webcrumbs_host_data_service.py index d39cc94..40b9e72 100644 --- a/services/webcrumbs_host_data_service.py +++ b/services/webcrumbs_host_data_service.py @@ -90,6 +90,18 @@ def _coverage_metadata(coverage: dict, row_count: int) -> dict: or 0 ), "decision_ready_rate": _num(coverage.get("decision_ready_rate")), + "decision_support_count": int(coverage.get("decision_support_count") or 0), + "decision_support_rate": _num(coverage.get("decision_support_rate")), + "decision_support_non_exact_count": int( + coverage.get("decision_support_non_exact_count") or 0 + ), + "catalog_comparable_count": int(coverage.get("catalog_comparable_count") or 0), + "catalog_comparable_rate": _num(coverage.get("catalog_comparable_rate")), + "catalog_variant_review_count": int(coverage.get("catalog_variant_review_count") or 0), + "catalog_unit_review_count": int(coverage.get("catalog_unit_review_count") or 0), + "catalog_identity_review_count": int(coverage.get("catalog_identity_review_count") or 0), + "catalog_review_plan": coverage.get("catalog_review_plan") or {}, + "unit_comparable_count": int(coverage.get("unit_comparable_count") or 0), "fresh_match_count": int(coverage.get("fresh_matches") or coverage.get("fresh_match_count") or 0), "fresh_match_rate": _num(coverage.get("fresh_match_rate")), "stale_match_count": int(coverage.get("stale_matches") or coverage.get("stale_match_count") or 0), diff --git a/templates/dashboard_v2.html b/templates/dashboard_v2.html index a0f4b63..1b7ee6e 100644 --- a/templates/dashboard_v2.html +++ b/templates/dashboard_v2.html @@ -632,6 +632,11 @@ {% endfor %} {% endif %} + {% if review.catalog_review_guidance %} +
+ 覆核建議:{{ review.catalog_review_guidance.lane_label }} · {{ review.catalog_review_guidance.action_hint }} +
+ {% endif %}
{% if review.candidate_count %} {{ review.candidate_count }} 筆候選 diff --git a/tests/test_competitor_intel_cache.py b/tests/test_competitor_intel_cache.py index ee1619e..18f6296 100644 --- a/tests/test_competitor_intel_cache.py +++ b/tests/test_competitor_intel_cache.py @@ -83,7 +83,7 @@ def test_competitor_coverage_counts_only_active_product_intersection(): "def _fetch_manual_review_summary", 1 )[0] - assert "coverage:v12" in source + assert "coverage:v13" in source assert "CATALOG_COMPARABLE_SCORE_FLOOR" in source assert "rescore_accepted_count" in coverage_source assert "(SELECT COUNT(*) FROM valid_competitor) AS valid_matches" not in coverage_source @@ -105,7 +105,13 @@ def test_competitor_coverage_counts_only_active_product_intersection(): 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_COMPARABLE_BLOCK_REASONS" in source assert "\"identity_coverage_matches\": valid" in coverage_source @@ -189,6 +195,9 @@ 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["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") @@ -225,13 +234,21 @@ def test_catalog_comparable_review_item_keeps_exact_match_guardrail(): }, }) - assert item["review_bucket"] == "catalog_comparable" - assert item["status_label"] == "型錄/任選可比" - assert "型錄、任選" in item["action_label"] + 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"] == "review_catalog_comparable" + 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"]) diff --git a/tests/test_competitor_match_attempts_persistence.py b/tests/test_competitor_match_attempts_persistence.py index 76230be..ae49f29 100644 --- a/tests/test_competitor_match_attempts_persistence.py +++ b/tests/test_competitor_match_attempts_persistence.py @@ -72,8 +72,13 @@ def test_competitor_review_queue_starts_from_latest_attempts_not_all_products(): assert "cma.hard_veto" in review_cte_body assert "cma.diagnostic_codes" in review_cte_body assert "catalog_comparable" in review_cte_body + assert "catalog_review_lane" in review_cte_body assert "_catalog_comparable_sql(\"la\")" in review_cte_body + assert "_catalog_review_lane_case_sql(\"la\")" in review_cte_body assert "status_filter == \"catalog_comparable\"" in review_cte_body + assert "\"catalog_variant_review\"" in review_cte_body + assert "\"catalog_unit_review\"" in review_cte_body + assert "\"catalog_identity_review\"" in review_cte_body assert "status_filter == \"true_low_confidence\"" in review_cte_body assert "FROM latest_momo lm" not in review_cte_body assert "valid_competitor AS" not in review_cte_body diff --git a/tests/test_frontend_v2_assets.py b/tests/test_frontend_v2_assets.py index f878010..972617f 100644 --- a/tests/test_frontend_v2_assets.py +++ b/tests/test_frontend_v2_assets.py @@ -164,6 +164,12 @@ def test_dashboard_v2_is_production_default_and_uses_real_dashboard_data(): assert "catalog_comparable_count" in route_source assert "'catalog_comparable'" in route_source assert "型錄可比" in route_source + assert "'catalog_variant_review'" in route_source + assert "選項待核" in route_source + assert "'catalog_unit_review'" in route_source + assert "入數待核" in route_source + assert "'catalog_identity_review'" in route_source + assert "身份待核" in route_source assert "rescore_accepted_count" in route_source assert "filter_type == 'pchome_review'" in route_source assert "total_items = review_queue_total" in route_source @@ -211,6 +217,8 @@ def test_dashboard_v2_is_production_default_and_uses_real_dashboard_data(): assert "否決候選" in dashboard assert "標記單位價" in dashboard assert "補搜尋" in dashboard + assert "覆核建議:" in dashboard + assert "review.catalog_review_guidance.action_hint" in dashboard assert 'data-review-action="needs_research"' in dashboard assert "人工閉環" in route_source assert "AI 挑品清單" in dashboard diff --git a/tests/test_webcrumbs_host_data_runtime_auth.py b/tests/test_webcrumbs_host_data_runtime_auth.py index f328f5b..6904ef4 100644 --- a/tests/test_webcrumbs_host_data_runtime_auth.py +++ b/tests/test_webcrumbs_host_data_runtime_auth.py @@ -60,6 +60,19 @@ def test_webcrumbs_host_data_api_allows_internal_key_when_login_disabled(monkeyp "matched_count": 88, "fresh_match_count": 70, "fresh_match_rate": 79.5, + "decision_support_count": 105, + "decision_support_rate": 14.7, + "catalog_comparable_count": 12, + "catalog_variant_review_count": 7, + "catalog_unit_review_count": 3, + "catalog_identity_review_count": 2, + "catalog_review_plan": { + "variant_review": 7, + "unit_review": 3, + "identity_review": 2, + "total": 12, + }, + "unit_comparable_count": 23, "stale_match_count": 18, "pending_match_count": 612, "writes_database": False, @@ -88,6 +101,14 @@ def test_webcrumbs_host_data_api_allows_internal_key_when_login_disabled(monkeyp assert payload["metadata"]["source"] == "competitor_intel_repository" assert payload["metadata"]["fresh_match_count"] == 70 assert payload["metadata"]["fresh_match_rate"] == 79.5 + assert payload["metadata"]["decision_support_count"] == 105 + assert payload["metadata"]["decision_support_rate"] == 14.7 + assert payload["metadata"]["catalog_comparable_count"] == 12 + assert payload["metadata"]["catalog_variant_review_count"] == 7 + assert payload["metadata"]["catalog_unit_review_count"] == 3 + assert payload["metadata"]["catalog_identity_review_count"] == 2 + assert payload["metadata"]["catalog_review_plan"]["total"] == 12 + assert payload["metadata"]["unit_comparable_count"] == 23 assert payload["metadata"]["stale_match_count"] == 18 assert payload["metadata"]["pending_match_count"] == 612 assert payload["boundary"]["auth_required"] is True diff --git a/tests/test_webcrumbs_host_data_service.py b/tests/test_webcrumbs_host_data_service.py index cdb5fde..ff29f57 100644 --- a/tests/test_webcrumbs_host_data_service.py +++ b/tests/test_webcrumbs_host_data_service.py @@ -45,6 +45,21 @@ def test_webcrumbs_host_data_maps_price_alert_exact_rows(monkeypatch): "fresh_match_rate": 79.5, "decision_ready_matches": 70, "decision_ready_rate": 9.8, + "decision_support_count": 105, + "decision_support_rate": 14.7, + "decision_support_non_exact_count": 35, + "catalog_comparable_count": 12, + "catalog_comparable_rate": 1.7, + "catalog_variant_review_count": 7, + "catalog_unit_review_count": 3, + "catalog_identity_review_count": 2, + "catalog_review_plan": { + "variant_review": 7, + "unit_review": 3, + "identity_review": 2, + "total": 12, + }, + "unit_comparable_count": 23, "stale_matches": 18, "pending": 612, }, @@ -104,6 +119,18 @@ def test_webcrumbs_host_data_maps_price_alert_exact_rows(monkeypatch): assert payload["metadata"]["identity_coverage_rate"] == 12.3 assert payload["metadata"]["decision_ready_count"] == 70 assert payload["metadata"]["decision_ready_rate"] == 9.8 + assert payload["metadata"]["decision_support_count"] == 105 + assert payload["metadata"]["decision_support_rate"] == 14.7 + assert payload["metadata"]["decision_support_non_exact_count"] == 35 + assert payload["metadata"]["catalog_comparable_count"] == 12 + assert payload["metadata"]["catalog_comparable_rate"] == 1.7 + assert payload["metadata"]["catalog_variant_review_count"] == 7 + assert payload["metadata"]["catalog_unit_review_count"] == 3 + assert payload["metadata"]["catalog_identity_review_count"] == 2 + assert payload["metadata"]["catalog_review_plan"]["variant_review"] == 7 + assert payload["metadata"]["catalog_review_plan"]["unit_review"] == 3 + assert payload["metadata"]["catalog_review_plan"]["identity_review"] == 2 + assert payload["metadata"]["unit_comparable_count"] == 23 assert payload["metadata"]["fresh_match_count"] == 70 assert payload["metadata"]["fresh_match_rate"] == 79.5 assert payload["metadata"]["stale_match_count"] == 18 @@ -124,7 +151,24 @@ def test_webcrumbs_host_data_uses_empty_state_without_risks(monkeypatch): monkeypatch.setattr( svc, "fetch_competitor_coverage", - lambda engine: {"valid_matches": 3, "fresh_matches": 1, "stale_matches": 2, "pending": 9}, + lambda engine: { + "valid_matches": 3, + "fresh_matches": 1, + "decision_support_count": 4, + "catalog_comparable_count": 2, + "catalog_variant_review_count": 1, + "catalog_unit_review_count": 1, + "catalog_identity_review_count": 0, + "catalog_review_plan": { + "variant_review": 1, + "unit_review": 1, + "identity_review": 0, + "total": 2, + }, + "unit_comparable_count": 1, + "stale_matches": 2, + "pending": 9, + }, ) payload = svc.build_webcrumbs_marketplace_host_data(engine=object(), limit=5) @@ -133,6 +177,13 @@ def test_webcrumbs_host_data_uses_empty_state_without_risks(monkeypatch): assert payload["aiCandidate"]["release_status"] == "blocked" assert payload["metadata"]["matched_count"] == 3 assert payload["metadata"]["decision_ready_count"] == 1 + assert payload["metadata"]["decision_support_count"] == 4 + assert payload["metadata"]["catalog_comparable_count"] == 2 + assert payload["metadata"]["catalog_variant_review_count"] == 1 + assert payload["metadata"]["catalog_unit_review_count"] == 1 + assert payload["metadata"]["catalog_identity_review_count"] == 0 + assert payload["metadata"]["catalog_review_plan"]["total"] == 2 + assert payload["metadata"]["unit_comparable_count"] == 1 assert payload["metadata"]["fresh_match_count"] == 1 assert payload["metadata"]["stale_match_count"] == 2 assert payload["metadata"]["pending_match_count"] == 9