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 }}
+ {% if pick.missing_evidence %} +