fix(web): show ai route fallback evidence
All checks were successful
CD Pipeline / tests (push) Successful in 5m57s
Code Review / ai-code-review (push) Successful in 11s
CD Pipeline / build-and-deploy (push) Successful in 3m56s
CD Pipeline / post-deploy-checks (push) Successful in 1m34s

This commit is contained in:
Your Name
2026-05-24 12:15:02 +08:00
parent b20daeabd8
commit df06c025ff
3 changed files with 103 additions and 13 deletions

View File

@@ -253,7 +253,19 @@
"humanGap": "Human gap",
"humanGapDetail": "{gate} missing {count}",
"modelRoute": "Model route",
"routeDetail": "{model}; fallback {fallback}",
"routeDetail": "{model}; current {selected}; {primary}={primaryStatus}; fallback {fallback}",
"routeReasonSeparator": "; ",
"routeReason": "Reason: {reason}",
"routeErrorDetail": "Route check failed: {error}",
"routeNoFallback": "none",
"routeHealth": {
"healthy": "healthy",
"slow": "slow",
"degraded": "degraded",
"offline": "offline",
"not_checked": "standby",
"unknown": "unknown"
},
"topGap": "Largest current gap: {gate}, {count} items."
}
},

View File

@@ -254,7 +254,19 @@
"humanGap": "人工缺口",
"humanGapDetail": "{gate} 缺 {count} 筆",
"modelRoute": "模型路由",
"routeDetail": "{model};備援 {fallback}",
"routeDetail": "{model}目前 {selected}{primary}={primaryStatus}備援 {fallback}",
"routeReasonSeparator": "",
"routeReason": "原因:{reason}",
"routeErrorDetail": "路由檢查失敗:{error}",
"routeNoFallback": "無",
"routeHealth": {
"healthy": "健康",
"slow": "偏慢",
"degraded": "降級",
"offline": "離線",
"not_checked": "待命",
"unknown": "未知"
},
"topGap": "目前最大缺口:{gate},共 {count} 筆。"
}
},

View File

@@ -79,12 +79,27 @@ interface RunsResponse {
items?: RunSummary[]
}
interface AiRoutePolicyItem {
provider_name: string
role?: string | null
runtime?: string | null
}
interface AiRouteHealthItem {
status?: string | null
latency_ms?: number | null
reason?: string | null
checked?: boolean
}
interface AiRouteStatusResponse {
policy_order?: AiRoutePolicyItem[]
selected_provider?: string | null
selected_model?: string | null
fallback_chain?: Array<{
provider_name: string
}>
fallback_chain?: AiRoutePolicyItem[]
route_reason?: string | null
route_error?: string | null
health?: Record<string, AiRouteHealthItem>
}
interface EvidenceSnapshot {
@@ -137,6 +152,42 @@ function gateLabelKey(gate?: string) {
}
}
function routeHealthLabelKey(status?: string | null) {
if (
status === 'healthy' ||
status === 'slow' ||
status === 'degraded' ||
status === 'offline' ||
status === 'not_checked'
) {
return `routeHealth.${status}`
}
return 'routeHealth.unknown'
}
function providerDisplayName(provider?: string | null) {
switch (provider) {
case 'ollama_gcp_a':
return 'GCP-A'
case 'ollama_gcp_b':
return 'GCP-B'
case 'ollama_local':
return '111'
case 'gemini':
return 'Gemini'
default:
return provider || '--'
}
}
function routeTone(route: AiRouteStatusResponse | null): Tone {
if (!route?.selected_provider || route.route_error) return 'warn'
const primaryProvider = route.policy_order?.[0]?.provider_name
if (primaryProvider && route.selected_provider !== primaryProvider) return 'warn'
const primaryStatus = primaryProvider ? route.health?.[primaryProvider]?.status : null
return primaryStatus === 'healthy' ? 'good' : 'warn'
}
async function fetchJson<T>(path: string, signal: AbortSignal): Promise<T | null> {
const response = await fetch(`${API_BASE}${path}`, { signal })
if (!response.ok) return null
@@ -254,10 +305,26 @@ export function AutomationEvidenceCard() {
const topGate = quality?.gate_failures?.[0]
const claimReady = Boolean(quality?.production_claim?.can_claim_full_auto_repair)
const selectedProvider = snapshot?.route?.selected_provider ?? '--'
const fallback = snapshot?.route?.fallback_chain
const route = snapshot?.route ?? null
const primaryProvider = route?.policy_order?.[0]?.provider_name ?? null
const primaryStatus = primaryProvider ? route?.health?.[primaryProvider]?.status : null
const selectedProvider = providerDisplayName(route?.selected_provider)
const fallback = route?.fallback_chain
?.map((item) => item.provider_name)
.map(providerDisplayName)
.join(' -> ')
const routeSummary = route?.route_error
? t('routeErrorDetail', { error: route.route_error })
: t('routeDetail', {
model: route?.selected_model ?? '--',
selected: selectedProvider,
primary: providerDisplayName(primaryProvider),
primaryStatus: t(routeHealthLabelKey(primaryStatus) as never),
fallback: fallback || t('routeNoFallback'),
})
const routeDetail = route?.route_reason && !route.route_error
? `${routeSummary}${t('routeReasonSeparator')}${t('routeReason', { reason: route.route_reason })}`
: routeSummary
return {
quality,
@@ -268,8 +335,10 @@ export function AutomationEvidenceCard() {
claimReady,
selectedProvider,
fallback,
routeDetail,
routeTone: routeTone(route),
}
}, [snapshot])
}, [snapshot, t])
const hasData = Boolean(snapshot?.quality || snapshot?.coverage || snapshot?.recurrence)
@@ -365,11 +434,8 @@ export function AutomationEvidenceCard() {
<EvidenceMetric
label={t('modelRoute')}
value={derived.selectedProvider}
detail={t('routeDetail', {
model: snapshot?.route?.selected_model ?? '--',
fallback: derived.fallback || '--',
})}
tone="neutral"
detail={derived.routeDetail}
tone={derived.routeTone}
icon={Route}
/>
</div>