diff --git a/app.py b/app.py index 64a826b..e8af78a 100644 --- a/app.py +++ b/app.py @@ -95,8 +95,8 @@ except Exception as e: sys_log.error(f"無法檢測磁碟空間: {e}") # 🚩 系統版本定義 (備份與顯示用) -# 🚩 2026-05-01 V10.66: Avoid redundant dashboard prewarm rebuilds across workers -SYSTEM_VERSION = "V10.66" +# 🚩 2026-05-01 V10.67: Show AI product pick evidence gaps on dashboard +SYSTEM_VERSION = "V10.67" # ========================================== # 🔒 SQL Injection 防護函數 diff --git a/config.py b/config.py index daf0991..c350986 100644 --- a/config.py +++ b/config.py @@ -254,7 +254,7 @@ YOUTUBE_API_KEY = os.getenv('YOUTUBE_API_KEY', '') # ========================================== # 系統版本與路徑 # ========================================== -SYSTEM_VERSION = "V10.66" +SYSTEM_VERSION = "V10.67" LOG_FILE_PATH = os.path.join(BASE_DIR, 'logs/system.log') public_url = PUBLIC_URL # 用於模板顯示 diff --git a/routes/dashboard_routes.py b/routes/dashboard_routes.py index 9521baf..8bb9f11 100644 --- a/routes/dashboard_routes.py +++ b/routes/dashboard_routes.py @@ -156,6 +156,36 @@ def _format_dashboard_dt(value): return str(value) +def _parse_agent_footprint(value): + if not value: + return {} + if isinstance(value, str): + try: + value = json.loads(value) + except Exception: + return {} + if not isinstance(value, dict): + return {} + agent = value.get('agent') + return agent if isinstance(agent, dict) else {} + + +def _ai_pick_evidence_fields(model_footprint): + agent = _parse_agent_footprint(model_footprint) + 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] + return { + 'opportunity_score': _to_float(agent.get('opportunity_score')) or 0, + 'evidence_quality': _to_float(agent.get('evidence_quality')) or 0, + 'confidence_band': agent.get('confidence_band') or 'needs_evidence', + 'missing_evidence': missing_evidence, + 'missing_evidence_text': '、'.join(missing_evidence), + 'margin_rate': _to_float(agent.get('margin_rate')), + } + + def _dashboard_decision_row(row, tone): sku = str(row.get('sku') or '') pchome_id = row.get('competitor_product_id') @@ -175,6 +205,7 @@ def _dashboard_decision_row(row, tone): 'pchome_name': row.get('competitor_product_name') or '', 'pchome_url': _build_pchome_product_url(pchome_id), 'crawled_at': _format_dashboard_dt(row.get('crawled_at') or row.get('created_at')), + **_ai_pick_evidence_fields(row.get('model_footprint')), } @@ -333,6 +364,7 @@ def _load_competitor_decision_overview(session): ar.gap_pct, ar.confidence, ar.reason, + ar.model_footprint, ar.created_at, vc.competitor_product_id, vc.competitor_product_name, @@ -410,6 +442,7 @@ def _load_ai_pick_selection(session, limit=PRODUCT_PICK_LIST_LIMIT): momo_price, pchome_price, gap_pct, + model_footprint, created_at FROM ai_price_recommendations WHERE strategy = 'product_pick' @@ -443,6 +476,7 @@ def _load_ai_pick_selection(session, limit=PRODUCT_PICK_LIST_LIMIT): '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')), + **_ai_pick_evidence_fields(row.get('model_footprint')), } return skus, pick_map @@ -454,15 +488,25 @@ def _summarize_ai_pick_selection(ai_pick_map): return { 'count': 0, 'avg_confidence': 0, + 'avg_evidence_quality': 0, + 'avg_opportunity_score': 0, 'avg_gap_pct': 0, 'max_gap_pct': 0, 'total_gap_amount': 0, 'high_confidence_count': 0, + 'needs_evidence_count': 0, + 'top_missing_evidence': [], 'generated_at': None, } confidence_values = [pick.get('confidence', 0) for pick in picks] + evidence_values = [pick.get('evidence_quality', 0) for pick in picks] + opportunity_values = [pick.get('opportunity_score', 0) for pick in picks] gap_values = [pick.get('gap_pct', 0) for pick in picks] + missing_counts = {} + for pick in picks: + for label in pick.get('missing_evidence', []): + missing_counts[label] = missing_counts.get(label, 0) + 1 total_gap_amount = sum( max((pick.get('momo_price') or 0) - (pick.get('pchome_price') or 0), 0) for pick in picks @@ -471,10 +515,17 @@ def _summarize_ai_pick_selection(ai_pick_map): return { 'count': len(picks), 'avg_confidence': round(sum(confidence_values) / len(confidence_values), 3), + 'avg_evidence_quality': round(sum(evidence_values) / len(evidence_values), 1), + 'avg_opportunity_score': round(sum(opportunity_values) / len(opportunity_values), 1), '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), + 'needs_evidence_count': sum(1 for pick in picks if pick.get('confidence_band') == 'needs_evidence'), + 'top_missing_evidence': [ + {'label': label, 'count': count} + for label, count in sorted(missing_counts.items(), key=lambda item: item[1], reverse=True)[:3] + ], 'generated_at': max( (pick.get('created_at') for pick in picks if pick.get('created_at')), default=None, diff --git a/templates/dashboard_v2.html b/templates/dashboard_v2.html index a39503f..fd1cc16 100644 --- a/templates/dashboard_v2.html +++ b/templates/dashboard_v2.html @@ -359,7 +359,7 @@ .dashboard-ai-summary-grid { display: grid; - grid-template-columns: repeat(5, minmax(0, 1fr)); + grid-template-columns: repeat(6, minmax(0, 1fr)); gap: 0; border-bottom: 1px solid var(--momo-border-light); } @@ -634,6 +634,39 @@ font-weight: 800; } + .dashboard-ai-pick-confidence.is-needs-evidence { + color: var(--momo-warning-text); + } + + .dashboard-ai-pick-confidence.is-medium { + color: var(--momo-accent-strong); + } + + .dashboard-ai-evidence-line { + display: flex; + align-items: center; + gap: 6px; + flex-wrap: wrap; + color: var(--momo-text-tertiary); + font-family: var(--momo-font-family-mono); + font-size: 10px; + font-weight: 800; + } + + .dashboard-ai-evidence-chip { + display: inline-flex; + max-width: 180px; + align-items: center; + padding: 2px 7px; + overflow: hidden; + color: var(--momo-warning-text); + background: var(--momo-warning-bg); + border: 1px solid rgba(161, 111, 35, 0.18); + border-radius: var(--momo-radius-pill); + text-overflow: ellipsis; + white-space: nowrap; + } + .dashboard-ai-pick-reason { display: -webkit-box; overflow: hidden; @@ -895,10 +928,19 @@ {{ pick.name }}
AI {{ (pick.confidence * 100) | round(0) | int if pick.confidence else 0 }}% + 證據 {{ pick.evidence_quality | round(0) | int }}% + 機會 {{ pick.opportunity_score | round(0) | int }} MOMO ${{ pick.momo_price | int | number_format }} PChome ${{ pick.pchome_price | int | number_format }} +{{ pick.gap_pct | round(1) }}%
+ {% if pick.missing_evidence %} +
+ {% for evidence in pick.missing_evidence[:2] %} + {{ evidence }} + {% endfor %} +
+ {% endif %} +
+
EVIDENCE
+
{{ ai_pick_summary.avg_evidence_quality | round(0) | int }}%
+
需補證據 {{ ai_pick_summary.needs_evidence_count | number_format }} 品
+
AVG GAP
+{{ ai_pick_summary.avg_gap_pct | round(1) }}%
@@ -1057,9 +1104,21 @@
清單內最大價格優勢
-
PRICE ROOM
-
${{ ai_pick_summary.total_gap_amount | int | number_format }}
-
50 品估算總價差空間
+
EVIDENCE GAP
+
+ {% if ai_pick_summary.top_missing_evidence %} + {{ ai_pick_summary.top_missing_evidence[0].count | number_format }} + {% else %} + 0 + {% endif %} +
+
+ {% if ai_pick_summary.top_missing_evidence %} + {{ ai_pick_summary.top_missing_evidence[0].label }} + {% else %} + 暫無待補證據 + {% endif %} +
{% endif %} @@ -1122,7 +1181,7 @@ {% endif %} {% if item.ai_pick %}
- AI挑品 #{{ item.ai_pick.rank }} · 信心 {{ (item.ai_pick.confidence * 100) | round(0) | int }}% · 價差 {{ item.ai_pick.gap_pct | round(1) }}% + AI挑品 #{{ item.ai_pick.rank }} · 信心 {{ (item.ai_pick.confidence * 100) | round(0) | int }}% · 證據 {{ item.ai_pick.evidence_quality | round(0) | int }}% · 價差 {{ item.ai_pick.gap_pct | round(1) }}%
{% endif %} @@ -1171,9 +1230,23 @@
#{{ item.ai_pick.rank }} - 信心 {{ (item.ai_pick.confidence * 100) | round(0) | int }}% + 信心 {{ (item.ai_pick.confidence * 100) | round(0) | int }}% +
+
+ 機會 {{ item.ai_pick.opportunity_score | round(0) | int }} + 證據 {{ item.ai_pick.evidence_quality | round(0) | int }}% + {% if item.ai_pick.margin_rate is not none %} + 毛利 {{ item.ai_pick.margin_rate | round(1) }}% + {% endif %}
{{ item.ai_pick.reason }}
+ {% if item.ai_pick.missing_evidence %} +
+ {% for evidence in item.ai_pick.missing_evidence[:3] %} + {{ evidence }} + {% endfor %} +
+ {% endif %}
{% else %} 尚無建議理由 diff --git a/tests/test_frontend_v2_assets.py b/tests/test_frontend_v2_assets.py index ae3a5cb..0f2cf0c 100644 --- a/tests/test_frontend_v2_assets.py +++ b/tests/test_frontend_v2_assets.py @@ -200,6 +200,8 @@ def test_ai_product_pick_agent_uses_real_competitor_data_and_dashboard_action(): assert "evidence_quality" in agent_source assert "opportunity_score" in agent_source assert "margin_rate" in agent_source + assert "missing_evidence" in agent_source + assert "confidence_band" in agent_source assert "_supersede_old_picks" in agent_source assert "status = 'superseded'" in agent_source assert "{date_col}::date" in agent_source @@ -215,6 +217,15 @@ def test_ai_product_pick_agent_uses_real_competitor_data_and_dashboard_action(): assert "完成後會重算 AI 挑品清單" in route_source assert "match_rate" in route_source assert "product_pick_count" in route_source + dashboard_route_source = (ROOT / "routes/dashboard_routes.py").read_text(encoding="utf-8") + dashboard_template = (ROOT / "templates/dashboard_v2.html").read_text(encoding="utf-8") + assert "model_footprint" in dashboard_route_source + assert "_ai_pick_evidence_fields" in dashboard_route_source + assert "avg_evidence_quality" in dashboard_route_source + assert "top_missing_evidence" in dashboard_route_source + assert "證據 {{ item.ai_pick.evidence_quality" in dashboard_template + assert "dashboard-ai-evidence-chip" in dashboard_template + assert "EVIDENCE GAP" in dashboard_template scheduler_source = (ROOT / "scheduler.py").read_text(encoding="utf-8") run_scheduler_source = (ROOT / "run_scheduler.py").read_text(encoding="utf-8")