From 296269bd43b5d112ed1110c4f3b479e48efca7a5 Mon Sep 17 00:00:00 2001 From: OoO Date: Wed, 20 May 2026 10:16:44 +0800 Subject: [PATCH] =?UTF-8?q?=E4=B8=B2=E6=8E=A5=20PChome=20=E4=BA=BA?= =?UTF-8?q?=E5=B7=A5=E8=A6=86=E6=A0=B8=E6=88=90=E6=95=88=E6=8C=87=E6=A8=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODO_NEXT_STEPS.txt | 1 + docs/AI_INTELLIGENCE_MODULE_SOT.md | 4 +- services/competitor_intel_repository.py | 76 ++++++++++++++++++++++++- templates/daily_sales.html | 12 ++++ templates/growth_analysis.html | 6 ++ tests/test_competitor_intel_cache.py | 11 ++++ 6 files changed, 107 insertions(+), 3 deletions(-) diff --git a/TODO_NEXT_STEPS.txt b/TODO_NEXT_STEPS.txt index 3d22f81..94de87f 100644 --- a/TODO_NEXT_STEPS.txt +++ b/TODO_NEXT_STEPS.txt @@ -4,6 +4,7 @@ ================================================================================ 【已完成】 + - V10.307 將 PChome 人工覆核成效接進 daily/growth/PPT 共用資料出口:`fetch_competitor_coverage()` 讀取 `competitor_match_reviews` 最新決策,輸出人工採用、人工否決、人工單位價與採用率;`daily_sales` 與 `growth_analysis` 的比價資料品質區塊直接顯示這些閉環指標,讓報表與簡報不只看待審數,也能看人工處理成效。 - V10.305 將 PChome 人工覆核回饋接回 feeder:下一輪搜尋若命中已被 `reject_identity` 否決的同一候選,會記錄 `manual_rejected` 並跳過正式寫入;已被標記 `unit_price_required` 的候選只保留單位價比較,不寫入正式總價差;人工 `accept_identity` 可保守覆蓋低分門檻但會打 `manual_review/manual_accept` 標籤,讓核心比價閉環可被後續報表與簡報追蹤。 - V10.304 補 PChome 比價人工覆核決策閉環:新增 `competitor_match_reviews`、`/api/pchome-review//decision` 與商品看板覆核列「採用同款 / 否決候選 / 標記單位價」動作;只有人工採用同款才寫入 `competitor_prices` + `competitor_price_history`,否決與單位價標記只追加 manual attempt 並關閉本輪覆核,避免錯配污染核心價差。 - V10.302 補 PChome 比價覆核匯出與診斷原因:`filter=pchome_review` 每筆覆核把 matcher `reasons=` 翻成品牌不符、商品線不符、容量差異、組合差異、需單位價、價差極端等可行動標籤;新增 `/api/export/excel/pchome-review` 匯出完整覆核隊列、人工處置、候選 PChome、單位價比較與原始診斷,避免核心比價只停在籠統「待對比」。 diff --git a/docs/AI_INTELLIGENCE_MODULE_SOT.md b/docs/AI_INTELLIGENCE_MODULE_SOT.md index 10b9280..6a688ff 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -2,7 +2,7 @@ > **最後更新**: 2026-05-20 (台北時間) > **狀態**: 🟢 四 AI Agent 自動化閉環已落地;LLM 路由紅線升級為 Ollama-first 三主機級聯,Gemini 僅備援 / 鎖定場景 -> **適用版本**: V10.305 +> **適用版本**: V10.307 --- @@ -56,7 +56,7 @@ SQL漏斗(~300筆) - 比對覆蓋率補強入口:`POST /api/ai/pchome-match/backfill`,優先補抓仍無有效 PChome 配對的高價 ACTIVE 商品,完成後自動重算 AI 挑品清單。 - 排程閉環:`run_pchome_match_backfill_task` 每日 10:30 執行,補抓 PChome 待比對商品、寫入歷史價格,再重算 `strategy='product_pick'` 清單。 - PChome / MOMO 競價摘要出口 `services/competitor_intel_repository.py` 使用 30 分鐘共享快取(`COMPETITOR_INTEL_CACHE_TTL_SECONDS` 可調),避免 `/growth_analysis`、`/daily_sales`、PPT/AI 報表每次請求重跑昂貴覆蓋率與價差趨勢查詢;`run_competitor_price_feeder_task` 與 PChome backfill 完成後會主動清除快取。快取只包摘要輸出,不改 matcher 的高信心門檻與 identity_v2 準確性規則。 -- 商品看板第一屏:`/` 的 V2 看板直接以 `products`、`price_records`、`competitor_prices`、`competitor_match_attempts`、`competitor_match_reviews`、`ai_price_recommendations` 顯示比對覆蓋率、PChome 優勢、MOMO 威脅、AI 挑品、待比對優先清單與 PChome 覆核隊列;`filter=ai_picks` 可查看 50 品 AI 挑品列表,`filter=pchome_review` 可直接查看需人工處理的比價覆核 SKU,並以 DB 分頁支援 search/category/status 後的完整隊列,不得只截前 50 筆。覆核狀態篩選必須至少包含全部、需單位價、身份否決、低信心、價格過期與找不到同款,讓人工可依 matcher 診斷類型分批處理。列內顯示候選 PChome 商品、候選價、match score、單位價換算摘要、人工動作與 matcher 診斷原因標籤(品牌不符、商品線不符、容量差異、組合差異、需單位價、價差極端等),不得只顯示籠統「待比對」。`/api/export/excel/pchome-review` 必須匯出同一套覆核隊列、人工處置、候選 PChome、單位價比較與原始診斷,讓人工覆核、簡報與後續 AI 分析共用同一份證據。`/api/pchome-review//decision` 是人工閉環入口:`accept_identity` 才可把候選寫入 `competitor_prices` 與 `competitor_price_history` 並打上 `manual_review/manual_accept/identity_v2`;`reject_identity` 與 `unit_price_required` 只寫 `competitor_match_reviews` 並追加 manual attempt,不得把不同販售組合或否決候選灌入正式價差。PChome feeder 後續搜尋同一候選時必須讀取 `competitor_match_reviews`:已否決候選寫 `manual_rejected` 並跳過正式寫入;已標記單位價候選寫 `manual_unit_price_required`;已採用候選可保守補到最低門檻並保留 `manual_review/manual_accept` 標籤。商品看板深度快取同時寫入 `data/dashboard_full_cache.pkl`,供多個 Gunicorn worker 共用,避免部署後各 worker 重複重建 7,000+ 商品統計造成開頁變慢;所有資料異動與 AI 挑品重算都透過 `clear_dashboard_cache()` 同步清除記憶體與共享快取,手動重算 API 會立即預熱商品看板快取,避免第一位使用者承擔重建成本。 +- 商品看板第一屏:`/` 的 V2 看板直接以 `products`、`price_records`、`competitor_prices`、`competitor_match_attempts`、`competitor_match_reviews`、`ai_price_recommendations` 顯示比對覆蓋率、PChome 優勢、MOMO 威脅、AI 挑品、待比對優先清單與 PChome 覆核隊列;`filter=ai_picks` 可查看 50 品 AI 挑品列表,`filter=pchome_review` 可直接查看需人工處理的比價覆核 SKU,並以 DB 分頁支援 search/category/status 後的完整隊列,不得只截前 50 筆。覆核狀態篩選必須至少包含全部、需單位價、身份否決、低信心、價格過期與找不到同款,讓人工可依 matcher 診斷類型分批處理。列內顯示候選 PChome 商品、候選價、match score、單位價換算摘要、人工動作與 matcher 診斷原因標籤(品牌不符、商品線不符、容量差異、組合差異、需單位價、價差極端等),不得只顯示籠統「待比對」。`/api/export/excel/pchome-review` 必須匯出同一套覆核隊列、人工處置、候選 PChome、單位價比較與原始診斷,讓人工覆核、簡報與後續 AI 分析共用同一份證據。`/api/pchome-review//decision` 是人工閉環入口:`accept_identity` 才可把候選寫入 `competitor_prices` 與 `competitor_price_history` 並打上 `manual_review/manual_accept/identity_v2`;`reject_identity` 與 `unit_price_required` 只寫 `competitor_match_reviews` 並追加 manual attempt,不得把不同販售組合或否決候選灌入正式價差。PChome feeder 後續搜尋同一候選時必須讀取 `competitor_match_reviews`:已否決候選寫 `manual_rejected` 並跳過正式寫入;已標記單位價候選寫 `manual_unit_price_required`;已採用候選可保守補到最低門檻並保留 `manual_review/manual_accept` 標籤。`fetch_competitor_coverage()` 必須輸出人工採用、人工否決、人工單位價與採用率,daily/growth/PPT 共用 payload 必須顯示人工閉環成效,避免只呈現待審數。商品看板深度快取同時寫入 `data/dashboard_full_cache.pkl`,供多個 Gunicorn worker 共用,避免部署後各 worker 重複重建 7,000+ 商品統計造成開頁變慢;所有資料異動與 AI 挑品重算都透過 `clear_dashboard_cache()` 同步清除記憶體與共享快取,手動重算 API 會立即預熱商品看板快取,避免第一位使用者承擔重建成本。 | 角色 | 模型 | 主機 | 成本 | 每日限額 | |------|------|------|------|---------| diff --git a/services/competitor_intel_repository.py b/services/competitor_intel_repository.py index f5a944c..9930cd0 100644 --- a/services/competitor_intel_repository.py +++ b/services/competitor_intel_repository.py @@ -67,6 +67,12 @@ ATTEMPT_ACTION_LABELS = { "manual_unit_price_required": "維持單位價比較,不寫入正式總價差", "manual_needs_research": "補搜尋詞或重新抓取後再判斷", } +MANUAL_REVIEW_ACTION_LABELS = { + "accept_identity": "人工採用", + "reject_identity": "人工否決", + "unit_price_required": "人工單位價", + "needs_research": "需補搜尋", +} MATCH_DIAGNOSTIC_REASON_LABELS = { "brand_conflict": "品牌不符", "product_line_conflict": "商品線不符", @@ -115,6 +121,18 @@ def _attempt_action_label(status: Any) -> str: return ATTEMPT_ACTION_LABELS.get(str(status or ""), "人工確認比對證據") +def _empty_manual_review_summary() -> dict[str, Any]: + return { + "total": 0, + "accept_identity": 0, + "reject_identity": 0, + "unit_price_required": 0, + "needs_research": 0, + "accept_rate": 0, + "action_labels": MANUAL_REVIEW_ACTION_LABELS, + } + + def _extract_match_diagnostic_reasons(diagnostic_text: Any) -> list[dict[str, str]]: """Translate matcher diagnostics into short operator-facing reason chips.""" text_value = str(diagnostic_text or "") @@ -255,7 +273,7 @@ def _cached_payload(cache_key: str, producer, ttl_seconds: int = COMPETITOR_INTE def fetch_competitor_coverage(engine) -> dict: return _cached_payload( - f"coverage:v2:floor={PCHOME_MATCH_SCORE_FLOOR}", + f"coverage:v3:floor={PCHOME_MATCH_SCORE_FLOOR}:manual_reviews=1", lambda: _fetch_competitor_coverage_uncached(engine), ) @@ -263,6 +281,9 @@ def fetch_competitor_coverage(engine) -> dict: def _fetch_competitor_coverage_uncached(engine) -> dict: """讀取目前 PChome 比價覆蓋率與待審分類。""" inspector = inspect(engine) + manual_review_summary = _empty_manual_review_summary() + if inspector.has_table("competitor_match_reviews"): + manual_review_summary = _fetch_manual_review_summary(engine) if not inspector.has_table("competitor_prices"): return { "active_with_price": 0, @@ -272,6 +293,12 @@ def _fetch_competitor_coverage_uncached(engine) -> dict: "attempt_status": {}, "unit_comparable_count": 0, "actionable_review_count": 0, + "manual_review_summary": manual_review_summary, + "manual_review_total": manual_review_summary["total"], + "manual_accept_count": manual_review_summary["accept_identity"], + "manual_reject_count": manual_review_summary["reject_identity"], + "manual_unit_price_count": manual_review_summary["unit_price_required"], + "manual_accept_rate": manual_review_summary["accept_rate"], } has_match_attempts = inspector.has_table("competitor_match_attempts") @@ -355,10 +382,57 @@ def _fetch_competitor_coverage_uncached(engine) -> dict: "attempt_status": statuses, "unit_comparable_count": unit_count, "actionable_review_count": actionable_count, + "manual_review_summary": manual_review_summary, + "manual_review_total": manual_review_summary["total"], + "manual_accept_count": manual_review_summary["accept_identity"], + "manual_reject_count": manual_review_summary["reject_identity"], + "manual_unit_price_count": manual_review_summary["unit_price_required"], + "manual_accept_rate": manual_review_summary["accept_rate"], "match_score_floor": PCHOME_MATCH_SCORE_FLOOR, } +def _fetch_manual_review_summary(engine) -> dict[str, Any]: + sql = text(""" + WITH latest_reviews AS ( + SELECT DISTINCT ON (sku, source, candidate_product_id) + sku, + source, + candidate_product_id, + review_action, + reviewed_at + FROM competitor_match_reviews + WHERE source = 'pchome' + ORDER BY sku, source, candidate_product_id, reviewed_at DESC, id DESC + ) + SELECT + review_action, + COUNT(*) AS action_count + FROM latest_reviews + GROUP BY review_action + """) + try: + with engine.connect() as conn: + rows = conn.execute(sql).mappings().all() + except Exception: + return _empty_manual_review_summary() + + summary = _empty_manual_review_summary() + for row in rows: + action = str(row.get("review_action") or "") + if action in summary: + summary[action] = int(row.get("action_count") or 0) + summary["total"] = sum( + int(summary.get(action) or 0) + for action in MANUAL_REVIEW_ACTION_LABELS + ) + summary["accept_rate"] = round( + summary["accept_identity"] / max(summary["total"], 1) * 100, + 1, + ) + return summary + + def fetch_competitor_gap_trend(engine, days: int = 30) -> dict: days = max(7, min(int(days or 30), 120)) return _cached_payload( diff --git a/templates/daily_sales.html b/templates/daily_sales.html index 70779c4..40409e8 100644 --- a/templates/daily_sales.html +++ b/templates/daily_sales.html @@ -359,6 +359,18 @@ 需單位價覆核 {{ comp_coverage.unit_comparable_count | default(0) | number_format }} +
+ 人工採用 + {{ comp_coverage.manual_accept_count | default(0) | number_format }} +
+
+ 人工否決 + {{ comp_coverage.manual_reject_count | default(0) | number_format }} +
+
+ 人工單位價 + {{ comp_coverage.manual_unit_price_count | default(0) | number_format }} +
{% if competitor_intel.review_queue %}
    diff --git a/templates/growth_analysis.html b/templates/growth_analysis.html index b1b3ae9..f0eda00 100644 --- a/templates/growth_analysis.html +++ b/templates/growth_analysis.html @@ -149,6 +149,12 @@ {{ coverage.pending | default(0) | number_format }} 需單位價覆核 {{ coverage.unit_comparable_count | default(0) | number_format }} + 人工採用 + {{ coverage.manual_accept_count | default(0) | number_format }} + 人工否決 + {{ coverage.manual_reject_count | default(0) | number_format }} + 人工單位價 + {{ coverage.manual_unit_price_count | default(0) | number_format }} diff --git a/tests/test_competitor_intel_cache.py b/tests/test_competitor_intel_cache.py index 9539fe6..d32553a 100644 --- a/tests/test_competitor_intel_cache.py +++ b/tests/test_competitor_intel_cache.py @@ -59,12 +59,23 @@ def test_competitor_review_queue_is_canonical_unit_price_handoff(): assert "def fetch_competitor_review_queue" in source assert "\"review_queue\": fetch_competitor_review_queue" in source assert "\"unit_comparable_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 "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.unit_comparable_count" 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 def test_competitor_ppt_prompt_uses_neutral_ewooc_viewpoint():