diff --git a/CONSTITUTION.md b/CONSTITUTION.md index 84d7af5..ce3b85a 100644 --- a/CONSTITUTION.md +++ b/CONSTITUTION.md @@ -2,7 +2,7 @@ > 本文件定義專案開發的核心準則與不可違反的規範 > **建立日期**: 2026-01-12 -> **當前版本**: V10.56 (Health-safe monitoring runtime) +> **當前版本**: V10.57 (Dashboard AI product pick list supports 50 items) > **最後更新**: 2026-05-01 --- diff --git a/app.py b/app.py index 7b325b9..d09d780 100644 --- a/app.py +++ b/app.py @@ -95,8 +95,8 @@ except Exception as e: sys_log.error(f"無法檢測磁碟空間: {e}") # 🚩 系統版本定義 (備份與顯示用) -# 🚩 2026-05-01 V10.56: Health-safe monitoring runtime -SYSTEM_VERSION = "V10.56" +# 🚩 2026-05-01 V10.57: Dashboard AI product pick list supports 50 items +SYSTEM_VERSION = "V10.57" # ========================================== # 🔒 SQL Injection 防護函數 diff --git a/config.py b/config.py index dd9e7fc..39b9f30 100644 --- a/config.py +++ b/config.py @@ -254,7 +254,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.56" +SYSTEM_VERSION = "V10.57" 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 ac8f3c8..98aea4e 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -37,7 +37,7 @@ SQL漏斗(~300筆) - 配對來源仍以 PChome crawler 真實搜尋結果為準;無競品資料時不生成挑品。 - 比對覆蓋率補強入口:`POST /api/ai/pchome-match/backfill`,優先補抓仍無有效 PChome 配對的高價 ACTIVE 商品,完成後自動重算 AI 挑品清單。 - 排程閉環:`run_pchome_match_backfill_task` 每日 10:30 執行,補抓 PChome 待比對商品、寫入歷史價格,再重算 `strategy='product_pick'` 清單。 -- 商品看板第一屏:`/` 的 V2 看板直接以 `products`、`price_records`、`competitor_prices`、`ai_price_recommendations` 顯示比對覆蓋率、PChome 優勢、MOMO 威脅、AI 挑品與待比對優先清單。 +- 商品看板第一屏:`/` 的 V2 看板直接以 `products`、`price_records`、`competitor_prices`、`ai_price_recommendations` 顯示比對覆蓋率、PChome 優勢、MOMO 威脅、AI 挑品與待比對優先清單;`filter=ai_picks` 可查看 50 品 AI 挑品列表。 | 角色 | 模型 | 主機 | 成本 | 每日限額 | |------|------|------|------|---------| diff --git a/routes/ai_routes.py b/routes/ai_routes.py index 56002ca..a59c363 100644 --- a/routes/ai_routes.py +++ b/routes/ai_routes.py @@ -1626,8 +1626,8 @@ def api_generate_product_picks(): from services.ai_product_pick_agent import generate_product_pick_list payload = request.get_json(silent=True) or {} - limit = int(payload.get('limit', 30)) - limit = max(5, min(limit, 80)) + limit = int(payload.get('limit', 50)) + limit = max(5, min(limit, 100)) engine = create_engine(DATABASE_PATH) result = generate_product_pick_list(engine, limit=limit) @@ -1639,7 +1639,7 @@ def api_generate_product_picks(): 'candidates': result.candidates, 'written': result.written, 'generated_at': result.generated_at, - 'picks': result.picks[:20], + 'picks': result.picks[:50], } }) except Exception as e: @@ -1665,7 +1665,7 @@ def api_pchome_match_backfill(): engine = create_engine(DATABASE_PATH) result = CompetitorPriceFeeder(engine=engine).run_unmatched_priority(limit=limit) - pick_result = generate_product_pick_list(engine, limit=30) + pick_result = generate_product_pick_list(engine, limit=50) logger.info( "[PChomeBackfill] done total=%s matched=%s no=%s low=%s errors=%s history=%s duration=%ss pick_written=%s", result.total_skus, diff --git a/routes/dashboard_routes.py b/routes/dashboard_routes.py index 5324cf2..f04bd0d 100644 --- a/routes/dashboard_routes.py +++ b/routes/dashboard_routes.py @@ -31,6 +31,8 @@ sys_log = SystemLogger("DashboardRoutes").get_logger() # Blueprint 定義 dashboard_bp = Blueprint('dashboard', __name__) +PRODUCT_PICK_LIST_LIMIT = 50 + def _build_pchome_product_url(product_id): if not product_id: @@ -392,6 +394,53 @@ def _load_competitor_decision_overview(session): return default +def _load_ai_pick_selection(session, limit=PRODUCT_PICK_LIST_LIMIT): + """讀取商品看板 AI 挑品清單排序,供列表篩選使用。""" + sql = text(""" + SELECT + sku, + confidence, + reason, + momo_price, + pchome_price, + gap_pct, + created_at + FROM ai_price_recommendations + WHERE strategy = 'product_pick' + AND status = 'pending' + ORDER BY confidence DESC NULLS LAST, gap_pct DESC NULLS LAST, created_at DESC + LIMIT :limit + """) + + try: + rows = session.execute(sql, {"limit": limit}).mappings().all() + except Exception as exc: + sys_log.warning(f"[Dashboard] AI 挑品清單讀取略過: {exc}") + try: + session.rollback() + except Exception: + pass + return [], {} + + skus = [] + pick_map = {} + for idx, row in enumerate(rows, start=1): + sku = str(row.get('sku') or '') + if not sku or sku in pick_map: + continue + skus.append(sku) + pick_map[sku] = { + 'rank': idx, + 'confidence': _to_float(row.get('confidence')) or 0, + 'reason': row.get('reason') or '', + 'momo_price': _to_float(row.get('momo_price')) or 0, + 'pchome_price': _to_float(row.get('pchome_price')) or 0, + 'gap_pct': _to_float(row.get('gap_pct')) or 0, + 'created_at': _format_dashboard_dt(row.get('created_at')), + } + return skus, pick_map + + # ========================================== # 快取與監控變數 # ========================================== @@ -894,6 +943,10 @@ def index(): scheduler_stats['edm_task'] = [scheduler_stats['edm_task']] filtered_items = [] + ai_pick_skus = [] + ai_pick_map = {} + if filter_type == 'ai_picks': + ai_pick_skus, ai_pick_map = _load_ai_pick_selection(session, PRODUCT_PICK_LIST_LIMIT) # 先處理搜尋 if search_query: @@ -913,6 +966,12 @@ def index(): filtered_items = [i for i in base_items if i in decrease_items] elif filter_type == 'new': filtered_items = [i for i in base_items if i['record'].product_id in new_product_ids] + elif filter_type == 'ai_picks': + pick_set = set(ai_pick_skus) + filtered_items = [ + i for i in base_items + if str(i['record'].product.i_code) in pick_set + ] elif filter_type == 'delisted': for item in today_delisted_items: class DelistedRecord: @@ -959,6 +1018,9 @@ def index(): return safe_get(item['yesterday_diff'], 0) if sort_by == 'week_change': return safe_get(item['stats']['7d_diff'], 0) + if filter_type == 'ai_picks': + sku = str(item['record'].product.i_code) + return -ai_pick_map.get(sku, {}).get('rank', 9999) return item['record'].timestamp sorted_items = sorted(filtered_items, key=get_sort_key, reverse=reverse) @@ -973,6 +1035,8 @@ def index(): # 為前端準備安全的 created_at 屬性 for item in paged_items: item['safe_created_at'] = getattr(item['record'].product, 'created_at', None) + sku = str(item['record'].product.i_code) + item['ai_pick'] = ai_pick_map.get(sku) # 為當前頁面項目添加顏色 for item in paged_items: @@ -1029,6 +1093,7 @@ def index(): most_active_category=most_active_category, most_active_count=most_active_count, competitor_overview=competitor_overview, + ai_pick_list_limit=PRODUCT_PICK_LIST_LIMIT, active_page='dashboard') except Exception as e: sys_log.error(f"[Web] [Dashboard] 渲染錯誤 | Error: {e}") diff --git a/scheduler.py b/scheduler.py index fc0ec62..b4a250f 100644 --- a/scheduler.py +++ b/scheduler.py @@ -2078,7 +2078,7 @@ def run_pchome_match_backfill_task(): engine = create_engine(DATABASE_PATH) feeder_result = CompetitorPriceFeeder(engine=engine).run_unmatched_priority(limit=120) - pick_result = generate_product_pick_list(engine, limit=30) + pick_result = generate_product_pick_list(engine, limit=50) stats = { "total_skus": feeder_result.total_skus, diff --git a/services/ai_product_pick_agent.py b/services/ai_product_pick_agent.py index ac3af48..7bc6d9e 100644 --- a/services/ai_product_pick_agent.py +++ b/services/ai_product_pick_agent.py @@ -357,7 +357,7 @@ def _write_pick(conn, pick: Dict[str, Any]) -> None: }) -def generate_product_pick_list(engine, limit: int = 30) -> ProductPickResult: +def generate_product_pick_list(engine, limit: int = 50) -> ProductPickResult: """產生並保存 AI 建議挑品清單。""" generated_at = datetime.now().isoformat(timespec="seconds") with engine.connect() as conn: diff --git a/templates/ai_intelligence.html b/templates/ai_intelligence.html index 5424e88..f15b0e3 100644 --- a/templates/ai_intelligence.html +++ b/templates/ai_intelligence.html @@ -371,7 +371,7 @@ async function generatePickList() { const res = await fetch('/api/ai/product-picks/generate', { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ limit: 30 }) + body: JSON.stringify({ limit: 50 }) }); const data = await res.json(); diff --git a/templates/dashboard_v2.html b/templates/dashboard_v2.html index 400b531..fc4bab6 100644 --- a/templates/dashboard_v2.html +++ b/templates/dashboard_v2.html @@ -110,6 +110,16 @@ font-size: 11px; } + .dashboard-kpi-sub-link { + color: inherit; + font-weight: 800; + text-decoration: none; + } + + .dashboard-kpi-sub-link:hover { + color: var(--momo-accent-strong); + } + .dashboard-focus-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); @@ -749,7 +759,9 @@
AI 挑品
{{ overview.ai_pick_count | default(0) | number_format }}
-
pending product_pick
+
+ 查看 {{ ai_pick_list_limit }} 品清單 +
待比對
@@ -876,6 +888,7 @@
全部 + AI挑品 新上架 漲價 降價 @@ -896,8 +909,14 @@
04 - 商品列表 - {{ total_items | number_format }} 筆 + {{ 'AI 挑品清單' if current_filter == 'ai_picks' else '商品列表' }} + + {% if current_filter == 'ai_picks' %} + {{ total_items | number_format }} / {{ ai_pick_list_limit }} 品 + {% else %} + {{ total_items | number_format }} 筆 + {% endif %} +
匯出全部 @@ -960,6 +979,11 @@ {% if competitor and competitor.product_name %}
PChome:{{ competitor.product_name }}
{% endif %} + {% if item.ai_pick %} +
+ AI挑品 #{{ item.ai_pick.rank }} · 信心 {{ (item.ai_pick.confidence * 100) | round(0) | int }}% · 價差 {{ item.ai_pick.gap_pct | round(1) }}% +
+ {% endif %}
diff --git a/tests/test_frontend_v2_assets.py b/tests/test_frontend_v2_assets.py index 9392d84..13fdd50 100644 --- a/tests/test_frontend_v2_assets.py +++ b/tests/test_frontend_v2_assets.py @@ -58,6 +58,11 @@ def test_dashboard_v2_is_production_default_and_uses_real_dashboard_data(): assert "overview.top_picks" in dashboard assert "overview.top_momo_threats" in dashboard assert "overview.pending_priority" in dashboard + assert "filter='ai_picks'" in dashboard + assert "AI 挑品清單" in dashboard + assert "{{ ai_pick_list_limit }} 品" in dashboard + assert "_load_ai_pick_selection(session, PRODUCT_PICK_LIST_LIMIT)" in route_source + assert "PRODUCT_PICK_LIST_LIMIT = 50" in route_source assert "ui='v2'" not in dashboard assert 'name="ui" value="v2"' not in dashboard assert "mockProducts" not in dashboard @@ -168,7 +173,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 "run_unmatched_priority(limit=limit)" in route_source - assert "generate_product_pick_list(engine, limit=30)" in route_source + assert "generate_product_pick_list(engine, limit=50)" in route_source + assert "payload.get('limit', 50)" in route_source + assert "JSON.stringify({ limit: 50 })" in template assert "完成後會重算 AI 挑品清單" in route_source assert "match_rate" in route_source assert "product_pick_count" in route_source