fix(observability): 缺表時改為安全空狀態
Some checks failed
CD Pipeline / deploy (push) Has been cancelled

This commit is contained in:
OoO
2026-05-05 14:19:09 +08:00
parent e28f604ec6
commit be986b8b97
3 changed files with 91 additions and 55 deletions

View File

@@ -297,6 +297,23 @@ def rag_queries_dashboard():
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("""
@@ -417,7 +434,7 @@ def rag_queries_dashboard():
active_page='obs_rag_queries', hours=hours,
caller_filter=caller_filter, saved_only=saved_only,
summary={}, callers=[], by_caller=[], queries=[],
error=f'查詢失敗: {type(e).__name__}: {str(e)[:200]}',
error='RAG 召回資料暫時不可用,已切換安全空狀態。',
)
finally:
session.close()
@@ -1067,31 +1084,53 @@ def ai_calls_dashboard():
).fetchall()
# 5. Phase 39 D-3: caller × RAG 命中率 × MCP 編排率(跨表 JOIN
# 展現「AI 自動化專業」核心:每個 caller 多大比例走了 RAG / MCP
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()
# 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',
@@ -1156,11 +1195,11 @@ def ai_calls_dashboard():
'caller': r[0],
'total_calls': int(r[1] or 0),
'rag_hits': int(r[2] or 0),
'mcp_orchestrated': int(r[3] or 0),
'avg_rag_feedback': round(float(r[4] or 0), 2),
'feedback_count': int(r[5] 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 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
],
@@ -1174,7 +1213,7 @@ def ai_calls_dashboard():
provider_filter=provider_filter,
summary={}, by_provider=[], recent=[], callers=[], caller_richness=[],
by_model=[], hourly_trend=[], recent_contexts=[],
error=f'查詢失敗: {type(e).__name__}: {str(e)[:200]}',
error='AI 呼叫資料暫時不可用,已切換安全空狀態。',
)
finally:
session.close()
@@ -1530,13 +1569,19 @@ def budget_dashboard():
session = get_session()
try:
budgets = session.execute(
sa_text("""
SELECT id, period, provider, budget_usd, alert_pct, updated_at
FROM ai_call_budgets
ORDER BY period, provider NULLS FIRST
"""),
).fetchall()
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("""
@@ -1687,7 +1732,7 @@ def budget_dashboard():
budget_strategies=[], cost_trend_30d=[],
top_cost_callers=[], price_rec_7d=[],
provider_cost_month=[],
error=f'查詢失敗: {type(e).__name__}: {str(e)[:200]}')
error='預算資料暫時不可用,已切換安全空狀態。')
finally:
session.close()

View File

@@ -140,10 +140,10 @@
.momo-observability-mode .ppt-title {
max-width: 780px;
font-family: 'Noto Sans TC', 'Inter', sans-serif !important;
font-size: clamp(1.9rem, 3.2vw, 2.75rem) !important;
line-height: 1.12 !important;
letter-spacing: -0.045em !important;
font-weight: 860 !important;
font-size: clamp(2.1rem, 4vw, 3.35rem) !important;
line-height: 1.05 !important;
letter-spacing: -0.055em !important;
font-weight: 900 !important;
}
.momo-observability-mode .obs-hero,
@@ -161,8 +161,6 @@
border: 1px solid rgba(201, 100, 66, 0.16) !important;
border-radius: 28px !important;
box-shadow: var(--obs-shadow) !important;
padding-top: clamp(1.05rem, 2vw, 1.65rem) !important;
padding-bottom: clamp(1.05rem, 2vw, 1.65rem) !important;
}
.momo-observability-mode .obs-hero::after,
@@ -226,8 +224,8 @@
.momo-observability-mode .quality-subtitle,
.momo-observability-mode .ppt-subtitle {
color: color-mix(in srgb, var(--obs-ink) 62%, var(--obs-muted)) !important;
font-size: 0.94rem !important;
line-height: 1.68 !important;
font-size: 0.98rem !important;
line-height: 1.75 !important;
letter-spacing: 0 !important;
}

View File

@@ -300,7 +300,7 @@
.biz-decision-card {
display: grid;
grid-template-columns: minmax(82px, .36fr) minmax(240px, 1.15fr) minmax(130px, .46fr);
grid-template-columns: minmax(82px, .35fr) minmax(180px, 1fr) minmax(120px, .45fr) minmax(160px, .7fr);
gap: .8rem;
align-items: center;
padding: .95rem;
@@ -329,12 +329,9 @@
}
.biz-decision-reason {
grid-column: 2 / 4;
color: var(--biz-muted);
font-size: .82rem;
line-height: 1.5;
padding-top: .55rem;
border-top: 1px dashed rgba(201, 100, 66, 0.18);
}
.biz-price-stack {
@@ -404,10 +401,6 @@
grid-template-columns: 1fr;
}
.biz-decision-reason {
grid-column: auto;
}
.biz-alert-strip {
align-items: flex-start;
flex-direction: column;