Add voice analytics filters and metrics

This commit is contained in:
2026-04-26 22:00:34 +08:00
parent 3805c18622
commit 55ca0985eb
25 changed files with 710 additions and 39 deletions

View File

@@ -80,6 +80,8 @@ const selectedUniverseId = ref('')
const sessionFilter = ref<SessionFilter>('active')
const attentionReasonFilter = ref<AttentionReasonFilter>('all')
const analyticsWindow = ref<'7' | '30' | 'all'>('30')
const analyticsProviderFilter = ref('')
const analyticsStatusFilter = ref('')
const textTurnInput = ref('')
const uploadTranscriptHint = ref('')
const loadingSessions = ref(false)
@@ -113,10 +115,30 @@ const profileOptions = computed(() =>
const universeOptions = computed(() =>
universes.value.map((universe) => ({ value: universe.id, label: universe.name })),
)
const analyticsProviderOptions = [
{ value: 'fallback', label: '文本 fallback' },
{ value: 'demo', label: 'Demo ASR' },
{ value: 'openai', label: 'OpenAI ASR' },
{ value: 'openai_asr', label: 'OpenAI ASR Adapter' },
]
const analyticsStatusOptions = [
{ value: 'draft', label: '草稿' },
{ value: 'active', label: '进行中' },
{ value: 'waiting_user', label: '等待用户' },
{ value: 'completed', label: '已完成' },
{ value: 'abandoned', label: '已放弃' },
]
const filteredSessions = computed(() => {
return resolveDisplayedSessions(sessions.value)
})
const getSessionInputModeSummary = (session: VoiceSessionSummary) => {
if (session.latest_requires_confirmation) return '上一轮待确认'
if (session.latest_safety_flags.length) return `安全介入 ${session.latest_safety_flags.length}`
if (session.latest_detected_intent) return `最近意图:${formatIntent(session.latest_detected_intent)}`
return '等待输入'
}
const activeTurnList = computed(() => activeSession.value?.recent_turns ?? [])
const hasPendingConfirmation = computed(() => activeSession.value?.latest_requires_confirmation ?? false)
const latestPendingConfirmationTurn = computed(
@@ -269,6 +291,50 @@ const finalizeConversionRateLabel = computed(() => {
if (!voiceAnalytics.value) return '0%'
return `${Math.round(voiceAnalytics.value.finalize_conversion_rate * 100)}%`
})
const confirmationRequestRateLabel = computed(() => {
if (!voiceAnalytics.value) return '0%'
return `${Math.round(voiceAnalytics.value.confirmation_request_rate * 100)}%`
})
const userAudioTurnRateLabel = computed(() => {
if (!voiceAnalytics.value) return '0%'
return `${Math.round(voiceAnalytics.value.user_audio_turn_rate * 100)}%`
})
const assistantAudioReadyRateLabel = computed(() => {
if (!voiceAnalytics.value) return '0%'
return `${Math.round(voiceAnalytics.value.assistant_audio_ready_rate * 100)}%`
})
const asrSuccessRateLabel = computed(() => {
if (!voiceAnalytics.value) return '0%'
return `${Math.round(voiceAnalytics.value.asr_success_rate * 100)}%`
})
const ttsSuccessRateLabel = computed(() => {
if (!voiceAnalytics.value) return '0%'
return `${Math.round(voiceAnalytics.value.tts_success_rate * 100)}%`
})
const avgConfidenceSummary = computed(() => {
if (!voiceAnalytics.value) return '转写 0%,意图 0%'
const transcript = Math.round(voiceAnalytics.value.avg_transcript_confidence * 100)
const intent = Math.round(voiceAnalytics.value.avg_intent_confidence * 100)
return `转写 ${transcript}%,意图 ${intent}%`
})
const avgUserAudioDurationLabel = computed(() => {
if (!voiceAnalytics.value || !voiceAnalytics.value.avg_user_audio_duration_ms) return '0.0 秒'
return `${(voiceAnalytics.value.avg_user_audio_duration_ms / 1000).toFixed(1)}`
})
const avgAssistantAudioDurationLabel = computed(() => {
if (!voiceAnalytics.value || !voiceAnalytics.value.avg_assistant_audio_duration_ms) return '0.0 秒'
return `${(voiceAnalytics.value.avg_assistant_audio_duration_ms / 1000).toFixed(1)}`
})
const formatDurationMs = (durationMs: number | null | undefined) => {
if (!durationMs) return '0.0 秒'
return `${(durationMs / 1000).toFixed(1)}`
}
const transcriptionProviderSummary = computed(() => {
const counts = voiceAnalytics.value?.transcription_provider_counts ?? {}
const entries = Object.entries(counts).sort((left, right) => right[1] - left[1])
if (!entries.length) return '暂无转写来源'
return entries.map(([provider, count]) => `${provider} ${count}`).join('')
})
const analyticsWindowLabel = computed(() =>
formatAnalyticsWindowLabel(voiceAnalytics.value?.window_days ?? null),
)
@@ -616,10 +682,19 @@ async function syncVoiceStudioRouteState(options?: {
}
function buildVoiceAnalyticsPath() {
if (analyticsWindow.value === 'all') {
return '/api/voice-sessions/analytics'
const params = new URLSearchParams()
if (analyticsWindow.value !== 'all') {
params.set('days', analyticsWindow.value)
}
return `/api/voice-sessions/analytics?days=${analyticsWindow.value}`
if (analyticsProviderFilter.value) {
params.set('provider', analyticsProviderFilter.value)
}
if (analyticsStatusFilter.value) {
params.set('session_status', analyticsStatusFilter.value)
}
const query = params.toString()
const path = '/api/voice-sessions/analytics'
return query ? `${path}?${query}` : path
}
function buildVoiceSessionListPath() {
@@ -1157,6 +1232,14 @@ function setAnalyticsWindow(value: '7' | '30' | 'all') {
analyticsWindow.value = value
}
function setAnalyticsProviderFilter(value: string | number) {
analyticsProviderFilter.value = String(value)
}
function setAnalyticsStatusFilter(value: string | number) {
analyticsStatusFilter.value = String(value)
}
function setSessionFilter(value: SessionFilter) {
suppressAutoAdvanceNotice.value = true
clearAttentionCompletionNotice()
@@ -1258,6 +1341,10 @@ watch(analyticsWindow, () => {
void loadVoiceAnalytics()
})
watch([analyticsProviderFilter, analyticsStatusFilter], () => {
void loadVoiceAnalytics()
})
watch(sessionFilter, () => {
void loadSessions()
})
@@ -1528,6 +1615,9 @@ onBeforeUnmount(() => {
<div class="mt-1 text-xs text-gray-500">
{{ formatSessionStatus(session.status) }} · {{ session.total_turns }}
</div>
<div class="mt-1 text-xs text-gray-400">
{{ getSessionInputModeSummary(session) }}
</div>
<div
class="mt-3 rounded-xl border px-3 py-2 text-xs leading-5"
:class="getVoiceSessionNextStep(session).toneClass"
@@ -1669,6 +1759,22 @@ onBeforeUnmount(() => {
全部
</button>
</div>
<div class="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2">
<BaseSelect
v-model="analyticsProviderFilter"
label="转写来源筛选"
:options="analyticsProviderOptions"
placeholder="全部来源"
@update:modelValue="setAnalyticsProviderFilter"
/>
<BaseSelect
v-model="analyticsStatusFilter"
label="会话状态筛选"
:options="analyticsStatusOptions"
placeholder="全部状态"
@update:modelValue="setAnalyticsStatusFilter"
/>
</div>
<div class="mt-4 grid grid-cols-2 gap-3 xl:grid-cols-4">
<div class="rounded-xl border border-gray-100 bg-gray-50 px-4 py-3">
<div class="text-xs text-gray-500">Turn 成功率</div>
@@ -1677,6 +1783,7 @@ onBeforeUnmount(() => {
<div class="rounded-xl border border-gray-100 bg-gray-50 px-4 py-3">
<div class="text-xs text-gray-500">低置信度触发</div>
<div class="mt-1 text-lg font-semibold text-amber-700">{{ voiceAnalytics.low_confidence_turns }}</div>
<div class="mt-1 text-xs text-gray-400">确认率 {{ confirmationRequestRateLabel }}</div>
</div>
<div class="rounded-xl border border-gray-100 bg-gray-50 px-4 py-3">
<div class="text-xs text-gray-500">安全介入</div>
@@ -1686,12 +1793,36 @@ onBeforeUnmount(() => {
<div class="text-xs text-gray-500">Finalize 转化率</div>
<div class="mt-1 text-lg font-semibold text-emerald-700">{{ finalizeConversionRateLabel }}</div>
</div>
<div class="rounded-xl border border-gray-100 bg-gray-50 px-4 py-3">
<div class="text-xs text-gray-500">上传语音占比</div>
<div class="mt-1 text-lg font-semibold text-sky-700">{{ userAudioTurnRateLabel }}</div>
<div class="mt-1 text-xs text-gray-400">上传 {{ voiceAnalytics.uploaded_audio_turns }} / 文本 {{ voiceAnalytics.text_fallback_turns }}</div>
</div>
<div class="rounded-xl border border-gray-100 bg-gray-50 px-4 py-3">
<div class="text-xs text-gray-500">助手语音覆盖</div>
<div class="mt-1 text-lg font-semibold text-purple-700">{{ assistantAudioReadyRateLabel }}</div>
<div class="mt-1 text-xs text-gray-400">{{ voiceAnalytics.assistant_audio_ready_turns }} 轮有语音</div>
</div>
<div class="rounded-xl border border-gray-100 bg-gray-50 px-4 py-3">
<div class="text-xs text-gray-500">ASR 成功率</div>
<div class="mt-1 text-lg font-semibold text-indigo-700">{{ asrSuccessRateLabel }}</div>
</div>
<div class="rounded-xl border border-gray-100 bg-gray-50 px-4 py-3">
<div class="text-xs text-gray-500">TTS 成功率</div>
<div class="mt-1 text-lg font-semibold text-fuchsia-700">{{ ttsSuccessRateLabel }}</div>
</div>
</div>
<p class="mt-4 text-sm text-gray-500">
ASR 失败 {{ voiceAnalytics.asr_failures }} TTS 失败 {{ voiceAnalytics.tts_failures }}
当前共有 {{ voiceAnalytics.total_sessions }} 个会话其中 {{ voiceAnalytics.attention_sessions }} 个仍需处理
已完成 {{ voiceAnalytics.finalized_sessions }}
</p>
<p class="mt-2 text-sm text-gray-500">
平均用户语音 {{ avgUserAudioDurationLabel }}平均助手语音 {{ avgAssistantAudioDurationLabel }}转写来源{{ transcriptionProviderSummary }}
</p>
<p class="mt-2 text-sm text-gray-500">
平均置信度{{ avgConfidenceSummary }}安全介入率 {{ Math.round(voiceAnalytics.safety_intervention_rate * 100) }}%
</p>
<p
v-if="voiceAnalytics.attention_sessions"
class="mt-2 text-sm text-gray-500"
@@ -2199,6 +2330,8 @@ onBeforeUnmount(() => {
<span>·</span>
<span>{{ formatIntent(turn.detected_intent) }}</span>
<span v-if="turn.transcription_provider">· {{ turn.transcription_provider }}</span>
<span v-if="turn.user_audio_duration_ms">· 用户语音 {{ formatDurationMs(turn.user_audio_duration_ms) }}</span>
<span v-if="turn.assistant_audio_duration_ms">· 助手语音 {{ formatDurationMs(turn.assistant_audio_duration_ms) }}</span>
</div>
<div class="mt-3 text-sm text-gray-800">
<span class="font-medium text-gray-900">孩子</span>