From b9b3a410ff283c976a564368a34909912bf40f47 Mon Sep 17 00:00:00 2001 From: OoO Date: Mon, 1 Jun 2026 00:51:05 +0800 Subject: [PATCH] =?UTF-8?q?V10.526=20=E8=A3=9C=E9=81=8E=E6=9C=9F=20identit?= =?UTF-8?q?y=20=E6=90=9C=E5=B0=8B=E6=95=91=E6=8F=B4=E5=85=A5=E5=8F=A3?= 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 | 2 + docs/memory/history_logs.md | 1 + routes/ai_routes.py | 190 ++++++++++++++++++ services/competitor_price_feeder.py | 90 +++++++++ services/pchome_backfill_status.py | 2 + templates/dashboard_v2.html | 7 + ...t_competitor_match_attempts_persistence.py | 136 +++++++++++++ tests/test_frontend_v2_assets.py | 16 ++ tests/test_pchome_backfill_status.py | 15 ++ web/static/js/page-dashboard-v2.js | 70 +++++++ 12 files changed, 531 insertions(+), 1 deletion(-) diff --git a/TODO_NEXT_STEPS.txt b/TODO_NEXT_STEPS.txt index c62610f..bed572a 100644 --- a/TODO_NEXT_STEPS.txt +++ b/TODO_NEXT_STEPS.txt @@ -4,6 +4,7 @@ ================================================================================ 【已完成】 + - V10.526 將 PChome 近門檻重評池與過期 identity 搜尋救援變成可觀測、可操作產線:`preview_retryable_candidate_revalidation()` / `preview_expired_identity_recovery()` 都是 read-only,不啟動 PChome 搜尋、不呼叫 LLM、不寫 DB;`/api/ai/pchome-match/backfill/status` 回傳 `revalidation_preview` / `stale_recovery_preview`,Dashboard 顯示「可重評 / 窄門 / 可救援」數字,並新增「救援過期 40 筆」按鈕呼叫 `/api/ai/pchome-match/recover-stale`,只在舊 PChome ID 缺失或低分時走受控 fresh-search recovery,最後仍經 hard veto、auto price write safety 與 overwrite protection。 - V10.525 補高分 review-gated exact 舊候選重評入口:`run_retryable_candidate_revalidation()` 仍以 `low_score / refresh_low_score / recoverable_low_score` 為主,只額外允許 Beauty Foot / KAMERIA / TS6 / Vaseline 這批已補 focused exact 規則、舊分數 >= 0.95、無商業狀態 / 款式 / 入數 / 組合阻擋理由的 `true_low_confidence` 進窄門重評,讓 V10.523 的安全規則可以實際回收舊資料,不把所有人工審核候選打開。 - V10.524 將「待刷新」變成可操作入口:商品看板 PChome 補抓產線新增「刷新過期 120 筆」按鈕,呼叫 `/api/ai/pchome-match/refresh-stale` 背景執行 `run_expired_identity_refresh()`,只刷新既有 `identity_v2` 的 PChome product_id,不跑 fresh search recovery、不呼叫 LLM,完成後重算 AI 挑品並清除 Dashboard / 競價快取。 - V10.523 補一批高分真同款 exact identity 比價規則:Beauty Foot 足膜、KAMERIA 積雪草足膜、TS6 蜜愛潤滑液 / 蜜桃煥白凝膠 / 極淨白+煥白組合、Vaseline 嬰兒高純修護凝膠在規格、入數、品牌與品線完全對齊時可進 `exact / total_price / price_alert_exact`,讓可用比價覆蓋增加;同時保留 TS6 香味衣物手洗精等 variant-sensitive 款式在 `manual_review`,不放寬全域門檻。 diff --git a/config.py b/config.py index 188a435..22b2cfe 100644 --- a/config.py +++ b/config.py @@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.525" +SYSTEM_VERSION = "V10.526" 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 d64fa2d..c078f1d 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -90,6 +90,7 @@ SQL漏斗(~300筆) - 配對來源仍以 PChome crawler 真實搜尋結果為準;無競品資料時不生成挑品。 - 比對覆蓋率補強入口:`POST /api/ai/pchome-match/backfill`,優先補抓仍無有效 PChome 配對的高價 ACTIVE 商品,完成後自動重算 AI 挑品清單。 - 過期價格刷新入口:`POST /api/ai/pchome-match/refresh-stale`,只針對已建立 `identity_v2` 但 `expires_at` 過期的 PChome product_id 執行 `run_expired_identity_refresh()`;不得跑 fresh search recovery,不得呼叫 LLM,完成後重算 AI 挑品並清除 Dashboard / competitor intel cache。 +- 過期 identity 搜尋救援入口:`POST /api/ai/pchome-match/recover-stale` 僅供操作員手動觸發,對已過期 `identity_v2` 先走既有 PChome product_id refresh;只有舊 ID 查無商品或重評低於門檻時,才允許受控 fresh search recovery。這條路徑可抓 PChome,但不得呼叫 LLM;正式寫入仍必須通過 matcher、hard veto、auto price write safety 與 overwrite protection。 - 補抓狀態入口:`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 準確性規則。 @@ -97,6 +98,7 @@ SQL漏斗(~300筆) - 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 中冒充單一操作分流。 - `run_retryable_candidate_revalidation()` 的自動回刷主戰場仍限 `low_score` / `refresh_low_score` / `recoverable_low_score`;`true_low_confidence` 只有在已補 focused exact 規則的窄範圍品線、舊分數 >= 0.95、`comparison_mode='exact_identity'`、含 `strong_exact_spec_match` 且不含 commercial / variant / count / bundle / refill 等阻擋理由時,才可進入重評,不得全面打開人工審核池。 +- `/api/ai/pchome-match/backfill/status` 必須把近門檻重評池與過期 identity 救援池以只讀 `revalidation_preview` / `stale_recovery_preview` 曝光給操作員;預覽只復用正式候選 SQL 並受 limit / 60 秒快取限制,不啟動 PChome 搜尋、不呼叫 LLM、不寫 `competitor_match_attempts` / `competitor_prices`,其中 `review_gated_count` 僅代表窄門 `true_low_confidence` exact 候選,不得被解讀為全量人工池可自動回刷。 - PChome re-score audit 預設必須先取每個 SKU 的最新 `competitor_match_attempts` 狀態,再套用 status / reason 篩選;舊低信心歷史候選只能透過 `--include-historical-candidates` 明確進入考古掃描,避免已入隊、已否決或已修正 SKU 被舊紀錄重新推回報表。 - production re-score `--apply-accepted` 僅可追加 `rescore_accepted_current` attempt 給人工覆核;執行後需清除 Dashboard / competitor intel cache,且必須抽查 `competitor_prices` / `competitor_price_history` 未新增正式價差。 - production re-score 若曾把 `variant_selection_review` 追加成 `rescore_accepted_current`,必須用 `audit_competitor_match_attempt_rescore.py --retract-variant-accepted` 追加最新 `true_low_confidence` 退回列;此路徑只寫 `competitor_match_attempts`,不得刪歷史紀錄,也不得寫 `competitor_prices` / `competitor_price_history`。 diff --git a/docs/memory/history_logs.md b/docs/memory/history_logs.md index be4cfb5..f03b66f 100644 --- a/docs/memory/history_logs.md +++ b/docs/memory/history_logs.md @@ -13,6 +13,7 @@ ## 📅 詳細更新日誌 (考古存檔) ### 2026-06-01:PChome 比價新鮮度操作閉環 +- **V10.526 PChome 重評預覽與過期 identity 搜尋救援**: `/api/ai/pchome-match/backfill/status` 新增 60 秒快取的 `revalidation_preview` 與 `stale_recovery_preview`,Dashboard 補抓產線顯示「可重評 / 窄門 / 可救援」數字;兩個 preview 都只讀 DB,不啟動 PChome 搜尋、不呼叫 LLM、不寫 `competitor_match_attempts` 或正式價格表。另新增 `/api/ai/pchome-match/recover-stale` 與「救援過期 40 筆」按鈕,對過期 `identity_v2` 先查既有 product_id,只有在舊 ID 缺失或低分時才走受控 fresh-search recovery,最後仍經 hard veto、auto price write safety 與 overwrite protection 才能寫入正式比價。 - **V10.525 高分 review-gated exact 舊候選窄門重評**: `run_retryable_candidate_revalidation()` 保持主戰場為 `low_score / refresh_low_score / recoverable_low_score`,只額外收 Beauty Foot、KAMERIA、TS6、Vaseline 這批已由 V10.523 補 focused exact 規則的 `true_low_confidence` 舊候選。入口要求舊分數 >= 0.95、仍為 `exact_identity`、具備 `strong_exact_spec_match`,且不得含 `commercial_condition_gap`、variant、count、bundle、refill 等阻擋理由;讓已驗證真同款可被回刷,不把整個人工審核池自動打開。 - **V10.524 PChome 過期價格刷新手動入口**: 商品看板 PChome 補抓產線新增「刷新過期 120 筆」按鈕與 `/api/ai/pchome-match/refresh-stale`,背景執行既有 `run_expired_identity_refresh()`,只刷新已建立 `identity_v2` 的 PChome product_id,不跑 fresh search recovery、不呼叫 LLM;完成後重算 AI 挑品並清除 Dashboard / competitor intel cache,讓 `stale_matches` 從觀測指標變成可直接操作的任務。 - **V10.523 高分真同款 exact identity 比價規則補強**: 針對正式環境反覆出現、分數已達 1.0 但因 `multi_component_pair` 或 variant review gate 被留在人工審核的真同款,補 Beauty Foot 足膜、KAMERIA 積雪草足膜、TS6 蜜愛潤滑液 / 蜜桃煥白凝膠 / 極淨白+煥白組合、Vaseline 嬰兒高純修護凝膠 focused exact identity。這些案例只有在品牌、品線、容量 / 重量與入數完全對齊時才進 `exact / total_price / price_alert_exact`;TS6 香味衣物手洗精等款式敏感商品仍維持 `manual_review`,全域 `MIN_MATCH_SCORE` 與 overwrite 保護不變。 diff --git a/routes/ai_routes.py b/routes/ai_routes.py index e2f181f..422911b 100644 --- a/routes/ai_routes.py +++ b/routes/ai_routes.py @@ -36,6 +36,16 @@ def load_product_categories(): return [] ai_bp = Blueprint('ai', __name__) +_PCHOME_REVALIDATION_PREVIEW_CACHE = { + "expires_at": 0.0, + "payload": None, +} +_PCHOME_REVALIDATION_PREVIEW_TTL_SECONDS = 60 +_PCHOME_STALE_RECOVERY_PREVIEW_CACHE = { + "expires_at": 0.0, + "payload": None, +} +_PCHOME_STALE_RECOVERY_PREVIEW_TTL_SECONDS = 60 # 服務實例 ollama_service = OllamaService() @@ -1992,6 +2002,115 @@ def api_pchome_match_refresh_stale(): }), 202 +@ai_bp.route('/api/ai/pchome-match/recover-stale', methods=['POST']) +@login_required +def api_pchome_match_recover_stale(): + """背景搜尋救援過期 identity_v2:舊 ID 缺失或低分時才允許 fresh search recovery。""" + import threading + + payload = request.get_json(silent=True) or {} + try: + limit = max(5, min(int(payload.get('limit', 40)), 80)) + except (TypeError, ValueError): + limit = 40 + + from services.pchome_backfill_status import ( + PchomeBackfillAlreadyRunning, + fail_pchome_backfill_run, + finish_pchome_backfill_run, + start_pchome_backfill_run, + update_pchome_backfill_run, + ) + + try: + run = start_pchome_backfill_run( + limit=limit, + operator=session.get('username') or 'web', + ) + except PchomeBackfillAlreadyRunning: + return jsonify({ + 'success': False, + 'message': 'PChome 產線已有任務執行中,請稍後查看進度', + 'data': _get_pchome_backfill_status_payload(), + }), 409 + + run_id = run['run_id'] + + def _run_recover_stale(): + engine = None + try: + from config import DATABASE_PATH + from sqlalchemy import create_engine + from services.ai_product_pick_agent import generate_product_pick_list + from services.cache_manager import clear_dashboard_cache + from services.competitor_intel_repository import clear_competitor_intel_cache + from services.competitor_price_feeder import CompetitorPriceFeeder + + update_pchome_backfill_run( + run_id, + stage='recovering_stale', + message=f'正在搜尋救援 {limit} 筆過期 identity_v2;舊 ID 失效或低分才改找新候選', + ) + engine = create_engine(DATABASE_PATH) + feeder = CompetitorPriceFeeder(engine=engine) + result = feeder.run_expired_identity_search_recovery(limit=limit) + result_payload = _feeder_result_payload(result) + update_pchome_backfill_run( + run_id, + stage='generating_picks', + message='搜尋救援完成,正在重算 AI 挑品清單', + result=result_payload, + ) + pick_result = generate_product_pick_list(engine, limit=50) + pick_payload = _pick_result_payload(pick_result) + update_pchome_backfill_run( + run_id, + stage='clearing_cache', + message='AI 挑品已重算,正在清除看板快取', + result=result_payload, + pick_result=pick_payload, + ) + clear_dashboard_cache() + clear_competitor_intel_cache() + finish_pchome_backfill_run( + run_id, + result=result_payload, + pick_result=pick_payload, + message=( + f"PChome 過期 identity 搜尋救援完成:檢查 {result_payload['total_skus']} 筆、" + f"更新 {result_payload['matched']} 筆、" + f"AI 挑品寫入 {pick_payload['written']} 筆" + ), + ) + logger.info( + "[PChomeRecoverStale] done total=%s matched=%s no=%s low=%s errors=%s history=%s duration=%ss pick_written=%s", + result_payload['total_skus'], + result_payload['matched'], + result_payload['skipped_no_result'], + result_payload['skipped_low_score'], + result_payload['errors'], + result_payload['history_written'], + result_payload['duration_sec'], + pick_result.written, + ) + except Exception as exc: + fail_pchome_backfill_run(run_id, str(exc)) + logger.error(f"[PChomeRecoverStale] 背景搜尋救援失敗: {exc}", exc_info=True) + finally: + if engine is not None: + engine.dispose() + + thread = threading.Thread(target=_run_recover_stale, daemon=True) + thread.start() + + return jsonify({ + 'success': True, + 'message': f'已啟動 PChome 過期 identity 搜尋救援,優先處理 {limit} 筆舊價格', + 'limit': limit, + 'data': _get_pchome_backfill_status_payload(), + }), 202 + + def _build_pchome_backfill_coverage_payload(): """讀取目前 PChome 身份覆蓋與價格新鮮度,供補抓狀態卡判斷下一步。""" engine = None @@ -2002,6 +2121,8 @@ def _build_pchome_backfill_coverage_payload(): engine = create_engine(DATABASE_PATH) coverage = fetch_competitor_coverage(engine) or {} + revalidation_preview = _build_pchome_revalidation_preview_payload(engine) + stale_recovery_preview = _build_pchome_stale_recovery_preview_payload(engine) return { 'available': True, 'active_with_price': int(coverage.get('active_with_price') or 0), @@ -2017,6 +2138,13 @@ def _build_pchome_backfill_coverage_payload(): '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), + 'revalidation_preview': revalidation_preview, + 'retryable_candidate_preview_count': int(revalidation_preview.get('candidate_count') or 0), + 'retryable_candidate_preview_has_more': bool(revalidation_preview.get('has_more')), + 'review_gated_exact_preview_count': int(revalidation_preview.get('review_gated_count') or 0), + 'stale_recovery_preview': stale_recovery_preview, + 'stale_recovery_preview_count': int(stale_recovery_preview.get('candidate_count') or 0), + 'stale_recovery_preview_has_more': bool(stale_recovery_preview.get('has_more')), } except Exception as exc: logger.warning(f"[PChomeBackfill] coverage snapshot unavailable: {exc}") @@ -2029,6 +2157,68 @@ def _build_pchome_backfill_coverage_payload(): engine.dispose() +def _build_pchome_revalidation_preview_payload(engine): + """回傳近門檻重評候選的只讀預覽,供操作員判讀下一步。""" + now_ts = datetime.now().timestamp() + cached_payload = _PCHOME_REVALIDATION_PREVIEW_CACHE.get("payload") + if cached_payload is not None and now_ts < float(_PCHOME_REVALIDATION_PREVIEW_CACHE.get("expires_at") or 0): + return cached_payload + + try: + from services.competitor_price_feeder import CompetitorPriceFeeder + + payload = CompetitorPriceFeeder(engine=engine).preview_retryable_candidate_revalidation( + limit=80, + min_score=0.70, + ) + except Exception as exc: + logger.warning(f"[PChomeBackfill] revalidation preview unavailable: {exc}") + payload = { + 'available': False, + 'error': str(exc), + 'candidate_count': 0, + 'has_more': False, + 'review_gated_count': 0, + 'boundary': 'read_only_no_crawl_no_llm_no_db_write', + } + + _PCHOME_REVALIDATION_PREVIEW_CACHE.update({ + "expires_at": now_ts + _PCHOME_REVALIDATION_PREVIEW_TTL_SECONDS, + "payload": payload, + }) + return payload + + +def _build_pchome_stale_recovery_preview_payload(engine): + """回傳過期 identity_v2 搜尋救援候選的只讀預覽。""" + now_ts = datetime.now().timestamp() + cached_payload = _PCHOME_STALE_RECOVERY_PREVIEW_CACHE.get("payload") + if cached_payload is not None and now_ts < float(_PCHOME_STALE_RECOVERY_PREVIEW_CACHE.get("expires_at") or 0): + return cached_payload + + try: + from services.competitor_price_feeder import CompetitorPriceFeeder + + payload = CompetitorPriceFeeder(engine=engine).preview_expired_identity_recovery( + limit=40, + ) + except Exception as exc: + logger.warning(f"[PChomeBackfill] stale recovery preview unavailable: {exc}") + payload = { + 'available': False, + 'error': str(exc), + 'candidate_count': 0, + 'has_more': False, + 'boundary': 'read_only_no_crawl_no_llm_no_db_write', + } + + _PCHOME_STALE_RECOVERY_PREVIEW_CACHE.update({ + "expires_at": now_ts + _PCHOME_STALE_RECOVERY_PREVIEW_TTL_SECONDS, + "payload": payload, + }) + return payload + + def _get_pchome_backfill_status_payload(): from services.pchome_backfill_status import get_pchome_backfill_status diff --git a/services/competitor_price_feeder.py b/services/competitor_price_feeder.py index 990e3f0..5fc2b35 100644 --- a/services/competitor_price_feeder.py +++ b/services/competitor_price_feeder.py @@ -1211,6 +1211,75 @@ class CompetitorPriceFeeder: ).fetchall() return [dict(r._mapping) for r in rows] + def preview_retryable_candidate_revalidation(self, limit: int = 80, min_score: float = 0.70) -> dict: + """ + 只讀預覽下一批可重評候選。 + + 這個方法刻意復用正式重評的候選 SQL,但只回傳受 limit 限制的摘要; + 不啟動 PChome 搜尋、不呼叫 LLM、不寫 competitor_match_attempts / competitor_prices。 + """ + preview_limit = max(1, min(int(limit), 300)) + rows = self._fetch_retryable_candidate_skus(limit=preview_limit, min_score=min_score) + status_counts: dict[str, int] = {} + examples: list[dict] = [] + review_gated_count = 0 + for row in rows: + status = str(row.get("attempt_status") or "unknown") + status_counts[status] = status_counts.get(status, 0) + 1 + if status == "true_low_confidence": + review_gated_count += 1 + if len(examples) < 5: + score = row.get("best_match_score") + examples.append({ + "sku": row.get("sku"), + "momo_name": row.get("name"), + "competitor_product_id": row.get("competitor_product_id"), + "competitor_product_name": row.get("competitor_product_name"), + "best_match_score": float(score) if score is not None else None, + "attempt_status": status, + }) + + return { + "available": True, + "preview_limit": preview_limit, + "candidate_count": len(rows), + "has_more": len(rows) >= preview_limit, + "review_gated_count": review_gated_count, + "status_counts": status_counts, + "examples": examples, + "min_score": float(min_score), + "boundary": "read_only_no_crawl_no_llm_no_db_write", + } + + def preview_expired_identity_recovery(self, limit: int = 40) -> dict: + """ + 只讀預覽過期 identity_v2 搜尋救援候選。 + + 這裡只查本地 DB 中已建立 identity_v2、但價格已過期的 SKU; + 不查 PChome、不重新比對、不寫 attempts / prices。 + """ + preview_limit = max(1, min(int(limit), 120)) + rows = self._fetch_expired_identity_skus(limit=preview_limit) + examples: list[dict] = [] + for row in rows[:5]: + examples.append({ + "sku": row.get("sku"), + "momo_name": row.get("name"), + "competitor_product_id": row.get("competitor_product_id"), + "competitor_product_name": row.get("competitor_product_name"), + "match_score": float(row.get("match_score")) if row.get("match_score") is not None else None, + "expires_at": str(row.get("expires_at")) if row.get("expires_at") is not None else None, + }) + + return { + "available": True, + "preview_limit": preview_limit, + "candidate_count": len(rows), + "has_more": len(rows) >= preview_limit, + "examples": examples, + "boundary": "read_only_no_crawl_no_llm_no_db_write", + } + def _fetch_expired_identity_skus(self, limit: int = 120) -> list: """ 取得 identity_v2 已確認、但 PChome 價格快取過期的商品。 @@ -2419,6 +2488,27 @@ class CompetitorPriceFeeder: allow_missing_recovery=False, ) + def run_expired_identity_search_recovery(self, limit: int = 40, source: str = "pchome") -> FeederResult: + """ + 搜尋救援已建立 identity_v2、但價格過期或舊 PChome ID 失效的商品。 + + 與一般 refresh-stale 不同,這條路徑允許在 known product_id 缺失或重評低分時, + 走受控 fresh_search_recovery;最後仍會經過 hard veto、auto price write + safety、overwrite protection 才能寫入正式 competitor_prices。 + """ + try: + skus = self._fetch_expired_identity_skus(limit=max(1, min(int(limit), 120))) + except Exception as e: + logger.error(f"[Feeder] 讀取過期 identity_v2 搜尋救援商品失敗: {e}") + return FeederResult(0, 0, 0, 0, 1, 0.0) + + return self._run_known_identity_refresh_items( + skus, + source=source, + label="identity_v2 過期價格搜尋救援", + allow_missing_recovery=True, + ) + def run_retryable_candidate_revalidation( self, limit: int = 80, diff --git a/services/pchome_backfill_status.py b/services/pchome_backfill_status.py index 8b2a39c..c25f271 100644 --- a/services/pchome_backfill_status.py +++ b/services/pchome_backfill_status.py @@ -27,6 +27,7 @@ ACTIVE_TTL_SECONDS = int(os.getenv("PCHOME_BACKFILL_ACTIVE_TTL_SECONDS", "7200") STAGE_ORDER = ( "queued", "refreshing_stale", + "recovering_stale", "revalidating", "matching", "generating_picks", @@ -38,6 +39,7 @@ STAGE_LABELS = { "idle": "尚未執行", "queued": "已排入背景補抓", "refreshing_stale": "刷新過期 PChome 價格", + "recovering_stale": "搜尋救援過期 PChome identity", "revalidating": "重新評分近門檻候選", "matching": "比對高優先未配對商品", "generating_picks": "重算 AI 挑品清單", diff --git a/templates/dashboard_v2.html b/templates/dashboard_v2.html index 248b11a..f636f23 100644 --- a/templates/dashboard_v2.html +++ b/templates/dashboard_v2.html @@ -65,6 +65,7 @@ data-pchome-backfill-card data-backfill-endpoint="/api/ai/pchome-match/backfill" data-refresh-stale-endpoint="/api/ai/pchome-match/refresh-stale" + data-recover-stale-endpoint="/api/ai/pchome-match/recover-stale" data-status-endpoint="/api/ai/pchome-match/backfill/status" data-pchome-backfill-action="backfillPchomeMatches">
@@ -88,6 +89,12 @@ data-limit="120"> 刷新過期 120 筆 +