feat(dashboard): 顯示 50 品 AI 挑品清單
All checks were successful
CD Pipeline / deploy (push) Successful in 3m12s

This commit is contained in:
OoO
2026-05-01 15:08:41 +08:00
parent 6bce46bbc7
commit a5de082437
11 changed files with 112 additions and 16 deletions

View File

@@ -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
---

4
app.py
View File

@@ -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 防護函數

View File

@@ -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 # 用於模板顯示

View File

@@ -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 挑品列表
| 角色 | 模型 | 主機 | 成本 | 每日限額 |
|------|------|------|------|---------|

View File

@@ -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,

View File

@@ -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}")

View File

@@ -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,

View File

@@ -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:

View File

@@ -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();

View File

@@ -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 @@
<div class="dashboard-kpi">
<div class="dashboard-kpi-label momo-mono">AI 挑品</div>
<div class="dashboard-kpi-value momo-mono is-success">{{ overview.ai_pick_count | default(0) | number_format }}</div>
<div class="dashboard-kpi-sub momo-mono">pending product_pick</div>
<div class="dashboard-kpi-sub momo-mono">
<a class="dashboard-kpi-sub-link" href="{{ url_for('dashboard.index', filter='ai_picks', category=current_category, q=search_query, sort_by='timestamp', order='desc') }}">查看 {{ ai_pick_list_limit }} 品清單</a>
</div>
</div>
<div class="dashboard-kpi">
<div class="dashboard-kpi-label momo-mono">待比對</div>
@@ -876,6 +888,7 @@
<div class="dashboard-segmented">
<a class="{% if current_filter == 'all' %}is-active{% endif %}" href="{{ url_for('dashboard.index', filter='all', category=current_category, q=search_query, sort_by=current_sort, order=current_order) }}">全部</a>
<a class="{% if current_filter == 'ai_picks' %}is-active{% endif %}" href="{{ url_for('dashboard.index', filter='ai_picks', category=current_category, q=search_query, sort_by='timestamp', order='desc') }}">AI挑品</a>
<a class="{% if current_filter == 'new' %}is-active{% endif %}" href="{{ url_for('dashboard.index', filter='new', category=current_category, q=search_query, sort_by=current_sort, order=current_order) }}">新上架</a>
<a class="{% if current_filter == 'increase' %}is-active{% endif %}" href="{{ url_for('dashboard.index', filter='increase', category=current_category, q=search_query, sort_by=current_sort, order=current_order) }}">漲價</a>
<a class="{% if current_filter == 'decrease' %}is-active{% endif %}" href="{{ url_for('dashboard.index', filter='decrease', category=current_category, q=search_query, sort_by=current_sort, order=current_order) }}">降價</a>
@@ -896,8 +909,14 @@
<div class="dashboard-table-card">
<div class="dashboard-table-head">
<span class="momo-mono" style="font-size:11px;font-weight:800;color:var(--momo-text-tertiary);letter-spacing:.08em;">04</span>
<span class="dashboard-table-title">商品列表</span>
<span class="dashboard-table-meta momo-mono">{{ total_items | number_format }} 筆</span>
<span class="dashboard-table-title">{{ 'AI 挑品清單' if current_filter == 'ai_picks' else '商品列表' }}</span>
<span class="dashboard-table-meta momo-mono">
{% if current_filter == 'ai_picks' %}
{{ total_items | number_format }} / {{ ai_pick_list_limit }} 品
{% else %}
{{ total_items | number_format }} 筆
{% endif %}
</span>
<div class="momo-topbar-spacer"></div>
<a class="dashboard-action-link" href="/api/export/excel/all">
<i class="fas fa-download"></i> 匯出全部
@@ -960,6 +979,11 @@
{% if competitor and competitor.product_name %}
<div class="dashboard-product-id momo-mono" title="{{ competitor.product_name }}">PChome{{ competitor.product_name }}</div>
{% endif %}
{% if item.ai_pick %}
<div class="dashboard-product-id momo-mono" title="{{ item.ai_pick.reason }}">
AI挑品 #{{ item.ai_pick.rank }} · 信心 {{ (item.ai_pick.confidence * 100) | round(0) | int }}% · 價差 {{ item.ai_pick.gap_pct | round(1) }}%
</div>
{% endif %}
</div>
</div>
</td>

View File

@@ -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