feat(dashboard): show AI pick evidence gaps
All checks were successful
CD Pipeline / deploy (push) Successful in 2m18s

This commit is contained in:
OoO
2026-05-01 17:17:03 +08:00
parent e86075d59d
commit 066cf1846f
5 changed files with 144 additions and 9 deletions

4
app.py
View File

@@ -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 防護函數

View File

@@ -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 # 用於模板顯示

View File

@@ -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,

View File

@@ -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 @@
<a class="dashboard-focus-row-title" href="{{ pick.momo_url }}" target="_blank" rel="noopener noreferrer">{{ pick.name }}</a>
<div class="dashboard-focus-row-meta momo-mono">
<span class="dashboard-focus-chip is-win">AI {{ (pick.confidence * 100) | round(0) | int if pick.confidence else 0 }}%</span>
<span>證據 {{ pick.evidence_quality | round(0) | int }}%</span>
<span>機會 {{ pick.opportunity_score | round(0) | int }}</span>
<span>MOMO ${{ pick.momo_price | int | number_format }}</span>
<span>PChome ${{ pick.pchome_price | int | number_format }}</span>
<span>+{{ pick.gap_pct | round(1) }}%</span>
</div>
{% if pick.missing_evidence %}
<div class="dashboard-ai-evidence-line">
{% for evidence in pick.missing_evidence[:2] %}
<span class="dashboard-ai-evidence-chip">{{ evidence }}</span>
{% endfor %}
</div>
{% endif %}
<div class="dashboard-focus-row-links">
<a class="dashboard-platform-link is-momo" href="{{ pick.momo_url }}" target="_blank" rel="noopener noreferrer">MOMO {{ pick.sku }}</a>
{% if pick.pchome_url %}
@@ -1046,6 +1088,11 @@
<div class="dashboard-ai-summary-value momo-mono">{{ (ai_pick_summary.avg_confidence * 100) | round(0) | int }}%</div>
<div class="dashboard-ai-summary-sub">高信心 {{ ai_pick_summary.high_confidence_count | number_format }} 品</div>
</div>
<div class="dashboard-ai-summary-item">
<div class="dashboard-ai-summary-label">EVIDENCE</div>
<div class="dashboard-ai-summary-value momo-mono">{{ ai_pick_summary.avg_evidence_quality | round(0) | int }}%</div>
<div class="dashboard-ai-summary-sub">需補證據 {{ ai_pick_summary.needs_evidence_count | number_format }} 品</div>
</div>
<div class="dashboard-ai-summary-item">
<div class="dashboard-ai-summary-label">AVG GAP</div>
<div class="dashboard-ai-summary-value momo-mono">+{{ ai_pick_summary.avg_gap_pct | round(1) }}%</div>
@@ -1057,9 +1104,21 @@
<div class="dashboard-ai-summary-sub">清單內最大價格優勢</div>
</div>
<div class="dashboard-ai-summary-item">
<div class="dashboard-ai-summary-label">PRICE ROOM</div>
<div class="dashboard-ai-summary-value momo-mono">${{ ai_pick_summary.total_gap_amount | int | number_format }}</div>
<div class="dashboard-ai-summary-sub">50 品估算總價差空間</div>
<div class="dashboard-ai-summary-label">EVIDENCE GAP</div>
<div class="dashboard-ai-summary-value momo-mono">
{% if ai_pick_summary.top_missing_evidence %}
{{ ai_pick_summary.top_missing_evidence[0].count | number_format }}
{% else %}
0
{% endif %}
</div>
<div class="dashboard-ai-summary-sub">
{% if ai_pick_summary.top_missing_evidence %}
{{ ai_pick_summary.top_missing_evidence[0].label }}
{% else %}
暫無待補證據
{% endif %}
</div>
</div>
</div>
{% endif %}
@@ -1122,7 +1181,7 @@
{% endif %}
{% if item.ai_pick %}
<div class="dashboard-product-id momo-mono" title="{{ item.ai_pick.reason }}">
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) }}%
</div>
{% endif %}
</div>
@@ -1171,9 +1230,23 @@
<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">信心 {{ (item.ai_pick.confidence * 100) | round(0) | int }}%</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-evidence-line">
<span>機會 {{ item.ai_pick.opportunity_score | round(0) | int }}</span>
<span>證據 {{ item.ai_pick.evidence_quality | round(0) | int }}%</span>
{% if item.ai_pick.margin_rate is not none %}
<span>毛利 {{ item.ai_pick.margin_rate | round(1) }}%</span>
{% endif %}
</div>
<div class="dashboard-ai-pick-reason">{{ item.ai_pick.reason }}</div>
{% if item.ai_pick.missing_evidence %}
<div class="dashboard-ai-evidence-line" title="{{ item.ai_pick.missing_evidence_text }}">
{% for evidence in item.ai_pick.missing_evidence[:3] %}
<span class="dashboard-ai-evidence-chip">{{ evidence }}</span>
{% endfor %}
</div>
{% endif %}
</div>
{% else %}
<span style="color:var(--momo-text-tertiary);">尚無建議理由</span>

View File

@@ -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")