From 41bd6c6e77496cf5beaeb1b9f8cc841ff63a6c57 Mon Sep 17 00:00:00 2001 From: OoO Date: Mon, 1 Jun 2026 00:16:19 +0800 Subject: [PATCH] =?UTF-8?q?V10.522=20=E8=A3=9C=20PChome=20backfill=20cover?= =?UTF-8?q?age=20=E7=8B=80=E6=85=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- TODO_NEXT_STEPS.txt | 1 + config.py | 2 +- docs/AI_INTELLIGENCE_MODULE_SOT.md | 1 + docs/memory/history_logs.md | 3 ++ routes/ai_routes.py | 52 +++++++++++++++++++++++++++--- tests/test_frontend_v2_assets.py | 7 ++++ web/static/js/page-dashboard-v2.js | 22 ++++++++++++- 7 files changed, 81 insertions(+), 7 deletions(-) diff --git a/TODO_NEXT_STEPS.txt b/TODO_NEXT_STEPS.txt index de57449..535d827 100644 --- a/TODO_NEXT_STEPS.txt +++ b/TODO_NEXT_STEPS.txt @@ -4,6 +4,7 @@ ================================================================================ 【已完成】 + - V10.522 將 PChome 補抓狀態 API 接上 read-only coverage snapshot:`/api/ai/pchome-match/backfill/status` 會同步回傳身份覆蓋、新鮮率、待刷新與待補抓數,Dashboard 補抓產線即使沒有最近任務結果,也能直接判讀下一步該刷新過期價格或補抓未搜尋商品。 - V10.521 將比價新鮮度 stale 指標上屏:首頁 KPI / PChome 補抓產線 / daily / growth 都顯示價格過期數,讓操作員分清「已確認同款但價格待刷新」與「尚未找到身份配對」;過期 identity refresh 也優先刷新 `total_price / price_alert_exact` 的正式價差配對。 - V10.520 拆開過期價格刷新與搜尋救援:`run_expired_identity_refresh()` 只刷新既有 `identity_v2` PChome product_id,不再因少數 product_id 查不到或低分而同步進入慢速 `fresh_search_recovery`;缺失 / 低分候選交給 `run_retryable_candidate_revalidation()` 處理,避免正式刷新 500+ 筆時被外部搜尋拖死,讓價格新鮮度可以穩定批次回升。 - V10.519 對齊 Webcrumbs host data metadata 與新版比價覆蓋口徑:`services/webcrumbs_host_data_service.py` 會同時輸出身份覆蓋、價格新鮮、過期配對與待補抓數,讓 shared-ui plugin / 其他專案 proxy 不會把 `coverage_rate` 誤讀成價格可用率。 diff --git a/config.py b/config.py index 523dc8e..4f61e1c 100644 --- a/config.py +++ b/config.py @@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.521" +SYSTEM_VERSION = "V10.522" 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 d53b680..4de9b1e 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -89,6 +89,7 @@ SQL漏斗(~300筆) - 後台入口:`POST /api/ai/product-picks/generate`,`/ai_intelligence` 可手動產生清單。 - 配對來源仍以 PChome crawler 真實搜尋結果為準;無競品資料時不生成挑品。 - 比對覆蓋率補強入口:`POST /api/ai/pchome-match/backfill`,優先補抓仍無有效 PChome 配對的高價 ACTIVE 商品,完成後自動重算 AI 挑品清單。 +- 補抓狀態入口:`GET /api/ai/pchome-match/backfill/status` 除背景任務狀態外,必須回傳 read-only coverage snapshot:`active_with_price` / `valid_matches` / `match_rate` / `fresh_matches` / `fresh_match_rate` / `stale_matches` / `pending` / `actionable_review_count`,供 Dashboard 顯示目前該刷新過期價格或補抓未搜尋商品;此端點不寫 DB、不呼叫 LLM、不抓外站。 - 排程閉環:`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` 與 `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 會立即預熱商品看板快取,避免第一位使用者承擔重建成本。 diff --git a/docs/memory/history_logs.md b/docs/memory/history_logs.md index 3f2e66e..2b6db5e 100644 --- a/docs/memory/history_logs.md +++ b/docs/memory/history_logs.md @@ -12,6 +12,9 @@ ## 📅 詳細更新日誌 (考古存檔) +### 2026-06-01:PChome 比價新鮮度操作閉環 +- **V10.522 PChome backfill status 附帶 coverage snapshot**: `/api/ai/pchome-match/backfill/status` 在背景任務狀態外同步回傳 read-only `fetch_competitor_coverage()` 摘要,包含身份覆蓋、新鮮率、過期價格與待補抓數;Dashboard 補抓產線即使尚無最近 run result,也會顯示「身份覆蓋 / 新鮮 / 待刷新 / 待補抓」,讓操作員能分辨下一步該跑 expired identity refresh 還是 unmatched backfill。此狀態端點不寫 DB、不呼叫 LLM、不抓外站。 + ### 2026-05-31:Webcrumbs 共用 UI Runtime 與市場情報 writer approval - **V10.521 比價新鮮度 stale 指標上屏**: 首頁比價監控總覽、PChome 補抓產線、daily 競價覆蓋與 growth 比價資料品質同步顯示 `stale_matches` / 價格過期數,讓操作員能分清「已確認同款但價格待刷新」與「尚未找到身份配對」,不再只看到新鮮率下降。過期 identity refresh 也優先刷新 `total_price / price_alert_exact` 的正式價差配對,讓最能進決策與告警的舊價格先回新鮮。 - **V10.520 PChome 過期價格刷新快慢路徑拆分**: `run_expired_identity_refresh()` 改為只刷新已確認 `identity_v2` 的既有 PChome product_id;若 product_id 已查不到或回傳後低分,不再同步跑慢速 fresh search recovery,而是記錄 `refresh_no_result` / low-score 並交給 `run_retryable_candidate_revalidation()` 的近門檻救援路徑。這能避免正式回刷 500+ 筆時被少數缺失 ID 拖到長時間卡住,讓價格新鮮度批次回升更可控。 diff --git a/routes/ai_routes.py b/routes/ai_routes.py index 876bc15..d3fcd96 100644 --- a/routes/ai_routes.py +++ b/routes/ai_routes.py @@ -1741,7 +1741,6 @@ def api_pchome_match_backfill(): PchomeBackfillAlreadyRunning, fail_pchome_backfill_run, finish_pchome_backfill_run, - get_pchome_backfill_status, start_pchome_backfill_run, update_pchome_backfill_run, ) @@ -1873,19 +1872,62 @@ def api_pchome_match_backfill(): 'success': True, 'message': f'已啟動 PChome 未搜尋補抓,優先處理 {limit} 筆高價未配對商品;完成後會重算 AI 挑品清單', 'limit': limit, - 'data': get_pchome_backfill_status(), + 'data': _get_pchome_backfill_status_payload(), }), 202 +def _build_pchome_backfill_coverage_payload(): + """讀取目前 PChome 身份覆蓋與價格新鮮度,供補抓狀態卡判斷下一步。""" + engine = None + try: + from config import DATABASE_PATH + from sqlalchemy import create_engine + from services.competitor_intel_repository import fetch_competitor_coverage + + engine = create_engine(DATABASE_PATH) + coverage = fetch_competitor_coverage(engine) or {} + return { + 'available': True, + 'active_with_price': int(coverage.get('active_with_price') or 0), + 'valid_matches': int(coverage.get('valid_matches') or 0), + 'match_rate': float(coverage.get('match_rate') or 0), + 'fresh_matches': int(coverage.get('fresh_matches') or 0), + 'fresh_match_rate': float(coverage.get('fresh_match_rate') or 0), + 'stale_matches': int(coverage.get('stale_matches') or 0), + 'pending': int(coverage.get('pending') or 0), + 'actionable_review_count': int(coverage.get('actionable_review_count') or 0), + 'unit_comparable_count': int(coverage.get('unit_comparable_count') or 0), + 'rescore_accepted_count': int(coverage.get('rescore_accepted_count') or 0), + 'manual_accept_count': int(coverage.get('manual_accept_count') or 0), + 'manual_reject_count': int(coverage.get('manual_reject_count') or 0), + 'manual_unit_price_count': int(coverage.get('manual_unit_price_count') or 0), + } + except Exception as exc: + logger.warning(f"[PChomeBackfill] coverage snapshot unavailable: {exc}") + return { + 'available': False, + 'error': str(exc), + } + finally: + if engine is not None: + engine.dispose() + + +def _get_pchome_backfill_status_payload(): + from services.pchome_backfill_status import get_pchome_backfill_status + + status = get_pchome_backfill_status() + status['coverage'] = _build_pchome_backfill_coverage_payload() + return status + + @ai_bp.route('/api/ai/pchome-match/backfill/status', methods=['GET']) @login_required def api_pchome_match_backfill_status(): """取得 PChome 未搜尋補抓的背景執行狀態。""" - from services.pchome_backfill_status import get_pchome_backfill_status - return jsonify({ 'success': True, - 'data': get_pchome_backfill_status(), + 'data': _get_pchome_backfill_status_payload(), }) diff --git a/tests/test_frontend_v2_assets.py b/tests/test_frontend_v2_assets.py index 5eef853..256da57 100644 --- a/tests/test_frontend_v2_assets.py +++ b/tests/test_frontend_v2_assets.py @@ -517,6 +517,9 @@ def test_ai_product_pick_agent_uses_real_competitor_data_and_dashboard_action(): assert "generate_product_pick_list(engine" in route_source assert "@ai_bp.route('/api/ai/pchome-match/backfill', methods=['POST'])" in route_source assert "@ai_bp.route('/api/ai/pchome-match/backfill/status', methods=['GET'])" in route_source + assert "_build_pchome_backfill_coverage_payload" in route_source + assert "fetch_competitor_coverage" in route_source + assert "status['coverage'] = _build_pchome_backfill_coverage_payload()" in route_source assert "run_unmatched_priority(limit=unmatched_limit)" in route_source assert "run_retryable_candidate_revalidation" in route_source assert "generate_product_pick_list(engine, limit=50)" in route_source @@ -559,6 +562,10 @@ def test_ai_product_pick_agent_uses_real_competitor_data_and_dashboard_action(): dashboard_js = (ROOT / "web/static/js/page-dashboard-v2.js").read_text(encoding="utf-8") assert "loadPchomeBackfillStatus" in dashboard_js assert "window.backfillPchomeMatches" in dashboard_js + assert "formatBackfillCoverageSummary" in dashboard_js + assert "status.coverage" in dashboard_js + assert "待刷新 ${formatBackfillCount(coverage.stale_matches)}" in dashboard_js + assert "待補抓 ${formatBackfillCount(coverage.pending)}" in dashboard_js assert "'product_pick':['bg-success'" in template assert "kpiMatchRate" in template diff --git a/web/static/js/page-dashboard-v2.js b/web/static/js/page-dashboard-v2.js index 2d8f7b7..80d8e76 100644 --- a/web/static/js/page-dashboard-v2.js +++ b/web/static/js/page-dashboard-v2.js @@ -299,6 +299,22 @@ let priceChartInstance = null; return Number(value || 0).toLocaleString(); } + function formatBackfillRate(value) { + const numeric = Number(value || 0); + if (!Number.isFinite(numeric)) return '0%'; + return `${numeric.toFixed(1).replace(/\.0$/, '')}%`; + } + + function formatBackfillCoverageSummary(coverage) { + if (!coverage || coverage.available === false) return ''; + return ( + `身份覆蓋 ${formatBackfillRate(coverage.match_rate)}` + + ` · 新鮮 ${formatBackfillRate(coverage.fresh_match_rate)}` + + ` · 待刷新 ${formatBackfillCount(coverage.stale_matches)}` + + ` · 待補抓 ${formatBackfillCount(coverage.pending)}` + ); + } + function schedulePchomeBackfillPoll() { if (pchomeBackfillPollTimer) { clearTimeout(pchomeBackfillPollTimer); @@ -314,6 +330,7 @@ let priceChartInstance = null; const currentRun = status.current_run || {}; const result = currentRun.result || status.last_result || {}; const pickResult = currentRun.pick_result || {}; + const coverageSummary = formatBackfillCoverageSummary(status.coverage); const running = Boolean(status.running || currentRun.running); const progressPct = Math.max(0, Math.min(Number(status.progress_pct || currentRun.progress_pct || 0), 100)); const statusKey = status.status || currentRun.status || 'idle'; @@ -337,9 +354,12 @@ let priceChartInstance = null; + ` · 待覆核 ${formatBackfillCount(result.skipped_low_score)}` + ` · 無結果 ${formatBackfillCount(result.skipped_no_result)}` + pickWritten + + (coverageSummary ? ` · ${coverageSummary}` : '') ); } else { - elements.result.textContent = running ? '正在累積結果' : '尚無最近結果'; + elements.result.textContent = running + ? (coverageSummary ? `正在累積結果 · ${coverageSummary}` : '正在累積結果') + : (coverageSummary || '尚無最近結果'); } } if (elements.trigger) {