From 8340df8cb7c56167f2081cf3757dd3157df16051 Mon Sep 17 00:00:00 2001 From: OoO Date: Sun, 24 May 2026 21:28:27 +0800 Subject: [PATCH] Split PChome review low-score buckets --- TODO_NEXT_STEPS.txt | 1 + config.py | 2 +- docs/AI_INTELLIGENCE_MODULE_SOT.md | 3 +- .../current_execution_queue_20260524.md | 2 +- docs/memory/history_logs.md | 1 + routes/dashboard_routes.py | 62 ++++++++++++++++++- services/competitor_intel_repository.py | 11 ++-- tests/test_competitor_intel_cache.py | 14 +++++ tests/test_frontend_v2_assets.py | 9 +++ 9 files changed, 96 insertions(+), 9 deletions(-) diff --git a/TODO_NEXT_STEPS.txt b/TODO_NEXT_STEPS.txt index 5b6260c..2ee748c 100644 --- a/TODO_NEXT_STEPS.txt +++ b/TODO_NEXT_STEPS.txt @@ -4,6 +4,7 @@ ================================================================================ 【已完成】 + - V10.451 拆分 PChome `low_score` 操作分流並補 read-only queue API:比價覆核頁把近門檻可救、證據不足、低信心舊候選拆成獨立篩選;repository 同步提供 `recoverable_low_score`、`true_low_confidence`、`legacy_low_score` 三個 status filter,`/api/pchome-review/queue` 可直接用同一套 review_status 做 smoke / operator tools 查詢,讓回刷、人工覆核與報表不再把所有低信心候選混在一起。 - V10.450 補 PChome 覆核 fast-count UI 語意與重算可採用指標:預設全量覆核頁跳過 exact count 時,模板會顯示「約」作為快取總數提示;搜尋、分類、單一狀態仍是精準總數。`fetch_competitor_coverage()` 同步輸出 `rescore_accepted_count`,讓 Dashboard、daily/growth 與 OpenClaw 摘要能把「重算可採用待審」從一般覆核隊列拆出來。 - V10.449 修正 PChome 覆核 exact count 條件:只有預設「全部覆核、無搜尋、無分類」頁跳過 exact count;只要有搜尋詞、分類篩選或單一 review status,就保留精準總數,避免分頁資訊失準。 - V10.448 讓 PChome 覆核「全部」頁跳過 exact count:`review_status=all` 使用 shared overview cache 的待處理總數作為分頁總數提示,只查當頁 50 筆;單一狀態分流仍保留 exact count,降低全量覆核頁互動成本。 diff --git a/config.py b/config.py index 7244064..86416a9 100644 --- a/config.py +++ b/config.py @@ -325,7 +325,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.450" +SYSTEM_VERSION = "V10.451" 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 d0b7b30..faad4bb 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -2,7 +2,7 @@ > **最後更新**: 2026-05-24 (台北時間) > **狀態**: 🟢 四 AI Agent 自動化閉環已落地;LLM 路由紅線升級為 Ollama-first 三主機級聯,Gemini 備援預設關閉 -> **適用版本**: V10.450 +> **適用版本**: V10.451 --- @@ -82,6 +82,7 @@ SQL漏斗(~300筆) - 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` 與 `needs_research` 只寫 `competitor_match_reviews` 並追加 manual attempt,不得把不同販售組合或否決候選灌入正式價差。PChome feeder 後續搜尋同一候選時必須讀取 `competitor_match_reviews`:已否決候選寫 `manual_rejected` 並跳過正式寫入,且必須繼續評估下一個候選,不能讓已否決候選長期阻塞同 SKU;已標記單位價候選寫 `manual_unit_price_required`;已要求補搜尋候選寫 `manual_needs_research` 並停留在覆核隊列;已採用候選可保守補到最低門檻並保留 `manual_review/manual_accept` 標籤。搜尋候選池只有強同款分數達 `0.90` 才可提前停止,避免 0.76 灰區候選卡掉後續更精準搜尋詞。人工 `reject_identity`、`unit_price_required`、`needs_research` 若命中當前正式候選,必須將同候選 `competitor_prices` 過期,不得繼續顯示正式總價差。商品列表必須將 `manual_rejected`、`manual_unit_price_required`、`manual_needs_research` 顯示為明確人工閉環狀態,不可回落成籠統「待比對」。`fetch_competitor_coverage()` 必須輸出人工採用、人工否決、人工單位價與採用率,daily/growth/PPT 共用 payload 必須顯示人工閉環成效,避免只呈現待審數。商品看板深度快取同時寫入 `data/dashboard_full_cache.pkl`,供多個 Gunicorn worker 共用,避免部署後各 worker 重複重建 7,000+ 商品統計造成開頁變慢;所有資料異動與 AI 挑品重算都透過 `clear_dashboard_cache()` 同步清除記憶體與共享快取,手動重算 API 會立即預熱商品看板快取,避免第一位使用者承擔重建成本。 - PChome re-score 回收線:`rescore_accepted_current` 只能表示最新版 matcher 判定「可人工採用」,不可直接寫入正式 `competitor_prices`;`fetch_competitor_coverage()` 必須輸出 `rescore_accepted_count`,Dashboard、daily/growth 與 OpenClaw 競品摘要都要把「重算可採用待審」獨立呈現,避免和一般低信心/單位價覆核混在一起。 +- PChome 低信心操作分流:Dashboard 與 read-only `/api/pchome-review/queue` 必須把近門檻可救、證據不足、低信心舊候選拆成 `recoverable_low_score`、`true_low_confidence`、`legacy_low_score` 三個可篩選桶;廣義 `low_score` 僅作 repository/export 相容查詢,不可在 UI 中冒充單一操作分流。 | 角色 | 模型 | 主機 | 成本 | 每日限額 | |------|------|------|------|---------| diff --git a/docs/memory/current_execution_queue_20260524.md b/docs/memory/current_execution_queue_20260524.md index fbbcff0..86de85a 100644 --- a/docs/memory/current_execution_queue_20260524.md +++ b/docs/memory/current_execution_queue_20260524.md @@ -8,7 +8,7 @@ - 確認 `main`、Gitea、正式環境版本一致。 - 修復或確認 SSH / Gitea / 188 hop 可用。 - 每次上線只 recreate `momo-app`、`scheduler`、`telegram-bot`,禁止使用 `--remove-orphans`,禁止影響 `momo-db`。 -- 2026-05-24 21:20 CST 狀態:正式 `/health` 仍為 `V10.447`;本地 `main` 已提交到 `V10.450` 且比 `origin/main` ahead 3。`192.168.0.110` ping/SSH timeout、`192.168.0.188` direct SSH no route、Gitea public SSH 22/2222/3022 timeout;HTTPS fetch 可用但 push 會觸發 macOS keychain,因此暫停推版,待 110/Gitea SSH 通道恢復後再推 Gitea 並部署三個 app 容器。 +- 2026-05-24 21:28 CST 狀態:正式 `/health` 仍為 `V10.447`;本地 `main` 已提交到 `V10.451` 且預期比 `origin/main` ahead 5。`192.168.0.110` ping/SSH timeout、`192.168.0.188` direct SSH no route、Gitea public SSH 22/2222/3022 timeout;HTTPS fetch 可用但 push 會觸發 macOS keychain,因此暫停推版,待 110/Gitea SSH 通道恢復後再推 Gitea 並部署三個 app 容器。 ## 1. MOMO / PChome 核心比價準確率 diff --git a/docs/memory/history_logs.md b/docs/memory/history_logs.md index 1314a8c..d037972 100644 --- a/docs/memory/history_logs.md +++ b/docs/memory/history_logs.md @@ -13,6 +13,7 @@ ## 📅 詳細更新日誌 (考古存檔) ### 2026-05-24:PChome 近門檻身份回收第二輪 +- **V10.451 low_score 操作分流拆分與 queue API**: Dashboard 比價覆核頁不再只給一個籠統低信心分頁;新增「近門檻可救」「證據不足」「低信心舊候選」三個篩選,`competitor_intel_repository.REVIEW_STATUS_FILTER_GROUPS` 同步提供對應分流,`/api/pchome-review/queue` 也能用同一套 `review_status` 做 read-only smoke / operator tools 查詢,讓 matcher 回刷、人工覆核、OpenClaw 報表能分清楚可自動回收、應保守等待、與需補搜尋的候選。 - **V10.450 PChome 覆核 fast-count UI 語意與重算可採用指標**: 預設全量覆核頁跳過 exact count 時,模板會以「約」標記快取總數,避免操作員把快取總數誤認為即時計算;搜尋、分類與單一狀態分流仍保留精準總數。`fetch_competitor_coverage()` 同步輸出 `rescore_accepted_count`,Dashboard、daily/growth 與 OpenClaw 摘要會把「重算可採用待審」獨立顯示,不再只混在一般覆核隊列。 - **V10.449 PChome 覆核 exact count 條件修正**: 只有預設「全部覆核、無搜尋、無分類」頁跳過 exact count;若使用搜尋詞、分類篩選或單一 review status,仍保留精準總數,避免操作員分頁資訊失準。 - **V10.448 PChome 覆核全量頁跳過 exact count**: `review_status=all` 改用 shared overview cache 的待處理總數作為分頁總數提示,當頁只查 50 筆;單一狀態分流仍保留 exact count,避免每次操作全量覆核頁都為總筆數重掃整個 review queue。 diff --git a/routes/dashboard_routes.py b/routes/dashboard_routes.py index 1728996..ce11960 100644 --- a/routes/dashboard_routes.py +++ b/routes/dashboard_routes.py @@ -72,7 +72,9 @@ REVIEW_STATUS_OPTIONS = [ 'statuses': ('unit_comparable', 'refresh_unit_comparable', 'manual_unit_price_required'), }, {'key': 'identity_veto', 'label': '已排除', 'statuses': ('identity_veto',)}, - {'key': 'low_score', 'label': '低信心', 'statuses': ('low_score', 'refresh_low_score', 'recoverable_low_score', 'true_low_confidence')}, + {'key': 'recoverable_low_score', 'label': '近門檻可救', 'statuses': ('recoverable_low_score',)}, + {'key': 'true_low_confidence', 'label': '證據不足', 'statuses': ('true_low_confidence',)}, + {'key': 'legacy_low_score', 'label': '低信心舊候選', 'statuses': ('low_score', 'refresh_low_score')}, {'key': 'protected_existing_match', 'label': '既有保護', 'statuses': ('protected_existing_match',)}, {'key': 'expired_match', 'label': '價格過期', 'statuses': ('expired_match',)}, {'key': 'no_result', 'label': '找不到同款', 'statuses': ('no_result', 'refresh_no_result')}, @@ -304,10 +306,13 @@ def _build_pchome_match_status(attempt=None, ineligible=None): ) if status == 'recoverable_low_score': label = '近門檻可回收' - summary = '同品線證據已足夠,但分數仍略低於正式採用門檻' + summary = '同品線證據已足夠,但分數仍略低於正式採用門檻;優先排入回刷或人工採用' elif status == 'true_low_confidence': label = '證據不足' summary = '目前候選仍缺乏足夠身份證據,先保守不採用' + elif status in {'low_score', 'refresh_low_score'} and label != '未找到可信同款': + label = '低信心舊候選' + summary = '這批是舊分數或刷新後仍低分的候選,應先補搜尋詞或重算,不直接寫入正式價差' return { 'label': label, 'tone': 'neutral', @@ -697,6 +702,8 @@ def _normalize_dashboard_category_filter(category_filter): def _normalize_review_status_filter(review_status): review_status = (review_status or '').strip() + if review_status == 'low_score': + review_status = 'legacy_low_score' valid_keys = {option['key'] for option in REVIEW_STATUS_OPTIONS} return review_status if review_status in valid_keys else 'all' @@ -2225,6 +2232,57 @@ def record_pchome_review_decision(sku): session.close() +@dashboard_bp.route('/api/pchome-review/queue') +@login_required +def get_pchome_review_queue_api(): + """Read-only PChome review queue API for smoke tests and operator tools.""" + page = request.args.get('page', 1, type=int) or 1 + per_page = request.args.get('per_page', type=int) or request.args.get('limit', type=int) or 50 + category_filter = request.args.get('category', 'all') + review_status = _normalize_review_status_filter( + request.args.get('review_status') or request.args.get('status') or 'all' + ) + search_query = request.args.get('q', request.args.get('search', '')).strip() + normalized_category = _normalize_dashboard_category_filter(category_filter) + count_total = ( + review_status != 'all' + or bool(search_query) + or bool(normalized_category) + ) + if str(request.args.get('count_total') or '').lower() in {'1', 'true', 'yes', 'on'}: + count_total = True + + db = DatabaseManager() + session = db.get_session() + try: + payload = _load_competitor_review_page( + session, + page=page, + per_page=per_page, + search_query=search_query, + category_filter=category_filter, + review_status=review_status, + count_total=count_total, + ) + total = int(payload.get('total') if payload.get('total') is not None else 0) + return jsonify({ + 'success': True, + 'items': payload.get('items') or [], + 'total': total, + 'total_is_estimated': total < 0, + 'page': int(payload.get('page') or page), + 'per_page': int(payload.get('per_page') or per_page), + 'review_status': review_status, + 'category': normalized_category or 'all', + 'q': search_query, + }) + except Exception as exc: + sys_log.error(f"[Dashboard] PChome 覆核隊列 API 讀取失敗 | error={exc}") + return jsonify({'success': False, 'message': f'覆核隊列讀取失敗:{exc}'}), 500 + finally: + session.close() + + @dashboard_bp.route('/') @login_required def index(): diff --git a/services/competitor_intel_repository.py b/services/competitor_intel_repository.py index 7e940f7..8601323 100644 --- a/services/competitor_intel_repository.py +++ b/services/competitor_intel_repository.py @@ -46,6 +46,9 @@ REVIEW_STATUS_FILTER_GROUPS = { "unit_comparable": ("unit_comparable", "refresh_unit_comparable", "manual_unit_price_required"), "identity_veto": ("identity_veto",), "low_score": ("low_score", "refresh_low_score", "recoverable_low_score", "true_low_confidence"), + "recoverable_low_score": ("recoverable_low_score",), + "true_low_confidence": ("true_low_confidence",), + "legacy_low_score": ("low_score", "refresh_low_score"), "protected_existing_match": ("protected_existing_match",), "expired_match": ("expired_match",), "no_result": ("no_result", "refresh_no_result"), @@ -56,8 +59,8 @@ ATTEMPT_STATUS_LABELS = { "unit_comparable": "需單位價比較", "refresh_unit_comparable": "需單位價比較", "identity_veto": "身份否決", - "low_score": "低信心待審", - "refresh_low_score": "刷新後仍低信心", + "low_score": "低信心舊候選", + "refresh_low_score": "刷新後仍低信心舊候選", "recoverable_low_score": "近門檻可救回", "true_low_confidence": "證據不足待觀察", "protected_existing_match": "既有較強配對保護中", @@ -75,8 +78,8 @@ ATTEMPT_ACTION_LABELS = { "unit_comparable": "人工確認檔期、贈品與單位價", "refresh_unit_comparable": "人工確認檔期、贈品與單位價", "identity_veto": "確認是否為不同商品線或規格", - "low_score": "人工審核候選商品身份", - "refresh_low_score": "檢查 refresh 後是否還有更好的同款候選", + "low_score": "先補搜尋或重算,避免舊候選直接進正式價差", + "refresh_low_score": "刷新後仍低分,需補搜尋詞或等待新證據", "recoverable_low_score": "優先回放這批近門檻同品線候選", "true_low_confidence": "保守保留,等待更明確的身份證據", "protected_existing_match": "比較新舊候選證據,避免覆蓋較強正式配對", diff --git a/tests/test_competitor_intel_cache.py b/tests/test_competitor_intel_cache.py index 88ea20d..4986cc1 100644 --- a/tests/test_competitor_intel_cache.py +++ b/tests/test_competitor_intel_cache.py @@ -128,6 +128,20 @@ def test_competitor_review_queue_is_canonical_unit_price_handoff(): assert "comp_coverage.rescore_accepted_count" 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["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_competitor_review_reasons_prefer_json_payload_labels(): from services.competitor_intel_repository import _format_competitor_review_item diff --git a/tests/test_frontend_v2_assets.py b/tests/test_frontend_v2_assets.py index 8dcb572..aae8bfe 100644 --- a/tests/test_frontend_v2_assets.py +++ b/tests/test_frontend_v2_assets.py @@ -157,6 +157,11 @@ def test_dashboard_v2_is_production_default_and_uses_real_dashboard_data(): assert "total_items = review_queue_total" in route_source assert "REVIEW_STATUS_OPTIONS" in route_source assert "current_review_status" in route_source + assert "review_status = 'legacy_low_score'" in route_source + assert "@dashboard_bp.route('/api/pchome-review/queue')" in route_source + assert "def get_pchome_review_queue_api" in route_source + assert "'total_is_estimated': total < 0" in route_source + assert "review_status != 'all'" in route_source assert "@dashboard_bp.route('/api/pchome-review//decision', methods=['POST'])" in route_source assert "record_competitor_match_review" in route_source assert "clear_competitor_intel_cache()" in route_source @@ -179,6 +184,10 @@ def test_dashboard_v2_is_production_default_and_uses_real_dashboard_data(): assert "filter='pchome_review'" in dashboard assert "review_status=option.key" in dashboard assert "需單位價" in dashboard + assert "近門檻可救" in route_source + assert "證據不足" in route_source + assert "低信心舊候選" in route_source + assert "'legacy_low_score'" in route_source assert "dashboard-review-segments" in dashboard assert "data-pchome-review-action" in dashboard assert "採用同款" in dashboard