fix: show product evidence on ai picks
Some checks failed
CD Pipeline / deploy (push) Has been cancelled

This commit is contained in:
ogt
2026-06-27 20:20:15 +08:00
parent 9425e8f05a
commit 90e44a8f8a
6 changed files with 144 additions and 27 deletions

View File

@@ -402,7 +402,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '')
# ==========================================
# 系統版本與路徑
# ==========================================
SYSTEM_VERSION = "V10.723"
SYSTEM_VERSION = "V10.724"
LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log')
public_url = PUBLIC_URL # 用於模板顯示

View File

@@ -2,7 +2,7 @@
> **最後更新**: 2026-06-27 (台北時間)
> **狀態**: 🟢 四 AI Agent 自動化閉環已落地LLM 路由紅線升級為 Ollama-first 三主機級聯PChome 後台業績匯入韌性已補強產品定位正名為「PChome 業績成長自動化作戰系統」外部市場來源正規化層、自動同步、作戰清單與價格參考表優先讀取、CSV 備援預檢、前台操作入口、高可見頁面繁中化守門、比價/作戰 UI 工作台化、跨平台來源治理與商品身份 UI 契約已建立GCP embedding 熔斷延後處理、110 proxy rescue 與 direct host health skip 已建立
> **適用版本**: V10.723
> **適用版本**: V10.724
---
@@ -808,3 +808,4 @@ POSTGRES_HOST=momo-db
| 2026-06-27 | 監控來源 API 不回傳前台不需要的內部設定欄位 | V10.721 起 `/api/crawlers` 的設定頁 response 會移除 `function``page_type``status``paused_date`,只保留前台需要的名稱、說明、啟用狀態、頻率、活動代碼與營運提示。 |
| 2026-06-27 | 服務更新監控頁不得以內部工具名當主語 | V10.722 起 `/cicd` 可見文字使用「測試站、正式站、監控圖表、自動化流程、今日更新」;前台模板不得用 `issue.error_log` 判斷顯示診斷資料,也不得顯示 `UAT 狀態``PROD 狀態``Grafana``n8n` 作為主按鈕文字。 |
| 2026-06-27 | 系統事件頁不得顯示或下載原始工程紀錄 | V10.723 起 `/logs` 改為「系統事件紀錄」,前台只顯示事件等級、營運判讀與建議處置;不得以 raw log 變數、舊下載檔名或「系統日誌/下載日誌」作為使用者可見介面。 |
| 2026-06-27 | AI 挑品必須直接顯示商品證據與賣場入口 | V10.724 起商品看板 AI 挑品清單會在 selection 階段合併最新 PChome 同款證據,卡片需顯示 MOMO 商品ID、PChome 商品ID、同款信心與兩邊賣場入口商品圖需使用 PChome 圖片作為 fallback不得只給一段建議理由。 |

View File

@@ -853,7 +853,7 @@ def _load_competitor_review_page(
return {'items': [], 'total': 0, 'page': page, 'per_page': per_page}
def _parse_agent_footprint(value):
def _parse_model_footprint(value):
if not value:
return {}
if isinstance(value, str):
@@ -863,16 +863,24 @@ def _parse_agent_footprint(value):
return {}
if not isinstance(value, dict):
return {}
return value
def _parse_agent_footprint(value):
value = _parse_model_footprint(value)
agent = value.get('agent')
return agent if isinstance(agent, dict) else {}
def _ai_pick_evidence_fields(model_footprint):
agent = _parse_agent_footprint(model_footprint)
footprint = _parse_model_footprint(model_footprint)
agent = footprint.get('agent') if isinstance(footprint.get('agent'), dict) else {}
competitor = footprint.get('competitor') if isinstance(footprint.get('competitor'), dict) else {}
missing_evidence = agent.get('missing_evidence') or []
if isinstance(missing_evidence, str):
missing_evidence = [missing_evidence]
missing_evidence = [str(item) for item in missing_evidence if item]
footprint_pchome_id = str(competitor.get('product_id') or '').strip()
return {
'opportunity_score': _to_float(agent.get('opportunity_score')) or 0,
'evidence_quality': _to_float(agent.get('evidence_quality')) or 0,
@@ -880,6 +888,10 @@ def _ai_pick_evidence_fields(model_footprint):
'missing_evidence': missing_evidence,
'missing_evidence_text': ''.join(missing_evidence),
'margin_rate': _to_float(agent.get('margin_rate')),
'footprint_pchome_id': footprint_pchome_id,
'footprint_pchome_name': str(competitor.get('product_name') or '').strip(),
'footprint_pchome_url': _build_pchome_product_url(footprint_pchome_id) if footprint_pchome_id else '',
'footprint_match_score': _to_float(competitor.get('match_score')),
}
@@ -1490,24 +1502,54 @@ def _load_pchome_growth_command_center(session):
def _load_ai_pick_selection(session, limit=PRODUCT_PICK_LIST_LIMIT):
"""讀取商品看板 AI 挑品清單排序,供列表篩選使用。"""
sql = text("""
WITH valid_competitor AS (
SELECT DISTINCT ON (cp.sku)
cp.sku,
cp.competitor_product_id,
cp.competitor_product_name,
cp.competitor_product_url,
cp.competitor_image_url,
cp.match_score,
cp.crawled_at
FROM competitor_prices cp
WHERE cp.source = 'pchome'
AND (cp.expires_at IS NULL OR cp.expires_at > CURRENT_TIMESTAMP)
AND cp.price IS NOT NULL
AND cp.price > 0
AND COALESCE(cp.match_score, 0) >= :match_score_floor
AND COALESCE(cp.tags, '[]'::jsonb) ? 'identity_v2'
ORDER BY cp.sku, cp.crawled_at DESC NULLS LAST
)
SELECT
sku,
confidence,
reason,
momo_price,
pchome_price,
gap_pct,
model_footprint,
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
ar.sku,
ar.confidence,
ar.reason,
ar.momo_price,
ar.pchome_price,
ar.gap_pct,
ar.model_footprint,
ar.created_at,
p.url AS momo_url,
vc.competitor_product_id,
vc.competitor_product_name,
vc.competitor_product_url,
vc.competitor_image_url,
vc.match_score,
vc.crawled_at
FROM ai_price_recommendations ar
LEFT JOIN products p ON p.i_code = ar.sku
LEFT JOIN valid_competitor vc ON vc.sku = ar.sku
WHERE ar.strategy = 'product_pick'
AND ar.status = 'pending'
ORDER BY ar.confidence DESC NULLS LAST, ar.gap_pct DESC NULLS LAST, ar.created_at DESC
LIMIT :limit
""")
try:
rows = session.execute(sql, {"limit": limit}).mappings().all()
rows = session.execute(
sql,
{"limit": limit, "match_score_floor": PCHOME_MATCH_SCORE_FLOOR},
).mappings().all()
except Exception as exc:
sys_log.warning(f"[Dashboard] AI 挑品清單讀取略過: {exc}")
try:
@@ -1522,6 +1564,13 @@ def _load_ai_pick_selection(session, limit=PRODUCT_PICK_LIST_LIMIT):
sku = str(row.get('sku') or '')
if not sku or sku in pick_map:
continue
evidence = _ai_pick_evidence_fields(row.get('model_footprint'))
pchome_id = str(row.get('competitor_product_id') or evidence.get('footprint_pchome_id') or '').strip()
pchome_url = (
row.get('competitor_product_url')
or evidence.get('footprint_pchome_url')
or _build_pchome_product_url(pchome_id)
)
skus.append(sku)
pick_map[sku] = {
'rank': idx,
@@ -1530,8 +1579,15 @@ def _load_ai_pick_selection(session, limit=PRODUCT_PICK_LIST_LIMIT):
'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,
'momo_url': normalize_momo_product_url(row.get('momo_url'), sku) or _build_momo_product_url(sku),
'pchome_id': pchome_id,
'pchome_name': row.get('competitor_product_name') or evidence.get('footprint_pchome_name') or '',
'pchome_url': pchome_url,
'pchome_image_url': row.get('competitor_image_url') or '',
'pchome_match_score': _to_float(row.get('match_score')) or evidence.get('footprint_match_score'),
'pchome_crawled_at': _format_dashboard_dt(row.get('crawled_at')),
'created_at': _format_dashboard_dt(row.get('created_at')),
**_ai_pick_evidence_fields(row.get('model_footprint')),
**evidence,
}
return skus, pick_map

View File

@@ -665,7 +665,8 @@
{% set competitor = item.pchome_competitor %}
{% set decision = item.competitor_decision %}
{% set match_status = item.pchome_match_status %}
{% set image_url = product.image_url or ('https://m.momoshop.com.tw/moscdn/goods/' ~ product.i_code ~ '_m.webp') %}
{% set pchome_fallback_image_url = competitor.image_url if competitor and competitor.image_url else (item.ai_pick.pchome_image_url if item.ai_pick and item.ai_pick.pchome_image_url else (item.pchome_match_attempt.competitor_image_url if item.pchome_match_attempt and item.pchome_match_attempt.competitor_image_url else '')) %}
{% set image_url = product.image_url or pchome_fallback_image_url or ('https://m.momoshop.com.tw/moscdn/goods/' ~ product.i_code ~ '_m.webp') %}
{% set safe_product_url = item.safe_momo_url or '#' %}
{% if current_filter == 'pchome_review' %}
{% set review = item.pchome_review %}
@@ -677,7 +678,7 @@
<div class="dashboard-review-product-stack">
<div class="dashboard-product-cell">
<span class="dashboard-product-thumb-frame">
<img class="dashboard-product-thumb" src="{{ image_url }}" alt="{{ product.name }}" loading="lazy" referrerpolicy="no-referrer" onerror="this.hidden=true; this.nextElementSibling.hidden=false;">
<img class="dashboard-product-thumb" src="{{ image_url }}" data-fallback-src="{{ pchome_fallback_image_url }}" alt="{{ product.name }}" loading="lazy" referrerpolicy="no-referrer" onerror="if (this.dataset.fallbackSrc && this.src !== this.dataset.fallbackSrc) { this.src = this.dataset.fallbackSrc; this.dataset.fallbackSrc = ''; } else { this.hidden=true; this.nextElementSibling.hidden=false; }">
<span class="dashboard-product-thumb-missing" hidden aria-label="待補商品圖"><i class="fas fa-image" aria-hidden="true"></i></span>
</span>
<div>
@@ -714,6 +715,7 @@
<span>{{ review.candidate_count }} 筆候選</span>
{% endif %}
</div>
<a class="dashboard-platform-link is-pchome" href="https://24h.pchome.com.tw/prod/{{ review.candidate_pc_id }}" target="_blank" rel="noopener noreferrer">開 PChome 待確認商品</a>
{% elif competitor and competitor.product_id %}
<a class="dashboard-review-candidate-title" href="{{ competitor.product_url or ('https://24h.pchome.com.tw/prod/' ~ competitor.product_id) }}" target="_blank" rel="noopener noreferrer">
{{ competitor.product_name or ('PChome ' ~ competitor.product_id) }}
@@ -837,7 +839,7 @@
<td>
<div class="dashboard-product-cell">
<span class="dashboard-product-thumb-frame">
<img class="dashboard-product-thumb" src="{{ image_url }}" alt="{{ product.name }}" loading="lazy" referrerpolicy="no-referrer" onerror="this.hidden=true; this.nextElementSibling.hidden=false;">
<img class="dashboard-product-thumb" src="{{ image_url }}" data-fallback-src="{{ pchome_fallback_image_url }}" alt="{{ product.name }}" loading="lazy" referrerpolicy="no-referrer" onerror="if (this.dataset.fallbackSrc && this.src !== this.dataset.fallbackSrc) { this.src = this.dataset.fallbackSrc; this.dataset.fallbackSrc = ''; } else { this.hidden=true; this.nextElementSibling.hidden=false; }">
<span class="dashboard-product-thumb-missing" hidden aria-label="待補商品圖"><i class="fas fa-image" aria-hidden="true"></i></span>
</span>
{% set safe_product_url = item.safe_momo_url or '#' %}
@@ -949,11 +951,27 @@
{% if current_filter == 'ai_picks' %}
<td>
{% if item.ai_pick %}
{% set ai_momo_url = item.ai_pick.momo_url or safe_product_url or '#' %}
{% set ai_pchome_id = item.ai_pick.pchome_id or (competitor.product_id if competitor else (item.pchome_match_attempt.best_competitor_product_id if item.pchome_match_attempt else '')) %}
{% set ai_pchome_name = item.ai_pick.pchome_name or (competitor.product_name if competitor else (item.pchome_match_attempt.best_competitor_product_name if item.pchome_match_attempt else '')) %}
{% set ai_pchome_url = item.ai_pick.pchome_url or (competitor.product_url if competitor else (item.pchome_match_attempt.competitor_product_url if item.pchome_match_attempt else '')) %}
{% set ai_pchome_score = item.ai_pick.pchome_match_score or (competitor.match_score if competitor else (item.pchome_match_attempt.best_match_score if item.pchome_match_attempt else none)) %}
<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 is-{{ item.ai_pick.confidence_band | replace('_', '-') }}">信心 {{ (item.ai_pick.confidence * 100) | round(0) | int }}%</span>
</div>
<div class="dashboard-ai-product-evidence" aria-label="AI 挑品商品證據">
<span>MOMO 商品ID {{ product.i_code }}</span>
{% if ai_pchome_id %}
<span title="{{ ai_pchome_name or ai_pchome_id }}">PChome 商品ID {{ ai_pchome_id }}</span>
{% else %}
<span class="is-pending">PChome 商品ID 待補</span>
{% endif %}
{% if ai_pchome_score %}
<span>同款信心 {{ (ai_pchome_score * 100) | round(0) | int }}%</span>
{% endif %}
</div>
<div class="dashboard-ai-evidence-line">
<span>機會 {{ item.ai_pick.opportunity_score | round(0) | int }}</span>
<span>證據 {{ item.ai_pick.evidence_quality | round(0) | int }}%</span>
@@ -963,8 +981,8 @@
</div>
<div class="dashboard-ai-pick-reason">{{ item.ai_pick.reason }}</div>
<div class="dashboard-ai-action-row" aria-label="商品賣場連結">
<a class="dashboard-platform-link is-momo momo-tracked-link" href="{{ safe_product_url or '#' }}" target="_blank" rel="noopener noreferrer"
data-momo-original-url="{{ safe_product_url or '#' }}"
<a class="dashboard-platform-link is-momo momo-tracked-link" href="{{ ai_momo_url or '#' }}" target="_blank" rel="noopener noreferrer"
data-momo-original-url="{{ ai_momo_url or '#' }}"
data-track-platform="momo"
data-track-source="dashboard-v2-ai-pick-card"
data-track-product-id="{{ product.id }}"
@@ -972,10 +990,12 @@
data-track-product-name="{{ product.name|e }}">
開 MOMO 賣場
</a>
{% if competitor and competitor.product_url %}
<a class="dashboard-platform-link is-pchome" href="{{ competitor.product_url }}" target="_blank" rel="noopener noreferrer">開 PChome 賣場</a>
{% elif item.pchome_match_attempt and item.pchome_match_attempt.competitor_product_url %}
<a class="dashboard-platform-link is-pchome" href="{{ item.pchome_match_attempt.competitor_product_url }}" target="_blank" rel="noopener noreferrer">開 PChome 待確認商品</a>
{% if ai_pchome_url %}
<a class="dashboard-platform-link is-pchome" href="{{ ai_pchome_url }}" target="_blank" rel="noopener noreferrer">開 PChome 賣場</a>
{% elif ai_pchome_id %}
<a class="dashboard-platform-link is-pchome" href="https://24h.pchome.com.tw/prod/{{ ai_pchome_id }}" target="_blank" rel="noopener noreferrer">開 PChome 賣場</a>
{% else %}
<span class="dashboard-platform-muted">PChome 連結待補</span>
{% endif %}
</div>
{% if item.ai_pick.missing_evidence %}

View File

@@ -444,6 +444,12 @@ def test_dashboard_v2_is_production_default_and_uses_real_dashboard_data():
assert "商品ID {{ product.i_code }}" in dashboard
assert "開 MOMO 賣場" in dashboard
assert "開 PChome 賣場" in dashboard
assert "dashboard-ai-product-evidence" in dashboard
assert "PChome 商品ID {{ ai_pchome_id }}" in dashboard
assert "PChome 商品ID 待補" in dashboard
assert "data-fallback-src" in dashboard
assert "item.ai_pick.pchome_url" in dashboard
assert ".dashboard-ai-product-evidence" in dashboard_css
assert "is-ai-picks-wrap" in dashboard
assert "dashboard-product-thumb-missing" in dashboard
assert "dashboard-product-identity" in dashboard_css
@@ -460,6 +466,10 @@ def test_dashboard_v2_is_production_default_and_uses_real_dashboard_data():
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 "WITH valid_competitor AS" in route_source
assert "vc.competitor_product_url" in route_source
assert "'pchome_id': pchome_id" in route_source
assert "'pchome_url': pchome_url" 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

View File

@@ -1691,6 +1691,36 @@
font-weight: 800;
}
.dashboard-ai-product-evidence {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.dashboard-ai-product-evidence span {
display: inline-flex;
align-items: center;
max-width: 210px;
min-height: 22px;
padding: 2px 7px;
overflow: hidden;
border: 1px solid var(--momo-border-light);
border-radius: var(--momo-radius-pill);
background: var(--momo-bg-paper);
color: var(--momo-text-secondary);
font-family: var(--momo-font-family-mono);
font-size: 10px;
font-weight: 800;
text-overflow: ellipsis;
white-space: nowrap;
}
.dashboard-ai-product-evidence span.is-pending {
color: var(--momo-warning-text);
background: var(--momo-warning-bg);
border-color: rgba(161, 111, 35, 0.18);
}
.dashboard-ai-evidence-chip {
display: inline-flex;
max-width: 180px;