diff --git a/docs/AI_INTELLIGENCE_MODULE_SOT.md b/docs/AI_INTELLIGENCE_MODULE_SOT.md index 58abbc2..49835ee 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -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,列內顯示候選 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 後的完整隊列,不得只截前 50 筆。列內顯示候選 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 6635181..d502b4b 100644 --- a/routes/dashboard_routes.py +++ b/routes/dashboard_routes.py @@ -509,6 +509,32 @@ def _merge_competitor_review_context(overview, review_context): return overview +def _normalize_dashboard_category_filter(category_filter): + if not category_filter or category_filter == 'all': + return '' + if "(" in category_filter and "筆)" in category_filter: + return category_filter.rsplit(" (", 1)[0] + return category_filter + + +def _load_competitor_review_page(session, page=1, per_page=50, search_query='', category_filter='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} + return fetch_competitor_review_queue_page( + engine, + page=page, + per_page=per_page, + search_query=search_query, + category=_normalize_dashboard_category_filter(category_filter), + ) + except Exception as exc: + sys_log.warning(f"[Dashboard] PChome 覆核隊列分頁讀取略過: {exc}") + return {'items': [], 'total': 0, 'page': page, 'per_page': per_page} + + def _parse_agent_footprint(value): if not value: return {} @@ -1712,12 +1738,20 @@ def index(): review_queue = [] review_queue_map = {} review_queue_order = {} + review_queue_total = 0 if filter_type == 'ai_picks': ai_pick_skus, ai_pick_map = _load_ai_pick_selection(session, PRODUCT_PICK_LIST_LIMIT) ai_pick_summary = _summarize_ai_pick_selection(ai_pick_map) elif filter_type == 'pchome_review': - review_context = _load_competitor_review_context(session, limit=50) - review_queue = review_context.get('review_queue') or [] + review_page = _load_competitor_review_page( + session, + page=page, + per_page=per_page, + search_query=search_query, + category_filter=category_filter, + ) + review_queue = review_page.get('items') or [] + review_queue_total = int(review_page.get('total') or len(review_queue)) review_queue_map = { str(row.get('sku') or ''): row for row in review_queue @@ -1776,9 +1810,7 @@ def index(): }) else: if category_filter != 'all': - real_category = category_filter - if "(" in category_filter and "筆)" in category_filter: - real_category = category_filter.rsplit(" (", 1)[0] + real_category = _normalize_dashboard_category_filter(category_filter) filtered_items = [item for item in base_items if item['record'].product.category == real_category] else: filtered_items = base_items @@ -1815,11 +1847,16 @@ def index(): sorted_items = sorted(filtered_items, key=get_sort_key, reverse=reverse) # 分頁 - total_items = len(sorted_items) - total_pages = math.ceil(total_items / per_page) + if filter_type == 'pchome_review': + total_items = review_queue_total + total_pages = math.ceil(total_items / per_page) + paged_items = sorted_items + else: + total_items = len(sorted_items) + total_pages = math.ceil(total_items / per_page) - start_idx = (page - 1) * per_page - paged_items = sorted_items[start_idx: start_idx + per_page] + start_idx = (page - 1) * per_page + paged_items = sorted_items[start_idx: start_idx + per_page] # 為前端準備安全的 created_at 屬性 for item in paged_items: diff --git a/services/competitor_intel_repository.py b/services/competitor_intel_repository.py index 3a5acfa..3409826 100644 --- a/services/competitor_intel_repository.py +++ b/services/competitor_intel_repository.py @@ -102,6 +102,28 @@ def _build_unit_comparison_for_attempt(row: dict[str, Any]) -> Optional[dict[str return {"comparable": False, "reason": "build_error"} +def _format_competitor_review_item(row: dict[str, Any]) -> dict[str, Any]: + item = dict(row) + unit_comparison = _build_unit_comparison_for_attempt(item) + return { + "sku": str(item.get("sku") or ""), + "name": item.get("name") or "", + "category": item.get("category") or "", + "momo_price": _num(item.get("momo_price")), + "attempt_status": item.get("attempt_status") or "", + "status_label": _attempt_status_label(item.get("attempt_status")), + "action_label": _attempt_action_label(item.get("attempt_status")), + "candidate_count": int(item.get("candidate_count") or 0), + "candidate_pc_id": item.get("best_competitor_product_id"), + "candidate_pc_name": item.get("best_competitor_product_name") or "", + "candidate_pc_price": _num(item.get("best_competitor_price")), + "best_match_score": _num(item.get("best_match_score")), + "match_diagnostic": item.get("error_message") or "", + "attempted_at": _date_label(item.get("attempted_at")), + "unit_comparison": unit_comparison, + } + + def clear_competitor_intel_cache() -> None: """Clear cached PChome/MOMO intelligence after crawler/import updates.""" with _CACHE_LOCK: @@ -478,6 +500,177 @@ def fetch_competitor_review_queue(engine, limit: int = 12) -> list[dict]: ) +def fetch_competitor_review_queue_page( + engine, + page: int = 1, + per_page: int = 50, + search_query: str = "", + category: 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() + cache_key = ( + "review_queue_page:v1:" + f"page={page}:per={per_page}:q={search_query.lower()}:cat={category}:" + f"floor={PCHOME_MATCH_SCORE_FLOOR}" + ) + return _cached_payload( + cache_key, + lambda: _fetch_competitor_review_queue_page_uncached( + engine, + page=page, + per_page=per_page, + search_query=search_query, + category=category, + ), + 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]]: + params: dict[str, Any] = {} + 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' + )""", + ] + if search_query: + params["search_like"] = f"%{search_query.lower()}%" + filters.append("(LOWER(lm.name) LIKE :search_like OR LOWER(lm.sku) LIKE :search_like)") + if category: + params["category"] = category + filters.append("lm.category = :category") + + where_sql = "\n AND ".join(filters) + cte = f""" + WITH latest_momo AS ( + SELECT + p.id AS product_id, + p.i_code AS sku, + p.name, + p.category, + pr.price AS momo_price, + ROW_NUMBER() OVER (PARTITION BY p.id ORDER BY pr.timestamp DESC, pr.id DESC) AS rn + FROM products p + JOIN price_records pr ON pr.product_id = p.id + WHERE p.status = 'ACTIVE' + ), + valid_competitor AS ( + SELECT DISTINCT ON (cp.sku) + cp.sku + FROM competitor_prices cp + WHERE cp.source = 'pchome' + AND (cp.expires_at IS NULL OR cp.expires_at > CURRENT_TIMESTAMP) + AND cp.price IS NOT NULL + AND cp.price > 0 + AND COALESCE(cp.match_score, 0) >= {PCHOME_MATCH_SCORE_FLOOR} + AND COALESCE(cp.tags, '[]'::jsonb) ? 'identity_v2' + ORDER BY cp.sku, cp.crawled_at DESC NULLS LAST + ), + latest_attempt AS ( + SELECT DISTINCT ON (cma.sku) + cma.sku, + cma.attempt_status, + cma.candidate_count, + cma.best_competitor_product_id, + cma.best_competitor_product_name, + cma.best_competitor_price, + cma.best_match_score, + cma.error_message, + cma.attempted_at + FROM competitor_match_attempts cma + WHERE cma.source = 'pchome' + ORDER BY cma.sku, cma.attempted_at DESC NULLS LAST + ), + review_rows AS ( + SELECT + lm.sku, + lm.name, + lm.category, + lm.momo_price, + la.attempt_status, + la.candidate_count, + la.best_competitor_product_id, + la.best_competitor_product_name, + la.best_competitor_price, + la.best_match_score, + la.error_message, + la.attempted_at, + CASE + WHEN la.attempt_status IN ('unit_comparable', 'refresh_unit_comparable') THEN 0 + WHEN la.attempt_status = 'identity_veto' THEN 1 + WHEN la.attempt_status = 'low_score' THEN 2 + WHEN la.attempt_status = 'expired_match' THEN 3 + ELSE 4 + END AS priority_rank + FROM latest_momo lm + JOIN latest_attempt la ON la.sku = lm.sku + LEFT JOIN valid_competitor vc ON vc.sku = lm.sku + WHERE {where_sql} + ) + """ + return cte, params + + +def _fetch_competitor_review_queue_page_uncached( + engine, + page: int = 1, + per_page: int = 50, + search_query: str = "", + category: str = "", +) -> dict: + inspector = inspect(engine) + if not ( + inspector.has_table("products") + and inspector.has_table("price_records") + 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} + + 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) + page_params = { + **params, + "limit": per_page, + "offset": (page - 1) * per_page, + } + count_sql = text(cte + " SELECT COUNT(*) AS total FROM review_rows") + page_sql = text(cte + """ + SELECT * + FROM review_rows + ORDER BY + priority_rank ASC, + momo_price DESC NULLS LAST, + best_match_score DESC NULLS LAST, + attempted_at DESC NULLS LAST + LIMIT :limit OFFSET :offset + """) + + with engine.connect() as conn: + total = int(conn.execute(count_sql, params).scalar() or 0) + rows = conn.execute(page_sql, page_params).mappings().all() + + return { + "items": [_format_competitor_review_item(dict(row)) for row in rows], + "total": total, + "page": page, + "per_page": per_page, + } + + def _fetch_competitor_review_queue_uncached(engine, limit: int = 12) -> list[dict]: inspector = inspect(engine) if not ( @@ -572,28 +765,7 @@ def _fetch_competitor_review_queue_uncached(engine, limit: int = 12) -> list[dic with engine.connect() as conn: rows = conn.execute(sql, {"limit": limit}).mappings().all() - queue = [] - for row in rows: - item = dict(row) - unit_comparison = _build_unit_comparison_for_attempt(item) - queue.append({ - "sku": str(item.get("sku") or ""), - "name": item.get("name") or "", - "category": item.get("category") or "", - "momo_price": _num(item.get("momo_price")), - "attempt_status": item.get("attempt_status") or "", - "status_label": _attempt_status_label(item.get("attempt_status")), - "action_label": _attempt_action_label(item.get("attempt_status")), - "candidate_count": int(item.get("candidate_count") or 0), - "candidate_pc_id": item.get("best_competitor_product_id"), - "candidate_pc_name": item.get("best_competitor_product_name") or "", - "candidate_pc_price": _num(item.get("best_competitor_price")), - "best_match_score": _num(item.get("best_match_score")), - "match_diagnostic": item.get("error_message") or "", - "attempted_at": _date_label(item.get("attempted_at")), - "unit_comparison": unit_comparison, - }) - return queue + return [_format_competitor_review_item(dict(row)) for row in rows] def fetch_competitor_comparison_results( diff --git a/tests/test_frontend_v2_assets.py b/tests/test_frontend_v2_assets.py index e7cf66e..04282ab 100644 --- a/tests/test_frontend_v2_assets.py +++ b/tests/test_frontend_v2_assets.py @@ -131,6 +131,8 @@ def test_dashboard_v2_is_production_default_and_uses_real_dashboard_data(): assert "force_rebuild=False" in route_source assert "def _load_competitor_decision_overview(session, latest_items=None)" in route_source assert "fetch_competitor_review_queue" in route_source + assert "fetch_competitor_review_queue_page" in route_source + assert "_load_competitor_review_page(" in route_source assert "_load_competitor_decision_overview(session, unique_items)" in route_source assert "item_map = {}" in route_source assert "competitor_map = _load_pchome_competitor_map(session, item_map.keys())" in route_source @@ -139,6 +141,7 @@ def test_dashboard_v2_is_production_default_and_uses_real_dashboard_data(): assert "review_queue_count" in route_source assert "unit_comparable_count" in route_source assert "filter_type == 'pchome_review'" in route_source + assert "total_items = review_queue_total" in route_source assert "MockRecord" not in route_source assert "{% for item in items %}" in dashboard assert "比價監控總覽" in dashboard