feat(dashboard): show AI pick evidence gaps
All checks were successful
CD Pipeline / deploy (push) Successful in 2m18s
All checks were successful
CD Pipeline / deploy (push) Successful in 2m18s
This commit is contained in:
4
app.py
4
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 防護函數
|
||||
|
||||
@@ -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 # 用於模板顯示
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user