Skip to content

Commit 6652b7f

Browse files
committed
feat(classroom): P0-3 真实置信度与低置信度质量门控
网关: transcribe 置信度估算(文本长度基线0.7渐近0.95/fallback 0.3);vision 截断标志透传且置信度减半 本地: estimateAsrConfidence(清洗前后长度比代理信号);流式final/flush与按段转写均携带confidence UI: UnifiedTimeline 低置信度(<0.55)弱化+角标;LiveTranscript/TranscriptEntry 增加confidence字段
1 parent 75f3f70 commit 6652b7f

9 files changed

Lines changed: 113 additions & 32 deletions

File tree

‎client/electron/ai/local-asr/SherpaAsrService.ts‎

Lines changed: 12 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,10 @@
2121
import * as path from 'path';
2222
import * as os from 'os';
2323
import { logger } from '../../logger.js';
24-
import { cleanAsrResult } from '../../../src/lib/capture/asrFilters.js';
24+
import {
25+
cleanAsrResult,
26+
estimateAsrConfidence,
27+
} from '../../../src/lib/capture/asrFilters.js';
2528
import {
2629
getLocalAsrConfig,
2730
getModelDir,
@@ -210,7 +213,7 @@ export function resetAvailabilityCache(): void {
210213
export async function transcribeStreaming(
211214
pcmData: Float32Array,
212215
hotwords?: string,
213-
): Promise<{ text: string; engine: 'zipformer'; durationMs: number }> {
216+
): Promise<{ text: string; confidence: number; engine: 'zipformer'; durationMs: number }> {
214217
const startTime = Date.now();
215218

216219
const recognizer = getOnlineRecognizer();
@@ -236,15 +239,17 @@ export async function transcribeStreaming(
236239
recognizer.decode(stream);
237240
}
238241

239-
const result = recognizer.getResult(stream);
242+
const rawText = recognizer.getResult(stream).text ?? '';
240243
// 输出后处理:相邻重复压缩 + 幻觉过滤
241244
// P1-1 两遍重打分接入点:此处为本地按段转写最终文本出口,
242245
// SenseVoice 重打分将在此处对 text 做句末复核(高置信度者胜出)
243-
const text = cleanAsrResult(result.text ?? '');
246+
const text = cleanAsrResult(rawText);
247+
// P0-3:置信度估算(清洗前后长度比代理信号,供 UI 低置信度标记)
248+
const confidence = estimateAsrConfidence(rawText, text);
244249
const durationMs = Date.now() - startTime;
245250

246251
logger.debug(`[LocalASR] Zipformer transcribe: ${text.length} chars, ${durationMs}ms`);
247-
return { text, engine: 'zipformer', durationMs };
252+
return { text, confidence, engine: 'zipformer', durationMs };
248253
} finally {
249254
stream.free?.();
250255
}
@@ -259,7 +264,7 @@ export async function transcribeStreaming(
259264
export async function transcribeLocal(
260265
audioBase64: string,
261266
options?: { language?: string; sampleRate?: number; channels?: number; hotwords?: string },
262-
): Promise<{ text: string; language: string; durationMs: number }> {
267+
): Promise<{ text: string; language: string; confidence: number; durationMs: number }> {
263268
const config = getLocalAsrConfig();
264269
const language = options?.language ?? config.language;
265270

@@ -275,5 +280,5 @@ export async function transcribeLocal(
275280
const pcmData = new Float32Array(rawBytes.buffer, rawBytes.byteOffset, rawBytes.byteLength / 4);
276281

277282
const result = await transcribeStreaming(pcmData, options?.hotwords);
278-
return { text: result.text, language, durationMs: result.durationMs };
283+
return { text: result.text, language, confidence: result.confidence, durationMs: result.durationMs };
279284
}

‎client/electron/ai/local-asr/streamingAsr.ts‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@
1212

1313
import type { BrowserWindow } from 'electron';
1414
import { logger } from '../../logger.js';
15-
import { cleanAsrResult, computeRms, SILENCE_RMS_THRESHOLD } from '../../../src/lib/capture/asrFilters.js';
15+
import { cleanAsrResult, computeRms, estimateAsrConfidence, SILENCE_RMS_THRESHOLD } from '../../../src/lib/capture/asrFilters.js';
1616
import { getOnlineRecognizer, feedWaveform, type OnlineStream } from './SherpaAsrService.js';
1717

1818
/** partial 推送节流:两次 partial 推送的最小间隔(ms) */
@@ -148,14 +148,16 @@ export function feedStreamingAsr(audioBuffer: ArrayBuffer, sampleRate?: number):
148148
// 端点检测:断句 → 推送 final 并以最新热词重建流(P0-6 热词生效点)
149149
if (recognizer.isEndpoint(_stream)) {
150150
// 输出后处理:相邻重复压缩 + 幻觉过滤(静音段重复输出防护)
151-
const finalText = cleanAsrResult(recognizer.getResult(_stream).text ?? '');
151+
const rawText = recognizer.getResult(_stream).text ?? '';
152+
const finalText = cleanAsrResult(rawText);
153+
const confidence = estimateAsrConfidence(rawText, finalText);
152154
_lastFinalText = finalText;
153155
// 重建流:以会话最新热词 createStream(热词变化无需重启即可生效)
154156
_stream = recognizer.createStream(_latestHotwords);
155157
_lastPartialText = '';
156158
_lastPartialEmitAt = 0;
157159
if (finalText) {
158-
emit('asr_stream_final', { text: finalText, timestamp: Date.now() });
160+
emit('asr_stream_final', { text: finalText, confidence, timestamp: Date.now() });
159161
}
160162
return;
161163
}
@@ -190,13 +192,13 @@ export function stopStreamingAsr(): void {
190192
// flush 尾句:仅在窗口存活且流内已有可交付文本时推送
191193
if (_win && !_win.isDestroyed()) {
192194
const recognizer = getOnlineRecognizer();
193-
const tailText = recognizer
194-
? cleanAsrResult(recognizer.getResult(_stream).text ?? '')
195-
: '';
195+
const rawTail = recognizer ? recognizer.getResult(_stream).text ?? '' : '';
196+
const tailText = rawTail ? cleanAsrResult(rawTail) : '';
196197
// P0-4 flush 去重:尾句与最近一次 final 完全一致时不再推送
197198
// (端点已推送过该句,flush 重复上屏是停止瞬间重复的兜底场景)
198199
if (tailText && tailText !== _lastFinalText) {
199-
emit('asr_stream_final', { text: tailText, timestamp: Date.now() });
200+
const confidence = estimateAsrConfidence(rawTail, tailText);
201+
emit('asr_stream_final', { text: tailText, confidence, timestamp: Date.now() });
200202
}
201203
}
202204
// 新版(1.13+)流对象无 free 方法(句柄由 GC 回收),可选调用

‎client/src/features/classroom/components/UnifiedTimeline.tsx‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ export interface TranscriptEntry {
2424
timestamp: number;
2525
/** P1-2:用户修正后的文本(存在时优先显示) */
2626
editedText?: string;
27+
/** P0-3:转写置信度(估算口径,<0.55 时 UI 弱化标记) */
28+
confidence?: number;
2729
}
2830

2931
interface UnifiedTimelineProps {
@@ -156,6 +158,9 @@ export function UnifiedTimeline({ bundle, liveTranscripts, autoAnchors = [], par
156158
const isEditing = editingId === t.id;
157159
const displayText = t.editedText ?? t.text;
158160
const isEdited = !!t.editedText;
161+
// P0-3 低置信度标记:置信度 <0.55 的转写弱化显示并加角标(估算口径,
162+
// 非统计置信度;未携带置信度的旧数据视为 1)
163+
const isLowConfidence = typeof t.confidence === 'number' && t.confidence < 0.55;
159164
const time = new Date(t.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
160165

161166
if (isEditing) {
@@ -183,8 +188,11 @@ export function UnifiedTimeline({ bundle, liveTranscripts, autoAnchors = [], par
183188
<div key={t.id} className="flex gap-2 p-2 rounded-kb-sm transition-colors hover:bg-bg-tertiary/30 group">
184189
<span className="text-[10px] text-text-tertiary flex-shrink-0 mt-0.5 tabular-nums">{time}</span>
185190
<Mic className="w-3.5 h-3.5 flex-shrink-0 mt-0.5 text-emerald-500/70" strokeWidth={1.5} />
186-
<span className={cn('flex-1 text-[12px] leading-relaxed min-w-0', idx === rows.length - 1 ? 'text-text-primary font-medium' : 'text-text-secondary')}>
191+
<span className={cn('flex-1 text-[12px] leading-relaxed min-w-0', idx === rows.length - 1 ? 'text-text-primary font-medium' : 'text-text-secondary', isLowConfidence && 'opacity-60')}>
187192
{displayText}
193+
{isLowConfidence && (
194+
<span className="ml-1.5 text-[10px] text-text-quaternary font-medium" title={`置信度 ${(t.confidence ?? 0).toFixed(2)},识别可能不准确`}>低置信</span>
195+
)}
188196
{isEdited && (
189197
<span className="ml-1.5 text-[10px] text-amber-500 font-medium" title={`原始: ${t.text}`}>已修正</span>
190198
)}

‎client/src/features/classroom/hooks/useClassroomEvents.ts‎

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ import { remapKeyframeMarkers } from '../utils/tipTapImageUtils';
3434
import { persistKeyframeImage } from '../utils/keyframePersistence';
3535
import { transcribeWithRetry, toAsrLanguage, useAsrSemaphore, isLocalAsrReady, setOnAsrFallback } from '../utils/asrTranscriber';
3636
import { applySessionReplaces, getSessionHotwordsString, addDynamicBoosts } from '../utils/hotwordRuntime';
37-
import { cleanAsrResult, dedupeAcrossFinals } from '@/lib/capture/asrFilters';
37+
import { cleanAsrResult, dedupeAcrossFinals, estimateAsrConfidence } from '@/lib/capture/asrFilters';
3838

3939
/** 触发一次增量分析所需的关键帧数 */
4040
const INCREMENTAL_BATCH_SIZE = 5;
@@ -50,6 +50,8 @@ export interface LiveTranscript {
5050
id: string;
5151
text: string;
5252
timestamp: number;
53+
/** P0-3:转写置信度(估算口径,<0.55 时 UI 弱化标记;缺失视为 1) */
54+
confidence?: number;
5355
}
5456

5557
interface UseClassroomEventsOptions {
@@ -260,10 +262,14 @@ export function useClassroomEvents({
260262
sample_rate: 16000,
261263
channels: 1,
262264
}, 1, hotwords || undefined)
263-
.then((text) => {
265+
.then((outcome) => {
264266
asr.markSuccess();
265267
// 输出清洗:相邻重复压缩 + 幻觉过滤(本地路径主进程已 clean,此处兑底云端降级)
266-
const cleaned = cleanAsrResult(text ?? '');
268+
const cleaned = cleanAsrResult(outcome?.text ?? '');
269+
// P0-3:清洗后文本变化时重估置信度(网关估算基于未清洗文本)
270+
const confidence = cleaned && outcome
271+
? (cleaned === outcome.text ? outcome.confidence : estimateAsrConfidence(outcome.text, cleaned))
272+
: 0;
267273
// 将转写结果回填到对应的音频段,并剥离 audioBase64 释放内存(单段约 1.2MB,
268274
// 长课堂数百段否则无界累积——内测 5GB 内存主因)。全量分析回退路径优先用
269275
// 已转写的 audioText(sessionAnalyzer: seg.audioText ?? transcribe),无需再持有原始音频;
@@ -281,7 +287,7 @@ export function useClassroomEvents({
281287
// 实时转录上屏(FIFO 上限控制);展示替换后文本(P1-3 替换词后处理),
282288
// 原始清洗后转写经 audioTextRaw 可回溯
283289
setLiveTranscripts((prev) => {
284-
const next = [...prev, { id: seg.id, text: applySessionReplaces(cleaned), timestamp: seg.timestampStart }];
290+
const next = [...prev, { id: seg.id, text: applySessionReplaces(cleaned), timestamp: seg.timestampStart, confidence }];
285291
return next.length > MAX_LIVE_TRANSCRIPTS
286292
? next.slice(next.length - MAX_LIVE_TRANSCRIPTS)
287293
: next;
@@ -329,7 +335,7 @@ export function useClassroomEvents({
329335
});
330336
const offFinal = window.electronAPI.on('asr_stream_final', (...args: unknown[]) => {
331337
if (statusRef.current !== 'capturing') return;
332-
const data = args[0] as { text: string; timestamp: number };
338+
const data = args[0] as { text: string; timestamp: number; confidence?: number };
333339
setPartialText('');
334340
// 双保险:主进程已 clean,此处兜底云端/旧版本主进程的未清洗输出
335341
const cleaned = cleanAsrResult(data?.text ?? '');
@@ -339,11 +345,15 @@ export function useClassroomEvents({
339345
const text = dedupeAcrossFinals(lastFinalTextRef.current, cleaned);
340346
lastFinalTextRef.current = text || lastFinalTextRef.current;
341347
if (!text) return;
348+
// P0-3:置信度透传(主进程估算;本地再清洗后文本变化时重估)
349+
const confidence = cleaned === data.text
350+
? (typeof data.confidence === 'number' ? data.confidence : 1)
351+
: estimateAsrConfidence(data.text ?? '', cleaned);
342352
const id = crypto.randomUUID();
343353
const timestamp = data.timestamp || Date.now();
344354
// 实时转录上屏(FIFO 上限控制);展示替换后文本(P1-3)
345355
setLiveTranscripts((prev) => {
346-
const next = [...prev, { id, text: applySessionReplaces(text), timestamp }];
356+
const next = [...prev, { id, text: applySessionReplaces(text), timestamp, confidence }];
347357
return next.length > MAX_LIVE_TRANSCRIPTS
348358
? next.slice(next.length - MAX_LIVE_TRANSCRIPTS)
349359
: next;

‎client/src/features/classroom/utils/asrTranscriber.ts‎

Lines changed: 21 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,15 @@ interface TranscribeResponse {
3030
text: string;
3131
model_used?: string;
3232
warning?: string | null;
33+
/** P0-3:网关估算置信度(质量门控弱化标记信号,非统计置信度) */
34+
confidence?: number;
35+
}
36+
37+
/** 统一转写结果:文本 + 置信度(P0-3 质量门控) */
38+
export interface TranscribeOutcome {
39+
text: string;
40+
/** 0-1 置信度(估算口径,见网关 transcribe_chain 与本地 estimateAsrConfidence) */
41+
confidence: number;
3342
}
3443

3544
// ================================================================
@@ -82,29 +91,33 @@ export function isLocalAsrReady(): boolean {
8291
// 本地 ASR 转写(IPC 调用主进程 sherpa-onnx)
8392
// ================================================================
8493

85-
async function transcribeLocalViaIpc(payload: TranscribePayload): Promise<string | null> {
94+
async function transcribeLocalViaIpc(payload: TranscribePayload): Promise<TranscribeOutcome | null> {
8695
if (!window.electronAPI) throw new Error('electronAPI 不可用');
8796
const result = await window.electronAPI.invoke('local_asr_transcribe', {
8897
audioBase64: payload.audio_base64,
8998
language: payload.language,
9099
sampleRate: payload.sample_rate,
91100
channels: payload.channels,
92101
hotwords: payload.hotwords,
93-
}) as { text: string; language: string; durationMs: number };
94-
return result.text?.trim() || null;
102+
}) as { text: string; language: string; confidence?: number; durationMs: number };
103+
const text = result.text?.trim() ?? '';
104+
if (!text) return null;
105+
return { text, confidence: typeof result.confidence === 'number' ? result.confidence : 1 };
95106
}
96107

97108
// ================================================================
98109
// 云端 ASR 转写(原有逻辑)
99110
// ================================================================
100111

101-
async function transcribeCloud(payload: TranscribePayload): Promise<string | null> {
112+
async function transcribeCloud(payload: TranscribePayload): Promise<TranscribeOutcome | null> {
102113
const resp = await aiClient.post<TranscribeResponse>('/api/v1/asr/transcribe', payload, { timeout: 30000 });
103114
// fallback 降级响应(含 warning 或 fallback 空文本)按失败处理
104115
if (resp.warning || (!resp.text?.trim() && resp.model_used === 'fallback')) {
105116
throw new Error(resp.warning || 'ASR 服务降级,转写结果为空');
106117
}
107-
return resp.text?.trim() || null;
118+
const text = resp.text?.trim() ?? '';
119+
if (!text) return null;
120+
return { text, confidence: typeof resp.confidence === 'number' ? resp.confidence : 1 };
108121
}
109122

110123
// ================================================================
@@ -121,12 +134,12 @@ async function transcribeCloud(payload: TranscribePayload): Promise<string | nul
121134
*
122135
* @param hotwords - 可选热词增强字符串(zipformer-transducer 支持,空格分隔)
123136
*/
124-
export async function transcribeWithRetry(payload: TranscribePayload, retries = 1, hotwords?: string): Promise<string | null> {
137+
export async function transcribeWithRetry(payload: TranscribePayload, retries = 1, hotwords?: string): Promise<TranscribeOutcome | null> {
125138
// ── 本地 ASR 优先 ──
126139
if (_localAsrAvailable) {
127140
try {
128-
const text = await transcribeLocalViaIpc({ ...payload, hotwords });
129-
if (text) return text;
141+
const outcome = await transcribeLocalViaIpc({ ...payload, hotwords });
142+
if (outcome) return outcome;
130143
} catch (localErr) {
131144
console.warn('[asrTranscriber] 本地 ASR 失败,尝试云端降级:', localErr);
132145
if (!_localAsrFallbackToCloud) throw localErr;

‎client/src/lib/capture/asrFilters.ts‎

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -186,3 +186,24 @@ function similarityOf(a: string, b: string): number {
186186
const union = setA.size + setB.size - intersection;
187187
return union > 0 ? intersection / union : 0;
188188
}
189+
190+
// ================================================================
191+
// 转写置信度估算(P0-3 质量门控)
192+
// ================================================================
193+
194+
/**
195+
* 估算转写置信度:sherpa zipformer 与云端 ASR API 均不返回统计置信度,
196+
* 用「清洗前后文本长度比」作代理信号——幻觉过滤/重复压缩会显著缩短文本,
197+
* 压缩损失越大说明原始输出越不可靠。
198+
* 口径:cleaned 为空 → 0;ratio = cleaned/raw;confidence = 0.35 + 0.65*ratio
199+
* (ratio=1 → 1.0;ratio=0.7 → 0.8;ratio=0.5 → 0.68)。
200+
* 语义:供 UI 低置信度弱化标记,非统计置信度(与网关 transcribe 估算口径一致)。
201+
*/
202+
export function estimateAsrConfidence(rawText: string, cleanedText: string): number {
203+
const raw = (rawText ?? '').trim();
204+
const cleaned = (cleanedText ?? '').trim();
205+
if (!raw || !cleaned) return 0;
206+
if (raw === cleaned) return 1;
207+
const ratio = cleaned.length / raw.length;
208+
return Math.round((0.35 + 0.65 * Math.max(0, Math.min(1, ratio))) * 100) / 100;
209+
}

‎server/ai-gateway/chains/transcribe_chain.py‎

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -97,8 +97,21 @@ async def run(
9797
# 后处理
9898
result = self._postprocess_result(result)
9999
result.setdefault("language", language)
100-
# GW-2#11: ASR API 不返回置信度,0.0 为占位常量而非测量值
101-
result.setdefault("confidence", 0.0)
100+
# GW-2#11 + P0-3:ASR API 不返回置信度,此处输出估算值供渲染进程
101+
# 质量门控(低置信度段落 UI 弱化标记),非统计置信度。估算口径:
102+
# 文本非空基线 0.7,按长度渐近 0.95;fallback/空文本为 0。
103+
result["confidence"] = self._estimate_confidence(
104+
result.get("text", ""), result.get("model", self.model),
105+
)
102106
result.setdefault("model", self.model)
103107

104108
return result
109+
110+
@staticmethod
111+
def _estimate_confidence(text: str, model: str) -> float:
112+
"""估算转写置信度(P0-3,语义见 run 内注释)"""
113+
if not text.strip():
114+
return 0.0
115+
if model == "fallback":
116+
return 0.3
117+
return round(min(0.95, 0.7 + min(len(text), 80) / 200), 2)

‎server/ai-gateway/chains/vision_extract_chain.py‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -281,11 +281,15 @@ async def run(
281281
# GW-2#4: 显式截断检测——GLM 等 provider 可能把 max_tokens clamp 到
282282
# 低于请求值(full 模式请求 4096 被 clamp 到 1024),输出在接近上限时
283283
# 大概率被截断。原实现静默返回残缺 JSON(结构化字段丢失)且照常缓存,
284-
# 用户无感知;此处记录 truncated 告警供排查与降级重试决策
284+
# 用户无感知;此处记录 truncated 告警供排查与降级重试决策。
285+
# P0-3:truncated 标志随结果返回,路由层据此将置信度减半(低置信度
286+
# 段落 UI 弱化标记的质量门控信号)
285287
content = result["content"]
286288
actual_max_tokens = result.get("max_tokens")
289+
truncated = False
287290
if actual_max_tokens and len(content) > actual_max_tokens * 3:
288291
# 1 token ≈ 3 字符的保守估算(中文 1 token ≈ 1-2 字符,英文 ≈ 4)
292+
truncated = True
289293
logger.warning(
290294
"视觉提取疑似截断: mode=%s, model=%s, content=%d 字符, "
291295
"max_tokens=%d(provider clamp 后),结构化字段可能缺失",
@@ -303,4 +307,5 @@ async def run(
303307
"model": result.get("model", self.model),
304308
"latency_ms": result.get("latency_ms", 0),
305309
"mode": effective_mode,
310+
"truncated": truncated,
306311
}

‎server/ai-gateway/routers/vision.py‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,10 @@ async def _run_chain(provider, model_name):
143143
aux_fields = [formulas, diagrams, key_points, code_blocks, concepts]
144144
aux_filled = sum(1 for v in aux_fields if v) / len(aux_fields)
145145
confidence = round(filled_text * 0.5 + aux_filled * 0.5, 2)
146+
# P0-3:疑似截断(max_tokens clamp 导致结构化字段缺失)时置信度减半,
147+
# 作为渲染进程低置信度弱化标记的质量门控信号
148+
if result.get("truncated"):
149+
confidence = round(confidence * 0.5, 2)
146150

147151
# 实际使用的模式
148152
effective_mode = result.get("mode", body.mode or "auto")

0 commit comments

Comments
 (0)