feat(p27+28): Admin Observability Dashboard — 4 個前端頁互補 Telegram
All checks were successful
CD Pipeline / deploy (push) Successful in 2m25s
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:
14
app.py
14
app.py
@@ -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
|
||||
# ==========================================
|
||||
|
||||
319
routes/admin_observability_routes.py
Normal file
319
routes/admin_observability_routes.py
Normal 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 表觀測 dashboard(24h 預設視窗)"""
|
||||
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,
|
||||
)
|
||||
122
templates/admin/ai_calls_dashboard.html
Normal file
122
templates/admin/ai_calls_dashboard.html
Normal 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 %}
|
||||
122
templates/admin/host_health.html
Normal file
122
templates/admin/host_health.html
Normal 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 Servers(Phase 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 %}
|
||||
104
templates/admin/promotion_review.html
Normal file
104
templates/admin/promotion_review.html
Normal 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 無回應自動 expired(weight 降為 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 %}
|
||||
102
templates/admin/quality_trend.html
Normal file
102
templates/admin/quality_trend.html
Normal 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 %}
|
||||
Reference in New Issue
Block a user