From 90e44a8f8a3d6dacc135e6039e6a48974137dc39 Mon Sep 17 00:00:00 2001 From: ogt Date: Sat, 27 Jun 2026 20:20:15 +0800 Subject: [PATCH] fix: show product evidence on ai picks --- config.py | 2 +- docs/AI_INTELLIGENCE_MODULE_SOT.md | 3 +- routes/dashboard_routes.py | 88 +++++++++++++++++++++++----- templates/dashboard_v2.html | 38 +++++++++--- tests/test_frontend_v2_assets.py | 10 ++++ web/static/css/page-dashboard-v2.css | 30 ++++++++++ 6 files changed, 144 insertions(+), 27 deletions(-) diff --git a/config.py b/config.py index 256b5f3..9780593 100644 --- a/config.py +++ b/config.py @@ -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 # 用於模板顯示 diff --git a/docs/AI_INTELLIGENCE_MODULE_SOT.md b/docs/AI_INTELLIGENCE_MODULE_SOT.md index 4484fcc..3ea186a 100644 --- a/docs/AI_INTELLIGENCE_MODULE_SOT.md +++ b/docs/AI_INTELLIGENCE_MODULE_SOT.md @@ -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,不得只給一段建議理由。 | diff --git a/routes/dashboard_routes.py b/routes/dashboard_routes.py index 0e46bd0..b860087 100644 --- a/routes/dashboard_routes.py +++ b/routes/dashboard_routes.py @@ -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 diff --git a/templates/dashboard_v2.html b/templates/dashboard_v2.html index fae28c8..7d305e3 100644 --- a/templates/dashboard_v2.html +++ b/templates/dashboard_v2.html @@ -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 @@
- {{ product.name }} + {{ product.name }}
@@ -714,6 +715,7 @@ {{ review.candidate_count }} 筆候選 {% endif %}
+ 開 PChome 待確認商品 {% elif competitor and competitor.product_id %} {{ competitor.product_name or ('PChome ' ~ competitor.product_id) }} @@ -837,7 +839,7 @@
- {{ product.name }} + {{ product.name }} {% set safe_product_url = item.safe_momo_url or '#' %} @@ -949,11 +951,27 @@ {% if current_filter == 'ai_picks' %} {% 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)) %}
#{{ item.ai_pick.rank }} 信心 {{ (item.ai_pick.confidence * 100) | round(0) | int }}%
+
+ MOMO 商品ID {{ product.i_code }} + {% if ai_pchome_id %} + PChome 商品ID {{ ai_pchome_id }} + {% else %} + PChome 商品ID 待補 + {% endif %} + {% if ai_pchome_score %} + 同款信心 {{ (ai_pchome_score * 100) | round(0) | int }}% + {% endif %} +
機會 {{ item.ai_pick.opportunity_score | round(0) | int }} 證據 {{ item.ai_pick.evidence_quality | round(0) | int }}% @@ -963,8 +981,8 @@
{{ item.ai_pick.reason }}
- 開 MOMO 賣場 - {% if competitor and competitor.product_url %} - 開 PChome 賣場 - {% elif item.pchome_match_attempt and item.pchome_match_attempt.competitor_product_url %} - 開 PChome 待確認商品 + {% if ai_pchome_url %} + 開 PChome 賣場 + {% elif ai_pchome_id %} + 開 PChome 賣場 + {% else %} + PChome 連結待補 {% endif %}
{% if item.ai_pick.missing_evidence %} diff --git a/tests/test_frontend_v2_assets.py b/tests/test_frontend_v2_assets.py index 9f8aa0d..b050e55 100644 --- a/tests/test_frontend_v2_assets.py +++ b/tests/test_frontend_v2_assets.py @@ -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 diff --git a/web/static/css/page-dashboard-v2.css b/web/static/css/page-dashboard-v2.css index 5b2aa69..a9aef71 100644 --- a/web/static/css/page-dashboard-v2.css +++ b/web/static/css/page-dashboard-v2.css @@ -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;