串接商品看板比價覆核隊列
All checks were successful
CD Pipeline / deploy (push) Successful in 1m7s

This commit is contained in:
OoO
2026-05-20 01:14:12 +08:00
parent baf5eb2881
commit 2bf2245cea
6 changed files with 243 additions and 26 deletions

View File

@@ -4,6 +4,8 @@
================================================================================
【已完成】
- V10.298 補市場情報 candidate queue review AI summary persistence run receipt新增 `/api/market_intel/manual_sample_review/candidate_queue_review_ai_summary_persistence_run_receipt` 與 UI 按鈕,審核操作員貼回的 metadata_json CLI writer output、post-write smoke、dedupe key、summary payload hash、artifact path 與 token 外洩風險API/UI 仍不讀 approval token、不執行 CLI、不連 DB、不寫 `metadata_json`、不派送 Telegram、不掛 scheduler。
- V10.297 將 PChome 單位價覆核隊列接回商品看板第一屏KPI 顯示待處理/需單位價覆核數,焦點區列出候選 PChome 商品、候選價、match score 與人工動作;新增 `filter=pchome_review` 的比價覆核隊列,讓使用者可直接進入待處理 SKU不再只在 daily/growth/PPT 摘要看到統計。
- V10.296 補核心 MOMO/PChome 比價第三層語意與覆核閉環:同核心商品但買送、套組、件數不同且只有單一基礎規格時標記 `unit_comparable`,只寫入 `competitor_match_attempts`商品看板、daily/growth 報表、OpenClaw/PPT 摘要共用 `competitor_intel_repository` 的覆核隊列,顯示「需單位價比較」、候選商品、候選 PChome 價格與單位價換算證據;多容量/多品項套組仍保持不可比較,避免把不同販售組合直接寫進正式總價差。
- V10.289 重排 Elephant Alpha L3 HITL `ea_escalation` Telegram 告警:改成專業 incident brief 格式分成決策狀態、背景摘要、風險摘要、TOP 待審 SKU 與建議處置;價格行動會拆出 MOMO/PChome 價格、價差、人工處置與 PChome ID避免長 bullet 難讀。
- V10.284 關閉 Code Review Hermes LLM scan 預設路徑Step 2 改 deterministic fast static scan不再讓部署後先卡三段 Ollama timeout若需要 LLM scan 可用 `CODE_REVIEW_HERMES_LLM_SCAN_ENABLED=true` 顯式開啟,仍只走本地矩陣、不走 Gemini。

View File

@@ -2,7 +2,7 @@
> **最後更新**: 2026-05-20 (台北時間)
> **狀態**: 🟢 四 AI Agent 自動化閉環已落地LLM 路由紅線升級為 Ollama-first 三主機級聯Gemini 僅備援 / 鎖定場景
> **適用版本**: V10.296
> **適用版本**: V10.297
---
@@ -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``ai_price_recommendations` 顯示比對覆蓋率、PChome 優勢、MOMO 威脅、AI 挑品待比對優先清單;`filter=ai_picks` 可查看 50 品 AI 挑品列表,並在列表上方顯示平均信心、平均價差、最大價差與估算總價差空間,列表列內顯示 AI 排名與建議理由,且可透過 `/api/export/excel/ai-picks` 匯出 50 品 Excel 操作清單。商品看板深度快取同時寫入 `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列內顯示候選 PChome 商品、候選價、match score、單位價換算摘要與人工動作。商品看板深度快取同時寫入 `data/dashboard_full_cache.pkl`,供多個 Gunicorn worker 共用,避免部署後各 worker 重複重建 7,000+ 商品統計造成開頁變慢;所有資料異動與 AI 挑品重算都透過 `clear_dashboard_cache()` 同步清除記憶體與共享快取,手動重算 API 會立即預熱商品看板快取,避免第一位使用者承擔重建成本。
| 角色 | 模型 | 主機 | 成本 | 每日限額 |
|------|------|------|------|---------|

View File

@@ -406,21 +406,36 @@ def _load_pchome_match_attempt_map(session, skus):
try:
stmt = text("""
WITH ranked AS (
WITH latest_momo AS (
SELECT
sku,
attempt_status,
candidate_count,
best_competitor_product_id,
best_competitor_product_name,
best_competitor_price,
best_match_score,
error_message,
attempted_at,
ROW_NUMBER() OVER (PARTITION BY sku ORDER BY attempted_at DESC) AS rn
FROM competitor_match_attempts
WHERE source = 'pchome'
AND sku IN :skus
p.i_code AS sku,
p.name AS momo_product_name,
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.i_code IN :skus
),
ranked AS (
SELECT
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,
lm.momo_product_name,
lm.momo_price,
ROW_NUMBER() OVER (PARTITION BY cma.sku ORDER BY cma.attempted_at DESC) AS rn
FROM competitor_match_attempts cma
LEFT JOIN latest_momo lm
ON lm.sku = cma.sku
AND lm.rn = 1
WHERE cma.source = 'pchome'
AND cma.sku IN :skus
)
SELECT *
FROM ranked
@@ -458,6 +473,42 @@ def _format_dashboard_dt(value):
return str(value)
def _get_session_engine(session):
try:
return session.get_bind()
except Exception:
return getattr(session, 'bind', None)
def _load_competitor_review_context(session, limit=12):
try:
from services.competitor_intel_repository import (
fetch_competitor_coverage,
fetch_competitor_review_queue,
)
engine = _get_session_engine(session)
if not engine:
return {'coverage': {}, 'review_queue': []}
return {
'coverage': fetch_competitor_coverage(engine) or {},
'review_queue': fetch_competitor_review_queue(engine, limit=limit) or [],
}
except Exception as exc:
sys_log.warning(f"[Dashboard] PChome 覆核隊列讀取略過: {exc}")
return {'coverage': {}, 'review_queue': []}
def _merge_competitor_review_context(overview, review_context):
coverage = review_context.get('coverage') or {}
review_queue = review_context.get('review_queue') or []
overview.update({
'review_queue_count': int(coverage.get('actionable_review_count') or len(review_queue) or 0),
'unit_comparable_count': int(coverage.get('unit_comparable_count') or 0),
'review_queue': review_queue[:3],
})
return overview
def _parse_agent_footprint(value):
if not value:
return {}
@@ -538,6 +589,9 @@ def _load_competitor_decision_overview(session, latest_items=None):
'top_pchome_advantages': [],
'top_momo_threats': [],
'pending_priority': [],
'review_queue_count': 0,
'unit_comparable_count': 0,
'review_queue': [],
}
if latest_items:
@@ -658,6 +712,10 @@ def _load_competitor_decision_overview(session, latest_items=None):
}
for row in sorted(pending_items, key=lambda row: row['momo_price'], reverse=True)[:3]
]
_merge_competitor_review_context(
overview,
_load_competitor_review_context(session, limit=12),
)
_DASHBOARD_DATA_CACHE[cache_key] = overview
_DASHBOARD_DATA_CACHE[cache_ts_key] = time.time()
return overview
@@ -852,6 +910,10 @@ def _load_competitor_decision_overview(session, latest_items=None):
}
for row in session.execute(pending_sql).mappings().all()
]
_merge_competitor_review_context(
overview,
_load_competitor_review_context(session, limit=12),
)
_DASHBOARD_DATA_CACHE[cache_key] = overview
_DASHBOARD_DATA_CACHE[cache_ts_key] = time.time()
return overview
@@ -861,6 +923,10 @@ def _load_competitor_decision_overview(session, latest_items=None):
session.rollback()
except Exception:
pass
_merge_competitor_review_context(
default,
_load_competitor_review_context(session, limit=12),
)
_DASHBOARD_DATA_CACHE[cache_key] = default
_DASHBOARD_DATA_CACHE[cache_ts_key] = time.time()
return default
@@ -1643,9 +1709,24 @@ def index():
ai_pick_skus = []
ai_pick_map = {}
ai_pick_summary = None
review_queue = []
review_queue_map = {}
review_queue_order = {}
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_queue_map = {
str(row.get('sku') or ''): row
for row in review_queue
if row.get('sku')
}
review_queue_order = {
sku: idx
for idx, sku in enumerate(review_queue_map.keys(), start=1)
}
# 先處理搜尋
if search_query:
@@ -1671,6 +1752,12 @@ def index():
i for i in base_items
if str(i['record'].product.i_code) in pick_set
]
elif filter_type == 'pchome_review':
review_set = set(review_queue_map.keys())
filtered_items = [
i for i in base_items
if str(i['record'].product.i_code) in review_set
]
elif filter_type == 'delisted':
for item in today_delisted_items:
class DelistedRecord:
@@ -1720,6 +1807,9 @@ def index():
if filter_type == 'ai_picks':
sku = str(item['record'].product.i_code)
return -ai_pick_map.get(sku, {}).get('rank', 9999)
if filter_type == 'pchome_review':
sku = str(item['record'].product.i_code)
return -review_queue_order.get(sku, 9999)
return item['record'].timestamp
sorted_items = sorted(filtered_items, key=get_sort_key, reverse=reverse)
@@ -1736,6 +1826,7 @@ def index():
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)
item['pchome_review'] = review_queue_map.get(sku)
item['safe_momo_url'] = (
item.get('safe_product_url')
or normalize_momo_product_url(item['record'].product.url, sku)
@@ -1820,6 +1911,7 @@ def index():
most_active_count=most_active_count,
competitor_overview=competitor_overview,
ai_pick_list_limit=PRODUCT_PICK_LIST_LIMIT,
build_momo_product_url=_build_momo_product_url,
active_page='dashboard')
except Exception as e:
sys_log.error(f"[Web] [Dashboard] 渲染錯誤 | Error: {e}")

View File

@@ -39,9 +39,12 @@
</div>
</div>
<div class="dashboard-kpi">
<div class="dashboard-kpi-label momo-mono">待比對</div>
<div class="dashboard-kpi-value momo-mono is-warning">{{ overview.pending_match_count | default(0) | number_format }}</div>
<div class="dashboard-kpi-sub momo-mono">高價品項優先補抓</div>
<div class="dashboard-kpi-label momo-mono">比價覆核</div>
<div class="dashboard-kpi-value momo-mono is-warning">{{ overview.review_queue_count | default(0) | number_format }}</div>
<div class="dashboard-kpi-sub momo-mono">
<a class="dashboard-kpi-sub-link" href="{{ url_for('dashboard.index', filter='pchome_review', category=current_category, q=search_query, sort_by='pchome_review', order='desc') }}">需單位價覆核 {{ overview.unit_comparable_count | default(0) | number_format }}</a>
· 待補抓 {{ overview.pending_match_count | default(0) | number_format }}
</div>
</div>
<div class="dashboard-kpi">
<div class="dashboard-kpi-label momo-mono">資料新鮮度</div>
@@ -143,8 +146,42 @@
</div>
<div class="dashboard-focus-card">
<div class="dashboard-focus-label momo-mono">補資料優先</div>
{% if overview.pending_priority %}
<div class="dashboard-focus-label momo-mono">覆核與補資料</div>
{% if overview.review_queue %}
<div class="dashboard-focus-list">
{% for item in overview.review_queue %}
<div class="dashboard-focus-row">
<a class="dashboard-focus-row-title" href="{{ url_for('dashboard.index', filter='pchome_review', q=item.sku, sort_by='pchome_review', order='desc') }}">{{ item.name }}</a>
<div class="dashboard-focus-row-meta momo-mono">
<span class="dashboard-focus-chip is-review">{{ item.status_label }}</span>
<span>MOMO ${{ item.momo_price | int | number_format }}</span>
{% if item.candidate_pc_price %}
<span>候選 PChome ${{ item.candidate_pc_price | int | number_format }}</span>
{% endif %}
{% if item.best_match_score %}
<span>match {{ (item.best_match_score * 100) | round(0) | int }}%</span>
{% endif %}
</div>
<div class="dashboard-focus-sub">{{ item.action_label }}</div>
{% if item.unit_comparison and item.unit_comparison.summary %}
<div class="dashboard-review-note momo-mono">{{ item.unit_comparison.summary }}</div>
{% endif %}
<div class="dashboard-focus-row-links">
<a class="dashboard-platform-link is-momo momo-tracked-link" href="{{ build_momo_product_url(item.sku) if build_momo_product_url is defined else '#' }}" data-momo-original-url="{{ build_momo_product_url(item.sku) if build_momo_product_url is defined else '#' }}" target="_blank" rel="noopener noreferrer"
data-track-platform="momo"
data-track-source="dashboard-v2-overview-review-queue"
data-track-product-id="{{ item.sku }}"
data-track-icode="{{ item.sku }}"
data-track-product-name="{{ item.name|e }}">MOMO {{ item.sku }}</a>
{% if item.candidate_pc_id %}
<a class="dashboard-platform-link is-pchome" href="https://24h.pchome.com.tw/prod/{{ item.candidate_pc_id }}" target="_blank" rel="noopener noreferrer">PChome {{ item.candidate_pc_id }}</a>
{% endif %}
</div>
</div>
{% endfor %}
</div>
<a class="dashboard-focus-action" href="{{ url_for('dashboard.index', filter='pchome_review', category=current_category, q=search_query, sort_by='pchome_review', order='desc') }}">查看高優先覆核隊列</a>
{% elif overview.pending_priority %}
<div class="dashboard-focus-list">
{% for item in overview.pending_priority %}
<div class="dashboard-focus-row">
@@ -172,8 +209,8 @@
{% endfor %}
</div>
{% else %}
<div class="dashboard-focus-title">待比對清單已清空</div>
<div class="dashboard-focus-sub momo-mono">目前 ACTIVE 商品都有有效 PChome 配對或尚無最新 MOMO 價格</div>
<div class="dashboard-focus-title">覆核隊列已清空</div>
<div class="dashboard-focus-sub momo-mono">目前 ACTIVE 商品沒有高優先 PChome 覆核項目</div>
{% endif %}
</div>
</div>
@@ -203,6 +240,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 == 'pchome_review' %}is-active{% endif %}" href="{{ url_for('dashboard.index', filter='pchome_review', category=current_category, q=search_query, sort_by='pchome_review', order='desc') }}">比價覆核</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>
@@ -223,10 +261,12 @@
<div class="dashboard-table-card">
<div class="dashboard-table-head">
<span class="dashboard-section-index momo-mono">04</span>
<span class="dashboard-table-title">{{ 'AI 挑品清單' if current_filter == 'ai_picks' else '商品列表' }}</span>
<span class="dashboard-table-title">{{ 'AI 挑品清單' if current_filter == 'ai_picks' else ('比價覆核隊列' if current_filter == 'pchome_review' else '商品列表') }}</span>
<span class="dashboard-table-meta momo-mono">
{% if current_filter == 'ai_picks' %}
{{ total_items | number_format }} / {{ ai_pick_list_limit }} 品
{% elif current_filter == 'pchome_review' %}
{{ total_items | number_format }} / {{ overview.review_queue_count | default(0) | number_format }} 待處理
{% else %}
{{ total_items | number_format }} 筆
{% endif %}
@@ -293,7 +333,7 @@
{% endif %}
<div class="dashboard-table-wrap">
<table class="dashboard-table {% if current_filter == 'ai_picks' %}is-ai-picks{% endif %}">
<table class="dashboard-table {% if current_filter == 'ai_picks' %}is-ai-picks{% elif current_filter == 'pchome_review' %}is-review{% endif %}">
<thead>
<tr>
<th>分類</th>
@@ -305,6 +345,8 @@
<th>競價判讀</th>
{% if current_filter == 'ai_picks' %}
<th>AI 建議</th>
{% elif current_filter == 'pchome_review' %}
<th>覆核動作</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>
@@ -442,6 +484,35 @@
<span class="dashboard-muted">尚無建議理由</span>
{% endif %}
</td>
{% elif current_filter == 'pchome_review' %}
<td>
{% set review = item.pchome_review %}
<div class="dashboard-review-card">
<div class="dashboard-ai-pick-head">
<span class="dashboard-ai-pick-rank">{{ review.status_label if review else match_status.label }}</span>
{% if review and review.best_match_score %}
<span class="dashboard-ai-pick-confidence is-medium">match {{ (review.best_match_score * 100) | round(0) | int }}%</span>
{% endif %}
</div>
<div class="dashboard-ai-pick-reason">{{ review.action_label if review else decision.summary }}</div>
{% if review %}
<div class="dashboard-ai-evidence-line">
{% if review.candidate_count %}
<span>{{ review.candidate_count }} 筆候選</span>
{% endif %}
{% if review.candidate_pc_id %}
<span>PChome {{ review.candidate_pc_id }}</span>
{% endif %}
{% if review.attempted_at %}
<span>{{ review.attempted_at }}</span>
{% endif %}
</div>
{% if review.unit_comparison and review.unit_comparison.summary %}
<div class="dashboard-review-note momo-mono">{{ review.unit_comparison.summary }}</div>
{% endif %}
{% endif %}
</div>
</td>
{% endif %}
<td class="text-end momo-mono">
{% if item.yesterday_diff > 0 %}
@@ -471,7 +542,7 @@
</tr>
{% else %}
<tr>
<td colspan="{{ 10 if current_filter == 'ai_picks' else 9 }}">
<td colspan="{{ 10 if current_filter in ['ai_picks', 'pchome_review'] else 9 }}">
<div class="dashboard-empty">
{% if search_query %}
找不到與「{{ search_query }}」相關的商品

View File

@@ -130,11 +130,15 @@ def test_dashboard_v2_is_production_default_and_uses_real_dashboard_data():
assert "warm_full_dashboard_cache" in route_source
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 "_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
assert "ai_price_recommendations" in route_source
assert "pending_match_count" in route_source
assert "review_queue_count" in route_source
assert "unit_comparable_count" in route_source
assert "filter_type == 'pchome_review'" in route_source
assert "MockRecord" not in route_source
assert "{% for item in items %}" in dashboard
assert "比價監控總覽" in dashboard
@@ -143,8 +147,13 @@ 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 "overview.review_queue" in dashboard
assert "需單位價覆核" in dashboard
assert "filter='ai_picks'" in dashboard
assert "filter='pchome_review'" in dashboard
assert "AI 挑品清單" in dashboard
assert "比價覆核隊列" in dashboard
assert "覆核動作" in dashboard
assert "dashboard-ai-summary-grid" in dashboard
assert "AI 建議" in dashboard
assert "/api/export/excel/ai-picks" in dashboard

View File

@@ -285,6 +285,32 @@
border: 1px solid var(--momo-border-light);
}
.dashboard-focus-chip.is-review {
color: var(--momo-warning-text);
background: var(--momo-warning-bg);
border: 1px solid rgba(161, 111, 35, 0.24);
}
.dashboard-focus-action {
display: inline-flex;
width: fit-content;
align-items: center;
margin-top: 12px;
padding: 6px 10px;
color: var(--momo-text-inverse);
background: var(--momo-ink);
border: 1px solid var(--momo-ink);
border-radius: 4px;
font-size: 12px;
font-weight: 800;
text-decoration: none;
}
.dashboard-focus-action:hover {
color: var(--momo-text-inverse);
background: var(--momo-ink-soft);
}
.dashboard-filter-card {
padding: 12px 16px;
}
@@ -464,6 +490,10 @@
min-width: 1460px;
}
.dashboard-table.is-review {
min-width: 1460px;
}
.dashboard-table th {
padding: 11px 14px;
color: var(--momo-text-tertiary);
@@ -738,6 +768,19 @@
-webkit-line-clamp: 3;
}
.dashboard-review-card {
display: grid;
min-width: 190px;
gap: 6px;
}
.dashboard-review-note {
color: var(--momo-warning-text);
font-size: 10px;
font-weight: 800;
line-height: 1.45;
}
.dashboard-history-button {
display: inline-flex;
align-items: center;