Files
ewoooc/routes/admin_observability_routes.py
OoO 46255720ee
Some checks failed
CD Pipeline / deploy (push) Failing after 2m36s
fix(p35): Critic HIGH #2 + MEDIUM #2 — SQL f-string + 動態 import 改寫
HIGH #2 — ai_calls 動態 WHERE 從 f-string 拼接改全綁參數:
  舊:sa_text(f"WHERE {' AND '.join(where_parts)}")
  新:sa_text("WHERE :since AND (:caller_f='' OR caller=:caller_f) AND ...")
  原本字串字面值來源安全,但下個 contributor 不慎把 request.args 拼進去
  就立即 SQL injection;改全綁參數消除類別風險。

MEDIUM #2 — ppt_audit_history 動態 __import__ 改頂部 import:
  舊:__import__('time').time() / __import__('datetime').datetime.fromtimestamp(...)
  新:頂部 import time(datetime 已有)+ 直接呼叫
  並新增 os.path.islink() 過濾,防 reports/ 內 symlink 攻擊逃出目錄。

12/12 tests 仍 PASS。
2026-05-04 14:23:52 +08:00

474 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
routes/admin_observability_routes.py
Operation Ollama-First v5.0 / Phase 27 — Admin Observability Dashboard
提供 admin 介面看戰役累積的觀測資料:
/observability/ai_calls — ai_calls 即時查詢(含篩選 / 圖表)
/observability/promotion_review — Phase 28 PromotionGate 待審核列表
/observability/quality_trend — Phase 25 caller 反饋趨勢
/observability/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 auth import login_required, get_current_user
from database.manager import get_session
admin_observability_bp = Blueprint(
'admin_observability',
__name__,
url_prefix='/observability',
)
# ─────────────────────────────────────────────────────────────────────────────
# /observability/ai_calls — Phase 27 主入口
# ─────────────────────────────────────────────────────────────────────────────
@admin_observability_bp.route('/ai_calls')
@login_required
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 100 calls — Phase 33 Critic HIGH #2 修補:
# 改用固定 SQL + 全綁參數,移除 f-string 動態 WHERE 拼接(防後人不慎注入)
recent = session.execute(
sa_text("""
SELECT id, called_at, caller, provider, model,
input_tokens, output_tokens, duration_ms, status,
cost_usd, cache_hit, rag_hit
FROM ai_calls
WHERE called_at >= :since
AND (:caller_f = '' OR caller = :caller_f)
AND (:provider_f = '' OR provider = :provider_f)
ORDER BY called_at DESC
LIMIT 100
"""),
{
'since': since,
'caller_f': caller_filter,
'provider_f': provider_filter,
},
).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()
# ─────────────────────────────────────────────────────────────────────────────
# /observability/promotion_review — Phase 28 PromotionGate 待審核列表
# ─────────────────────────────────────────────────────────────────────────────
@admin_observability_bp.route('/promotion_review')
@login_required
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'])
@login_required
def promotion_review_approve(episode_id: int):
"""Web 介面「通過」按鈕 — 等同於 Telegram pg_ok callback"""
try:
from services.learning_pipeline import promotion_gate, hash_human_approver
# 從 Flask session 取(已過 @login_required— 不信任 client header
user = get_current_user() or {}
username = user.get('username', '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'])
@login_required
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
# ─────────────────────────────────────────────────────────────────────────────
# /observability/quality_trend — Phase 25 caller 反饋趨勢視覺化
# ─────────────────────────────────────────────────────────────────────────────
@admin_observability_bp.route('/quality_trend')
@login_required
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]}',
)
# ─────────────────────────────────────────────────────────────────────────────
# /observability/budget — Phase 29 預算管理 + 手動 throttle
# ─────────────────────────────────────────────────────────────────────────────
@admin_observability_bp.route('/budget')
@login_required
def budget_dashboard():
"""ai_call_budgets 編輯 + 當月 spent 即時對比"""
from datetime import datetime as _dt
today = _dt.now()
month_start = _dt(today.year, today.month, 1)
session = get_session()
try:
budgets = session.execute(
sa_text("""
SELECT id, period, provider, budget_usd, alert_pct, updated_at
FROM ai_call_budgets
ORDER BY period, provider NULLS FIRST
"""),
).fetchall()
spent_rows = session.execute(
sa_text("""
SELECT provider, COALESCE(SUM(cost_usd), 0) AS spent
FROM ai_calls
WHERE called_at >= :ms
GROUP BY provider
"""),
{'ms': month_start},
).fetchall()
spent_map = {r[0]: float(r[1] or 0) for r in spent_rows}
# throttle 狀態
throttle_state = {}
try:
from services.cost_throttle_service import get_throttle_state
throttle_state = get_throttle_state()
except Exception:
pass
rows = []
for b in budgets:
provider = b[2] # 可能 None全供應商總額
spent = spent_map.get(provider, 0.0) if provider else sum(spent_map.values())
budget_usd = float(b[3] or 0)
ratio = (spent / budget_usd) if budget_usd > 0 else 0
rows.append({
'id': b[0], 'period': b[1], 'provider': provider or '(all)',
'budget_usd': budget_usd, 'alert_pct': int(b[4] or 80),
'spent': spent, 'ratio': ratio,
'throttled': throttle_state.get(provider, {}).get('throttled', False) if provider else False,
'updated_at': b[5].strftime('%Y-%m-%d %H:%M') if b[5] else '-',
})
return render_template('admin/budget.html', rows=rows, error=None)
except Exception as e:
return render_template('admin/budget.html', rows=[],
error=f'查詢失敗: {type(e).__name__}: {str(e)[:200]}')
finally:
session.close()
@admin_observability_bp.route('/budget/update/<int:budget_id>', methods=['POST'])
@login_required
def budget_update(budget_id: int):
"""更新 budget_usd / alert_pct"""
try:
new_budget = float(request.json.get('budget_usd'))
new_alert = int(request.json.get('alert_pct', 80))
if new_budget <= 0 or not (1 <= new_alert <= 100):
return jsonify({'ok': False, 'error': 'invalid range'}), 400
session = get_session()
try:
session.execute(
sa_text("""
UPDATE ai_call_budgets
SET budget_usd = :b, alert_pct = :a, updated_at = NOW()
WHERE id = :id
"""),
{'b': new_budget, 'a': new_alert, 'id': budget_id},
)
session.commit()
return jsonify({'ok': True})
finally:
session.close()
except Exception as e:
return jsonify({'ok': False, 'error': str(e)[:200]}), 500
# ─────────────────────────────────────────────────────────────────────────────
# /observability/ppt_audit_history — Phase 29 PPT 視覺審核歷史
# ─────────────────────────────────────────────────────────────────────────────
@admin_observability_bp.route('/ppt_audit_history')
@login_required
def ppt_audit_history():
"""掃 reports/ 目錄列近 7 日 .pptx 檔 + 即時跑 audit如已啟用"""
import os
import time
reports_dir = 'reports'
files = []
error = None
try:
if not os.path.isdir(reports_dir):
error = f'{reports_dir} 目錄不存在'
else:
cutoff = time.time() - 7 * 86400
for f in os.listdir(reports_dir):
if not f.lower().endswith('.pptx'):
continue
full = os.path.join(reports_dir, f)
# symlink 防護reports/ 內不接受 symlink避免目錄逃逸Critic MEDIUM #2
if os.path.islink(full):
continue
try:
mtime = os.path.getmtime(full)
if mtime >= cutoff:
files.append({
'name': f,
'size_kb': round(os.path.getsize(full) / 1024, 1),
'mtime': datetime.fromtimestamp(mtime).strftime('%Y-%m-%d %H:%M'),
'mtime_ts': mtime,
})
except OSError:
continue
files.sort(key=lambda x: x['mtime_ts'], reverse=True)
except Exception as e:
error = f'{type(e).__name__}: {str(e)[:200]}'
# PPT vision 啟用狀態
try:
from services.ppt_vision_service import is_ppt_vision_enabled
vision_enabled = is_ppt_vision_enabled()
except Exception:
vision_enabled = False
return render_template(
'admin/ppt_audit_history.html',
files=files,
vision_enabled=vision_enabled,
error=error,
)
# ─────────────────────────────────────────────────────────────────────────────
# /observability/host_health — 三主機 + MCP 健康度
# ─────────────────────────────────────────────────────────────────────────────
@admin_observability_bp.route('/host_health')
@login_required
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,
)