feat(dashboard): 強化 AI 挑品清單決策資訊
All checks were successful
CD Pipeline / deploy (push) Successful in 2m22s

This commit is contained in:
OoO
2026-05-01 15:22:21 +08:00
parent a5de082437
commit 1d1a7f6e94
7 changed files with 201 additions and 8 deletions

View File

@@ -2,7 +2,7 @@
> 本文件定義專案開發的核心準則與不可違反的規範
> **建立日期**: 2026-01-12
> **當前版本**: V10.57 (Dashboard AI product pick list supports 50 items)
> **當前版本**: V10.58 (Dashboard AI pick list adds decision summary and reason column)
> **最後更新**: 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.57: Dashboard AI product pick list supports 50 items
SYSTEM_VERSION = "V10.57"
# 🚩 2026-05-01 V10.58: Dashboard AI pick list adds decision summary and reason column
SYSTEM_VERSION = "V10.58"
# ==========================================
# 🔒 SQL Injection 防護函數

View File

@@ -254,7 +254,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
# ==========================================
# 系統版本與路徑
# ==========================================
SYSTEM_VERSION = "V10.57"
SYSTEM_VERSION = "V10.58"
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 挑品與待比對優先清單;`filter=ai_picks` 可查看 50 品 AI 挑品列表。
- 商品看板第一屏:`/` 的 V2 看板直接以 `products``price_records``competitor_prices``ai_price_recommendations` 顯示比對覆蓋率、PChome 優勢、MOMO 威脅、AI 挑品與待比對優先清單;`filter=ai_picks` 可查看 50 品 AI 挑品列表,並在列表上方顯示平均信心、平均價差、最大價差與估算總價差空間,列表列內顯示 AI 排名與建議理由
| 角色 | 模型 | 主機 | 成本 | 每日限額 |
|------|------|------|------|---------|

View File

@@ -441,6 +441,41 @@ def _load_ai_pick_selection(session, limit=PRODUCT_PICK_LIST_LIMIT):
return skus, pick_map
def _summarize_ai_pick_selection(ai_pick_map):
"""彙整目前 AI 挑品清單的可操作摘要,全部來自 ai_price_recommendations。"""
picks = list(ai_pick_map.values())
if not picks:
return {
'count': 0,
'avg_confidence': 0,
'avg_gap_pct': 0,
'max_gap_pct': 0,
'total_gap_amount': 0,
'high_confidence_count': 0,
'generated_at': None,
}
confidence_values = [pick.get('confidence', 0) for pick in picks]
gap_values = [pick.get('gap_pct', 0) for pick in picks]
total_gap_amount = sum(
max((pick.get('momo_price') or 0) - (pick.get('pchome_price') or 0), 0)
for pick in picks
)
return {
'count': len(picks),
'avg_confidence': round(sum(confidence_values) / len(confidence_values), 3),
'avg_gap_pct': round(sum(gap_values) / len(gap_values), 1),
'max_gap_pct': round(max(gap_values), 1),
'total_gap_amount': round(total_gap_amount),
'high_confidence_count': sum(1 for value in confidence_values if value >= 0.65),
'generated_at': max(
(pick.get('created_at') for pick in picks if pick.get('created_at')),
default=None,
),
}
# ==========================================
# 快取與監控變數
# ==========================================
@@ -945,8 +980,10 @@ def index():
filtered_items = []
ai_pick_skus = []
ai_pick_map = {}
ai_pick_summary = None
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)
# 先處理搜尋
if search_query:
@@ -1081,6 +1118,7 @@ def index():
search_query=search_query,
current_sort=sort_by,
current_order=order,
ai_pick_summary=ai_pick_summary,
scheduler_stats=scheduler_stats,
avg_increase=avg_increase,
avg_decrease=avg_decrease,

View File

@@ -357,6 +357,45 @@
font-size: 11px;
}
.dashboard-ai-summary-grid {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 0;
border-bottom: 1px solid var(--momo-border-light);
}
.dashboard-ai-summary-item {
min-width: 0;
padding: 14px 18px;
border-right: 1px solid var(--momo-border-light);
}
.dashboard-ai-summary-item:last-child {
border-right: 0;
}
.dashboard-ai-summary-label {
margin-bottom: 5px;
color: var(--momo-text-tertiary);
font-family: var(--momo-font-family-mono);
font-size: 10px;
font-weight: 800;
letter-spacing: 0.08em;
}
.dashboard-ai-summary-value {
color: var(--momo-text-primary);
font-size: 18px;
font-weight: 800;
line-height: 1.15;
}
.dashboard-ai-summary-sub {
margin-top: 4px;
color: var(--momo-text-secondary);
font-size: 11px;
}
.dashboard-table-wrap {
overflow-x: auto;
}
@@ -368,6 +407,10 @@
font-size: var(--momo-font-size-sm);
}
.dashboard-table.is-ai-picks {
min-width: 1460px;
}
.dashboard-table th {
padding: 11px 14px;
color: var(--momo-text-tertiary);
@@ -559,6 +602,48 @@
line-height: 1.5;
}
.dashboard-ai-pick-card {
display: grid;
min-width: 170px;
gap: 6px;
}
.dashboard-ai-pick-head {
display: flex;
align-items: center;
gap: 6px;
flex-wrap: wrap;
}
.dashboard-ai-pick-rank {
display: inline-flex;
align-items: center;
padding: 3px 8px;
color: var(--momo-text-inverse);
background: var(--momo-ink);
border-radius: var(--momo-radius-pill);
font-family: var(--momo-font-family-mono);
font-size: 10px;
font-weight: 800;
}
.dashboard-ai-pick-confidence {
color: var(--momo-success);
font-family: var(--momo-font-family-mono);
font-size: 11px;
font-weight: 800;
}
.dashboard-ai-pick-reason {
display: -webkit-box;
overflow: hidden;
color: var(--momo-text-secondary);
font-size: 11px;
line-height: 1.45;
-webkit-box-orient: vertical;
-webkit-line-clamp: 3;
}
.dashboard-history-button {
display: inline-flex;
align-items: center;
@@ -693,6 +778,14 @@
grid-template-columns: repeat(3, minmax(0, 1fr));
}
.dashboard-ai-summary-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.dashboard-ai-summary-item:nth-child(2n) {
border-right: 0;
}
.dashboard-focus-grid {
grid-template-columns: 1fr 1fr;
}
@@ -705,10 +798,20 @@
@media (max-width: 640px) {
.dashboard-kpi-grid,
.dashboard-focus-grid {
.dashboard-focus-grid,
.dashboard-ai-summary-grid {
grid-template-columns: 1fr;
}
.dashboard-ai-summary-item {
border-right: 0;
border-bottom: 1px solid var(--momo-border-light);
}
.dashboard-ai-summary-item:last-child {
border-bottom: 0;
}
.dashboard-kpi {
border-right: 0;
border-bottom: 1px solid var(--momo-border-light);
@@ -926,8 +1029,38 @@
</a>
</div>
{% if current_filter == 'ai_picks' and ai_pick_summary %}
<div class="dashboard-ai-summary-grid">
<div class="dashboard-ai-summary-item">
<div class="dashboard-ai-summary-label">PICK COUNT</div>
<div class="dashboard-ai-summary-value momo-mono">{{ ai_pick_summary.count | number_format }}</div>
<div class="dashboard-ai-summary-sub">目前清單上限 {{ ai_pick_list_limit }} 品</div>
</div>
<div class="dashboard-ai-summary-item">
<div class="dashboard-ai-summary-label">AVG CONFIDENCE</div>
<div class="dashboard-ai-summary-value momo-mono">{{ (ai_pick_summary.avg_confidence * 100) | round(0) | int }}%</div>
<div class="dashboard-ai-summary-sub">高信心 {{ ai_pick_summary.high_confidence_count | number_format }} 品</div>
</div>
<div class="dashboard-ai-summary-item">
<div class="dashboard-ai-summary-label">AVG GAP</div>
<div class="dashboard-ai-summary-value momo-mono">+{{ ai_pick_summary.avg_gap_pct | round(1) }}%</div>
<div class="dashboard-ai-summary-sub">PChome 相對 MOMO 價差</div>
</div>
<div class="dashboard-ai-summary-item">
<div class="dashboard-ai-summary-label">BEST GAP</div>
<div class="dashboard-ai-summary-value momo-mono">+{{ ai_pick_summary.max_gap_pct | round(1) }}%</div>
<div class="dashboard-ai-summary-sub">清單內最大價格優勢</div>
</div>
<div class="dashboard-ai-summary-item">
<div class="dashboard-ai-summary-label">PRICE ROOM</div>
<div class="dashboard-ai-summary-value momo-mono">${{ ai_pick_summary.total_gap_amount | int | number_format }}</div>
<div class="dashboard-ai-summary-sub">50 品估算總價差空間</div>
</div>
</div>
{% endif %}
<div class="dashboard-table-wrap">
<table class="dashboard-table">
<table class="dashboard-table {% if current_filter == 'ai_picks' %}is-ai-picks{% endif %}">
<thead>
<tr>
<th>分類</th>
@@ -937,6 +1070,9 @@
</th>
<th class="text-end">PChome 價格</th>
<th>競價判讀</th>
{% if current_filter == 'ai_picks' %}
<th>AI 建議</th>
{% endif %}
<th class="text-end">
<a href="{{ url_for('dashboard.index', page=1, sort_by='yesterday_change', order='asc' if current_sort == 'yesterday_change' and current_order == 'desc' else 'desc', category=current_category, filter=current_filter, q=search_query) }}">昨日漲跌</a>
</th>
@@ -1024,6 +1160,21 @@
<span class="dashboard-competition-meta">{{ decision.summary }}</span>
</div>
</td>
{% if current_filter == 'ai_picks' %}
<td>
{% if item.ai_pick %}
<div class="dashboard-ai-pick-card">
<div class="dashboard-ai-pick-head">
<span class="dashboard-ai-pick-rank">#{{ item.ai_pick.rank }}</span>
<span class="dashboard-ai-pick-confidence">信心 {{ (item.ai_pick.confidence * 100) | round(0) | int }}%</span>
</div>
<div class="dashboard-ai-pick-reason">{{ item.ai_pick.reason }}</div>
</div>
{% else %}
<span style="color:var(--momo-text-tertiary);">尚無建議理由</span>
{% endif %}
</td>
{% endif %}
<td class="text-end momo-mono">
{% if item.yesterday_diff > 0 %}
<span class="dashboard-change-up">▲ +{{ item.yesterday_diff | abs | int | number_format }}</span>
@@ -1052,7 +1203,7 @@
</tr>
{% else %}
<tr>
<td colspan="9">
<td colspan="{{ 10 if current_filter == 'ai_picks' else 9 }}">
<div class="dashboard-empty">
{% if search_query %}
找不到與「{{ search_query }}」相關的商品

View File

@@ -60,6 +60,10 @@ def test_dashboard_v2_is_production_default_and_uses_real_dashboard_data():
assert "overview.pending_priority" in dashboard
assert "filter='ai_picks'" in dashboard
assert "AI 挑品清單" in dashboard
assert "dashboard-ai-summary-grid" in dashboard
assert "AI 建議" in dashboard
assert "item.ai_pick.reason" in dashboard
assert "_summarize_ai_pick_selection(ai_pick_map)" in route_source
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