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 %} +
+
+ AI 價格決策 by strategy({{ days }} 日) + 資料來源:ai_price_recommendations +
+
+ + + + + + + + + + + + {% for s in rec_by_strategy %} + + + + + + + + {% endfor %} + +
策略數量平均信心平均 gap %平均 7d 銷量變化 %
+ {% 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) }}% + +
+
+
+ {% else %} +
+ + 過去 {{ days }} 日無 AI 價格決策資料(ai_price_recommendations 表)。 +
+ {% endif %} + + + {% if latest_recommendations %} +
+
+ 最近 20 筆 AI 價格建議 + 資料來源:ai_price_recommendations(含競品快照) +
+
+ + + + + + + + + + + + + + {% for r in latest_recommendations %} + + + + + + + + + + + + + {% endfor %} + +
時間SKU商品策略信心MOMO 價PChome 價Gap7d 銷量原因
{{ 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 %}
+
+
+ {% endif %} + + + {% if loop_records %} +
+
+ 閉環學習:plan → outcome 全鏈追蹤({{ days }} 日) + 資料來源:action_plans LEFT JOIN action_outcomes(ADR-012 核心) +
+
+ + + + + + + + + + + + {% for l in loop_records %} + + + + + + + + + + + + + + + {% endfor %} + +
PlanSKU類型狀態建立者建立時間執行時間Verdict指標BeforeAfter變化
#{{ 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 %} +
+
+
+ {% endif %} + + + {% if verdict_stats %} +
+
+ Outcomes Verdict 分布 + {{ days }} 日 · AI 動作的實際成效閉環 +
+
+
+ {% 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 %} +
+
+ 競品比對嘗試({{ days }} 日) + 資料來源:competitor_match_attempts — AI 找不到對應商品時的失敗追蹤 +
+
+ + + + + + + + + + + {% for m in match_stats %} + + + + + + + {% endfor %} + +
狀態次數平均候選數平均匹配分
+ {% 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) }}
+
+
+ {% endif %} + + + {% if recent_competitor_prices %} +
+
+ 過去 24h 競品價格抓取(match_score ≥ 0.7) + 資料來源:competitor_price_history — AI 看到的競品價格全景 +
+
+ + + + + + + + + + + {% for c in recent_competitor_prices %} + + + + + + + + + + + {% endfor %} + +
時間SKU競品商品PChomeMOMOGap折扣匹配
{{ 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) }}
+
+
+ {% 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 %} - +
-
7 大子頁入口
+
8 大子頁入口