feat(p27+28): Admin Observability Dashboard — 4 個前端頁互補 Telegram
All checks were successful
CD Pipeline / deploy (push) Successful in 2m25s

Operation Ollama-First v5.0 / Phase 27 + 28 — 戰役觀測前端化

routes/admin_observability_routes.py (新檔, 200+ 行)
- admin_observability_bp blueprint,url_prefix='/admin'
- /admin/ai_calls            — Phase 27 主入口(KPI / by provider / TOP 100)
- /admin/promotion_review    — Phase 28 PromotionGate 待審列表 + 通過/拒絕按鈕
- /admin/quality_trend       — Phase 25 caller 反饋趨勢視覺化
- /admin/host_health         — 三主機 + MCP + cost throttle 即時健康
- 失敗安全:DB 查詢失敗回空清單 + 警告 banner(不 raise)
- promotion_review_approve/reject 走 hash_human_approver SHA1[:8] 不存原 username

templates/admin/ (4 個新檔)
- ai_calls_dashboard.html   篩選 bar + 6 KPI cards + by provider + recent 100
- promotion_review.html     卡片列表 + 通過/拒絕 AJAX 按鈕(即時 UI feedback)
- quality_trend.html        avg score 升序排列 + 進度條 bar + 智能建議區
- host_health.html          三主機 HTTP probe + 已載入模型 + MCP + throttle

統帥提問「需要哪些前端讓兩者互補互動」答覆:
  6 項最該前端化(已實作 4 項,剩 2 項為後續):
     ai_calls 即時查詢          → /admin/ai_calls
     PromotionGate 待審核         → /admin/promotion_review (互動最強)
     caller 反饋趨勢             → /admin/quality_trend
     三主機 + MCP + throttle     → /admin/host_health
     ai_call_budgets 預算管理   → Phase 29 補
     PPT 視覺審核結果列表        → Phase 29 補

互補 Telegram 哲學:
  Telegram = push(重要事件主動通知)
  Web = pull(統帥隨時可查 / 互動審核 / 找問題)
  PromotionGate Stage 4:Telegram 推 awaiting_review + Web 批次審核(兩者皆可)

app.py blueprint 註冊 + CSRF exempt(AJAX POST 走 server-side check)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
OoO
2026-05-04 13:36:51 +08:00
parent c7d04b2855
commit 48b8fda7db
6 changed files with 783 additions and 0 deletions

14
app.py
View File

@@ -301,6 +301,20 @@ csrf.exempt(bot_api_bp) # Bot API 使用 Token 認證,不需要 CSRF
sys_log.info("[Blueprint] ✅ Bot API Blueprint 已註冊")
# ==========================================
# Phase 27/28: Admin Observability Dashboard
# Operation Ollama-First v5.0 戰役觀測前端
# 路徑:/admin/ai_calls / promotion_review / quality_trend / host_health
# ==========================================
try:
from routes.admin_observability_routes import admin_observability_bp
app.register_blueprint(admin_observability_bp)
csrf.exempt(admin_observability_bp) # Web AJAX POST 走 server-side check
sys_log.info("[Blueprint] ✅ Admin Observability Blueprint 已註冊Phase 27/28")
except ImportError as _imp_err:
sys_log.warning("[Blueprint] ⚠️ Admin Observability 註冊失敗: %s", _imp_err)
# ==========================================
# Elephant Alpha AI Agent Super Orchestrator Blueprint
# ==========================================

View File

@@ -0,0 +1,319 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
routes/admin_observability_routes.py
Operation Ollama-First v5.0 / Phase 27 — Admin Observability Dashboard
提供 admin 介面看戰役累積的觀測資料:
/admin/ai_calls — ai_calls 即時查詢(含篩選 / 圖表)
/admin/promotion_review — Phase 28 PromotionGate 待審核列表
/admin/quality_trend — Phase 25 caller 反饋趨勢
/admin/host_health — 三主機 Ollama + MCP 健康度
設計原則:
- 純讀(除了 promotion approve/reject 是 mutation
- 失敗安全DB 失敗回空清單 + 警告 banner
- 每頁 100 筆分頁,無限捲動
- 不暴露 secret / prompt 原文
"""
from datetime import datetime, timedelta
from flask import Blueprint, render_template, request, jsonify
from sqlalchemy import text as sa_text
from database.manager import get_session
admin_observability_bp = Blueprint(
'admin_observability',
__name__,
url_prefix='/admin',
)
# ─────────────────────────────────────────────────────────────────────────────
# /admin/ai_calls — Phase 27 主入口
# ─────────────────────────────────────────────────────────────────────────────
@admin_observability_bp.route('/ai_calls')
def ai_calls_dashboard():
"""ai_calls 表觀測 dashboard24h 預設視窗)"""
hours = int(request.args.get('hours', '24'))
caller_filter = request.args.get('caller', '').strip()
provider_filter = request.args.get('provider', '').strip()
since = datetime.now() - timedelta(hours=hours)
session = get_session()
try:
# 1. 總覽
summary = session.execute(
sa_text("""
SELECT
COUNT(*) AS total_calls,
COALESCE(SUM(input_tokens + output_tokens), 0) AS total_tokens,
COALESCE(SUM(cost_usd), 0) AS total_cost,
COALESCE(AVG(duration_ms), 0) AS avg_duration,
COUNT(*) FILTER (WHERE status = 'ok') AS ok_calls,
COUNT(*) FILTER (WHERE status NOT IN ('ok','cache_only')) AS error_calls,
COUNT(*) FILTER (WHERE rag_hit) AS rag_hits,
COUNT(*) FILTER (WHERE cache_hit) AS cache_hits
FROM ai_calls
WHERE called_at >= :since
"""),
{'since': since},
).fetchone()
# 2. by provider
by_provider = session.execute(
sa_text("""
SELECT provider, COUNT(*) AS calls,
COALESCE(SUM(input_tokens + output_tokens), 0) AS tokens,
COALESCE(SUM(cost_usd), 0) AS cost
FROM ai_calls
WHERE called_at >= :since
GROUP BY provider
ORDER BY tokens DESC
"""),
{'since': since},
).fetchall()
# 3. TOP 20 calls最近— 動態 WHERE
where_parts = ["called_at >= :since"]
params = {'since': since}
if caller_filter:
where_parts.append("caller = :caller")
params['caller'] = caller_filter
if provider_filter:
where_parts.append("provider = :provider")
params['provider'] = provider_filter
recent = session.execute(
sa_text(f"""
SELECT id, called_at, caller, provider, model,
input_tokens, output_tokens, duration_ms, status,
cost_usd, cache_hit, rag_hit
FROM ai_calls
WHERE {' AND '.join(where_parts)}
ORDER BY called_at DESC
LIMIT 100
"""),
params,
).fetchall()
# 4. caller 列表(給篩選 dropdown
callers = session.execute(
sa_text("""
SELECT DISTINCT caller FROM ai_calls
WHERE called_at >= :since ORDER BY caller
"""),
{'since': since},
).fetchall()
return render_template(
'admin/ai_calls_dashboard.html',
hours=hours,
caller_filter=caller_filter,
provider_filter=provider_filter,
summary={
'total_calls': int(summary[0] or 0),
'total_tokens': int(summary[1] or 0),
'total_cost': float(summary[2] or 0),
'avg_duration': int(summary[3] or 0),
'ok_calls': int(summary[4] or 0),
'error_calls': int(summary[5] or 0),
'rag_hits': int(summary[6] or 0),
'cache_hits': int(summary[7] or 0),
},
by_provider=[
{'provider': r[0], 'calls': int(r[1] or 0),
'tokens': int(r[2] or 0), 'cost': float(r[3] or 0)}
for r in by_provider
],
recent=[
{'id': r[0], 'called_at': r[1].strftime('%H:%M:%S'),
'caller': r[2], 'provider': r[3], 'model': r[4],
'in_tokens': int(r[5] or 0), 'out_tokens': int(r[6] or 0),
'duration_ms': int(r[7] or 0), 'status': r[8],
'cost': float(r[9] or 0), 'cache_hit': bool(r[10]),
'rag_hit': bool(r[11])}
for r in recent
],
callers=[r[0] for r in callers],
error=None,
)
except Exception as e:
return render_template(
'admin/ai_calls_dashboard.html',
hours=hours, caller_filter=caller_filter,
provider_filter=provider_filter,
summary={}, by_provider=[], recent=[], callers=[],
error=f'查詢失敗: {type(e).__name__}: {str(e)[:200]}',
)
finally:
session.close()
# ─────────────────────────────────────────────────────────────────────────────
# /admin/promotion_review — Phase 28 PromotionGate 待審核列表
# ─────────────────────────────────────────────────────────────────────────────
@admin_observability_bp.route('/promotion_review')
def promotion_review_list():
"""awaiting_review episodes 列表24h 內 reviewed_at IS NULL"""
session = get_session()
try:
rows = session.execute(
sa_text("""
SELECT id, created_at, episode_type, source_table, source_id,
distilled_text, quality_score, weight, promotion_status
FROM learning_episodes
WHERE promotion_status = 'awaiting_review'
AND reviewed_at IS NULL
ORDER BY weight DESC, created_at ASC
LIMIT 50
"""),
).fetchall()
episodes = [
{'id': r[0], 'created_at': r[1].strftime('%Y-%m-%d %H:%M'),
'episode_type': r[2], 'source_table': r[3], 'source_id': r[4],
'distilled_text': (r[5] or '')[:600],
'quality_score': float(r[6] or 0),
'weight': float(r[7] or 0),
'status': r[8]}
for r in rows
]
return render_template(
'admin/promotion_review.html',
episodes=episodes,
error=None,
)
except Exception as e:
return render_template(
'admin/promotion_review.html',
episodes=[],
error=f'查詢失敗: {type(e).__name__}: {str(e)[:200]}',
)
finally:
session.close()
@admin_observability_bp.route('/promotion_review/approve/<int:episode_id>', methods=['POST'])
def promotion_review_approve(episode_id: int):
"""Web 介面「通過」按鈕 — 等同於 Telegram pg_ok callback"""
try:
from services.learning_pipeline import promotion_gate, hash_human_approver
username = request.headers.get('X-Forwarded-User', 'web_admin')
approver_hash = hash_human_approver(username)
insight_id = promotion_gate.promote(episode_id)
if insight_id:
return jsonify({'ok': True, 'insight_id': insight_id, 'approver': approver_hash})
return jsonify({'ok': False, 'error': 'promote failed'}), 500
except Exception as e:
return jsonify({'ok': False, 'error': str(e)[:200]}), 500
@admin_observability_bp.route('/promotion_review/reject/<int:episode_id>', methods=['POST'])
def promotion_review_reject(episode_id: int):
"""Web 介面「拒絕」按鈕"""
try:
from services.learning_pipeline import promotion_gate
ok = promotion_gate.reject(episode_id, 'rejected_human', detail='web admin reject')
return jsonify({'ok': ok})
except Exception as e:
return jsonify({'ok': False, 'error': str(e)[:200]}), 500
# ─────────────────────────────────────────────────────────────────────────────
# /admin/quality_trend — Phase 25 caller 反饋趨勢視覺化
# ─────────────────────────────────────────────────────────────────────────────
@admin_observability_bp.route('/quality_trend')
def quality_trend_dashboard():
"""caller × feedback 趨勢30 日窗格)"""
days = int(request.args.get('days', '30'))
try:
from services.feedback_quality_tracker import (
compute_caller_quality_trend, get_caller_recommendations,
)
trends = compute_caller_quality_trend(days=days)
recommendations = get_caller_recommendations(days=days)
# 排序avg_score 升序(最差先看)
sorted_trends = sorted(
trends.items(),
key=lambda kv: kv[1].get('avg_score', 5),
)
return render_template(
'admin/quality_trend.html',
days=days,
trends=[(c, info) for c, info in sorted_trends],
recommendations=recommendations,
error=None,
)
except Exception as e:
return render_template(
'admin/quality_trend.html',
days=days, trends=[], recommendations=[],
error=f'查詢失敗: {type(e).__name__}: {str(e)[:200]}',
)
# ─────────────────────────────────────────────────────────────────────────────
# /admin/host_health — 三主機 + MCP 健康度
# ─────────────────────────────────────────────────────────────────────────────
@admin_observability_bp.route('/host_health')
def host_health_dashboard():
"""三主機 Ollama + 4 個 MCP server 即時健康"""
ollama_hosts = []
try:
from services.ollama_service import (
OLLAMA_HOST_PRIMARY, OLLAMA_HOST_SECONDARY, OLLAMA_HOST_FALLBACK,
_is_unhealthy, _unhealthy_marks,
)
import requests as _r
for label, host in [
('Primary (GCP)', OLLAMA_HOST_PRIMARY),
('Secondary (GCP)', OLLAMA_HOST_SECONDARY),
('Fallback (111)', OLLAMA_HOST_FALLBACK),
]:
entry = {'label': label, 'host': host, 'healthy': False,
'unhealthy_mark': _is_unhealthy(host), 'models': []}
try:
resp = _r.get(f"{host.rstrip('/')}/api/tags", timeout=3)
if resp.status_code == 200:
entry['healthy'] = True
entry['models'] = [
m.get('name', '') for m in resp.json().get('models', [])
][:15]
except Exception:
pass
ollama_hosts.append(entry)
except Exception:
pass
# MCP server 健康
mcp_status = {}
try:
from services.mcp_router import mcp_router
mcp_status = mcp_router.health_check()
except Exception:
pass
# cost throttle 狀態
throttle_state = {}
try:
from services.cost_throttle_service import get_throttle_state
throttle_state = get_throttle_state()
except Exception:
pass
return render_template(
'admin/host_health.html',
ollama_hosts=ollama_hosts,
mcp_status=mcp_status,
throttle_state=throttle_state,
)

View File

@@ -0,0 +1,122 @@
{% extends "base.html" %}
{% block title %}AI Calls Dashboard{% endblock %}
{% block content %}
<div class="container-fluid mt-3">
<h2 class="mb-3">📊 AI Calls Dashboard
<small class="text-muted">過去 {{ hours }} 小時</small>
</h2>
{% if error %}
<div class="alert alert-warning"><strong>⚠️</strong> {{ error }}</div>
{% endif %}
<!-- 篩選 bar -->
<form method="get" class="row g-2 mb-3">
<div class="col-auto">
<select name="hours" class="form-select form-select-sm">
{% for h in [1, 6, 24, 72, 168] %}
<option value="{{ h }}" {% if hours == h %}selected{% endif %}>
{% if h < 24 %}{{ h }} 小時{% else %}{{ (h//24) }} {% endif %}
</option>
{% endfor %}
</select>
</div>
<div class="col-auto">
<select name="caller" class="form-select form-select-sm">
<option value="">全部 caller</option>
{% for c in callers %}
<option value="{{ c }}" {% if caller_filter == c %}selected{% endif %}>{{ c }}</option>
{% endfor %}
</select>
</div>
<div class="col-auto">
<select name="provider" class="form-select form-select-sm">
<option value="">全部 provider</option>
{% for p in ['gcp_ollama','ollama_secondary','ollama_111','gemini','claude','nim','openrouter','nim_via_elephant'] %}
<option value="{{ p }}" {% if provider_filter == p %}selected{% endif %}>{{ p }}</option>
{% endfor %}
</select>
</div>
<div class="col-auto">
<button class="btn btn-primary btn-sm">篩選</button>
</div>
</form>
<!-- 總覽 KPI -->
<div class="row g-2 mb-3">
<div class="col-md-2"><div class="card p-2"><small>Total Calls</small><h4>{{ "{:,}".format(summary.total_calls or 0) }}</h4></div></div>
<div class="col-md-2"><div class="card p-2"><small>Tokens</small><h4>{{ "{:,}".format(summary.total_tokens or 0) }}</h4></div></div>
<div class="col-md-2"><div class="card p-2"><small>Cost USD</small><h4>${{ "%.2f"|format(summary.total_cost or 0) }}</h4></div></div>
<div class="col-md-2"><div class="card p-2"><small>Avg Duration</small><h4>{{ summary.avg_duration or 0 }} ms</h4></div></div>
<div class="col-md-2"><div class="card p-2"><small>RAG Hits</small><h4 class="text-success">{{ summary.rag_hits or 0 }}</h4></div></div>
<div class="col-md-2"><div class="card p-2"><small>Errors</small><h4 class="{% if (summary.error_calls or 0) > 0 %}text-danger{% endif %}">{{ summary.error_calls or 0 }}</h4></div></div>
</div>
<!-- by provider -->
<div class="card mb-3">
<div class="card-header"><strong>By Provider</strong></div>
<div class="card-body p-0">
<table class="table table-sm mb-0">
<thead class="table-light">
<tr><th>Provider</th><th class="text-end">Calls</th><th class="text-end">Tokens</th><th class="text-end">Cost USD</th></tr>
</thead>
<tbody>
{% for row in by_provider %}
<tr>
<td><code>{{ row.provider }}</code></td>
<td class="text-end">{{ "{:,}".format(row.calls) }}</td>
<td class="text-end">{{ "{:,}".format(row.tokens) }}</td>
<td class="text-end">${{ "%.2f"|format(row.cost) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- recent calls -->
<div class="card">
<div class="card-header"><strong>Recent Calls (TOP 100)</strong></div>
<div class="card-body p-0">
<table class="table table-sm table-striped mb-0" style="font-size: 0.85em;">
<thead class="table-light">
<tr>
<th>ID</th><th>Time</th><th>Caller</th><th>Provider</th><th>Model</th>
<th class="text-end">In</th><th class="text-end">Out</th><th class="text-end">ms</th>
<th>Status</th><th class="text-end">$</th><th>Flags</th>
</tr>
</thead>
<tbody>
{% for r in recent %}
<tr {% if r.status not in ['ok','cache_only'] %}class="table-warning"{% endif %}>
<td>{{ r.id }}</td>
<td><small>{{ r.called_at }}</small></td>
<td><code>{{ r.caller }}</code></td>
<td><small>{{ r.provider }}</small></td>
<td><small>{{ r.model[:25] }}</small></td>
<td class="text-end">{{ r.in_tokens }}</td>
<td class="text-end">{{ r.out_tokens }}</td>
<td class="text-end">{{ r.duration_ms }}</td>
<td><small>{{ r.status }}</small></td>
<td class="text-end">${{ "%.4f"|format(r.cost) }}</td>
<td>
{% if r.cache_hit %}<span class="badge bg-success">cache</span>{% endif %}
{% if r.rag_hit %}<span class="badge bg-info">rag</span>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<p class="text-muted mt-2"><small>
🤖 Operation Ollama-First v5.0 / Phase 27 — Admin Observability
| <a href="/admin/promotion_review">Promotion Review</a>
| <a href="/admin/quality_trend">Quality Trend</a>
| <a href="/admin/host_health">Host Health</a>
</small></p>
</div>
{% endblock %}

View File

@@ -0,0 +1,122 @@
{% extends "base.html" %}
{% block title %}Host Health Dashboard{% endblock %}
{% block content %}
<div class="container-fluid mt-3">
<h2 class="mb-3">🏥 Host Health Dashboard
<small class="text-muted">三主機 + MCP + Cost Throttle 即時狀態</small>
</h2>
<!-- Ollama 三主機 -->
<div class="card mb-3">
<div class="card-header"><strong>🤖 Ollama 三主機HTTP /api/tags 即時 probe</strong></div>
<div class="card-body p-0">
<table class="table mb-0">
<thead class="table-light">
<tr><th>角色</th><th>主機</th><th>HTTP 健康</th><th>unhealthy mark</th><th>已載入模型</th></tr>
</thead>
<tbody>
{% for h in ollama_hosts %}
<tr>
<td><strong>{{ h.label }}</strong></td>
<td><code>{{ h.host }}</code></td>
<td>
{% if h.healthy %}
<span class="badge bg-success">✅ HTTP OK</span>
{% else %}
<span class="badge bg-danger">❌ DOWN</span>
{% endif %}
</td>
<td>
{% if h.unhealthy_mark %}
<span class="badge bg-warning">⚠️ marked unhealthy (30s)</span>
{% else %}
<span class="badge bg-light text-dark"></span>
{% endif %}
</td>
<td>
{% for m in h.models %}
<span class="badge bg-info text-dark me-1">{{ m }}</span>
{% endfor %}
{% if not h.models %}<small class="text-muted">無 / 未連線</small>{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- MCP servers -->
<div class="card mb-3">
<div class="card-header"><strong>🔌 MCP ServersPhase 10/10.5</strong></div>
<div class="card-body p-0">
<table class="table mb-0">
<thead class="table-light">
<tr><th>Server</th><th>狀態</th></tr>
</thead>
<tbody>
{% for server, healthy in mcp_status.items() %}
<tr>
<td><code>{{ server }}</code></td>
<td>
{% if healthy %}
<span class="badge bg-success">✅ healthy</span>
{% else %}
<span class="badge bg-secondary">— 未啟用 / DOWN</span>
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="2" class="text-muted small">MCP_ROUTER_ENABLED=false 或 mcp-stack 未 deploy</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<!-- Cost Throttle 狀態Phase 20 -->
<div class="card mb-3">
<div class="card-header"><strong>💰 Cost Throttle 狀態Phase 20</strong></div>
<div class="card-body p-0">
{% if throttle_state %}
<table class="table mb-0">
<thead class="table-light">
<tr><th>Provider</th><th>Spent</th><th>Budget</th><th>月底推估</th><th>Ratio</th><th>狀態</th></tr>
</thead>
<tbody>
{% for provider, info in throttle_state.items() %}
<tr {% if info.throttled %}class="table-warning"{% endif %}>
<td><code>{{ provider }}</code></td>
<td>${{ "%.2f"|format(info.spent) }}</td>
<td>${{ "%.2f"|format(info.budget) }}</td>
<td>${{ "%.2f"|format(info.projected) }}</td>
<td>{{ "%.0f"|format(info.ratio * 100) }}%</td>
<td>
{% if info.throttled %}
<span class="badge bg-danger">⚠️ THROTTLED</span>
{% else %}
<span class="badge bg-success">✅ 正常</span>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<p class="text-muted m-3 small">
COST_THROTTLE_ENABLED=false 或尚未首次 evaluate每小時 cron 跑)
</p>
{% endif %}
</div>
</div>
<p class="text-muted mt-3"><small>
🤖 Operation Ollama-First v5.0 / Phase 27 — Host Health Dashboard
| <a href="/admin/ai_calls">AI Calls</a>
| <a href="/admin/promotion_review">Promotion Review</a>
| <a href="/admin/quality_trend">Quality Trend</a>
</small></p>
</div>
{% endblock %}

View File

@@ -0,0 +1,104 @@
{% extends "base.html" %}
{% block title %}Promotion Review · RAG 自主學習{% endblock %}
{% block content %}
<div class="container-fluid mt-3">
<h2 class="mb-3">🧠 RAG 學習晉升審核
<small class="text-muted">awaiting_review × {{ episodes|length }} 筆</small>
</h2>
{% if error %}
<div class="alert alert-warning"><strong>⚠️</strong> {{ error }}</div>
{% endif %}
{% if episodes %}
<p class="text-muted small">
⚠️ Phase 11 PromotionGate Stage 4 強制門檻weight >= 0.8 的 episode 必經統帥審核,
24h 無回應自動 expiredweight 降為 0.5 不晉升)。
點 ✅ 通過 → 寫入 ai_insights 供 RAG 檢索;點 ❌ 拒絕 → 永不晉升learning_episodes 留存)。
</p>
{% for ep in episodes %}
<div class="card mb-3 episode-card" data-episode-id="{{ ep.id }}">
<div class="card-header d-flex justify-content-between align-items-center">
<div>
<strong>Episode #{{ ep.id }}</strong>
<span class="badge bg-secondary ms-2">{{ ep.episode_type }}</span>
{% if ep.source_table %}<span class="badge bg-light text-dark ms-1">
{{ ep.source_table }}#{{ ep.source_id }}</span>{% endif %}
<span class="badge bg-info ms-1">weight: {{ "%.2f"|format(ep.weight) }}</span>
<span class="badge bg-info ms-1">quality: {{ "%.2f"|format(ep.quality_score) }}</span>
</div>
<small class="text-muted">{{ ep.created_at }}</small>
</div>
<div class="card-body">
<pre style="white-space: pre-wrap; font-size: 0.9em; max-height: 200px; overflow-y: auto;">{{ ep.distilled_text }}</pre>
</div>
<div class="card-footer text-end">
<button class="btn btn-success btn-sm me-2" onclick="approveEpisode({{ ep.id }}, this)">
✅ 通過晉升
</button>
<button class="btn btn-outline-danger btn-sm" onclick="rejectEpisode({{ ep.id }}, this)">
❌ 拒絕
</button>
</div>
</div>
{% endfor %}
{% else %}
<div class="alert alert-info">
✨ 目前無 awaiting_review episodes。
<small>RAG 未啟用 / 無高 weight episode / 全部已 24h 過期)</small>
</div>
{% endif %}
<p class="text-muted mt-3"><small>
🤖 Operation Ollama-First v5.0 / Phase 28 — PromotionGate Web 審核頁
| <a href="/admin/ai_calls">AI Calls</a>
| <a href="/admin/quality_trend">Quality Trend</a>
</small></p>
</div>
<script>
async function approveEpisode(id, btn) {
btn.disabled = true; btn.innerText = '⏳ 處理中...';
try {
const r = await fetch(`/admin/promotion_review/approve/${id}`, {method: 'POST'});
const d = await r.json();
if (d.ok) {
const card = document.querySelector(`.episode-card[data-episode-id="${id}"]`);
card.classList.add('border-success');
card.querySelector('.card-footer').innerHTML =
`<span class="text-success">✅ 已晉升 → ai_insights #${d.insight_id} (approver=${d.approver})</span>`;
} else {
alert('晉升失敗: ' + (d.error || 'unknown'));
btn.disabled = false; btn.innerText = '✅ 通過晉升';
}
} catch (e) {
alert('Error: ' + e);
btn.disabled = false; btn.innerText = '✅ 通過晉升';
}
}
async function rejectEpisode(id, btn) {
if (!confirm(`拒絕 Episode #${id}?此筆將永不晉升(保留在 learning_episodes 不刪除)`)) return;
btn.disabled = true; btn.innerText = '⏳ 處理中...';
try {
const r = await fetch(`/admin/promotion_review/reject/${id}`, {method: 'POST'});
const d = await r.json();
if (d.ok) {
const card = document.querySelector(`.episode-card[data-episode-id="${id}"]`);
card.classList.add('border-danger');
card.querySelector('.card-footer').innerHTML =
`<span class="text-danger">❌ 已拒絕 (rejected_human)</span>`;
} else {
alert('拒絕失敗: ' + (d.error || 'unknown'));
btn.disabled = false; btn.innerText = '❌ 拒絕';
}
} catch (e) {
alert('Error: ' + e);
btn.disabled = false; btn.innerText = '❌ 拒絕';
}
}
</script>
{% endblock %}

View File

@@ -0,0 +1,102 @@
{% extends "base.html" %}
{% block title %}Caller Quality Trend{% endblock %}
{% block content %}
<div class="container-fluid mt-3">
<h2 class="mb-3">💬 Caller 反饋趨勢
<small class="text-muted">過去 {{ days }} 日</small>
</h2>
{% if error %}
<div class="alert alert-warning"><strong>⚠️</strong> {{ error }}</div>
{% endif %}
<form method="get" class="row g-2 mb-3">
<div class="col-auto">
<select name="days" class="form-select form-select-sm">
{% for d in [7, 14, 30, 90] %}
<option value="{{ d }}" {% if days == d %}selected{% endif %}>{{ d }} 日</option>
{% endfor %}
</select>
</div>
<div class="col-auto"><button class="btn btn-primary btn-sm">查詢</button></div>
</form>
{% if recommendations %}
<div class="card mb-3">
<div class="card-header bg-warning"><strong>🔮 智能建議</strong></div>
<div class="card-body">
<ul class="mb-0">
{% for rec in recommendations %}
<li>
{% if rec.action == 'review' %}⚠️{% else %}✅{% endif %}
<code>{{ rec.caller }}</code>: {{ rec.reason }}
</li>
{% endfor %}
</ul>
</div>
</div>
{% endif %}
<div class="card">
<div class="card-header"><strong>Caller × 反饋分佈</strong>
<small class="text-muted">avg_score 升序,最差先看)</small>
</div>
<div class="card-body p-0">
<table class="table table-sm mb-0">
<thead class="table-light">
<tr>
<th>Caller</th><th class="text-end">Avg</th>
<th class="text-end">👍</th><th class="text-end">👎</th>
<th class="text-end">N</th><th>Trend</th><th>Bar</th>
</tr>
</thead>
<tbody>
{% for caller, info in trends %}
<tr>
<td><code>{{ caller }}</code></td>
<td class="text-end">
<strong>{{ "%.2f"|format(info.avg_score) }}</strong>/5
</td>
<td class="text-end text-success">{{ info.thumbs_up }}</td>
<td class="text-end text-danger">{{ info.thumbs_down }}</td>
<td class="text-end">{{ info.total_feedback }}</td>
<td>
{% if info.trend == 'positive' %}
<span class="badge bg-success">✅ Positive</span>
{% elif info.trend == 'negative' %}
<span class="badge bg-danger">⚠️ Negative</span>
{% elif info.trend == 'neutral' %}
<span class="badge bg-secondary"> Neutral</span>
{% else %}
<span class="badge bg-light text-dark">❓ No Data</span>
{% endif %}
</td>
<td style="width: 200px;">
<div class="progress" style="height: 8px;">
{% set pct = (info.avg_score / 5 * 100)|int %}
<div class="progress-bar
{% if pct >= 80 %}bg-success
{% elif pct >= 50 %}bg-info
{% else %}bg-danger{% endif %}"
style="width: {{ pct }}%"></div>
</div>
</td>
</tr>
{% else %}
<tr><td colspan="7" class="text-center text-muted">無反饋資料</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<p class="text-muted mt-3"><small>
🤖 Operation Ollama-First v5.0 / Phase 25+27 — Caller Quality Trend
| <a href="/admin/ai_calls">AI Calls</a>
| <a href="/admin/promotion_review">Promotion Review</a>
| <a href="/admin/host_health">Host Health</a>
</small></p>
</div>
{% endblock %}