#!/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 原文 """ import logging import re import threading import time from datetime import datetime, timedelta from flask import Blueprint, render_template, request, jsonify, send_file, url_for from sqlalchemy import text as sa_text from auth import login_required, get_current_user from database.manager import get_session logger = logging.getLogger(__name__) admin_observability_bp = Blueprint( 'admin_observability', __name__, url_prefix='/observability', ) _PPT_AIDER_HEAL_LOCK = threading.Lock() _PPT_AIDER_HEAL_ACTIVE = {} _HEALTH_INDICATOR_CACHE_LOCK = threading.Lock() _HEALTH_INDICATOR_CACHE = { 'expires_at': 0.0, 'payload': None, } _HEALTH_INDICATOR_CACHE_TTL_SECONDS = 30 _PPT_PUBLIC_RUNTIME_ERROR = '視覺審核暫時無法完成;請先用線上預覽並由 AI 自動驗證確認版面,稍後重新執行審核。' _PPT_INTERNAL_ERROR_MARKERS = ( 'all 3 hosts failed', 'httpconnectionpool', 'multimodal data provided', 'model does not support', '/api/generate', 'connectionerror', 'readtimeout', 'traceback', 'gcp-a', 'gcp-b', ) _PPT_PUBLIC_REPLACEMENTS = ( ('AiderHeal', '修復流程'), ('RAG', '知識建議'), ('Ollama', 'AI 建議服務'), ('minicpm-v', '視覺模型'), ('LibreOffice', '轉檔服務'), ('runtime', '執行條件'), ('DB', '產出紀錄'), ('database', '產出紀錄'), ('filesystem', '檔案來源'), ('ppt_audit_results', '審核紀錄'), ('ppt_generation_runs', '產出紀錄'), ) _GEMINI_BACKUP_CALLER_DISPLAY = { # 專用備援 caller 落地前的舊資料仍會存在;顯示時標成備援,避免誤判 Gemini-first。 'code_review_openclaw': 'code_review_openclaw_gemini', 'openclaw_qa': 'openclaw_qa_gemini_fallback', } _GEMINI_BACKUP_CALLERS = { 'code_review_openclaw_gemini', 'openclaw_daily_gemini_fallback', 'openclaw_daily_insight_gemini_fallback', 'openclaw_weekly_gemini_fallback', 'openclaw_monthly_gemini_fallback', 'openclaw_meta_gemini_fallback', 'openclaw_qa_gemini_fallback', 'openclaw_bot_gemini', 'openclaw_bot_image_gemini', } def _list_ppt_aider_heal_active_jobs(): with _PPT_AIDER_HEAL_LOCK: jobs = [dict(job) for job in _PPT_AIDER_HEAL_ACTIVE.values()] for job in jobs: job['diagnosis'] = _public_ppt_text(job.get('diagnosis'), max_chars=100) return jobs def _ppt_text_has_internal_detail(value) -> bool: text = str(value or '') if not text: return False lowered = text.lower() if any(marker in lowered for marker in _PPT_INTERNAL_ERROR_MARKERS): return True return bool(re.search(r'\b(?:\d{1,3}\.){3}\d{1,3}(?::\d+)?\b', text)) def _public_ppt_text(value, *, empty='', max_chars=180): """把 PPT 觀測台的內部錯誤轉成操作員可讀的處置文字。""" text = str(value or '').strip() if not text: return empty if _ppt_text_has_internal_detail(text): return _PPT_PUBLIC_RUNTIME_ERROR for raw, label in _PPT_PUBLIC_REPLACEMENTS: text = text.replace(raw, label) text = re.sub(r'\b(?:\d{1,3}\.){3}\d{1,3}(?::\d+)?\b', '內部主機', text) text = re.sub(r'\s+', ' ', text).strip() if max_chars and len(text) > max_chars: return text[:max_chars].rstrip() + '…' return text def _public_ppt_text_list(values, *, max_chars=120): public_values = [] for value in values or []: text = _public_ppt_text(value, max_chars=max_chars) if text and text not in public_values: public_values.append(text) return public_values def _public_ppt_source_label(source): return { 'both': '檔案 + 產出紀錄', 'database': '產出紀錄', 'filesystem': '檔案來源', }.get(str(source or '').strip(), '檔案來源') def _public_ppt_vision_status(status): status = dict(status or {}) raw_blockers = [str(item).strip() for item in status.get('blockers') or [] if str(item).strip()] status['runtime_dependency_blockers'] = [ item for item in raw_blockers if 'PPT_VISION_ENABLED' in item or 'LibreOffice' in item ] status['summary'] = _public_ppt_text( status.get('summary'), empty='視覺檢查狀態待確認。', max_chars=140, ) status['status_label'] = _public_ppt_text( status.get('status_label'), empty='待確認', max_chars=40, ) status['model_label'] = '視覺模型' if status.get('model') else '未啟用' status['converter_label'] = '轉檔服務' if status.get('converter') else '轉檔條件待確認' status['blockers'] = _public_ppt_text_list( status.get('blockers'), max_chars=80, ) or ['視覺檢查條件待確認'] status['next_actions'] = _public_ppt_text_list( status.get('next_actions'), max_chars=90, ) or ['確認視覺檢查條件後重新整理此頁。'] sanitized_checks = [] for check in status.get('readiness_checks') or []: if not isinstance(check, dict): continue item = dict(check) item['label'] = _public_ppt_text(item.get('label'), empty='檢查項目', max_chars=50) item['value'] = _public_ppt_text(item.get('value'), empty='待確認', max_chars=50) item['detail'] = _public_ppt_text(item.get('detail'), empty='等待檢查結果', max_chars=90) sanitized_checks.append(item) status['readiness_checks'] = sanitized_checks return status def _public_ppt_vision_audit_status(status): status = dict(status or {}) status['status_label'] = _public_ppt_text(status.get('status_label'), empty='待確認', max_chars=60) status['message'] = _public_ppt_text( status.get('message'), empty='視覺檢查狀態待確認。', max_chars=140, ) return status def _build_ai_call_recent_row(row): """整理最近 ai_calls 列表,讓 Ollama-first 備援語意可被辨識。""" caller = row[2] or '' provider = row[3] or '' caller_display = caller route_badges = [] if provider == 'gemini': if caller in _GEMINI_BACKUP_CALLER_DISPLAY: caller_display = _GEMINI_BACKUP_CALLER_DISPLAY[caller] route_badges.append('雲端備援') route_badges.append('舊 caller') elif caller in _GEMINI_BACKUP_CALLERS or caller.endswith('_gemini'): route_badges.append('雲端備援') else: route_badges.append('ADR-028 鎖定/升級') return { 'id': row[0], 'called_at': row[1].strftime('%H:%M:%S'), 'caller': caller, 'caller_display': caller_display, 'provider': provider, 'model': row[4], 'in_tokens': int(row[5] or 0), 'out_tokens': int(row[6] or 0), 'duration_ms': int(row[7] or 0), 'status': row[8], 'cost': float(row[9] or 0), 'cache_hit': bool(row[10]), 'rag_hit': bool(row[11]), 'route_badges': route_badges, } # ───────────────────────────────────────────────────────────────────────────── # /observability/overview — Phase 45 總覽(單頁聚合 6 項 KPI) # ───────────────────────────────────────────────────────────────────────────── @admin_observability_bp.route('/') @admin_observability_bp.route('/overview') @login_required def observability_overview(): """Phase 45 — 觀測台總覽:一頁式聚合 6 個 sub-page 的關鍵 KPI。 對應 Phase 44 daily Telegram summary 的 web 版本,做為 sidebar 入口頁。 所有區塊失敗安全:個別 query 失敗只跳過該卡片,不擋整頁渲染。 """ from datetime import datetime as _dt today = _dt.now() month_start = _dt(today.year, today.month, 1) summary = {} session = get_session() try: # 三主機 24h 在線率 try: host_rows = session.execute( sa_text(""" SELECT host_label, COUNT(*) AS total, COUNT(*) FILTER (WHERE healthy) AS up, COALESCE(AVG(response_ms) FILTER (WHERE healthy), 0) AS avg_ms FROM host_health_probes WHERE probed_at >= NOW() - INTERVAL '24 hours' GROUP BY host_label ORDER BY host_label """), ).fetchall() summary['hosts'] = [ { 'label': r[0], 'total': int(r[1] or 0), 'up': int(r[2] or 0), 'avg_ms': int(r[3] or 0), 'uptime_pct': (float(r[2] or 0) / float(r[1]) * 100) if r[1] else 0, } for r in host_rows ] except Exception: summary['hosts'] = [] # AI 呼叫 24h try: ai = session.execute( sa_text(""" SELECT COUNT(*), COALESCE(SUM(input_tokens + output_tokens), 0), COALESCE(SUM(cost_usd), 0), COUNT(*) FILTER (WHERE status NOT IN ('ok','cache_only')), COUNT(*) FILTER (WHERE rag_hit), COUNT(*) FILTER (WHERE cache_hit) FROM ai_calls WHERE called_at >= NOW() - INTERVAL '24 hours' """), ).fetchone() total = int(ai[0] or 0) summary['ai_calls'] = { 'total': total, 'tokens': int(ai[1] or 0), 'cost_24h': float(ai[2] or 0), 'errors': int(ai[3] or 0), 'rag_hits': int(ai[4] or 0), 'cache_hits': int(ai[5] or 0), 'error_rate': (float(ai[3] or 0) / total * 100) if total else 0, 'rag_rate': (float(ai[4] or 0) / total * 100) if total else 0, 'cache_rate': (float(ai[5] or 0) / total * 100) if total else 0, } except Exception: summary['ai_calls'] = {} # 當月成本 try: month_cost = session.execute( sa_text("SELECT COALESCE(SUM(cost_usd), 0) FROM ai_calls WHERE called_at >= :ms"), {'ms': month_start}, ).fetchone()[0] summary['month_cost'] = float(month_cost or 0) except Exception: summary['month_cost'] = 0 # 預算 over 80% try: budgets = session.execute( sa_text(""" SELECT b.period, b.provider, b.budget_usd, b.alert_pct, COALESCE(( SELECT SUM(cost_usd) FROM ai_calls WHERE called_at >= :ms AND (b.provider IS NULL OR provider = b.provider) ), 0) AS spent FROM ai_call_budgets b """), {'ms': month_start}, ).fetchall() over_threshold = [] for r in budgets: budget = float(r[2] or 0) spent = float(r[4] or 0) ratio = spent / budget if budget > 0 else 0 threshold = float(r[3] or 80) / 100 if ratio >= threshold: over_threshold.append({ 'period': r[0], 'provider': r[1] or '(全部)', 'spent': spent, 'budget': budget, 'ratio': ratio, }) summary['budget_alerts'] = over_threshold except Exception: summary['budget_alerts'] = [] # 待審 + 蒸餾池 try: ep_pending = session.execute( sa_text("SELECT COUNT(*) FROM learning_episodes WHERE promotion_status = 'awaiting_review' AND reviewed_at IS NULL"), ).fetchone()[0] ep_total_30d = session.execute( sa_text("SELECT COUNT(*) FROM learning_episodes WHERE created_at >= NOW() - INTERVAL '30 days'"), ).fetchone()[0] ep_approved_30d = session.execute( sa_text("SELECT COUNT(*) FROM learning_episodes WHERE created_at >= NOW() - INTERVAL '30 days' AND promotion_status = 'approved'"), ).fetchone()[0] summary['episodes'] = { 'pending': int(ep_pending or 0), 'total_30d': int(ep_total_30d or 0), 'approved_30d': int(ep_approved_30d or 0), 'approval_rate': (float(ep_approved_30d or 0) / float(ep_total_30d) * 100) if ep_total_30d else 0, } except Exception: summary['episodes'] = {} # PPT 視覺審核 7d try: ppt = session.execute( sa_text(""" SELECT COUNT(*), COUNT(*) FILTER (WHERE audit_status='passed'), COUNT(*) FILTER (WHERE audit_status='failed') FROM ppt_audit_results WHERE audited_at >= NOW() - INTERVAL '7 days' """), ).fetchone() ppt_total = int(ppt[0] or 0) summary['ppt'] = { 'total': ppt_total, 'passed': int(ppt[1] or 0), 'failed': int(ppt[2] or 0), 'pass_rate': (float(ppt[1] or 0) / ppt_total * 100) if ppt_total else 0, } except Exception: summary['ppt'] = {} # AIOps 7d try: inc = session.execute( sa_text(""" SELECT COUNT(*), COUNT(*) FILTER (WHERE status='open'), COUNT(*) FILTER (WHERE severity IN ('P0','P1')) FROM incidents WHERE created_at >= NOW() - INTERVAL '7 days' """), ).fetchone() heal = session.execute( sa_text(""" SELECT COUNT(*), COUNT(*) FILTER (WHERE result='success') FROM heal_logs WHERE created_at >= NOW() - INTERVAL '7 days' """), ).fetchone() summary['aiops'] = { 'incidents_total': int(inc[0] or 0), 'incidents_open': int(inc[1] or 0), 'incidents_p0_p1': int(inc[2] or 0), 'heals_total': int(heal[0] or 0), 'heals_success': int(heal[1] or 0), 'heal_rate': (float(heal[1] or 0) / float(heal[0]) * 100) if heal[0] else 0, } except Exception: summary['aiops'] = {} # MCP 24h try: mcp = session.execute( sa_text(""" SELECT COUNT(*), COUNT(DISTINCT server), COUNT(*) FILTER (WHERE cache_hit), COALESCE(SUM(cost_usd), 0) FROM mcp_calls WHERE called_at >= NOW() - INTERVAL '24 hours' """), ).fetchone() mcp_total = int(mcp[0] or 0) summary['mcp'] = { 'total': mcp_total, 'servers': int(mcp[1] or 0), 'cache_hits': int(mcp[2] or 0), 'cost': float(mcp[3] or 0), 'cache_rate': (float(mcp[2] or 0) / mcp_total * 100) if mcp_total else 0, } except Exception: summary['mcp'] = {} finally: session.close() # Phase 51 O-3: 24h 三主機健康 sparkline 資料(每小時 bucket × 3 host) host_sparkline = {} try: s_sp = get_session() try: sp_rows = s_sp.execute( sa_text(""" SELECT host_label, date_trunc('hour', probed_at) AS hr, COUNT(*) AS total, COUNT(*) FILTER (WHERE healthy) AS up FROM host_health_probes WHERE probed_at >= NOW() - INTERVAL '24 hours' GROUP BY host_label, hr ORDER BY host_label, hr ASC """), ).fetchall() for r in sp_rows: label, hr, total, up = r[0], r[1], int(r[2] or 0), int(r[3] or 0) if label not in host_sparkline: host_sparkline[label] = {'hours': [], 'uptime_pct': []} host_sparkline[label]['hours'].append( hr.strftime('%H:00') if hr else '' ) host_sparkline[label]['uptime_pct'].append( (up / total * 100) if total else 0 ) finally: s_sp.close() except Exception: pass return render_template( 'admin/observability_overview.html', active_page='obs_overview', summary=summary, host_sparkline=host_sparkline, today=today.strftime('%Y-%m-%d'), ) # ───────────────────────────────────────────────────────────────────────────── # /observability/rag_queries — Phase 51 RAG 召回詳情 # ───────────────────────────────────────────────────────────────────────────── @admin_observability_bp.route('/rag_queries') @login_required def rag_queries_dashboard(): """Phase 51 — RAG 召回詳情:每筆 query 的命中、saved_call、反饋。 補完 RAG 觀測深度:之前只看 caller 級命中率,現在看每筆查詢的真實內容。 """ hours = int(request.args.get('hours', '24')) caller_filter = request.args.get('caller', '').strip() saved_only = request.args.get('saved_only', '').strip() == '1' session = get_session() try: rag_query_log_exists = bool(session.execute( sa_text("SELECT to_regclass('public.rag_query_log') IS NOT NULL") ).scalar()) if not rag_query_log_exists: return render_template( 'admin/rag_queries.html', active_page='obs_rag_queries', hours=hours, caller_filter=caller_filter, saved_only=saved_only, summary={}, callers=[], by_caller=[], queries=[], error='rag_query_log 尚未建立,RAG 召回資料待接入。', ) # 整體統計 summary_row = session.execute( sa_text(""" SELECT COUNT(*) AS total, COUNT(*) FILTER (WHERE saved_call) AS saved, COUNT(*) FILTER (WHERE hit_count > 0) AS with_hits, COALESCE(AVG(hit_count), 0) AS avg_hits, COALESCE(AVG(feedback_score) FILTER (WHERE feedback_score IS NOT NULL), 0) AS avg_score, COUNT(*) FILTER (WHERE feedback_score IS NOT NULL) AS feedback_count, COUNT(DISTINCT caller) AS distinct_callers FROM rag_query_log WHERE queried_at >= NOW() - (:h * INTERVAL '1 hour') """), {'h': hours}, ).fetchone() total = int(summary_row[0] or 0) saved = int(summary_row[1] or 0) with_hits = int(summary_row[2] or 0) summary = { 'total': total, 'saved': saved, 'with_hits': with_hits, 'no_hits': total - with_hits, 'avg_hits': round(float(summary_row[3] or 0), 2), 'avg_score': round(float(summary_row[4] or 0), 2), 'feedback_count': int(summary_row[5] or 0), 'distinct_callers': int(summary_row[6] or 0), 'saved_rate': (float(saved) / total * 100) if total else 0, 'hit_rate': (float(with_hits) / total * 100) if total else 0, } # caller 列表(dropdown) callers = session.execute( sa_text(""" SELECT DISTINCT caller FROM rag_query_log WHERE queried_at >= NOW() - (:h * INTERVAL '1 hour') ORDER BY caller """), {'h': hours}, ).fetchall() caller_list = [r[0] for r in callers] # by caller 統計 by_caller = session.execute( sa_text(""" SELECT caller, COUNT(*) AS total, COUNT(*) FILTER (WHERE saved_call) AS saved, COUNT(*) FILTER (WHERE hit_count > 0) AS with_hits, COALESCE(AVG(feedback_score) FILTER (WHERE feedback_score IS NOT NULL), 0) AS avg_score, COUNT(*) FILTER (WHERE feedback_score IS NOT NULL) AS fb_count FROM rag_query_log WHERE queried_at >= NOW() - (:h * INTERVAL '1 hour') GROUP BY caller ORDER BY total DESC """), {'h': hours}, ).fetchall() # 最近 50 筆查詢(套 caller filter + saved_only) params = {'h': hours, 'caller_f': caller_filter} recent_queries = session.execute( sa_text(f""" SELECT id, queried_at, caller, LEFT(query_text, 200) AS qtext, top_k, threshold, hit_count, used_results, saved_call, feedback_score, request_id FROM rag_query_log WHERE queried_at >= NOW() - (:h * INTERVAL '1 hour') AND (:caller_f = '' OR caller = :caller_f) {"AND saved_call = TRUE" if saved_only else ""} ORDER BY queried_at DESC LIMIT 50 """), params, ).fetchall() queries = [] for r in recent_queries: used_ids = list(r[7]) if r[7] else [] queries.append({ 'id': int(r[0]), 'queried_at': r[1].strftime('%Y-%m-%d %H:%M:%S') if r[1] else '', 'caller': r[2], 'query_text': r[3] or '', 'take_count': int(r[4] or 0), 'threshold': round(float(r[5] or 0), 3), 'hit_count': int(r[6] or 0), 'used_results': used_ids, 'saved_call': bool(r[8]), 'feedback_score': int(r[9]) if r[9] is not None else None, 'request_id': r[10], }) return render_template( 'admin/rag_queries.html', active_page='obs_rag_queries', hours=hours, caller_filter=caller_filter, saved_only=saved_only, summary=summary, callers=caller_list, by_caller=[ { 'caller': r[0], 'total': int(r[1] or 0), 'saved': int(r[2] or 0), 'with_hits': int(r[3] or 0), 'avg_score': round(float(r[4] or 0), 2), 'fb_count': int(r[5] or 0), 'saved_rate': (float(r[2] or 0) / float(r[1]) * 100) if r[1] else 0, 'hit_rate': (float(r[3] or 0) / float(r[1]) * 100) if r[1] else 0, } for r in by_caller ], queries=queries, error=None, ) except Exception as e: return render_template( 'admin/rag_queries.html', active_page='obs_rag_queries', hours=hours, caller_filter=caller_filter, saved_only=saved_only, summary={}, callers=[], by_caller=[], queries=[], error='RAG 召回資料暫時不可用,已切換安全空狀態。', ) finally: session.close() @admin_observability_bp.route('/rag_queries//hits', methods=['GET']) @login_required def rag_query_hits(query_id: int): """Phase 51 — JSON API:回傳單筆 query 的 hits 詳細內容(給 modal 展開)。""" try: session = get_session() try: row = session.execute( sa_text(""" SELECT id, query_text, used_results, hit_count, threshold FROM rag_query_log WHERE id = :id """), {'id': query_id}, ).fetchone() if not row: return jsonify({'ok': False, 'error': 'not found'}), 404 used_ids = list(row[2]) if row[2] else [] hits = [] if used_ids: rows = session.execute( sa_text(""" SELECT id, insight_type, period, product_sku, LEFT(content, 300) AS preview, created_at FROM ai_insights WHERE id = ANY(:ids) ORDER BY created_at DESC """), {'ids': used_ids}, ).fetchall() hits = [ { 'id': int(h[0]), 'insight_type': h[1], 'period': h[2], 'product_sku': h[3], 'content': h[4] or '', 'created_at': h[5].strftime('%Y-%m-%d') if h[5] else '', } for h in rows ] return jsonify({ 'ok': True, 'query_id': query_id, 'query_text': row[1], 'hit_count': int(row[3] or 0), 'threshold': round(float(row[4] or 0), 3), 'hits': hits, }) finally: session.close() except Exception as e: return jsonify({'ok': False, 'error': f'{type(e).__name__}: {str(e)[:200]}'}), 500 # ───────────────────────────────────────────────────────────────────────────── # /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, cph.source FROM competitor_price_history cph WHERE cph.crawled_at >= NOW() - INTERVAL '24 hours' AND cph.match_score >= 0.7 AND COALESCE(cph.tags, '[]'::jsonb) ? 'identity_v2' 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 '', 'source': r[7] or '外部電商', } for r in recent_competitor ] promo_watch_rows = [] for item in recent_competitor_prices: discount_pct = float(item.get('discount_pct') or 0) gap = item.get('gap') gap_value = float(gap) if gap is not None else 0.0 is_external_pressure = gap_value < 0 is_discount_signal = discount_pct >= 5 if not (is_external_pressure or is_discount_signal): continue if is_external_pressure: pressure_label = '外部低價壓力' recommended_action = '檢查 PChome 售價、折扣券、組合包與商品頁主賣點' else: pressure_label = '外部促銷訊號' recommended_action = '比對活動條件後,安排 PChome 主推曝光或會員回饋' promo_watch_rows.append({ **item, 'pressure_label': pressure_label, 'recommended_action': recommended_action, 'gap_abs': abs(gap_value), }) if len(promo_watch_rows) >= 8: break # 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, promo_watch_rows=promo_watch_rows, 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=[], promo_watch_rows=[], unfollowed_count=0, error='商業 AI 資料暫時不可用,已切換安全空狀態。', ) finally: session.close() # ───────────────────────────────────────────────────────────────────────────── # /observability/agent_orchestration — Phase 46 編排矩陣 # ───────────────────────────────────────────────────────────────────────────── # caller → agent 歸類規則(同 services/* 各 agent 真實 caller 值) _AGENT_CALLER_GROUPS = { 'openclaw': [ 'openclaw_qa', 'openclaw_qa_gemini_fallback', 'openclaw_qa_nim', 'openclaw_daily', 'openclaw_daily_gemini_fallback', 'openclaw_daily_nim', 'openclaw_daily_insight', 'openclaw_daily_insight_gemini_fallback', 'openclaw_daily_insight_nim', 'openclaw_meta', 'openclaw_meta_gemini_fallback', 'openclaw_meta_nim', 'openclaw_monthly', 'openclaw_monthly_gemini_fallback', 'openclaw_monthly_nim', 'openclaw_weekly', 'openclaw_weekly_gemini_fallback', 'openclaw_weekly_nim', 'openclaw_bot_main', 'openclaw_bot_gemini', 'openclaw_bot_nim', 'sales_copy', 'code_review_openclaw', 'code_review_openclaw_gemini', ], 'hermes': [ 'hermes_analyst', 'hermes_intent', 'code_review_hermes', ], 'nemotron': [ 'nemotron_dispatch', ], 'elephant_alpha': [ 'ea_engine', 'code_review_elephant', ], } _AGENT_LABELS = { 'openclaw': ('🤖 OpenClaw', '主編排者 / Bot 對話 / 報告生成'), 'hermes': ('🔍 Hermes', '價格/程式碼分析師'), 'nemotron': ('🧬 NemoTron', '任務 dispatcher'), 'elephant_alpha': ('🐘 ElephantAlpha', '自主決策引擎'), } # Provider → 類別歸類 _PROVIDER_TIER = { 'gcp_ollama': 'ollama_local', 'ollama_secondary': 'ollama_local', 'ollama_111': 'ollama_local', 'ollama_other': 'ollama_local', 'gemini': 'paid_external', 'claude': 'paid_external', 'nim': 'paid_external', 'nim_via_elephant': 'paid_external', 'openrouter': 'paid_external', } @admin_observability_bp.route('/agent_orchestration') @login_required def agent_orchestration_dashboard(): """Phase 46 — 4 Agent × Models × MCP × RAG 編排矩陣 展現「組合發揮」:每個 agent 在 24h 內如何調用 Ollama/Gemini, 搭配 MCP tool(外部 + 內部 mcp_collector),與 RAG 知識庫的協作。 資料來源:ai_calls × mcp_calls × rag_query_log 三表跨 JOIN + caller 分組。 """ hours = int(request.args.get('hours', '24')) session = get_session() try: # 1. 整體統計 overall = session.execute( sa_text(""" SELECT COUNT(*), COALESCE(SUM(cost_usd), 0), COUNT(*) FILTER (WHERE provider IN ('gemini','claude','nim','openrouter','nim_via_elephant')), COUNT(*) FILTER (WHERE provider IN ('gcp_ollama','ollama_secondary','ollama_111','ollama_other')), COUNT(*) FILTER (WHERE rag_hit), COALESCE(SUM(input_tokens + output_tokens), 0) FROM ai_calls WHERE called_at >= NOW() - (:h * INTERVAL '1 hour') """), {'h': hours}, ).fetchone() total_calls = int(overall[0] or 0) total_cost = float(overall[1] or 0) paid_calls = int(overall[2] or 0) local_calls = int(overall[3] or 0) rag_hits = int(overall[4] or 0) total_tokens = int(overall[5] or 0) mcp_calls_table_exists = bool(session.execute( sa_text("SELECT to_regclass('public.mcp_calls') IS NOT NULL") ).scalar()) # 2. 每個 agent group 的細節 agent_matrix = [] for agent_key, callers in _AGENT_CALLER_GROUPS.items(): ag_row = session.execute( sa_text(""" SELECT COUNT(*) AS calls, COALESCE(SUM(input_tokens + output_tokens), 0) AS tokens, COALESCE(SUM(cost_usd), 0) AS cost, COUNT(*) FILTER (WHERE rag_hit) AS rag_hits, COUNT(*) FILTER (WHERE provider IN ('gcp_ollama','ollama_secondary','ollama_111','ollama_other')) AS ollama, COUNT(*) FILTER (WHERE provider = 'gcp_ollama') AS ollama_gcp_a, COUNT(*) FILTER (WHERE provider = 'ollama_secondary') AS ollama_gcp_b, COUNT(*) FILTER (WHERE provider = 'ollama_111') AS ollama_111, COUNT(*) FILTER (WHERE provider = 'gemini') AS gemini, COUNT(*) FILTER (WHERE provider IN ('claude','nim','openrouter','nim_via_elephant')) AS other_paid, COUNT(*) FILTER (WHERE status NOT IN ('ok','cache_only')) AS errors, COALESCE(AVG(duration_ms), 0) AS avg_ms FROM ai_calls WHERE called_at >= NOW() - (:h * INTERVAL '1 hour') AND caller = ANY(:callers) """), {'h': hours, 'callers': callers}, ).fetchone() calls = int(ag_row[0] or 0) if calls == 0: # 沒呼叫也佔位顯示 agent_matrix.append({ 'key': agent_key, 'label': _AGENT_LABELS[agent_key][0], 'desc': _AGENT_LABELS[agent_key][1], 'calls': 0, 'tokens': 0, 'cost': 0, 'rag_hits': 0, 'rag_rate': 0, 'ollama_pct': 0, 'gemini_pct': 0, 'paid_pct': 0, 'ollama_gcp_a': 0, 'ollama_gcp_b': 0, 'ollama_111': 0, 'gemini': 0, 'other_paid': 0, 'errors': 0, 'error_rate': 0, 'avg_ms': 0, 'mcp_calls': 0, 'mcp_rate': 0, 'callers_in_group': callers, }) continue # MCP 編排率(透過 request_id 串接)。mcp_calls 尚未 migration 時安全降級為 0。 if mcp_calls_table_exists: mcp_count = session.execute( sa_text(""" SELECT COUNT(DISTINCT a.request_id) FROM ai_calls a INNER JOIN mcp_calls m ON m.request_id = a.request_id WHERE a.called_at >= NOW() - (:h * INTERVAL '1 hour') AND a.caller = ANY(:callers) AND a.request_id IS NOT NULL """), {'h': hours, 'callers': callers}, ).fetchone()[0] or 0 else: mcp_count = 0 errors = int(ag_row[10] or 0) ollama = int(ag_row[4] or 0) gemini = int(ag_row[8] or 0) other_paid = int(ag_row[9] or 0) agent_matrix.append({ 'key': agent_key, 'label': _AGENT_LABELS[agent_key][0], 'desc': _AGENT_LABELS[agent_key][1], 'calls': calls, 'tokens': int(ag_row[1] or 0), 'cost': float(ag_row[2] or 0), 'rag_hits': int(ag_row[3] or 0), 'rag_rate': (float(ag_row[3] or 0) / calls * 100) if calls else 0, 'ollama': ollama, 'ollama_pct': (ollama / calls * 100) if calls else 0, 'ollama_gcp_a': int(ag_row[5] or 0), 'ollama_gcp_b': int(ag_row[6] or 0), 'ollama_111': int(ag_row[7] or 0), 'gemini': gemini, 'gemini_pct': (gemini / calls * 100) if calls else 0, 'other_paid': other_paid, 'paid_pct': ((gemini + other_paid) / calls * 100) if calls else 0, 'errors': errors, 'error_rate': (errors / calls * 100) if calls else 0, 'avg_ms': int(ag_row[11] or 0), 'mcp_calls': int(mcp_count), 'mcp_rate': (float(mcp_count) / calls * 100) if calls else 0, 'callers_in_group': callers, }) # 3. MCP server 24h 工作量(同 host_health 邏輯) if mcp_calls_table_exists: mcp_servers = session.execute( sa_text(""" SELECT server, caller, COUNT(*) AS calls, COUNT(*) FILTER (WHERE cache_hit) AS cache_hits, COALESCE(SUM(cost_usd), 0) AS cost FROM mcp_calls WHERE called_at >= NOW() - (:h * INTERVAL '1 hour') GROUP BY server, caller ORDER BY calls DESC LIMIT 30 """), {'h': hours}, ).fetchall() mcp_matrix = [ { 'server': r[0], 'caller': r[1], 'calls': int(r[2] or 0), 'cache_hits': int(r[3] or 0), 'cost': float(r[4] or 0), 'cache_rate': (float(r[3] or 0) / float(r[2]) * 100) if r[2] else 0, } for r in mcp_servers ] else: mcp_matrix = [] # 4. 自動編排建議(rule-based 提案) recommendations = [] if not mcp_calls_table_exists: recommendations.append({ 'severity': 'med', 'agent': 'MCP 觀測', 'finding': 'mcp_calls 尚未建立,MCP 編排率目前以 0 顯示', 'suggestion': '執行 Phase 10.7 migration 後,本頁會自動接回 MCP server/caller 矩陣', }) for ag in agent_matrix: if ag['calls'] == 0: continue # 規則 1:付費比例 > 50% 且 ollama 比例 < 20% → 建議切 Hermes-first if ag['paid_pct'] > 50 and ag['ollama_pct'] < 20: recommendations.append({ 'severity': 'high', 'agent': ag['label'], 'finding': f"付費 LLM 比例 {ag['paid_pct']:.0f}%(cost ${ag['cost']:.2f})", 'suggestion': '改用 Hermes-first 短路機制:先試 Ollama 三主機 5s timeout,0 hits 才 escalate Gemini', }) # 規則 2:錯誤率 > 10% → 建議跑 code review if ag['error_rate'] > 10: recommendations.append({ 'severity': 'high', 'agent': ag['label'], 'finding': f"錯誤率 {ag['error_rate']:.1f}%({ag['errors']}/{ag['calls']})", 'suggestion': '觸發程式碼審查流程找回歸問題(可由 AI 流量控制台執行)', }) # 規則 3:MCP 編排率 < 5% 但 calls 多 → 建議擴大 MCP 使用 if mcp_calls_table_exists and ag['mcp_rate'] < 5 and ag['calls'] > 50: recommendations.append({ 'severity': 'med', 'agent': ag['label'], 'finding': f"MCP 編排率僅 {ag['mcp_rate']:.1f}%,未善用外部工具", 'suggestion': '考慮加 MCP omnisearch / firecrawl 補強事實查證鏈', }) # 規則 4:RAG 命中率高(≥40%)但有 saved_call=False 的多 → 提醒 feedback if ag['rag_rate'] >= 40 and ag['rag_hits'] >= 20: recommendations.append({ 'severity': 'low', 'agent': ag['label'], 'finding': f"RAG 命中率 {ag['rag_rate']:.1f}%({ag['rag_hits']} hits)— 知識庫貢獻度高", 'suggestion': '推 Telegram inline button 收集 feedback_score 強化 promotion gate', }) # 規則 5:111 fallback 比例 > 20% → 警示 if ag['calls'] > 0 and ag['ollama_111'] / max(ag['calls'], 1) > 0.20: fb_pct = ag['ollama_111'] / ag['calls'] * 100 recommendations.append({ 'severity': 'med', 'agent': ag['label'], 'finding': f"111 fallback 比例 {fb_pct:.0f}%(GCP 兩台不可達?)", 'suggestion': '檢查 mo.wooo.work/observability/host_health AIOps incidents', }) return render_template( 'admin/agent_orchestration.html', active_page='obs_agent_orchestration', hours=hours, agent_matrix=agent_matrix, mcp_matrix=mcp_matrix, recommendations=recommendations, overall={ 'total_calls': total_calls, 'total_cost': total_cost, 'total_tokens': total_tokens, 'paid_calls': paid_calls, 'local_calls': local_calls, 'rag_hits': rag_hits, 'paid_pct': (paid_calls / total_calls * 100) if total_calls else 0, 'local_pct': (local_calls / total_calls * 100) if total_calls else 0, 'rag_rate': (rag_hits / total_calls * 100) if total_calls else 0, }, error=None, ) except Exception as e: return render_template( 'admin/agent_orchestration.html', active_page='obs_agent_orchestration', hours=hours, agent_matrix=[], mcp_matrix=[], recommendations=[], overall={}, error='資料查詢暫時不可用,已切換安全空狀態。', ) finally: session.close() # ───────────────────────────────────────────────────────────────────────────── # /observability/ai_calls — Phase 27 主入口 # ───────────────────────────────────────────────────────────────────────────── @admin_observability_bp.route('/ai_calls') @login_required 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 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() # 5b. Phase 47 K-2: by model 細分(不只 provider,到實際 model) by_model = session.execute( sa_text(""" SELECT model, provider, COUNT(*) AS calls, COALESCE(SUM(input_tokens + output_tokens), 0) AS tokens, COALESCE(SUM(cost_usd), 0) AS cost, COALESCE(AVG(duration_ms), 0) AS avg_ms, COUNT(*) FILTER (WHERE status NOT IN ('ok','cache_only')) AS errors FROM ai_calls WHERE called_at >= :since AND model IS NOT NULL AND model != '' GROUP BY model, provider ORDER BY calls DESC LIMIT 15 """), {'since': since}, ).fetchall() # 5c. Phase 47 K-2: hourly 呼叫量趨勢(24 個 bucket) hourly_trend = session.execute( sa_text(""" SELECT date_trunc('hour', called_at) AS hr, COUNT(*) AS calls, COALESCE(SUM(cost_usd), 0) AS cost, COUNT(*) FILTER (WHERE status NOT IN ('ok','cache_only')) AS errors FROM ai_calls WHERE called_at >= NOW() - INTERVAL '24 hours' GROUP BY hr ORDER BY hr ASC """), ).fetchall() # 5d. Phase 47 K-2: agent_context 最近 10 筆(OpenClaw/Hermes 對話上下文) recent_contexts = session.execute( sa_text(""" SELECT created_at, agent_name, context_key, ttl_minutes, LEFT(context_val, 120) AS preview FROM agent_context ORDER BY created_at DESC LIMIT 10 """), ).fetchall() # 5. Phase 39 D-3: caller × RAG 命中率 × MCP 編排率(跨表 JOIN) # mcp_calls / rag_query_log 尚未 migration 時安全降級,不曝露 DB exception。 mcp_calls_table_exists = bool(session.execute( sa_text("SELECT to_regclass('public.mcp_calls') IS NOT NULL") ).scalar()) rag_query_log_exists = bool(session.execute( sa_text("SELECT to_regclass('public.rag_query_log') IS NOT NULL") ).scalar()) if mcp_calls_table_exists and rag_query_log_exists: caller_richness = session.execute( sa_text(""" SELECT a.caller, COUNT(*) AS total_calls, COUNT(*) FILTER (WHERE a.rag_hit) AS rag_hits, COUNT(DISTINCT m.request_id) AS mcp_orchestrated, COALESCE(AVG(rl.feedback_score) FILTER (WHERE rl.feedback_score IS NOT NULL), 0) AS avg_rag_feedback, COUNT(rl.feedback_score) AS feedback_count FROM ai_calls a LEFT JOIN mcp_calls m ON m.request_id = a.request_id AND m.called_at >= :since LEFT JOIN rag_query_log rl ON rl.caller = a.caller AND rl.queried_at >= :since WHERE a.called_at >= :since GROUP BY a.caller HAVING COUNT(*) >= 5 ORDER BY total_calls DESC LIMIT 12 """), {'since': since}, ).fetchall() else: caller_richness = session.execute( sa_text(""" SELECT caller, COUNT(*) AS total_calls, COUNT(*) FILTER (WHERE rag_hit) AS rag_hits FROM ai_calls WHERE called_at >= :since GROUP BY caller HAVING COUNT(*) >= 5 ORDER BY total_calls DESC LIMIT 12 """), {'since': since}, ).fetchall() return render_template( 'admin/ai_calls_dashboard.html', active_page='obs_ai_calls', 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=[_build_ai_call_recent_row(r) for r in recent], callers=[r[0] for r in callers], by_model=[ { 'model': r[0], 'provider': r[1], 'calls': int(r[2] or 0), 'tokens': int(r[3] or 0), 'cost': float(r[4] or 0), 'avg_ms': int(r[5] or 0), 'errors': int(r[6] or 0), } for r in by_model ], hourly_trend=[ { 'hour': r[0].strftime('%H:%M') if r[0] else '', 'calls': int(r[1] or 0), 'cost': float(r[2] or 0), 'errors': int(r[3] or 0), } for r in hourly_trend ], recent_contexts=[ { 'created_at': r[0].strftime('%Y-%m-%d %H:%M') if r[0] else '', 'agent_name': r[1], 'context_key': r[2], 'ttl_minutes': int(r[3] or 0), 'preview': r[4] or '', } for r in recent_contexts ], caller_richness=[ { 'caller': r[0], 'total_calls': int(r[1] or 0), 'rag_hits': int(r[2] or 0), 'mcp_orchestrated': int(r[3] or 0) if mcp_calls_table_exists and rag_query_log_exists else 0, 'avg_rag_feedback': round(float(r[4] or 0), 2) if mcp_calls_table_exists and rag_query_log_exists else 0, 'feedback_count': int(r[5] or 0) if mcp_calls_table_exists and rag_query_log_exists else 0, 'rag_hit_rate': (float(r[2] or 0) / float(r[1]) * 100) if r[1] else 0, 'mcp_rate': (float(r[3] or 0) / float(r[1]) * 100) if mcp_calls_table_exists and rag_query_log_exists and r[1] else 0, } for r in caller_richness ], error=None, ) except Exception as e: return render_template( 'admin/ai_calls_dashboard.html', active_page='obs_ai_calls', hours=hours, caller_filter=caller_filter, provider_filter=provider_filter, summary={}, by_provider=[], recent=[], callers=[], caller_richness=[], by_model=[], hourly_trend=[], recent_contexts=[], error='AI 呼叫資料暫時不可用,已切換安全空狀態。', ) 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) Phase 39(D-1):每筆 episode 自動跑 RAG 找 Top 3 相似已晉升 ai_insights, 輔助人工判斷晉升價值。RAG fail-safe:失敗則 similar_insights=[],不擋頁面。 """ 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() # ai_insights 全表大小(給「晉升後 KB 增長」對照) kb_size = 0 try: kb_row = session.execute( sa_text("SELECT COUNT(*) FROM ai_insights"), ).fetchone() kb_size = int(kb_row[0] or 0) except Exception: pass 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], 'similar_insights': []} for r in rows ] # Phase 39 D-1:對每筆 episode 跑 RAG 找 Top 3 相似已晉升 try: from services.rag_service import rag_service for ep in episodes: try: rag_result = rag_service.query( text=ep['distilled_text'][:500], caller='admin_promotion_review', top_k=3, threshold=0.7, ) ep['similar_insights'] = [ { 'id': h.get('id'), 'insight_type': h.get('insight_type'), 'content': (h.get('content') or '')[:180], 'similarity': round(float(h.get('similarity', 0)), 3), 'created_at': h.get('created_at').strftime('%Y-%m-%d') if h.get('created_at') else '', } for h in rag_result.hits[:3] ] except Exception: logger.debug( "Promotion review similar-insight lookup failed for episode_id=%s", ep.get('id'), exc_info=True, ) except Exception: logger.debug("Promotion review RAG service unavailable; skipping similar insights", exc_info=True) # Phase 47 K-4: 蒸餾池 status 分布(30d) ep_distribution = session.execute( sa_text(""" SELECT promotion_status, COUNT(*) AS cnt FROM learning_episodes WHERE created_at >= NOW() - INTERVAL '30 days' GROUP BY promotion_status ORDER BY cnt DESC """), ).fetchall() episode_distribution_30d = {r[0]: int(r[1] or 0) for r in ep_distribution} # Phase 47 K-4: ai_insights 最近 10 筆已晉升(type/created_at 視覺) latest_insights = session.execute( sa_text(""" SELECT id, insight_type, period, product_sku, created_at, LEFT(content, 160) AS preview FROM ai_insights ORDER BY created_at DESC LIMIT 10 """), ).fetchall() # Phase 47 K-4: agent_strategy_weights TOP 12(OpenClaw 學習權重) strategy_weights = session.execute( sa_text(""" SELECT strategy_key, weight, success_cnt, fail_cnt, updated_at FROM agent_strategy_weights ORDER BY (success_cnt + fail_cnt) DESC LIMIT 12 """), ).fetchall() return render_template( 'admin/promotion_review.html', active_page='obs_promotion_review', episodes=episodes, kb_size=kb_size, episode_distribution_30d=episode_distribution_30d, latest_insights=[ { 'id': r[0], 'insight_type': r[1], 'period': r[2], 'product_sku': r[3], 'created_at': r[4].strftime('%Y-%m-%d %H:%M') if r[4] else '', 'preview': r[5] or '', } for r in latest_insights ], strategy_weights=[ { 'strategy_key': r[0], 'weight': float(r[1] or 0), 'success': int(r[2] or 0), 'fail': int(r[3] or 0), 'updated_at': r[4].strftime('%Y-%m-%d') if r[4] else '', 'success_rate': ( float(r[2] or 0) / float((r[2] or 0) + (r[3] or 0)) * 100 ) if ((r[2] or 0) + (r[3] or 0)) > 0 else 0, } for r in strategy_weights ], error=None, ) except Exception as e: return render_template( 'admin/promotion_review.html', active_page='obs_promotion_review', episodes=[], kb_size=0, episode_distribution_30d={}, latest_insights=[], strategy_weights=[], error='RAG 晉升資料暫時不可用,已切換安全空狀態。', ) finally: session.close() @admin_observability_bp.route('/promotion_review/approve/', 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, human_approver=approver_hash, ) 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/', methods=['POST']) @login_required def promotion_review_reject(episode_id: int): """Web 介面「拒絕」按鈕""" try: from services.learning_pipeline import promotion_gate, hash_human_approver user = get_current_user() or {} username = user.get('username', 'web_admin') approver_hash = hash_human_approver(username) ok = promotion_gate.reject( episode_id, 'rejected_human', detail='web admin reject', human_approver=approver_hash, ) 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), ) # Phase 40 D-6: learning_episodes 各 status 分布(蒸餾池飽和度) episode_distribution = {} try: session = get_session() try: rows = session.execute( sa_text(""" SELECT promotion_status, COUNT(*) AS cnt FROM learning_episodes WHERE created_at >= NOW() - INTERVAL ':days days' GROUP BY promotion_status """).bindparams(days=days), ).fetchall() if False else session.execute( sa_text(f""" SELECT promotion_status, COUNT(*) AS cnt FROM learning_episodes WHERE created_at >= NOW() - INTERVAL '{int(days)} days' GROUP BY promotion_status """), ).fetchall() episode_distribution = {r[0]: int(r[1] or 0) for r in rows} finally: session.close() except Exception: pass # Phase 40 D-6: 對最差 3 名 caller 跑 RAG 找根因建議 rag_root_causes = [] try: from services.rag_service import rag_service worst_3 = sorted_trends[:3] if len(sorted_trends) >= 3 else sorted_trends for caller, info in worst_3: if info.get('avg_score', 5) < 3.0 and info.get('total_feedback', 0) >= 3: try: q = ( f"caller {caller} 反饋分數低 平均 " f"{info.get('avg_score', 0):.1f}/5 應採取什麼根因排查" ) rag_result = rag_service.query( text=q, caller='admin_quality_trend', top_k=2, threshold=0.6, ) if rag_result.hits: rag_root_causes.append({ 'caller': caller, 'avg_score': info.get('avg_score', 0), 'feedback_n': info.get('total_feedback', 0), 'hits': [ { 'id': h.get('id'), 'insight_type': h.get('insight_type'), 'content': (h.get('content') or '')[:200], 'similarity': round(float(h.get('similarity', 0)), 3), } for h in rag_result.hits[:2] ], }) except Exception: pass except Exception: pass # Phase 47 K-5: action_outcomes verdict 統計(ADR-012 閉環學習結果) action_outcomes_stats = [] action_plans_status = [] rag_overall_dist = [] try: session = get_session() try: # action_outcomes verdict 分布(30d) ao_rows = session.execute( sa_text(f""" SELECT verdict, COUNT(*) AS cnt FROM action_outcomes WHERE created_at >= NOW() - INTERVAL '{int(days)} days' GROUP BY verdict ORDER BY cnt DESC """), ).fetchall() action_outcomes_stats = [{'verdict': r[0] or 'unknown', 'count': int(r[1] or 0)} for r in ao_rows] # action_plans status 分布(30d) ap_rows = session.execute( sa_text(f""" SELECT status, plan_type, COUNT(*) AS cnt FROM action_plans WHERE created_at >= NOW() - INTERVAL '{int(days)} days' GROUP BY status, plan_type ORDER BY cnt DESC """), ).fetchall() action_plans_status = [ {'status': r[0], 'plan_type': r[1] or 'misc', 'count': int(r[2] or 0)} for r in ap_rows ] # rag_query_log 整體 feedback 分布(不只 caller-level,整體) rag_dist_rows = session.execute( sa_text(f""" SELECT feedback_score, COUNT(*) AS cnt FROM rag_query_log WHERE queried_at >= NOW() - INTERVAL '{int(days)} days' AND feedback_score IS NOT NULL GROUP BY feedback_score ORDER BY feedback_score """), ).fetchall() rag_overall_dist = [{'score': int(r[0] or 0), 'count': int(r[1] or 0)} for r in rag_dist_rows] finally: session.close() except Exception: pass return render_template( 'admin/quality_trend.html', active_page='obs_quality_trend', days=days, trends=[(c, info) for c, info in sorted_trends], recommendations=recommendations, episode_distribution=episode_distribution, rag_root_causes=rag_root_causes, action_outcomes_stats=action_outcomes_stats, action_plans_status=action_plans_status, rag_overall_dist=rag_overall_dist, error=None, ) except Exception as e: return render_template( 'admin/quality_trend.html', active_page='obs_quality_trend', days=days, trends=[], recommendations=[], episode_distribution={}, rag_root_causes=[], action_outcomes_stats=[], action_plans_status=[], rag_overall_dist=[], error='AI 品質資料暫時不可用,已切換安全空狀態。', ) # ───────────────────────────────────────────────────────────────────────────── # /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: ai_call_budgets_exists = bool(session.execute( sa_text("SELECT to_regclass('public.ai_call_budgets') IS NOT NULL") ).scalar()) if ai_call_budgets_exists: 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() else: budgets = [] 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 '-', }) # Phase 47 K-3: 30d daily cost trend by provider cost_30d = session.execute( sa_text(""" SELECT date_trunc('day', called_at)::date AS d, provider, COALESCE(SUM(cost_usd), 0) AS cost FROM ai_calls WHERE called_at >= NOW() - INTERVAL '30 days' GROUP BY d, provider ORDER BY d DESC, cost DESC """), ).fetchall() cost_trend_30d = [] for r in cost_30d: cost_trend_30d.append({ 'date': r[0].strftime('%m-%d') if r[0] else '', 'provider': r[1], 'cost': float(r[2] or 0), }) # Phase 55 S-3: 當月各 provider cost 分布(給圓餅圖用) provider_cost_month = session.execute( sa_text(""" SELECT provider, COALESCE(SUM(cost_usd), 0) AS cost FROM ai_calls WHERE called_at >= :ms AND cost_usd > 0 GROUP BY provider ORDER BY cost DESC """), {'ms': month_start}, ).fetchall() # Phase 47 K-3: top 5 cost-burning caller (當月) top_cost_callers = session.execute( sa_text(""" SELECT caller, COUNT(*) AS calls, COALESCE(SUM(cost_usd), 0) AS cost, COALESCE(SUM(input_tokens + output_tokens), 0) AS tokens FROM ai_calls WHERE called_at >= :ms AND cost_usd > 0 GROUP BY caller ORDER BY cost DESC LIMIT 5 """), {'ms': month_start}, ).fetchall() # Phase 47 K-3: ai_price_recommendations 7d 統計 price_rec_7d = session.execute( sa_text(""" SELECT strategy, COUNT(*) AS cnt, COALESCE(AVG(confidence), 0) AS avg_conf FROM ai_price_recommendations WHERE created_at >= NOW() - INTERVAL '7 days' GROUP BY strategy ORDER BY cnt DESC """), ).fetchall() # Phase 39 D-4: RAG 自動建議策略(針對超 80% 的 row) budget_strategies = [] over_threshold_rows = [r for r in rows if r.get('ratio', 0) >= 0.8] if over_threshold_rows: try: from services.rag_service import rag_service top_breach = max(over_threshold_rows, key=lambda r: r.get('ratio', 0)) query_text = ( f"預算超出 alert_pct provider={top_breach['provider']} " f"ratio={int(top_breach['ratio']*100)}% 應採取什麼節流策略" ) rag_result = rag_service.query( text=query_text, caller='admin_budget_dashboard', top_k=3, threshold=0.65, ) budget_strategies = [ { 'id': h.get('id'), 'insight_type': h.get('insight_type'), 'content': (h.get('content') or '')[:240], 'similarity': round(float(h.get('similarity', 0)), 3), } for h in rag_result.hits[:3] ] except Exception: pass return render_template( 'admin/budget.html', active_page='obs_budget', rows=rows, budget_strategies=budget_strategies, cost_trend_30d=cost_trend_30d, top_cost_callers=[ { 'caller': r[0], 'calls': int(r[1] or 0), 'cost': float(r[2] or 0), 'tokens': int(r[3] or 0), } for r in top_cost_callers ], provider_cost_month=[ {'provider': r[0], 'cost': float(r[1] or 0)} for r in provider_cost_month ], price_rec_7d=[ { 'strategy': r[0], 'count': int(r[1] or 0), 'avg_confidence': round(float(r[2] or 0), 3), } for r in price_rec_7d ], error=None, ) except Exception as e: return render_template('admin/budget.html', active_page='obs_budget', rows=[], budget_strategies=[], cost_trend_30d=[], top_cost_callers=[], price_rec_7d=[], provider_cost_month=[], error='預算資料暫時不可用,已切換安全空狀態。') finally: session.close() @admin_observability_bp.route('/ai_calls/trigger_code_review', methods=['POST']) @login_required def ai_calls_trigger_code_review(): """Phase 40 D-7 (L2 自動化):對高錯誤率時段觸發程式碼審查流程。 用途:admin 在觀測台看到某 caller 錯誤率飆高時,一鍵觸發 5-step pipeline (read→hermes_scan→openclaw_summary→ea_decision→nemoton_act) 在 daemon thread 自動審查最近 commit 變更檔案,找出可能的回歸問題。 """ try: import subprocess import threading from services.code_review_pipeline_service import CodeReviewPipeline # 取最新 commit + 變更檔案 commit_sha = subprocess.check_output( ['git', 'rev-parse', 'HEAD'], stderr=subprocess.DEVNULL, ).decode().strip() changed = subprocess.check_output( ['git', 'diff-tree', '--no-commit-id', '--name-only', '-r', '-m', commit_sha], stderr=subprocess.DEVNULL, ).decode().strip().split('\n') changed = list(dict.fromkeys(f for f in changed if f)) if not changed: return jsonify({'ok': False, 'error': '最新 commit 無變更檔案'}), 503 pipeline = CodeReviewPipeline( commit_sha=commit_sha, changed_files=changed, branch='main', deploy_type='manual_observability', ) threading.Thread(target=pipeline.run, daemon=True).start() return jsonify({ 'ok': True, 'pipeline_id': pipeline.pipeline_id, 'commit_sha': commit_sha[:8], 'changed_files_count': len(changed), 'message': f'已觸發程式碼審查流程(流程編號:{pipeline.pipeline_id})在背景執行,' f'5 個步驟完成後會推 Telegram 通知。', }) except Exception as e: return jsonify({'ok': False, 'error': f'{type(e).__name__}: {str(e)[:200]}'}), 500 @admin_observability_bp.route('/ppt_audit/trigger_aider_heal', methods=['POST']) @login_required def ppt_audit_trigger_aider_heal(): """Phase 40 D-8 (L2 自動化):對失敗 PPT audit 觸發 AiderHeal 修 generator。 用途:admin 在觀測台看到 PPT vision audit 連續失敗時,一鍵觸發 AiderHeal 自動修 services/ppt_generator.py(或對應 template generator), 結果會 git push 到 main 觸發 CD 自動部署。 """ try: from services import aider_heal_executor data = request.get_json(silent=True) or {} error_msg = (data.get('error_msg') or '').strip() issue_summary = (data.get('issue_summary') or '').strip() pptx_filename = (data.get('pptx_filename') or '').strip() diagnosis = error_msg or issue_summary if not diagnosis: return jsonify({'ok': False, 'error': '需提供 error_msg 或 issue_summary'}), 400 error_message = diagnosis[:500] if pptx_filename: error_message = f"PPT 視覺審核失敗:{pptx_filename}\n診斷:{error_message}" # 構造 context 給 AiderHeal context = { 'error_type': 'ppt_vision_audit_failure', 'error_message': error_message[:500], 'target_file': 'services/ppt_generator.py', 'pptx_filename': pptx_filename, 'triggered_by': 'admin_observability', 'issue_summary': issue_summary[:500], } heal_key = pptx_filename or diagnosis[:160] or 'manual' queued_at = datetime.now().strftime('%Y-%m-%d %H:%M:%S') active_job = { 'key': heal_key, 'pptx_filename': pptx_filename, 'target_file': 'services/ppt_generator.py', 'queued_at': queued_at, 'diagnosis': _public_ppt_text(diagnosis, max_chars=120), } with _PPT_AIDER_HEAL_LOCK: if heal_key in _PPT_AIDER_HEAL_ACTIVE: existing_job = dict(_PPT_AIDER_HEAL_ACTIVE.get(heal_key) or active_job) existing_job['diagnosis'] = _public_ppt_text(existing_job.get('diagnosis'), max_chars=120) return jsonify({ 'ok': True, 'status': 'already_running', 'action': 'CODE_FIX', 'message': '這份簡報的修復流程已在背景執行中,請等通知結果回報。', 'target_file': 'services/ppt_generator.py', 'active_count': len(_PPT_AIDER_HEAL_ACTIVE), 'job': existing_job, }), 202 _PPT_AIDER_HEAL_ACTIVE[heal_key] = active_job def _heal_worker(): try: result = aider_heal_executor.execute_code_fix( error_type='ppt_vision_audit_failure', error_message=error_message, target_file='services/ppt_generator.py', context=context, ) logger.info( "[PPTAudit] AiderHeal 背景任務完成 | file=%s | ok=%s | message=%s", pptx_filename or '-', bool(result.get('success')), (result.get('message') or '')[:160], ) except Exception: logger.exception( "[PPTAudit] AiderHeal 背景任務失敗 | file=%s", pptx_filename or '-', ) finally: with _PPT_AIDER_HEAL_LOCK: _PPT_AIDER_HEAL_ACTIVE.pop(heal_key, None) thread_key = ''.join(ch for ch in pptx_filename if ch.isalnum())[:24] or 'manual' threading.Thread( target=_heal_worker, daemon=True, name=f"ppt-aider-heal-{thread_key}", ).start() return jsonify({ 'ok': True, 'status': 'queued', 'action': 'CODE_FIX', 'message': '修復流程已排入背景執行;完成後會由通知結果回報。', 'target_file': 'services/ppt_generator.py', 'active_count': len(_list_ppt_aider_heal_active_jobs()), 'job': active_job, }), 202 except Exception as e: return jsonify({'ok': False, 'error': f'{type(e).__name__}: {str(e)[:200]}'}), 500 @admin_observability_bp.route('/ppt_audit/aider_heal_status') @login_required def ppt_audit_aider_heal_status(): jobs = _list_ppt_aider_heal_active_jobs() return jsonify({ 'ok': True, 'active_count': len(jobs), 'jobs': jobs, }) @admin_observability_bp.route('/ppt_audit/generate_missing', methods=['POST']) @login_required def ppt_audit_generate_missing(): """補齊 PPT audit 頁定義中的簡報產出。 這是非阻塞入口:Web 頁面只負責排入背景 thread,真正的產生流程共用 Telegram/OpenClaw 既有 generator 與 cache key。 """ try: from services.ppt_auto_generation_service import start_defined_ppt_generation_background data = request.get_json(silent=True) or {} report_types = data.get('report_types') or None force = bool(data.get('force')) result = start_defined_ppt_generation_background( report_types=report_types, schedule_kind='manual', force=force, ) return jsonify(result), 202 if result.get('status') == 'queued' else 200 except Exception as e: return jsonify({'ok': False, 'error': f'{type(e).__name__}: {str(e)[:200]}'}), 500 @admin_observability_bp.route('/ppt_audit/run_vision', methods=['POST']) @login_required def ppt_audit_run_vision(): """Queue a non-blocking visual QA run for selected generated PPT files.""" try: from services.ppt_vision_service import start_ppt_vision_audit_background data = request.get_json(silent=True) or {} filenames = data.get('filenames') or [] if isinstance(filenames, str): filenames = [filenames] filenames = [str(name) for name in filenames if str(name).lower().endswith('.pptx')] max_files = data.get('max_files') or (len(filenames) if filenames else 10) try: max_files = max(1, min(int(max_files), 20)) except Exception: max_files = 10 result = start_ppt_vision_audit_background( reports_dir=None, filenames=filenames, max_files=max_files, hours=24, ) return jsonify(result), 202 if result.get('status') == 'queued' else 200 except Exception as e: return jsonify({'ok': False, 'error': f'{type(e).__name__}: {str(e)[:200]}'}), 500 @admin_observability_bp.route('/ppt_audit/vision_status') @login_required def ppt_audit_vision_status(): """Expose current/last background PPT vision audit status for the admin UI.""" try: from services.ppt_vision_service import get_ppt_vision_audit_status return jsonify(get_ppt_vision_audit_status()) except Exception as e: return jsonify({'ok': False, 'error': f'{type(e).__name__}: {str(e)[:200]}'}), 500 def _resolve_ppt_report_path(filename: str): """在 REPORTS_DIR 內解析簡報檔名,並阻擋路徑逃逸。""" import os from utils.security import safe_join reports_dir = os.environ.get('REPORTS_DIR', '/app/data/reports') safe_path = safe_join(reports_dir, filename) if not safe_path.exists() or not safe_path.is_file(): return None, ('檔案不存在', 404) if safe_path.suffix.lower() != '.pptx': return None, ('不支援的檔案格式', 400) return safe_path, None def _validate_pptx_for_preview(safe_path): import zipfile try: with zipfile.ZipFile(safe_path, 'r') as zf: bad = zf.testzip() if bad is not None: return f'PPT 檔案損毀,無法預覽(損毀區段:{bad})' except zipfile.BadZipFile: return 'PPT 檔案損毀,無法預覽(非有效 zip)' except Exception as e: return f'預覽檢查失敗:{type(e).__name__}' return None @admin_observability_bp.route('/ppt_audit_file//prewarm', methods=['POST']) @login_required def ppt_audit_file_prewarm(filename: str): """建立單一 PPT 的 PDF 預覽快取,並回傳 JSON 狀態。""" try: safe_path, error_response = _resolve_ppt_report_path(filename) if error_response: message, status_code = error_response return jsonify({'ok': False, 'error': message}), status_code validation_error = _validate_pptx_for_preview(safe_path) if validation_error: return jsonify({'ok': False, 'error': validation_error}), 409 from services.ppt_preview_service import build_ppt_preview preview = build_ppt_preview(safe_path) if not preview.ok or not preview.pdf_path: return jsonify({'ok': False, 'error': preview.error or '無法產生預覽'}), 409 return jsonify({ 'ok': True, 'filename': safe_path.name, 'cache_hit': bool(preview.cache_hit), 'converter': preview.converter, 'message': 'PDF 預覽快取已建立' if not preview.cache_hit else 'PDF 預覽快取已存在', }) except ValueError: return jsonify({'ok': False, 'error': '非法路徑'}), 400 except Exception as e: return jsonify({'ok': False, 'error': f'{type(e).__name__}: {str(e)[:200]}'}), 500 @admin_observability_bp.route('/ppt_audit_file/') @login_required def ppt_audit_file(filename: str): """提供觀測台簡報檔案預覽/下載。 - action=view 開啟預覽(預設) - action=pdf 產生/回傳線上預覽 PDF - action=download 直接下載 """ action = (request.args.get('action', 'view') or 'view').strip().lower() try: safe_path, error_response = _resolve_ppt_report_path(filename) if error_response: message, status_code = error_response return message, status_code if action in ('view', 'pdf'): validation_error = _validate_pptx_for_preview(safe_path) if validation_error: return validation_error, 409 if action in ('view', 'pdf'): from services.ppt_preview_service import build_ppt_preview preview = build_ppt_preview(safe_path) if action == 'pdf': if not preview.ok or not preview.pdf_path: return preview.error or '無法產生預覽', 409 return send_file( preview.pdf_path, mimetype='application/pdf', as_attachment=False, download_name=f'{safe_path.stem}.pdf', ) return render_template( 'admin/ppt_audit_preview.html', active_page='obs_ppt_audit', filename=safe_path.name, file_size_kb=round(safe_path.stat().st_size / 1024, 1), file_mtime=datetime.fromtimestamp(safe_path.stat().st_mtime).strftime('%Y-%m-%d %H:%M'), preview=preview, pdf_url=url_for('admin_observability.ppt_audit_file', filename=safe_path.name, action='pdf'), download_url=url_for('admin_observability.ppt_audit_file', filename=safe_path.name, action='download'), back_url=url_for('admin_observability.ppt_audit_history'), ) return send_file( str(safe_path), mimetype='application/vnd.openxmlformats-officedocument.presentationml.presentation', as_attachment=(action == 'download'), download_name=safe_path.name, ) except ValueError: return '非法路徑', 400 except Exception as e: return f'{type(e).__name__}: {str(e)[:200]}', 500 @admin_observability_bp.route('/api/health_indicator') @login_required def health_indicator_api(): """Phase 52 P-1:給 topbar 觀測台 indicator 用的輕量 JSON API。 回傳當前是否有「需要關注」的事件: - 三主機掛掉 - 待審 episode > 0 - 過去 1h 錯誤率 ≥ 30% - 預算 ≥ 90% """ try: now_ts = time.time() with _HEALTH_INDICATOR_CACHE_LOCK: cached_payload = _HEALTH_INDICATOR_CACHE.get('payload') if cached_payload and now_ts < float(_HEALTH_INDICATOR_CACHE.get('expires_at') or 0): return jsonify(dict(cached_payload)) session = get_session() try: # 三主機最新狀態 host_unhealthy = 0 try: rows = session.execute( sa_text(""" WITH latest AS ( SELECT host_label, FIRST_VALUE(healthy) OVER ( PARTITION BY host_label ORDER BY probed_at DESC ) AS healthy FROM host_health_probes WHERE probed_at >= NOW() - INTERVAL '1 hour' ) SELECT host_label, BOOL_AND(NOT healthy) AS down FROM latest GROUP BY host_label """), ).fetchall() host_unhealthy = sum(1 for r in rows if r[1]) except Exception: pass # 待審 episode ep_pending = 0 try: ep_pending = int(session.execute( sa_text("SELECT COUNT(*) FROM learning_episodes WHERE promotion_status = 'awaiting_review' AND reviewed_at IS NULL"), ).fetchone()[0] or 0) except Exception: pass # 1h 錯誤率 error_rate = 0 try: row = session.execute( sa_text(""" SELECT COUNT(*), COUNT(*) FILTER (WHERE status NOT IN ('ok','cache_only')) FROM ai_calls WHERE called_at >= NOW() - INTERVAL '1 hour' """), ).fetchone() total = int(row[0] or 0) errs = int(row[1] or 0) error_rate = (errs / total * 100) if total > 20 else 0 except Exception: pass # 預算告警(任一 ≥ 90%) budget_alert = False try: from datetime import datetime as _dt today = _dt.now() ms = _dt(today.year, today.month, 1) bgs = session.execute( sa_text(""" SELECT b.budget_usd, COALESCE((SELECT SUM(cost_usd) FROM ai_calls WHERE called_at >= :ms AND (b.provider IS NULL OR provider = b.provider)), 0) AS spent FROM ai_call_budgets b """), {'ms': ms}, ).fetchall() for budget, spent in bgs: if budget and float(budget) > 0 and float(spent) / float(budget) >= 0.9: budget_alert = True break except Exception: pass alert_count = ( host_unhealthy + (1 if ep_pending > 0 else 0) + (1 if error_rate >= 30 else 0) + (1 if budget_alert else 0) ) payload = { 'ok': True, 'alert_count': alert_count, 'host_unhealthy': host_unhealthy, 'ep_pending': ep_pending, 'error_rate_high': error_rate >= 30, 'budget_alert': budget_alert, 'tooltip': _build_indicator_tooltip(host_unhealthy, ep_pending, error_rate, budget_alert), } with _HEALTH_INDICATOR_CACHE_LOCK: _HEALTH_INDICATOR_CACHE['payload'] = dict(payload) _HEALTH_INDICATOR_CACHE['expires_at'] = time.time() + _HEALTH_INDICATOR_CACHE_TTL_SECONDS return jsonify(payload) finally: session.close() except Exception as e: return jsonify({'ok': False, 'error': f'{type(e).__name__}: {str(e)[:200]}'}), 500 def _build_indicator_tooltip(host_unhealthy, ep_pending, error_rate, budget_alert) -> str: parts = [] if host_unhealthy: parts.append(f"{host_unhealthy} 主機異常") if ep_pending > 0: parts.append(f"{ep_pending} 待審") if error_rate >= 30: parts.append(f"錯誤率 {error_rate:.0f}%") if budget_alert: parts.append("預算 ≥ 90%") if not parts: return "AI 觀測台(一切正常)" return "AI 觀測台 — " + " / ".join(parts) def _latest_host_probe_unhealthy(host_label: str, window_minutes: int = 30) -> bool: """查 DB 最新 host_health_probe,作為 AutoHeal 按鈕的真實狀態來源。 `_is_unhealthy()` 只代表 Ollama client 在 30 秒 TTL 內的記憶體標記; scheduler / 頁面 probe 寫入的是 `host_health_probes`。L2 AutoHeal 入口 必須接受 DB 最新探針異常,避免 Telegram 或 Web 顯示主機已掛、按鈕卻拒絕執行。 """ if not host_label: return False session = get_session() try: row = session.execute( sa_text(""" SELECT healthy FROM host_health_probes WHERE host_label = :label AND probed_at >= NOW() - (:minutes || ' minutes')::interval ORDER BY probed_at DESC LIMIT 1 """), {'label': host_label, 'minutes': int(window_minutes)}, ).fetchone() return bool(row is not None and row[0] is False) except Exception: return False finally: session.close() @admin_observability_bp.route('/playbooks/toggle/', methods=['POST']) @login_required def playbook_toggle(playbook_id: int): """Phase 50 N-3:一鍵啟用/停用 playbook(is_active 翻轉)。 用途:在 host_health 觀測台直接管理 AutoHeal playbook, 不需 SSH 188 改 DB。 """ try: session = get_session() try: row = session.execute( sa_text("SELECT id, name, is_active FROM playbooks WHERE id = :id"), {'id': playbook_id}, ).fetchone() if not row: return jsonify({'ok': False, 'error': f'playbook #{playbook_id} 不存在'}), 404 new_active = not bool(row[2]) session.execute( sa_text("UPDATE playbooks SET is_active = :a, updated_at = NOW() WHERE id = :id"), {'a': new_active, 'id': playbook_id}, ) session.commit() return jsonify({ 'ok': True, 'playbook_id': playbook_id, 'name': row[1], 'is_active': new_active, 'message': f'Playbook 「{row[1]}」已{"啟用" if new_active else "停用"}', }) finally: session.close() except Exception as e: return jsonify({'ok': False, 'error': f'{type(e).__name__}: {str(e)[:200]}'}), 500 @admin_observability_bp.route('/host_health/trigger_autoheal', methods=['POST']) @login_required def host_health_trigger_autoheal(): """Phase 40 D-9 (L2 自動化):對掛掉的主機觸發 AutoHeal playbook。 用途:admin 看到某台 Ollama 主機標記 unhealthy 時一鍵觸發 AutoHeal (ADR-013) 跑對應 playbook(DOCKER_RESTART / SSH_CMD / ALERT_ONLY)。 安全:只能對已標記 unhealthy 的 host 觸發;不接受任意 host URL(防 SSRF)。 """ try: data = request.json or {} host_label = (data.get('host_label') or '').strip() from services.auto_heal_service import auto_heal_service from services.ollama_service import _is_unhealthy, OLLAMA_HOST_PRIMARY, OLLAMA_HOST_SECONDARY, OLLAMA_HOST_FALLBACK # 白名單對應 host_map = { 'Primary (GCP)': OLLAMA_HOST_PRIMARY, 'Secondary (GCP)': OLLAMA_HOST_SECONDARY, 'Fallback (111)': OLLAMA_HOST_FALLBACK, } host_url = host_map.get(host_label) if not host_url: return jsonify({'ok': False, 'error': f'未知 host_label: {host_label}'}), 400 if not (_is_unhealthy(host_url) or _latest_host_probe_unhealthy(host_label)): return jsonify({ 'ok': False, 'error': f'{host_label} 目前未標記異常,無需 AutoHeal', }), 400 result = auto_heal_service.handle_exception( error_type='ollama_unhealthy', context={ 'host_label': host_label, 'host_url': host_url, 'error_message': f'Ollama host {host_label} ({host_url}) marked unhealthy', 'triggered_by': 'admin_observability', }, ) return jsonify({ 'ok': bool(getattr(result, 'success', False)), 'action': getattr(result, 'action', None), 'message': getattr(result, 'message', '') or 'AutoHeal 已派遣', }) except Exception as e: return jsonify({'ok': False, 'error': f'{type(e).__name__}: {str(e)[:200]}'}), 500 @admin_observability_bp.route('/budget/force_throttle', methods=['POST']) @login_required def budget_force_throttle(): """Phase 39 D-4 (L2 自動化):立即強制執行 cost_throttle evaluate(不等 hourly cron)。 用途:admin 在觀測台看到 ratio 飆超 110% 時不需等下次 cron, 直接點按鈕強制 re-evaluate 三主機 throttle 狀態(claude→gemini fallback 立即生效)。 """ try: from services.cost_throttle_service import ( evaluate_throttle_status, is_cost_throttle_enabled, ) if not is_cost_throttle_enabled(): return jsonify({ 'ok': False, 'error': 'COST_THROTTLE_ENABLED=false(先設環境變數)', }), 400 new_state = evaluate_throttle_status() throttled = [p for p, s in new_state.items() if s.get('throttled')] return jsonify({ 'ok': True, 'throttled_providers': throttled, 'state': new_state, 'message': f'已立即重算 throttle 狀態,被節流的 provider:{throttled or "(無)"}', }) except Exception as e: return jsonify({'ok': False, 'error': f'{type(e).__name__}: {str(e)[:200]}'}), 500 @admin_observability_bp.route('/budget/update/', 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 視覺審核歷史 # ───────────────────────────────────────────────────────────────────────────── def _guess_ppt_report_type_from_filename(filename: str) -> str: """從產出的 PPT 檔名推回報表類型,供 QA 失敗重跑與 triage 使用。""" name = str(filename or "") if not name: return "" try: from services.ppt_auto_generation_service import REPORT_PREFIXES for report_type, prefix in REPORT_PREFIXES.items(): if prefix and name.startswith(prefix): return report_type except Exception: pass fallback_prefixes = { "ocbot_daily_": "daily", "ocbot_weekly_": "weekly", "ocbot_monthly_": "monthly", "ocbot_quarterly_": "quarterly", "ocbot_half_yearly_": "half_yearly", "ocbot_annual_": "annual", "ocbot_ttm_": "ttm", "ocbot_strategy_": "strategy", "ocbot_competitor_v4_": "competitor_v4", "ocbot_competitor_": "competitor", "ocbot_promo_compare_": "promo_compare", "ocbot_promo_": "promo", "ocbot_forecast_pre_event_": "forecast_pre_event", "ocbot_vendor_": "vendor", "ocbot_category_": "category", "ocbot_customer_": "customer", "ocbot_new_product_": "new_product", "ocbot_market_intel_": "market_intel", "ocbot_price_elasticity_": "price_elasticity", } for prefix, report_type in fallback_prefixes.items(): if name.startswith(prefix): return report_type return "" def _ppt_filename_matches_month(filename: str, *, year: int, month: int) -> bool: """判斷檔名是否明確帶有指定月份,用於補足歷史檔案 mtime 漂移。""" name = str(filename or "") for match in re.finditer(r"(? 0: health_status = 'partial' health_title = '定義簡報尚未全數補齊' health_message = f'本月已完成 {ready_count}/{total_count} 類,仍缺 {missing_count} 類,可等排程或手動補齊。' elif audit_total and pass_rate < 80: health_status = 'partial' health_title = '審核通過率偏低' health_message = f'本月視覺 QA 通過率 {pass_rate:.1f}%,需優先檢查失敗熱點與修復建議。' elif total_count: health_status = 'ready' health_title = '產線覆蓋完整' health_message = '定義簡報、產出紀錄、線上預覽與視覺 QA 都已具備可追蹤入口。' else: health_status = 'planned' health_title = '產線等待資料' health_message = '目前尚未讀到定義簡報覆蓋資料,頁面會保留安全空狀態。' if audit_total: qa_value = f'{pass_rate:.0f}%' qa_meta = f'{audit_total} 筆審核 / {audit_issues} 個問題' qa_status = 'ready' if pass_rate >= 80 and audit_issues == 0 else 'partial' else: qa_value = '待審核' qa_meta = '可立即補跑,或等待 22:00 排程' qa_status = 'planned' stages = [ { 'key': 'schedule', 'icon': 'calendar-check', 'label': '排程節奏', 'value': '6 條', 'meta': '每日 / 每週 / 每月 / 每季 / 半年 / 年度', 'detail': auto_generation.get('cadence_summary') or '等待排程設定', 'status': 'ready' if auto_generation.get('enabled') else 'partial', }, { 'key': 'coverage', 'icon': 'diagram-project', 'label': '定義覆蓋', 'value': f'{ready_count}/{total_count}' if total_count else '—', 'meta': f'{coverage_pct:.1f}% 完成', 'detail': f'缺漏 {missing_count} 類' if missing_count else '當期目標完整', 'status': 'ready' if total_count and missing_count == 0 else 'partial', }, { 'key': 'records', 'icon': 'database', 'label': '產出紀錄', 'value': f'{len(generation_runs)} 筆', 'meta': f'{run_ready_count} 成功 / {run_error_count} 失敗', 'detail': latest_run.get('started_at') or '尚無本月產出紀錄', 'status': 'error' if run_error_count else ('ready' if generation_runs else 'planned'), }, { 'key': 'preview', 'icon': 'desktop', 'label': '線上預覽', 'value': f'{valid_preview_count} 份', 'meta': f'{cached_preview_count} 份 PDF 快取', 'detail': latest_file.get('name') or '尚無可預覽檔案', 'status': 'error' if broken_file_count else ('ready' if valid_preview_count else 'planned'), }, { 'key': 'qa', 'icon': 'eye', 'label': '視覺 QA', 'value': qa_value, 'meta': qa_meta, 'detail': '視覺檢查 + 修復建議 + 派工', 'status': qa_status, }, ] missing_items = [ item for item in auto_generation.get('items', []) if not item.get('ready') ] preview_items = [ item for item in files if item.get('file_exists') and item.get('is_valid_ppt') ] audit_attention = [ item for item in audit_records if item.get('audit_status') in ('failed', 'error') ] run_failures = [ item for item in generation_runs if item.get('status') == 'error' ] broken_files = [ item for item in files if item.get('file_exists') and not item.get('is_valid_ppt') ] triage_entries = [] for item in run_failures[:3]: triage_entries.append({ 'title': item.get('report_label') or item.get('report_type') or '未知簡報', 'meta': item.get('started_at') or '時間未知', 'detail': _public_ppt_text( item.get('error_msg'), empty=f"{item.get('schedule_label') or '手動'} · {item.get('target_label') or '最新資料'}", ), 'status_label': '產出失敗', 'filename': item.get('file_name') or '', 'report_type': item.get('report_type') or '', 'can_regenerate': bool(item.get('report_type')), }) for item in broken_files[:3]: triage_entries.append({ 'title': item.get('name') or '未命名檔案', 'meta': item.get('mtime') or '時間未知', 'detail': _public_ppt_text( item.get('file_error'), empty='PPTX 檔案不可預覽,建議重新產生。', ), 'status_label': '檔案異常', 'filename': item.get('name') or '', 'report_type': item.get('report_type') or '', 'can_regenerate': bool(item.get('report_type')), }) for item in audit_attention[:3]: filename = item.get('pptx_filename') or '' inferred_report_type = item.get('report_type') or _guess_ppt_report_type_from_filename(filename) triage_entries.append({ 'title': filename or '未命名檔案', 'meta': item.get('audited_at') or '時間未知', 'detail': _public_ppt_text( item.get('issue_summary') or item.get('error_msg'), empty=f"問題 {item.get('issues_count', 0)} 個", ), 'status_label': '視覺 QA', 'filename': filename, 'report_type': inferred_report_type, 'can_regenerate': bool(inferred_report_type), }) action_lanes = [ { 'key': 'triage', 'label': '異常優先', 'status': 'error' if triage_entries else 'ready', 'count': len(triage_entries), 'empty_text': '目前沒有產出失敗、檔案異常或視覺 QA 失敗。', 'entries': triage_entries[:4], }, { 'key': 'missing', 'label': '待補齊', 'status': 'partial' if missing_items else 'ready', 'count': len(missing_items), 'empty_text': '目前定義簡報都已對齊本期目標。', 'entries': [ { 'title': item.get('label') or item.get('key') or '未命名簡報', 'meta': item.get('target_label') or '最新資料', 'detail': item.get('status_hint') or item.get('status_label') or '等待排程補齊', 'status_label': item.get('status_label') or '待補齊', 'report_type': item.get('key') or '', 'can_regenerate': bool(item.get('key')), } for item in missing_items[:4] ], }, { 'key': 'preview', 'label': '可預覽', 'status': 'ready' if preview_items else 'planned', 'count': len(preview_items), 'empty_text': '目前沒有可線上預覽的 PPTX 檔案。', 'entries': [ { 'title': item.get('name') or '未命名檔案', 'meta': item.get('mtime') or '時間未知', 'detail': ( f"{item.get('size_kb') if item.get('size_kb') is not None else '—'} KB · " f"{item.get('source_label') or _public_ppt_source_label(item.get('source'))} · " f"{'PDF 已快取' if item.get('preview_cache_ready') else '開啟時轉檔'}" ), 'status_label': 'PDF 快取' if item.get('preview_cache_ready') else '線上預覽', 'filename': item.get('name'), } for item in preview_items[:4] ], }, { 'key': 'audit', 'label': '視覺 QA', 'status': 'error' if audit_attention else ('ready' if audit_total else 'planned'), 'count': len(audit_attention) if audit_attention else audit_total, 'empty_text': '目前沒有需要處理的視覺 QA 失敗紀錄。', 'entries': [ { 'title': item.get('pptx_filename') or '未命名檔案', 'meta': item.get('audited_at') or '時間未知', 'detail': _public_ppt_text( item.get('issue_summary') or item.get('error_msg'), empty=f"問題 {item.get('issues_count', 0)} 個,信心 {item.get('confidence', 0):.2f}", ), 'status_label': '需修復' if item.get('audit_status') == 'failed' else '需排查', 'filename': item.get('pptx_filename'), 'report_type': item.get('report_type') or _guess_ppt_report_type_from_filename(item.get('pptx_filename') or ''), 'can_regenerate': bool(item.get('report_type') or _guess_ppt_report_type_from_filename(item.get('pptx_filename') or '')), } for item in audit_attention[:4] ], }, { 'key': 'records', 'label': '產出紀錄', 'status': 'error' if run_error_count else ('ready' if generation_runs else 'planned'), 'count': len(generation_runs), 'empty_text': '本月尚未看到簡報產出紀錄。', 'entries': [ { 'title': item.get('report_label') or item.get('report_type') or '未知簡報', 'meta': item.get('started_at') or '時間未知', 'detail': f"{item.get('schedule_label') or '手動'} · {item.get('target_label') or '最新資料'}", 'status_label': item.get('status_label') or item.get('status') or '未知', 'filename': item.get('file_name') or '', } for item in generation_runs[:4] ], }, ] return { 'status': health_status, 'title': health_title, 'message': health_message, 'ready_count': ready_count, 'total_count': total_count, 'missing_count': missing_count, 'coverage_pct': coverage_pct, 'valid_preview_count': valid_preview_count, 'cached_preview_count': cached_preview_count, 'uncached_preview_count': uncached_preview_count, 'broken_file_count': broken_file_count, 'db_backed_count': db_backed_count, 'run_error_count': run_error_count, 'pass_rate': pass_rate, 'audit_total': audit_total, 'latest_run': latest_run, 'latest_file': latest_file, 'stages': stages, 'action_lanes': action_lanes, } def _build_ppt_operator_summary(files, auto_generation, pipeline_view, vision_status, audit_stats, generation_runs): """Build first-screen operator copy that prioritizes deck work over raw pipeline states.""" files = files or [] auto_generation = auto_generation or {} pipeline_view = pipeline_view or {} vision_status = vision_status or {} audit_stats = audit_stats or {} generation_runs = generation_runs or [] latest_preview = next( ( item for item in files if item.get('file_exists') and item.get('is_valid_ppt') and item.get('name') ), None, ) issue_count = int(audit_stats.get('total_issues') or 0) if audit_stats else 0 missing_count = int(auto_generation.get('missing_count') or 0) valid_preview_count = int(pipeline_view.get('valid_preview_count') or 0) cached_preview_count = int(pipeline_view.get('cached_preview_count') or 0) audit_total = int(pipeline_view.get('audit_total') or 0) run_error_count = int(pipeline_view.get('run_error_count') or 0) broken_file_count = int(pipeline_view.get('broken_file_count') or 0) blockers = vision_status.get('blockers') or [] if run_error_count or broken_file_count: status = 'error' headline = '先處理異常,再放行簡報' message = f'目前有 {run_error_count} 筆產出失敗、{broken_file_count} 份檔案不可預覽,建議先看 Action Queue。' primary_action = '查看待處理' primary_anchor = '#ppt-action-queue' elif missing_count: status = 'partial' headline = '定期簡報尚未全數補齊' message = f'本期還有 {missing_count} 類定義簡報缺漏,可手動補齊或等待排程寫入產出紀錄。' primary_action = '補齊缺漏' primary_anchor = '#ppt-production-center' elif not vision_status.get('ready'): status = 'partial' headline = '簡報可管理,視覺 QA 待啟用' message = 'PPT 產出與預覽入口仍可用;視覺檢查與轉檔條件補齊後才會自動審核。' primary_action = '查看就緒檢查' primary_anchor = '#ppt-runtime-diagnostic' elif issue_count: status = 'partial' headline = '有視覺問題待回放' message = f'本期視覺 QA 發現 {issue_count} 個問題,請從問題追蹤或審核歷史回放檢查。' primary_action = '查看問題' primary_anchor = '#ppt-issue-board' else: status = 'ready' if valid_preview_count else 'planned' headline = '簡報工作台待命' message = '最新簡報、PDF 預覽、產出紀錄與視覺 QA 都集中在同一頁追蹤。' primary_action = '查看簡報' primary_anchor = '#ppt-deck-workbench' latest_run = generation_runs[0] if generation_runs else {} latest_deck_label = latest_preview.get('name') if latest_preview else '尚無可預覽 PPTX' latest_deck_meta = ( f"{latest_preview.get('mtime') or '時間未知'} · " f"{latest_preview.get('size_kb') if latest_preview.get('size_kb') is not None else '—'} KB · " f"{'PDF 已快取' if latest_preview and latest_preview.get('preview_cache_ready') else '首次開啟轉檔'}" if latest_preview else '請先補齊本期簡報或切換月份 / 報表類型' ) return { 'status': status, 'headline': headline, 'message': message, 'primary_action': primary_action, 'primary_anchor': primary_anchor, 'latest_deck': latest_preview or {}, 'latest_deck_label': latest_deck_label, 'latest_deck_meta': latest_deck_meta, 'latest_run_label': latest_run.get('report_label') or latest_run.get('report_type') or '尚無產出紀錄', 'latest_run_meta': latest_run.get('started_at') or '等待下一次排程寫入', 'blocker_text': ';'.join(_public_ppt_text_list(blockers[:2])) if blockers else '', 'signals': [ { 'label': '可預覽簡報', 'value': valid_preview_count, 'meta': f'{cached_preview_count} 份 PDF 快取', 'status': 'ready' if valid_preview_count else 'planned', }, { 'label': '待補齊定義', 'value': missing_count, 'meta': f"{auto_generation.get('ready_count', 0)}/{auto_generation.get('total', 0)} 已覆蓋", 'status': 'ready' if missing_count == 0 and auto_generation.get('total') else 'partial', }, { 'label': '視覺 QA', 'value': audit_total if audit_total else '待跑', 'meta': '已就緒' if vision_status.get('ready') else '執行條件待確認', 'status': 'ready' if vision_status.get('ready') and not issue_count else 'partial', }, { 'label': '視覺問題', 'value': issue_count, 'meta': '需回放' if issue_count else '目前無待處理', 'status': 'partial' if issue_count else 'ready', }, ], } def _enrich_ppt_coverage_items(auto_generation_items, files, generation_runs, audit_records): """Join coverage rows with file, DB run, preview and QA state for the UI matrix.""" files = files or [] generation_runs = generation_runs or [] audit_records = audit_records or [] file_by_name = {item.get('name'): item for item in files if item.get('name')} latest_file_by_type = {} for item in files: report_type = item.get('report_type') if report_type and report_type not in latest_file_by_type: latest_file_by_type[report_type] = item latest_run_by_type = {} for item in generation_runs: report_type = item.get('report_type') if report_type and report_type not in latest_run_by_type: latest_run_by_type[report_type] = item latest_audit_by_file = {} for item in audit_records: filename = item.get('pptx_filename') if filename and filename not in latest_audit_by_file: latest_audit_by_file[filename] = item enriched = [] for raw_item in auto_generation_items or []: item = dict(raw_item) report_type = item.get('key') or '' latest_run = latest_run_by_type.get(report_type, {}) candidate_file = latest_file_by_type.get(report_type, {}) file_name = ( item.get('latest_file_name') or latest_run.get('file_name') or candidate_file.get('name') or '' ) file_item = file_by_name.get(file_name) or candidate_file or {} audit = latest_audit_by_file.get(file_name, {}) sources = set(item.get('sources') or []) if file_item.get('source'): sources.add(file_item.get('source')) file_exists = bool(file_item.get('file_exists') or ('filesystem' in sources and file_name)) valid_ppt = bool(file_exists and (file_item.get('is_valid_ppt') is not False)) db_backed = bool( latest_run or 'database' in sources or file_item.get('source') in ('database', 'both') ) preview_cached = bool(valid_ppt and file_item.get('preview_cache_ready')) audit_status = audit.get('audit_status') or '' run_status = latest_run.get('status') or '' if latest_run and run_status == 'error': db_status, db_label = 'error', '產出紀錄失敗' elif db_backed: db_status, db_label = 'ready', '紀錄已保留' else: db_status, db_label = 'planned', '待保留紀錄' if valid_ppt and preview_cached: preview_status, preview_label = 'ready', 'PDF 快取' elif valid_ppt: preview_status, preview_label = 'partial', '可預覽' elif file_name: preview_status, preview_label = 'error', '不可預覽' else: preview_status, preview_label = 'planned', '待產檔' if audit_status == 'passed': qa_status, qa_label = 'ready', 'QA 通過' elif audit_status == 'failed': qa_status, qa_label = 'error', 'QA 有問題' elif audit_status == 'error': qa_status, qa_label = 'error', 'QA 錯誤' elif audit_status == 'skipped': qa_status, qa_label = 'partial', 'QA 跳過' elif valid_ppt: qa_status, qa_label = 'planned', '待 QA' else: qa_status, qa_label = 'planned', '待產檔' if not item.get('ready'): delivery_status, delivery_label = 'missing', '待產出' elif latest_run and run_status == 'error': delivery_status, delivery_label = 'error', '產出失敗' elif file_name and not valid_ppt: delivery_status, delivery_label = 'error', '檔案異常' elif qa_status == 'error': delivery_status, delivery_label = 'error', '需修復' elif valid_ppt and db_backed and audit_status == 'passed': delivery_status, delivery_label = 'ready', '可交付' elif valid_ppt: delivery_status, delivery_label = 'partial', '待驗收' else: delivery_status, delivery_label = item.get('status') or 'planned', item.get('status_label') or '待確認' item.update({ 'latest_file_name': file_name, 'latest_file_mtime': file_item.get('mtime') or item.get('latest_generated_at') or '', 'latest_file_size_kb': file_item.get('size_kb'), 'file_exists': file_exists, 'is_valid_ppt': valid_ppt, 'preview_cache_ready': preview_cached, 'db_status': db_status, 'db_label': db_label, 'preview_status': preview_status, 'preview_label': preview_label, 'qa_status': qa_status, 'qa_label': qa_label, 'delivery_status': delivery_status, 'delivery_label': delivery_label, 'audit_summary': _public_ppt_text( audit.get('issue_summary') or audit.get('error_msg'), max_chars=160, ), 'can_preview': valid_ppt and bool(file_name), 'can_prewarm': valid_ppt and bool(file_name) and not preview_cached, 'can_regenerate': bool(report_type), }) enriched.append(item) return enriched @admin_observability_bp.route('/ppt_audit_history') @login_required def ppt_audit_history(): """掃 reports/ 目錄列指定月份 daily 報表 + 從 ppt_audit_results 讀審核歷史(Phase 38)""" import os import zipfile reports_dir = os.environ.get('REPORTS_DIR', '/app/data/reports') files = [] audit_records = [] error = None month_arg = request.args.get('month', '').strip() report_type = request.args.get('report_type', 'daily').strip().lower() or 'daily' try: from services.ppt_auto_generation_service import get_report_type_options report_type_options = get_report_type_options() except Exception: report_type_options = [ {'key': 'daily', 'label': '每日日報', 'prefix': 'ocbot_daily_'}, {'key': 'weekly', 'label': '週報', 'prefix': 'ocbot_weekly_'}, {'key': 'monthly', 'label': '月報', 'prefix': 'ocbot_monthly_'}, {'key': 'strategy', 'label': '策略', 'prefix': 'ocbot_strategy_'}, {'key': 'competitor', 'label': '競品', 'prefix': 'ocbot_competitor_'}, {'key': 'promo', 'label': '促銷', 'prefix': 'ocbot_promo_'}, {'key': 'all', 'label': '全部', 'prefix': 'all'}, ] report_type_map = {opt['key']: opt for opt in report_type_options} if report_type not in report_type_map: report_type = 'daily' selected_report_type = report_type_map[report_type] report_prefix = selected_report_type['prefix'] now = datetime.now() target_year = now.year target_month = now.month if month_arg: sep = '-' if '-' in month_arg else '/' if '/' in month_arg else None parts = month_arg.split(sep) if sep else [month_arg] try: if len(parts) == 2: target_year = int(parts[0]) target_month = int(parts[1]) elif len(parts) == 1 and 1 <= len(parts[0]) <= 2: target_month = int(parts[0]) else: raise ValueError if not (1 <= target_month <= 12): raise ValueError except Exception: target_year = now.year target_month = now.month month_start = datetime(target_year, target_month, 1) month_end = datetime(target_year + 1, 1, 1) if target_month == 12 else datetime(target_year, target_month + 1, 1) month_start_ts = int(month_start.timestamp()) month_end_ts = int(month_end.timestamp()) month_label = month_start.strftime('%Y-%m') prev_month = target_month - 1 prev_year = target_year if prev_month == 0: prev_month = 12 prev_year -= 1 next_month = target_month + 1 next_year = target_year if next_month == 13: next_month = 1 next_year += 1 prev_month_label = f"{prev_year:04d}-{prev_month:02d}" next_month_label = f"{next_year:04d}-{next_month:02d}" show_next_month = (next_year < now.year) or (next_year == now.year and next_month <= now.month) def _inspect_ppt_file(file_path: str): try: with zipfile.ZipFile(file_path, 'r') as zf: bad = zf.testzip() return (bad is None, None if bad is None else f'壓縮檔異常:{bad}') except zipfile.BadZipFile: return False, 'PPTX 檔案損毀(非有效 zip)' except Exception as e: return False, f'檢查失敗:{str(e)[:60]}' def _guess_report_type(filename: str) -> str: for opt in report_type_options: prefix = opt.get('prefix') if prefix and prefix != 'all' and filename.startswith(prefix): return opt.get('key') or '' return report_type if report_type != 'all' else '' def _preview_cache_payload(file_path: str): payload = { 'preview_cache_ready': False, 'preview_cache_size_kb': None, 'preview_cache_mtime': '', } try: from services.ppt_preview_service import get_ppt_preview_cache_info info = get_ppt_preview_cache_info(file_path) payload['preview_cache_ready'] = bool(info.cache_exists) payload['preview_cache_size_kb'] = info.cache_size_kb if info.cache_mtime_ts: payload['preview_cache_mtime'] = datetime.fromtimestamp(info.cache_mtime_ts).strftime('%Y-%m-%d %H:%M') except Exception: logger.debug("PPT preview cache state unavailable", exc_info=True) return payload try: if not os.path.isdir(reports_dir): error = f'{reports_dir} 目錄不存在' else: files_by_name = {} for f in os.listdir(reports_dir): if not f.lower().endswith('.pptx'): continue if report_prefix != 'all' and not f.startswith(report_prefix): 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) matches_selected_month = ( month_start_ts <= mtime < month_end_ts or _ppt_filename_matches_month(f, year=target_year, month=target_month) ) if matches_selected_month: is_valid, check_msg = _inspect_ppt_file(full) files_by_name[f] = { 'source': 'filesystem', 'name': f, 'size_kb': round(os.path.getsize(full) / 1024, 1), 'mtime': datetime.fromtimestamp(mtime).strftime('%Y-%m-%d %H:%M'), 'mtime_ts': mtime, 'file_exists': True, 'file_path': full, 'report_type': _guess_report_type(f), 'is_valid_ppt': is_valid, 'file_error': check_msg, **_preview_cache_payload(full), } except OSError: continue # 補充:若 188 主機僅保留 DB 快取紀錄或掃描過程漏掉,仍可回補當月報表清單 try: session = get_session() try: sql = """ SELECT report_type, file_path, file_size, generated_at FROM ppt_reports WHERE generated_at >= :month_start AND generated_at < :month_end """ params = {'month_start': month_start, 'month_end': month_end} if report_type != 'all': sql += " AND report_type = :report_type" params['report_type'] = report_type rows = session.execute(sa_text(sql), params).fetchall() for rpt_type, file_path, file_size, generated_at in rows: if not file_path: continue name = os.path.basename(file_path) if not name.lower().endswith('.pptx'): continue if report_prefix != 'all' and not name.startswith(report_prefix): continue if name in files_by_name: files_by_name[name]['source'] = 'both' continue if not generated_at: continue mtime = generated_at.timestamp() if not (month_start_ts <= mtime < month_end_ts): continue candidate_path = os.path.join(reports_dir, name) exists = os.path.isfile(candidate_path) is_valid = False check_msg = '檔案未落盤' if exists: is_valid, check_msg = _inspect_ppt_file(candidate_path) files_by_name[name] = { 'source': 'database', 'name': name, 'size_kb': round((file_size or 0) / 1024, 1), 'mtime': generated_at.strftime('%Y-%m-%d %H:%M'), 'mtime_ts': mtime, 'file_exists': exists, 'file_path': candidate_path if exists else file_path, 'report_type': rpt_type, 'is_valid_ppt': is_valid, 'file_error': None if exists else check_msg, **(_preview_cache_payload(candidate_path) if exists else {}), } finally: session.close() except Exception: pass files = list(files_by_name.values()) files.sort(key=lambda x: x['mtime_ts'], reverse=True) for item in files: item['source_label'] = _public_ppt_source_label(item.get('source')) item['file_error'] = _public_ppt_text(item.get('file_error'), max_chars=120) except Exception as e: error = _public_ppt_text(f'{type(e).__name__}: {str(e)[:200]}', empty='簡報清單讀取異常') audit_filter_sql = "" audit_params = {'month_start': month_start, 'month_end': month_end} if report_prefix != 'all': audit_filter_sql = " AND pptx_filename LIKE :audit_prefix" audit_params['audit_prefix'] = f"{report_prefix}%" def _summarize_ppt_issues(raw_issues) -> str: """把 ppt_audit_results.issues_found 壓成表格可讀的診斷摘要。""" if not raw_issues: return '' try: import json as _json issues_payload = _json.loads(raw_issues) if isinstance(raw_issues, str) else raw_issues except Exception: return '' if not isinstance(issues_payload, list): return '' snippets = [] for item in issues_payload: if not isinstance(item, dict): continue slide = item.get('slide') for issue in item.get('issues') or []: text = _public_ppt_text(issue, max_chars=120) if not text: continue prefix = f"S{slide}: " if slide else "" snippets.append(f"{prefix}{text}") if len(snippets) >= 3: return ';'.join(snippets) return ';'.join(snippets) def _load_ppt_issues(raw_issues): if not raw_issues: return [] try: import json as _json issues_payload = _json.loads(raw_issues) if isinstance(raw_issues, str) else raw_issues except Exception: return [] return issues_payload if isinstance(issues_payload, list) else [] def _classify_ppt_issue(issue_text: str): text = issue_text or '' if _ppt_text_has_internal_detail(text): return '審核執行', 'warn' if any(k in text for k in ['圖表', '切掉', '截斷', '超出', '溢出']): return '版面越界', 'error' if any(k in text for k in ['空白', '未填', '缺少', '無資料']): return '內容缺漏', 'warn' if any(k in text for k in ['低對比', '顏色', '字體', '字型', '閱讀']): return '可讀性', 'warn' return '視覺問題', 'warn' def _extract_ppt_issue_items(raw_issues, *, pptx_filename: str, audited_at: str): issue_items = [] report_type_for_file = _guess_report_type(pptx_filename) for slide_item in _load_ppt_issues(raw_issues): if not isinstance(slide_item, dict): continue slide = slide_item.get('slide') slide_label = f"S{slide}" if slide else 'S?' for raw_issue in slide_item.get('issues') or []: category, status = _classify_ppt_issue(str(raw_issue or '')) issue_text = _public_ppt_text(raw_issue, max_chars=140) if not issue_text: continue issue_items.append({ 'pptx_filename': pptx_filename, 'report_type': report_type_for_file, 'audited_at': audited_at, 'slide_label': slide_label, 'category': category, 'status': status, 'text': issue_text, }) return issue_items # Phase 38+:讀指定月份 / 指定簡報類型 audit 歷史 try: session = get_session() try: audit_rows = session.execute( sa_text(f""" SELECT audited_at, pptx_filename, audit_status, issues_count, confidence, duration_ms, error_msg, issues_found FROM ppt_audit_results WHERE audited_at >= :month_start AND audited_at < :month_end {audit_filter_sql} ORDER BY audited_at DESC LIMIT 1000 """), audit_params, ).fetchall() audit_records = [] for r in audit_rows: audited_at = r[0].strftime('%Y-%m-%d %H:%M') pptx_filename = r[1] raw_issues = r[7] audit_records.append({ 'audited_at': audited_at, 'pptx_filename': pptx_filename, 'report_type': _guess_report_type(pptx_filename), 'audit_status': r[2], 'issues_count': int(r[3] or 0), 'confidence': float(r[4] or 0), 'duration_ms': int(r[5] or 0), 'error_msg': _public_ppt_text(r[6], max_chars=160), 'issue_summary': _summarize_ppt_issues(raw_issues), 'issue_items': _extract_ppt_issue_items( raw_issues, pptx_filename=pptx_filename, audited_at=audited_at, ), }) finally: session.close() except Exception: logger.debug("PPT audit history table unavailable; rendering empty audit history", exc_info=True) issue_items = [ issue for record in audit_records for issue in record.get('issue_items', []) ] issue_files = {issue.get('pptx_filename') for issue in issue_items if issue.get('pptx_filename')} issue_digest = { 'total': len(issue_items), 'files': len(issue_files), 'error_count': sum(1 for issue in issue_items if issue.get('status') == 'error'), 'warn_count': sum(1 for issue in issue_items if issue.get('status') == 'warn'), 'latest_audit': issue_items[0].get('audited_at') if issue_items else '', } # PPT vision 啟用狀態 vision_status = { 'enabled': False, 'ready': False, 'blockers': ['視覺狀態讀取失敗'], 'ready_count': 0, 'check_count': 0, 'summary': '視覺 QA runtime 狀態讀取失敗。', 'readiness_checks': [], 'next_actions': ['確認 ppt_vision_service import 與 runtime 設定後重新整理此頁。'], } try: from services.ppt_vision_service import get_ppt_vision_audit_status, get_ppt_vision_runtime_status vision_status = get_ppt_vision_runtime_status() vision_enabled = bool(vision_status.get('enabled')) vision_audit_status = get_ppt_vision_audit_status() except Exception: vision_enabled = False vision_audit_status = { 'ok': False, 'running': False, 'status': 'unknown', 'status_label': '讀取失敗', 'message': '最近視覺 QA 狀態讀取失敗。', 'last_run': None, } vision_status = _public_ppt_vision_status(vision_status) vision_audit_status = _public_ppt_vision_audit_status(vision_audit_status) # Phase 47 K-6: 月報表統計 + top failure files audit_30d_stats = {} top_failure_files = [] try: s_ppt = get_session() try: stat_row = s_ppt.execute( sa_text(f""" SELECT COUNT(*), COUNT(*) FILTER (WHERE audit_status = 'passed'), COUNT(*) FILTER (WHERE audit_status = 'failed'), COUNT(*) FILTER (WHERE audit_status = 'skipped'), COUNT(*) FILTER (WHERE audit_status = 'error'), COALESCE(AVG(confidence) FILTER (WHERE audit_status = 'passed'), 0), COALESCE(SUM(issues_count), 0) FROM ppt_audit_results WHERE audited_at >= :month_start AND audited_at < :month_end {audit_filter_sql} """), audit_params, ).fetchone() total_30d = int(stat_row[0] or 0) audit_30d_stats = { 'total': total_30d, 'passed': int(stat_row[1] or 0), 'failed': int(stat_row[2] or 0), 'skipped': int(stat_row[3] or 0), 'error': int(stat_row[4] or 0), 'avg_confidence': round(float(stat_row[5] or 0), 3), 'total_issues': int(stat_row[6] or 0), 'pass_rate': (float(stat_row[1] or 0) / total_30d * 100) if total_30d else 0, } top_fail_rows = s_ppt.execute( sa_text(f""" SELECT pptx_filename, COUNT(*) AS attempts, SUM(issues_count) AS total_issues, MAX(audited_at) AS last_audit FROM ppt_audit_results WHERE audit_status IN ('failed', 'error') AND audited_at >= :month_start AND audited_at < :month_end {audit_filter_sql} GROUP BY pptx_filename ORDER BY attempts DESC, total_issues DESC LIMIT 10 """), audit_params, ).fetchall() top_failure_files = [ { 'filename': r[0], 'attempts': int(r[1] or 0), 'total_issues': int(r[2] or 0), 'last_audit': r[3].strftime('%Y-%m-%d %H:%M') if r[3] else '', } for r in top_fail_rows ] finally: s_ppt.close() except Exception: pass # Phase 41 E-2: 對最近 3 筆 failed audit 跑 RAG 找相似修法 rag_fixes = [] failed_records = [r for r in audit_records if r.get('audit_status') in ('failed', 'error')][:3] if failed_records: try: from services.rag_service import rag_service for fr in failed_records: try: err_text = fr.get('error_msg') or 'PPT vision audit failed' rag_result = rag_service.query( text=f"PPT 視覺審核失敗 {err_text[:200]} 怎麼修", caller='admin_ppt_audit', top_k=2, threshold=0.6, ) if rag_result.hits: rag_fixes.append({ 'pptx_filename': fr.get('pptx_filename'), 'audited_at': fr.get('audited_at'), 'error_msg': _public_ppt_text(err_text, max_chars=160), 'hits': [ { 'id': h.get('id'), 'insight_type': h.get('insight_type'), 'content': _public_ppt_text(h.get('content'), max_chars=180), 'similarity': round(float(h.get('similarity', 0)), 3), } for h in rag_result.hits[:2] ], }) except Exception: pass except Exception: pass auto_generation = { 'enabled': False, 'items': [], 'missing_report_types': [], 'missing_count': 0, 'ready_count': 0, 'total': 0, 'last_run': None, 'can_auto_start': False, 'cadences': [], 'cadence_summary': '', } generation_runs = [] try: from services.ppt_auto_generation_service import ( get_defined_report_coverage, get_generation_run_history, get_schedule_cadence_status, ) auto_generation = get_defined_report_coverage( month_start=month_start, month_end=month_end, reports_dir=reports_dir, ) auto_generation.setdefault('cadences', get_schedule_cadence_status(auto_generation.get('items', []))) auto_generation.setdefault( 'cadence_summary', '、'.join(c.get('schedule_text', '') for c in auto_generation.get('cadences', []) if c.get('schedule_text')), ) auto_generation['can_auto_start'] = ( bool(auto_generation.get('enabled')) and int(auto_generation.get('missing_count') or 0) > 0 and month_label == datetime.now().strftime('%Y-%m') ) generation_runs = get_generation_run_history( month_start=month_start, month_end=month_end, limit=24, ) generation_runs = [ { **dict(item), 'error_msg': _public_ppt_text(item.get('error_msg'), max_chars=160), 'status_label': _public_ppt_text(item.get('status_label'), max_chars=60) or item.get('status_label'), } for item in generation_runs ] except Exception: logger.debug("PPT auto-generation coverage unavailable", exc_info=True) pipeline_view = _build_ppt_pipeline_view( files=files, auto_generation=auto_generation, audit_stats=audit_30d_stats, generation_runs=generation_runs, vision_status=vision_status, audit_records=audit_records, ) operator_summary = _build_ppt_operator_summary( files=files, auto_generation=auto_generation, pipeline_view=pipeline_view, vision_status=vision_status, audit_stats=audit_30d_stats, generation_runs=generation_runs, ) vision_audit_filenames = [ item.get('name') for item in files if item.get('file_exists') and item.get('is_valid_ppt') and item.get('name') ][:10] aider_heal_active_jobs = _list_ppt_aider_heal_active_jobs() auto_generation_items = _enrich_ppt_coverage_items( auto_generation.get('items', []), files, generation_runs, audit_records, ) return render_template( 'admin/ppt_audit_history.html', active_page='obs_ppt_audit', report_month=month_label, report_type=report_type, report_type_options=report_type_options, selected_report_type=selected_report_type, prev_month_label=prev_month_label, next_month_label=next_month_label, show_next_month=show_next_month, files=files, audit_records=audit_records, rag_fixes=rag_fixes, audit_30d_stats=audit_30d_stats, top_failure_files=top_failure_files, vision_enabled=vision_enabled, vision_status=vision_status, vision_audit_status=vision_audit_status, auto_generation=auto_generation, auto_generation_items=auto_generation_items, auto_generation_missing_report_types=auto_generation.get('missing_report_types', []), generation_runs=generation_runs, pipeline_view=pipeline_view, operator_summary=operator_summary, vision_audit_filenames=vision_audit_filenames, issue_items=issue_items, issue_digest=issue_digest, aider_heal_active_jobs=aider_heal_active_jobs, aider_heal_active_count=len(aider_heal_active_jobs), error=error, ) # ───────────────────────────────────────────────────────────────────────────── # /observability/host_health — 三主機 + MCP 健康度 # ───────────────────────────────────────────────────────────────────────────── @admin_observability_bp.route('/host_health') @login_required def host_health_dashboard(): """三主機 Ollama + 4 個 MCP server 即時健康(同時寫入 host_health_probes 留歷史)""" import time as _time def _session_uses_sqlite(session) -> bool: try: bind = session.get_bind() return getattr(getattr(bind, 'dialect', None), 'name', None) == 'sqlite' except Exception: return False ollama_hosts = [] probe_records = [] # 收集本次 probe 結果以批次寫 DB try: from services.ollama_service import ( OLLAMA_HOST_PRIMARY, OLLAMA_HOST_SECONDARY, OLLAMA_HOST_FALLBACK, _is_unhealthy, _unhealthy_marks, ) from services.ollama_health_probe import ( host_health_model_probe_enabled, probe_ollama_embedding_runtime, ) 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': [], 'error': None} t0 = _time.monotonic() err = None 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] if host_health_model_probe_enabled(label): model_ok, model_err = probe_ollama_embedding_runtime(_r, host) if not model_ok: entry['healthy'] = False err = model_err else: err = f"HTTP {resp.status_code}" except Exception as e: err = f"{type(e).__name__}: {str(e)[:200]}" entry['error'] = err response_ms = int((_time.monotonic() - t0) * 1000) probe_records.append({ 'host_label': label, 'host_url': host, 'healthy': entry['healthy'], 'unhealthy_mark': entry['unhealthy_mark'], 'models_count': len(entry['models']), 'response_ms': response_ms, 'error_msg': err, }) ollama_hosts.append(entry) except Exception: pass # Phase 38:寫入 host_health_probes 留歷史(失敗安全,不擋頁面渲染) if probe_records: try: _session = get_session() try: if _session_uses_sqlite(_session): logger.debug("Skipping host health probe persistence on SQLite") else: for rec in probe_records: _session.execute( sa_text(""" INSERT INTO host_health_probes (host_label, host_url, healthy, unhealthy_mark, models_count, response_ms, error_msg) VALUES (:host_label, :host_url, :healthy, :unhealthy_mark, :models_count, :response_ms, :error_msg) """), rec, ) _session.commit() finally: _session.close() except Exception: logger.warning("Failed to persist host health probe records", exc_info=True) # 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 # Phase 38:讀過去 24h 三主機健康歷史(給趨勢卡片) health_history = [] mcp_24h = [] # Phase 39 D-2: MCP 24h 各 server 工作量 aiops_summary = {} # Phase 39 D-5: incidents + heal_logs 7d 統計 try: _session2 = get_session() try: history_rows = _session2.execute( sa_text(""" SELECT host_label, COUNT(*) FILTER (WHERE healthy) AS up_count, COUNT(*) FILTER (WHERE NOT healthy) AS down_count, COALESCE(AVG(response_ms) FILTER (WHERE healthy), 0) AS avg_ms, COUNT(*) AS total FROM host_health_probes WHERE probed_at >= NOW() - INTERVAL '24 hours' GROUP BY host_label ORDER BY host_label """), ).fetchall() health_history = [ { 'host_label': r[0], 'up_count': int(r[1] or 0), 'down_count': int(r[2] or 0), 'avg_ms': int(r[3] or 0), 'total': int(r[4] or 0), 'uptime_pct': (float(r[1] or 0) / float(r[4]) * 100) if r[4] else 0, } for r in history_rows ] # Phase 39 D-5:incidents + heal_logs 過去 7d 統計 try: inc_rows = _session2.execute( sa_text(""" SELECT COUNT(*) AS total_incidents, COUNT(*) FILTER (WHERE status = 'open') AS open_count, COUNT(*) FILTER (WHERE status = 'resolved') AS resolved_count, COUNT(*) FILTER (WHERE severity = 'P0') AS p0_count, COUNT(*) FILTER (WHERE severity = 'P1') AS p1_count FROM incidents WHERE created_at >= NOW() - INTERVAL '7 days' """), ).fetchone() heal_rows = _session2.execute( sa_text(""" SELECT COUNT(*) AS total_heals, COUNT(*) FILTER (WHERE result = 'success') AS heal_success, COUNT(*) FILTER (WHERE result = 'failed') AS heal_failed, COALESCE(AVG(duration_ms) FILTER (WHERE result = 'success'), 0) AS avg_ms FROM heal_logs WHERE created_at >= NOW() - INTERVAL '7 days' """), ).fetchone() aiops_summary = { 'incidents_total': int(inc_rows[0] or 0), 'incidents_open': int(inc_rows[1] or 0), 'incidents_resolved': int(inc_rows[2] or 0), 'incidents_p0': int(inc_rows[3] or 0), 'incidents_p1': int(inc_rows[4] or 0), 'heals_total': int(heal_rows[0] or 0), 'heals_success': int(heal_rows[1] or 0), 'heals_failed': int(heal_rows[2] or 0), 'heals_avg_ms': int(heal_rows[3] or 0), 'heal_success_rate': ( float(heal_rows[1] or 0) / float(heal_rows[0]) * 100 ) if heal_rows[0] else 0, } # Phase 54 R-3: heal 7d daily success rate sparkline heal_daily = _session2.execute( sa_text(""" SELECT date_trunc('day', created_at)::date AS d, COUNT(*) AS total, COUNT(*) FILTER (WHERE result = 'success') AS ok FROM heal_logs WHERE created_at >= NOW() - INTERVAL '7 days' GROUP BY d ORDER BY d ASC """), ).fetchall() aiops_summary['heal_sparkline'] = [ { 'date': r[0].strftime('%m-%d') if r[0] else '', 'total': int(r[1] or 0), 'ok': int(r[2] or 0), 'rate': (float(r[2] or 0) / float(r[1]) * 100) if r[1] else 0, } for r in heal_daily ] except Exception: aiops_summary = {} # Phase 39 D-2:MCP 24h 工作量(每個 server) mcp_rows = _session2.execute( sa_text(""" SELECT server, COUNT(*) AS total_calls, COUNT(*) FILTER (WHERE status = 'ok') AS ok_calls, COUNT(*) FILTER (WHERE cache_hit) AS cache_hits, COALESCE(SUM(cost_usd), 0) AS total_cost, COALESCE(AVG(duration_ms), 0) AS avg_ms, COUNT(DISTINCT tool) AS tools_used FROM mcp_calls WHERE called_at >= NOW() - INTERVAL '24 hours' GROUP BY server ORDER BY total_calls DESC """), ).fetchall() mcp_24h = [ { 'server': r[0], 'total_calls': int(r[1] or 0), 'ok_calls': int(r[2] or 0), 'cache_hits': int(r[3] or 0), 'total_cost': float(r[4] or 0), 'avg_ms': int(r[5] or 0), 'tools_used': int(r[6] or 0), 'success_rate': (float(r[2] or 0) / float(r[1]) * 100) if r[1] else 0, 'cache_rate': (float(r[3] or 0) / float(r[1]) * 100) if r[1] else 0, } for r in mcp_rows ] finally: _session2.close() except Exception: logger.debug("MCP calls table unavailable; rendering empty MCP 24h summary", exc_info=True) # Phase 47 K-1: incidents + heal_logs 詳細列表 + playbooks 排行 + backup + embed queue recent_incidents = [] recent_heals = [] playbook_ranking = [] backup_history = [] embed_queue_pending = 0 embed_queue_failed = 0 try: s3 = get_session() try: inc_rows = s3.execute( sa_text(""" SELECT id, created_at, task_name, error_type, severity, status, error_message, retry_count, resolved_at FROM incidents ORDER BY created_at DESC LIMIT 10 """), ).fetchall() recent_incidents = [ { 'id': r[0], 'created_at': r[1].strftime('%Y-%m-%d %H:%M'), 'task_name': r[2], 'error_type': r[3], 'severity': r[4], 'status': r[5], 'error_message': (r[6] or '')[:200], 'retry_count': int(r[7] or 0), 'resolved_at': r[8].strftime('%Y-%m-%d %H:%M') if r[8] else None, } for r in inc_rows ] heal_rows = s3.execute( sa_text(""" SELECT h.id, h.created_at, h.action_type, h.result, h.duration_ms, h.action_detail, h.incident_id, i.error_type FROM heal_logs h LEFT JOIN incidents i ON i.id = h.incident_id ORDER BY h.created_at DESC LIMIT 10 """), ).fetchall() recent_heals = [ { 'id': r[0], 'created_at': r[1].strftime('%Y-%m-%d %H:%M'), 'action_type': r[2], 'result': r[3], 'duration_ms': int(r[4] or 0), 'action_detail': (r[5] or '')[:160], 'incident_id': r[6], 'error_type': r[7], } for r in heal_rows ] # playbooks 庫排行(success_count + fail_count + 是否 active) pb_rows = s3.execute( sa_text(""" SELECT id, name, error_type, action_type, severity_min, success_count, fail_count, is_active, cooldown_min FROM playbooks ORDER BY (success_count + fail_count) DESC, success_count DESC LIMIT 12 """), ).fetchall() playbook_ranking = [ { 'id': int(r[0]), 'name': r[1], 'error_type': r[2], 'action_type': r[3], 'severity': r[4], 'success': int(r[5] or 0), 'fail': int(r[6] or 0), 'is_active': bool(r[7]), 'cooldown_min': int(r[8] or 0), 'success_rate': ( float(r[5] or 0) / float((r[5] or 0) + (r[6] or 0)) * 100 ) if ((r[5] or 0) + (r[6] or 0)) > 0 else 0, } for r in pb_rows ] # backup_log 7d 歷史 bk_rows = s3.execute( sa_text(""" SELECT created_at, backup_type, status, file_size_bytes, duration_seconds, error_message FROM backup_log WHERE created_at >= NOW() - INTERVAL '7 days' ORDER BY created_at DESC LIMIT 10 """), ).fetchall() backup_history = [ { 'created_at': r[0].strftime('%Y-%m-%d %H:%M'), 'backup_type': r[1], 'status': r[2], 'size_mb': round(float(r[3] or 0) / (1024 * 1024), 1), 'duration_s': round(float(r[4] or 0), 1), 'error': (r[5] or '')[:120], } for r in bk_rows ] # embedding_retry_queue pending / failed embed_q = s3.execute( sa_text(""" SELECT COUNT(*) FILTER (WHERE status = 'pending'), COUNT(*) FILTER (WHERE status = 'failed') FROM embedding_retry_queue """), ).fetchone() embed_queue_pending = int(embed_q[0] or 0) embed_queue_failed = int(embed_q[1] or 0) finally: s3.close() except Exception: pass return render_template( 'admin/host_health.html', active_page='obs_host_health', ollama_hosts=ollama_hosts, mcp_status=mcp_status, throttle_state=throttle_state, health_history=health_history, mcp_24h=mcp_24h, aiops_summary=aiops_summary, recent_incidents=recent_incidents, recent_heals=recent_heals, playbook_ranking=playbook_ranking, backup_history=backup_history, embed_queue_pending=embed_queue_pending, embed_queue_failed=embed_queue_failed, )