diff --git a/routes/admin_observability_routes.py b/routes/admin_observability_routes.py
index c0bc878..aecd803 100644
--- a/routes/admin_observability_routes.py
+++ b/routes/admin_observability_routes.py
@@ -247,6 +247,205 @@ def observability_overview():
)
+# ─────────────────────────────────────────────────────────────────────────────
+# /observability/business_intel — Phase 48 商業面 × AI 編排
+# ─────────────────────────────────────────────────────────────────────────────
+
+@admin_observability_bp.route('/business_intel')
+@login_required
+def business_intel_dashboard():
+ """Phase 48 — 商業面 × AI 編排:把 AI 觀測台延伸到商業層級。
+
+ 展現「AI 在做什麼生意」:
+ - ai_price_recommendations × competitor_prices: AI 看到什麼定價機會
+ - action_plans × action_outcomes: 計畫到 verdict 的閉環
+ - competitor_match_attempts: 競品比對失敗追蹤
+ """
+ days = int(request.args.get('days', '7'))
+ session = get_session()
+ try:
+ # 1. ai_price_recommendations 30d 總覽
+ rec_summary = session.execute(
+ sa_text(f"""
+ SELECT strategy, COUNT(*) AS cnt,
+ COALESCE(AVG(confidence), 0) AS avg_conf,
+ COALESCE(AVG(gap_pct), 0) AS avg_gap_pct,
+ COALESCE(AVG(sales_7d_delta), 0) AS avg_sales_delta
+ FROM ai_price_recommendations
+ WHERE created_at >= NOW() - INTERVAL '{int(days)} days'
+ GROUP BY strategy ORDER BY cnt DESC
+ """),
+ ).fetchall()
+ rec_by_strategy = [
+ {
+ 'strategy': r[0], 'count': int(r[1] or 0),
+ 'avg_confidence': round(float(r[2] or 0), 3),
+ 'avg_gap_pct': round(float(r[3] or 0), 2),
+ 'avg_sales_delta': round(float(r[4] or 0), 2),
+ }
+ for r in rec_summary
+ ]
+
+ # 2. ai_price_recommendations 最近 20 筆詳細
+ latest_recs = session.execute(
+ sa_text("""
+ SELECT id, sku, LEFT(name, 50), strategy, confidence,
+ momo_price, pchome_price, gap_pct, sales_7d_delta,
+ LEFT(reason, 120), created_at
+ FROM ai_price_recommendations
+ ORDER BY created_at DESC LIMIT 20
+ """),
+ ).fetchall()
+ latest_recommendations = [
+ {
+ 'id': r[0], 'sku': r[1], 'name': r[2], 'strategy': r[3],
+ 'confidence': round(float(r[4] or 0), 3),
+ 'momo_price': float(r[5] or 0),
+ 'pchome_price': float(r[6] or 0) if r[6] else None,
+ 'gap_pct': round(float(r[7] or 0), 2),
+ 'sales_delta': round(float(r[8] or 0), 2) if r[8] is not None else None,
+ 'reason': r[9] or '',
+ 'created_at': r[10].strftime('%m-%d %H:%M') if r[10] else '',
+ }
+ for r in latest_recs
+ ]
+
+ # 3. action_plans × action_outcomes 閉環(30d)
+ closed_loops = session.execute(
+ sa_text(f"""
+ SELECT p.id, p.sku, p.plan_type, p.status,
+ p.created_by, p.created_at, p.executed_at,
+ o.verdict, o.metric_type, o.before_val, o.after_val
+ FROM action_plans p
+ LEFT JOIN action_outcomes o ON o.plan_id = p.id
+ WHERE p.created_at >= NOW() - INTERVAL '{int(days)} days'
+ ORDER BY p.created_at DESC LIMIT 25
+ """),
+ ).fetchall()
+ loop_records = []
+ for r in closed_loops:
+ before = float(r[9]) if r[9] is not None else None
+ after = float(r[10]) if r[10] is not None else None
+ change_pct = None
+ if before and before != 0 and after is not None:
+ change_pct = (after - before) / abs(before) * 100
+ loop_records.append({
+ 'plan_id': r[0], 'sku': r[1], 'plan_type': r[2],
+ 'status': r[3], 'created_by': r[4],
+ 'created_at': r[5].strftime('%m-%d %H:%M') if r[5] else '',
+ 'executed_at': r[6].strftime('%m-%d %H:%M') if r[6] else None,
+ 'verdict': r[7], 'metric_type': r[8],
+ 'before': before, 'after': after, 'change_pct': change_pct,
+ })
+
+ # 4. action_outcomes verdict 統計
+ verdict_summary = session.execute(
+ sa_text(f"""
+ SELECT verdict, COUNT(*) AS cnt,
+ AVG(after_val - before_val) AS avg_delta
+ FROM action_outcomes
+ WHERE created_at >= NOW() - INTERVAL '{int(days)} days'
+ AND before_val IS NOT NULL AND after_val IS NOT NULL
+ GROUP BY verdict ORDER BY cnt DESC
+ """),
+ ).fetchall()
+ verdict_stats = [
+ {
+ 'verdict': r[0] or 'unknown', 'count': int(r[1] or 0),
+ 'avg_delta': round(float(r[2] or 0), 2),
+ }
+ for r in verdict_summary
+ ]
+
+ # 5. competitor_match_attempts 失敗統計(30d)
+ match_attempts = session.execute(
+ sa_text(f"""
+ SELECT attempt_status, COUNT(*) AS cnt,
+ COALESCE(AVG(candidate_count), 0) AS avg_candidates,
+ COALESCE(AVG(best_match_score), 0) AS avg_score
+ FROM competitor_match_attempts
+ WHERE attempted_at >= NOW() - INTERVAL '{int(days)} days'
+ GROUP BY attempt_status ORDER BY cnt DESC
+ """),
+ ).fetchall()
+ match_stats = [
+ {
+ 'status': r[0], 'count': int(r[1] or 0),
+ 'avg_candidates': round(float(r[2] or 0), 1),
+ 'avg_score': round(float(r[3] or 0), 3),
+ }
+ for r in match_attempts
+ ]
+
+ # 6. competitor_prices 24h 變動 TOP 10
+ recent_competitor = session.execute(
+ sa_text("""
+ SELECT cph.sku, cph.competitor_product_name, cph.price,
+ cph.momo_price, cph.discount_pct, cph.match_score,
+ cph.crawled_at
+ FROM competitor_price_history cph
+ WHERE cph.crawled_at >= NOW() - INTERVAL '24 hours'
+ AND cph.match_score >= 0.7
+ ORDER BY cph.crawled_at DESC LIMIT 12
+ """),
+ ).fetchall()
+ recent_competitor_prices = [
+ {
+ 'sku': r[0],
+ 'product_name': (r[1] or '')[:50],
+ 'pchome_price': float(r[2] or 0),
+ 'momo_price': float(r[3] or 0) if r[3] else None,
+ 'discount_pct': int(r[4]) if r[4] else None,
+ 'match_score': round(float(r[5] or 0), 3),
+ 'gap': (float(r[3]) - float(r[2])) if (r[2] and r[3]) else None,
+ 'crawled_at': r[6].strftime('%m-%d %H:%M') if r[6] else '',
+ }
+ for r in recent_competitor
+ ]
+
+ # 7. 高 confidence 但未 follow-through (recommendation 沒對應 action_plan)
+ unfollowed = session.execute(
+ sa_text(f"""
+ SELECT COUNT(*)
+ FROM ai_price_recommendations r
+ WHERE r.created_at >= NOW() - INTERVAL '{int(days)} days'
+ AND r.confidence >= 0.7
+ AND NOT EXISTS (
+ SELECT 1 FROM action_plans p
+ WHERE p.sku = r.sku
+ AND p.created_at >= r.created_at
+ AND p.created_at < r.created_at + INTERVAL '7 days'
+ )
+ """),
+ ).fetchone()
+ unfollowed_count = int(unfollowed[0] or 0) if unfollowed else 0
+
+ return render_template(
+ 'admin/business_intel.html',
+ active_page='obs_business_intel',
+ days=days,
+ rec_by_strategy=rec_by_strategy,
+ latest_recommendations=latest_recommendations,
+ loop_records=loop_records,
+ verdict_stats=verdict_stats,
+ match_stats=match_stats,
+ recent_competitor_prices=recent_competitor_prices,
+ unfollowed_count=unfollowed_count,
+ error=None,
+ )
+ except Exception as e:
+ return render_template(
+ 'admin/business_intel.html',
+ active_page='obs_business_intel', days=days,
+ rec_by_strategy=[], latest_recommendations=[], loop_records=[],
+ verdict_stats=[], match_stats=[], recent_competitor_prices=[],
+ unfollowed_count=0,
+ error=f'查詢失敗: {type(e).__name__}: {str(e)[:200]}',
+ )
+ finally:
+ session.close()
+
+
# ─────────────────────────────────────────────────────────────────────────────
# /observability/agent_orchestration — Phase 46 編排矩陣
# ─────────────────────────────────────────────────────────────────────────────
diff --git a/templates/admin/business_intel.html b/templates/admin/business_intel.html
new file mode 100644
index 0000000..c0b5ae2
--- /dev/null
+++ b/templates/admin/business_intel.html
@@ -0,0 +1,327 @@
+{% extends "ewoooc_base.html" %}
+
+{% block title %}商業面 × AI 編排{% endblock %}
+
+{% block ewooo_content %}
+
+
商業面 × AI 編排
+ 過去 {{ days }} 日 · AI 在做什麼生意?實際生效嗎?
+
+
+ {% if error %}
+
{{ error }}
+ {% endif %}
+
+
+
+ {% if unfollowed_count > 0 %}
+
+
+ 過去 {{ days }} 日有 {{ unfollowed_count }} 筆 high-confidence (≥0.7) AI 建議
+ 未轉化為 action_plan — 機會流失!
+
+ {% endif %}
+
+
+ {% if rec_by_strategy %}
+
+
+
+
+
+
+ | 策略 |
+ 數量 |
+ 平均信心 |
+ 平均 gap % |
+ 平均 7d 銷量變化 % |
+
+
+
+ {% for s in rec_by_strategy %}
+
+ |
+ {% if s.strategy == 'promote' %}promote 推廣
+ {% elif s.strategy == 'watch' %}watch 觀察
+ {% elif s.strategy == 'hold' %}hold 持平
+ {% else %}{{ s.strategy }}{% endif %}
+ |
+ {{ s.count }} |
+ {{ "%.2f"|format(s.avg_confidence) }} |
+
+
+ {{ "%.1f"|format(s.avg_gap_pct) }}%
+
+ |
+
+
+ {{ "%+.1f"|format(s.avg_sales_delta) }}%
+
+ |
+
+ {% endfor %}
+
+
+
+
+ {% else %}
+
+
+ 過去 {{ days }} 日無 AI 價格決策資料(ai_price_recommendations 表)。
+
+ {% endif %}
+
+
+ {% if latest_recommendations %}
+
+
+
+
+
+
+ | 時間 | SKU | 商品 | 策略 |
+ 信心 |
+ MOMO 價 |
+ PChome 價 |
+ Gap |
+ 7d 銷量 |
+ 原因 |
+
+
+
+ {% for r in latest_recommendations %}
+
+ | {{ r.created_at }} |
+ {{ r.sku }} |
+ {{ r.name }}{% if r.name|length >= 50 %}…{% endif %} |
+
+ {% if r.strategy == 'promote' %}推
+ {% elif r.strategy == 'watch' %}觀
+ {% elif r.strategy == 'hold' %}持
+ {% else %}{{ r.strategy }}{% endif %}
+ |
+
+
+ {{ "%.2f"|format(r.confidence) }}
+
+ |
+ ${{ "%.0f"|format(r.momo_price) }} |
+
+ {% if r.pchome_price %}${{ "%.0f"|format(r.pchome_price) }}{% else %}—{% endif %}
+ |
+
+
+ {{ "%+.1f"|format(r.gap_pct) }}%
+
+ |
+
+ {% if r.sales_delta is not none %}
+
+ {{ "%+.1f"|format(r.sales_delta) }}%
+
+ {% else %}—{% endif %}
+ |
+ {{ r.reason }}{% if r.reason|length >= 120 %}…{% endif %} |
+
+ {% endfor %}
+
+
+
+
+ {% endif %}
+
+
+ {% if loop_records %}
+
+
+
+
+
+
+ | Plan | SKU | 類型 | 狀態 |
+ 建立者 | 建立時間 | 執行時間 |
+ Verdict | 指標 |
+ Before | After |
+ 變化 |
+
+
+
+ {% for l in loop_records %}
+
+ #{{ l.plan_id }} |
+ {{ l.sku or '—' }} |
+ {{ l.plan_type or '—' }} |
+
+ {% if l.status == 'pending' %}待審
+ {% elif l.status == 'approved' %}已批
+ {% elif l.status == 'executed' %}已執
+ {% elif l.status == 'rejected' %}拒絕
+ {% else %}{{ l.status }}{% endif %}
+ |
+ {{ l.created_by or '—' }} |
+ {{ l.created_at }} |
+ {{ l.executed_at or '—' }} |
+
+ {% if l.verdict == 'effective' %}有效
+ {% elif l.verdict == 'backfired' %}適得其反
+ {% elif l.verdict == 'neutral' %}無變
+ {% else %}—{% endif %}
+ |
+ {{ l.metric_type or '—' }} |
+ {% if l.before is not none %}{{ "%.1f"|format(l.before) }}{% else %}—{% endif %} |
+ {% if l.after is not none %}{{ "%.1f"|format(l.after) }}{% else %}—{% endif %} |
+
+ {% if l.change_pct is not none %}
+
+ {{ "%+.1f"|format(l.change_pct) }}%
+
+ {% else %}—{% endif %}
+ |
+
+ {% endfor %}
+
+
+
+
+ {% endif %}
+
+
+ {% if verdict_stats %}
+
+
+
+
+ {% set total_v = (verdict_stats | sum(attribute='count')) or 1 %}
+ {% for v in verdict_stats %}
+
+
+
+ {% if v.verdict == 'effective' %} 有效
+ {% elif v.verdict == 'backfired' %} 適得其反
+ {% elif v.verdict == 'neutral' %} 無變化
+ {% else %}{{ v.verdict }}{% endif %}
+
+ {{ v.count }}
+ {{ "%.0f"|format(v.count / total_v * 100) }}% · Δ {{ "%+.1f"|format(v.avg_delta) }}
+
+
+ {% endfor %}
+
+
+
+ {% endif %}
+
+
+ {% if match_stats %}
+
+
+
+
+
+
+ | 狀態 |
+ 次數 |
+ 平均候選數 |
+ 平均匹配分 |
+
+
+
+ {% for m in match_stats %}
+
+ |
+ {% if 'success' in (m.status or '').lower() %}{{ m.status }}
+ {% elif 'fail' in (m.status or '').lower() or 'no_' in (m.status or '').lower() %}{{ m.status }}
+ {% else %}{{ m.status }}{% endif %}
+ |
+ {{ "{:,}".format(m.count) }} |
+ {{ m.avg_candidates }} |
+ {{ "%.2f"|format(m.avg_score) }} |
+
+ {% endfor %}
+
+
+
+
+ {% endif %}
+
+
+ {% if recent_competitor_prices %}
+
+
+
+
+
+
+ | 時間 | SKU | 競品商品 |
+ PChome | MOMO |
+ Gap | 折扣 |
+ 匹配 |
+
+
+
+ {% for c in recent_competitor_prices %}
+
+ | {{ c.crawled_at }} |
+ {{ c.sku }} |
+ {{ c.product_name }} |
+ ${{ "%.0f"|format(c.pchome_price) }} |
+
+ {% if c.momo_price %}${{ "%.0f"|format(c.momo_price) }}{% else %}—{% endif %}
+ |
+
+ {% if c.gap is not none %}
+
+ {{ "%+.0f"|format(c.gap) }}
+
+ {% else %}—{% endif %}
+ |
+
+ {% if c.discount_pct %}-{{ c.discount_pct }}%
+ {% else %}—{% endif %}
+ |
+ {{ "%.2f"|format(c.match_score) }} |
+
+ {% endfor %}
+
+
+
+
+ {% endif %}
+
+
+ Operation Ollama-First v5.0 / Phase 48 — 商業面 × AI 編排
+ (6 表跨 JOIN:ai_price_recommendations / action_plans / action_outcomes /
+ competitor_price_history / competitor_match_attempts / competitor_prices)
+
+
+{% endblock %}
diff --git a/templates/admin/observability_overview.html b/templates/admin/observability_overview.html
index 220318c..1a59ad5 100644
--- a/templates/admin/observability_overview.html
+++ b/templates/admin/observability_overview.html
@@ -219,9 +219,9 @@
{% endif %}
-
+
-
+
@@ -230,6 +230,12 @@
4 Agent × Ollama × Gemini × MCP × RAG 全景 + 自動建議
+