feat(dashboard): 顯示 50 品 AI 挑品清單
All checks were successful
CD Pipeline / deploy (push) Successful in 3m12s
All checks were successful
CD Pipeline / deploy (push) Successful in 3m12s
This commit is contained in:
@@ -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
4
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 防護函數
|
||||
|
||||
@@ -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 # 用於模板顯示
|
||||
|
||||
|
||||
@@ -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 挑品列表。
|
||||
|
||||
| 角色 | 模型 | 主機 | 成本 | 每日限額 |
|
||||
|------|------|------|------|---------|
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user