diff --git a/TODO_NEXT_STEPS.txt b/TODO_NEXT_STEPS.txt index ca38ca6..a90d19c 100644 --- a/TODO_NEXT_STEPS.txt +++ b/TODO_NEXT_STEPS.txt @@ -4,6 +4,7 @@ ================================================================================ 【已完成】 + - V10.300 補商品看板比價覆核狀態分流:`filter=pchome_review` 新增全部、需單位價、身份否決、低信心、價格過期、找不到同款 segmented 篩選與分頁保留參數,讓 6,000+ 筆覆核隊列能依 matcher 診斷類型分批處理;同步修正覆核列表表頭/分頁連結狀態保留。 - V10.299 補市場情報 candidate queue review AI summary persistence run closeout:新增 `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_run_closeout` 與 UI 按鈕,在 receipt 通過後收尾 metadata_json persistence gate,確認 closeout artifact、操作員確認與後續 Telegram dispatch 必須另開 gate;API/UI 仍不讀 approval token、不執行 CLI、不連 DB、不寫 `metadata_json`、不派送 Telegram、不掛 scheduler。 - V10.298 補市場情報 candidate queue review AI summary persistence run receipt:新增 `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_run_receipt` 與 UI 按鈕,審核操作員貼回的 metadata_json CLI writer output、post-write smoke、dedupe key、summary payload hash、artifact path 與 token 外洩風險;API/UI 仍不讀 approval token、不執行 CLI、不連 DB、不寫 `metadata_json`、不派送 Telegram、不掛 scheduler。 - V10.297 將 PChome 單位價覆核隊列接回商品看板第一屏:KPI 顯示待處理/需單位價覆核數,焦點區列出候選 PChome 商品、候選價、match score 與人工動作;新增 `filter=pchome_review` 的比價覆核隊列,讓使用者可直接進入待處理 SKU,不再只在 daily/growth/PPT 摘要看到統計。 diff --git a/config.py b/config.py index 9c0008b..8989822 100644 --- a/config.py +++ b/config.py @@ -320,7 +320,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.299" +SYSTEM_VERSION = "V10.300" 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 49835ee..32a6368 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.297 +> **適用版本**: V10.300 --- @@ -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`、`ai_price_recommendations` 顯示比對覆蓋率、PChome 優勢、MOMO 威脅、AI 挑品、待比對優先清單與 PChome 覆核隊列;`filter=ai_picks` 可查看 50 品 AI 挑品列表,`filter=pchome_review` 可直接查看需人工處理的比價覆核 SKU,並以 DB 分頁支援 search/category 後的完整隊列,不得只截前 50 筆。列內顯示候選 PChome 商品、候選價、match score、單位價換算摘要與人工動作。商品看板深度快取同時寫入 `data/dashboard_full_cache.pkl`,供多個 Gunicorn worker 共用,避免部署後各 worker 重複重建 7,000+ 商品統計造成開頁變慢;所有資料異動與 AI 挑品重算都透過 `clear_dashboard_cache()` 同步清除記憶體與共享快取,手動重算 API 會立即預熱商品看板快取,避免第一位使用者承擔重建成本。 +- 商品看板第一屏:`/` 的 V2 看板直接以 `products`、`price_records`、`competitor_prices`、`competitor_match_attempts`、`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、單位價換算摘要與人工動作。商品看板深度快取同時寫入 `data/dashboard_full_cache.pkl`,供多個 Gunicorn worker 共用,避免部署後各 worker 重複重建 7,000+ 商品統計造成開頁變慢;所有資料異動與 AI 挑品重算都透過 `clear_dashboard_cache()` 同步清除記憶體與共享快取,手動重算 API 會立即預熱商品看板快取,避免第一位使用者承擔重建成本。 | 角色 | 模型 | 主機 | 成本 | 每日限額 | |------|------|------|------|---------| diff --git a/routes/dashboard_routes.py b/routes/dashboard_routes.py index d502b4b..b16b283 100644 --- a/routes/dashboard_routes.py +++ b/routes/dashboard_routes.py @@ -41,6 +41,30 @@ dashboard_bp = Blueprint('dashboard', __name__) PRODUCT_PICK_LIST_LIMIT = 50 PCHOME_MATCH_SCORE_FLOOR = 0.76 +REVIEW_STATUS_OPTIONS = [ + { + 'key': 'all', + 'label': '全部', + 'statuses': ( + 'unit_comparable', + 'refresh_unit_comparable', + 'identity_veto', + 'low_score', + 'expired_match', + 'refresh_no_result', + 'no_result', + ), + }, + { + 'key': 'unit_comparable', + 'label': '需單位價', + 'statuses': ('unit_comparable', 'refresh_unit_comparable'), + }, + {'key': 'identity_veto', 'label': '身份否決', 'statuses': ('identity_veto',)}, + {'key': 'low_score', 'label': '低信心', 'statuses': ('low_score',)}, + {'key': 'expired_match', 'label': '價格過期', 'statuses': ('expired_match',)}, + {'key': 'no_result', 'label': '找不到同款', 'statuses': ('no_result', 'refresh_no_result')}, +] def _build_pchome_product_url(product_id): @@ -501,9 +525,17 @@ def _load_competitor_review_context(session, limit=12): def _merge_competitor_review_context(overview, review_context): coverage = review_context.get('coverage') or {} review_queue = review_context.get('review_queue') or [] + attempt_status = coverage.get('attempt_status') or {} + review_status_counts = {} + for option in REVIEW_STATUS_OPTIONS: + review_status_counts[option['key']] = sum( + int(attempt_status.get(status) or 0) + for status in option['statuses'] + ) overview.update({ 'review_queue_count': int(coverage.get('actionable_review_count') or len(review_queue) or 0), 'unit_comparable_count': int(coverage.get('unit_comparable_count') or 0), + 'review_status_counts': review_status_counts, 'review_queue': review_queue[:3], }) return overview @@ -517,18 +549,45 @@ def _normalize_dashboard_category_filter(category_filter): return category_filter -def _load_competitor_review_page(session, page=1, per_page=50, search_query='', category_filter='all'): +def _normalize_review_status_filter(review_status): + review_status = (review_status or '').strip() + valid_keys = {option['key'] for option in REVIEW_STATUS_OPTIONS} + return review_status if review_status in valid_keys else 'all' + + +def _build_review_status_options(overview): + counts = (overview or {}).get('review_status_counts') or {} + return [ + { + 'key': option['key'], + 'label': option['label'], + 'count': int(counts.get(option['key']) or 0), + } + for option in REVIEW_STATUS_OPTIONS + ] + + +def _load_competitor_review_page( + session, + page=1, + per_page=50, + search_query='', + category_filter='all', + review_status='all', +): try: from services.competitor_intel_repository import fetch_competitor_review_queue_page engine = _get_session_engine(session) if not engine: return {'items': [], 'total': 0, 'page': page, 'per_page': per_page} + review_status = _normalize_review_status_filter(review_status) return fetch_competitor_review_queue_page( engine, page=page, per_page=per_page, search_query=search_query, category=_normalize_dashboard_category_filter(category_filter), + status_filter='' if review_status == 'all' else review_status, ) except Exception as exc: sys_log.warning(f"[Dashboard] PChome 覆核隊列分頁讀取略過: {exc}") @@ -1676,6 +1735,7 @@ def index(): sort_by = request.args.get('sort_by', 'timestamp') filter_type = request.args.get('filter', 'all') order = request.args.get('order', 'desc') + review_status = _normalize_review_status_filter(request.args.get('review_status', 'all')) search_query = request.args.get('q', '').strip() per_page = 50 @@ -1749,6 +1809,7 @@ def index(): per_page=per_page, search_query=search_query, category_filter=category_filter, + review_status=review_status, ) review_queue = review_page.get('items') or [] review_queue_total = int(review_page.get('total') or len(review_queue)) @@ -1915,6 +1976,7 @@ def index(): data['competitor_overview'] = competitor_overview _DASHBOARD_DATA_CACHE['full_data'] = data _write_shared_full_dashboard_cache(data) + review_status_options = _build_review_status_options(competitor_overview) template_name = 'dashboard_v2.html' return render_template(template_name, @@ -1936,6 +1998,8 @@ def index(): public_url=public_url, current_category=category_filter, current_filter=filter_type, + current_review_status=review_status, + review_status_options=review_status_options, search_query=search_query, current_sort=sort_by, current_order=order, diff --git a/services/competitor_intel_repository.py b/services/competitor_intel_repository.py index 3409826..d85f0be 100644 --- a/services/competitor_intel_repository.py +++ b/services/competitor_intel_repository.py @@ -32,6 +32,13 @@ ACTIONABLE_ATTEMPT_STATUSES = { "refresh_no_result", "no_result", } +REVIEW_STATUS_FILTER_GROUPS = { + "unit_comparable": ("unit_comparable", "refresh_unit_comparable"), + "identity_veto": ("identity_veto",), + "low_score": ("low_score",), + "expired_match": ("expired_match",), + "no_result": ("no_result", "refresh_no_result"), +} ATTEMPT_STATUS_LABELS = { "unit_comparable": "需單位價比較", "refresh_unit_comparable": "需單位價比較", @@ -506,15 +513,20 @@ def fetch_competitor_review_queue_page( per_page: int = 50, search_query: str = "", category: str = "", + status_filter: str = "", ) -> dict: """Paginated PChome review queue for operator-facing Dashboard pages.""" page = max(1, int(page or 1)) per_page = max(1, min(int(per_page or 50), 100)) search_query = (search_query or "").strip() category = (category or "").strip() + status_filter = (status_filter or "").strip() + if status_filter not in REVIEW_STATUS_FILTER_GROUPS: + status_filter = "" cache_key = ( "review_queue_page:v1:" f"page={page}:per={per_page}:q={search_query.lower()}:cat={category}:" + f"status={status_filter}:" f"floor={PCHOME_MATCH_SCORE_FLOOR}" ) return _cached_payload( @@ -525,25 +537,25 @@ def fetch_competitor_review_queue_page( per_page=per_page, search_query=search_query, category=category, + status_filter=status_filter, ), ttl_seconds=min(COMPETITOR_INTEL_CACHE_TTL_SECONDS, 300), ) -def _review_queue_cte_and_filter(search_query: str = "", category: str = "") -> tuple[str, dict[str, Any]]: +def _review_queue_cte_and_filter( + search_query: str = "", + category: str = "", + status_filter: str = "", +) -> tuple[str, dict[str, Any]]: params: dict[str, Any] = {} + status_filter = (status_filter or "").strip() + status_values = REVIEW_STATUS_FILTER_GROUPS.get(status_filter) or tuple(ACTIONABLE_ATTEMPT_STATUSES) + status_sql = ", ".join(f"'{status}'" for status in status_values) filters = [ "lm.rn = 1", "vc.sku IS NULL", - """la.attempt_status IN ( - 'unit_comparable', - 'refresh_unit_comparable', - 'identity_veto', - 'low_score', - 'expired_match', - 'refresh_no_result', - 'no_result' - )""", + f"la.attempt_status IN ({status_sql})", ] if search_query: params["search_like"] = f"%{search_query.lower()}%" @@ -629,6 +641,7 @@ def _fetch_competitor_review_queue_page_uncached( per_page: int = 50, search_query: str = "", category: str = "", + status_filter: str = "", ) -> dict: inspector = inspect(engine) if not ( @@ -637,11 +650,21 @@ def _fetch_competitor_review_queue_page_uncached( and inspector.has_table("competitor_prices") and inspector.has_table("competitor_match_attempts") ): - return {"items": [], "total": 0, "page": max(1, int(page or 1)), "per_page": per_page} + return { + "items": [], + "total": 0, + "page": max(1, int(page or 1)), + "per_page": per_page, + "status_filter": status_filter, + } page = max(1, int(page or 1)) per_page = max(1, min(int(per_page or 50), 100)) - cte, params = _review_queue_cte_and_filter(search_query=search_query, category=category) + cte, params = _review_queue_cte_and_filter( + search_query=search_query, + category=category, + status_filter=status_filter, + ) page_params = { **params, "limit": per_page, @@ -668,6 +691,7 @@ def _fetch_competitor_review_queue_page_uncached( "total": total, "page": page, "per_page": per_page, + "status_filter": status_filter, } diff --git a/templates/dashboard_v2.html b/templates/dashboard_v2.html index f91d8b2..8386ae0 100644 --- a/templates/dashboard_v2.html +++ b/templates/dashboard_v2.html @@ -231,6 +231,7 @@ {% endfor %} +