Skip to content

Commit 0e27603

Browse files
committed
fix(classroom): 修复精细采集三症状——视觉抓页面元数据、ASR静音幻觉、截断JSON泄漏
内测反馈课堂助手视频与音频识别异常,定位为三个独立缺陷: - 视觉 prompt 未排除浏览器 UI,模型忠实 OCR 了 B 站标题/播放量/URL → auto/full prompt 增加忽略清单,无教学内容时 text 返回空串 - Path A 音频链无 VAD(Path B 有),静音段直送 ASR 触发幻觉输出 重复语气词与脏话 → 新增 asrFilters:RMS 0.008 静音门控 + 输出端 幻觉过滤(纯标点/重复灌水/短句脏话,宁放过不误杀) - 模型输出被 max_tokens 截断后,_parse_response 兜底把原始 JSON 整段当 text 返回并渲染进时间线 → 改为正则抢救 text 字段, 抢救不到返回空文本 新增 vitest 10 例 + pytest 6 例,用例取自内测截图真实幻觉文本
1 parent c521d3f commit 0e27603

6 files changed

Lines changed: 268 additions & 3 deletions

File tree

‎client/src/lib/ai/asrWorker.ts‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import type {
1313
ExtractionResult,
1414
AudioChunkData,
1515
} from '@/lib/capture/captureTypes';
16+
import { isSilentChunk, isLikelyHallucination } from '@/lib/capture/asrFilters';
1617
import { aiClient } from '@/lib/http/apiClient';
1718

1819
// ================================================================
@@ -54,6 +55,11 @@ export class ASRWorker implements PipelineWorker {
5455
async process(message: PipelineMessage): Promise<ExtractionResult | null> {
5556
const audioData = message.data as AudioChunkData;
5657

58+
// 静音门控:低能量块不送 ASR(静音段会诱发模型幻觉文本,且白白消耗配额)
59+
if (isSilentChunk(audioData.audioBuffer)) {
60+
return null;
61+
}
62+
5763
// ArrayBuffer → base64
5864
const base64 = arrayBufferToBase64(audioData.audioBuffer);
5965

@@ -79,6 +85,12 @@ export class ASRWorker implements PipelineWorker {
7985
return null;
8086
}
8187

88+
// 幻觉过滤:重复字符灌水/纯标点/短句脏话是静音段 ASR 的典型幻觉形态,
89+
// 不进入时间线(宁放过不误杀,规则见 asrFilters.ts)
90+
if (isLikelyHallucination(response.text)) {
91+
return null;
92+
}
93+
8294
// 转换为 ExtractionResult
8395
return {
8496
text: response.text,
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
/**
2+
* @ai-context: asrFilters 单元测试——静音门控(RMS)与 ASR 幻觉文本过滤。
3+
* 用例源自内测真实故障:静音段幻觉输出"嗯嗯嗯""。""是是是"及短句脏话。
4+
*/
5+
import { describe, it, expect } from 'vitest';
6+
import {
7+
computeRms,
8+
isSilentChunk,
9+
isLikelyHallucination,
10+
SILENCE_RMS_THRESHOLD,
11+
} from './asrFilters';
12+
13+
/** 构造固定幅值的 Float32 PCM 块 */
14+
function pcm(amplitude: number, length = 4800): ArrayBuffer {
15+
return new Float32Array(length).fill(amplitude).buffer;
16+
}
17+
18+
describe('computeRms / isSilentChunk', () => {
19+
it('全零样本 RMS 为 0,判定为静音', () => {
20+
expect(computeRms(pcm(0))).toBe(0);
21+
expect(isSilentChunk(pcm(0))).toBe(true);
22+
});
23+
24+
it('低于阈值的底噪判定为静音', () => {
25+
expect(isSilentChunk(pcm(SILENCE_RMS_THRESHOLD / 2))).toBe(true);
26+
});
27+
28+
it('正常语音能量不判定为静音', () => {
29+
expect(isSilentChunk(pcm(0.1))).toBe(false);
30+
});
31+
32+
it('空 buffer 判定为静音', () => {
33+
expect(isSilentChunk(new ArrayBuffer(0))).toBe(true);
34+
});
35+
});
36+
37+
describe('isLikelyHallucination', () => {
38+
it('过滤纯标点与空白(内测症状:"。")', () => {
39+
expect(isLikelyHallucination('。')).toBe(true);
40+
expect(isLikelyHallucination(' ')).toBe(true);
41+
expect(isLikelyHallucination(',。,。')).toBe(true);
42+
});
43+
44+
it('过滤重复字符灌水(内测症状:"嗯嗯嗯…""是是是…")', () => {
45+
expect(isLikelyHallucination('嗯嗯嗯嗯嗯嗯嗯嗯嗯嗯嗯嗯')).toBe(true);
46+
expect(isLikelyHallucination('是是是是是是是是是是')).toBe(true);
47+
expect(isLikelyHallucination('嗯嗯,嗯嗯嗯。')).toBe(true);
48+
});
49+
50+
it('过滤静音段短句脏话幻觉', () => {
51+
expect(isLikelyHallucination('我操你妈的。')).toBe(true);
52+
});
53+
54+
it('保留正常教学语音转写', () => {
55+
expect(isLikelyHallucination('打鼾的根本原因是气道在睡眠中变窄')).toBe(false);
56+
expect(isLikelyHallucination('软腭和咽部肌肉松弛导致气流受阻')).toBe(false);
57+
expect(isLikelyHallucination('嗯,这个知识点我们再讲一遍')).toBe(false);
58+
});
59+
60+
it('长句即使包含敏感词也不误杀(可能是课程内容引述)', () => {
61+
expect(
62+
isLikelyHallucination('有些人骂人时会说畜生,这在语言学上属于詈语范畴,本节课我们分析其构词'),
63+
).toBe(false);
64+
});
65+
});
Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,55 @@
1+
/**
2+
* ASR 前置静音门控与幻觉过滤
3+
*
4+
* @ai-context: Path A(精细采集)音频链无 VAD,固定切片直送 ASR 会让
5+
* 静音/背景噪声段触发 ASR 幻觉(重复语气词"嗯嗯嗯"、纯标点、短句脏话)。
6+
* 本模块提供两道防线:送 ASR 前的 RMS 静音门控 + ASR 返回后的幻觉文本过滤。
7+
* RMS 阈值与 vadMarker 的 loopback 预设阈值保持一致(0.008),修改需同步。
8+
*/
9+
10+
/** RMS 静音阈值(Float32 PCM,与 vadMarker loopback 预设一致) */
11+
export const SILENCE_RMS_THRESHOLD = 0.008;
12+
13+
/** 计算 Float32 PCM 块的 RMS 能量 */
14+
export function computeRms(buffer: ArrayBuffer): number {
15+
const samples = new Float32Array(buffer);
16+
if (samples.length === 0) return 0;
17+
let sum = 0;
18+
for (let i = 0; i < samples.length; i++) {
19+
sum += samples[i] * samples[i];
20+
}
21+
return Math.sqrt(sum / samples.length);
22+
}
23+
24+
/** 判断音频块是否为静音(低于阈值不值得送 ASR,直接跳过) */
25+
export function isSilentChunk(
26+
buffer: ArrayBuffer,
27+
threshold: number = SILENCE_RMS_THRESHOLD,
28+
): boolean {
29+
return computeRms(buffer) < threshold;
30+
}
31+
32+
/** 纯标点/空白检测 */
33+
const PUNCT_ONLY_RE = /^[\s。,、..,!??!…~~·\-—]*$/;
34+
35+
/** 短句脏话模式:静音/噪声段 ASR 幻觉的高频形态,正常教学语音几乎不会独立出现 */
36+
const PROFANITY_RE = /操你|草泥马|傻逼|妈的|畜生/;
37+
38+
/**
39+
* 判断 ASR 输出是否为幻觉文本(保守规则,宁放过不误杀):
40+
* 1. 纯标点/空白(如"。")
41+
* 2. 重复字符灌水:去标点后 unique 字符 ≤ 2 且长度 ≥ 4("嗯嗯嗯嗯""是是是是")
42+
* 3. 短句脏话(≤ 20 字符且命中模式)
43+
*/
44+
export function isLikelyHallucination(text: string): boolean {
45+
const trimmed = text.trim();
46+
if (!trimmed) return true;
47+
if (PUNCT_ONLY_RE.test(trimmed)) return true;
48+
49+
const compact = trimmed.replace(/[\s。,、..,!??!…~~]/g, '');
50+
if (compact.length >= 4 && new Set(compact).size <= 2) return true;
51+
52+
if (trimmed.length <= 20 && PROFANITY_RE.test(trimmed)) return true;
53+
54+
return false;
55+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
# 知识卡片 · 踩坑记录
2+
3+
## 基本信息
4+
5+
| 字段 | 内容 |
6+
|------|------|
7+
| 标题 | 课堂助手精细采集三症状:视觉抓取页面元数据、ASR 静音幻觉、截断 JSON 泄漏 UI |
8+
| 日期 | 2026-07-31 |
9+
| 类型 | 踩坑记录 |
10+
| 标签 | #课堂助手 #多模态 #ASR幻觉 #VAD #prompt工程 #JSON解析 |
11+
12+
---
13+
14+
## 症状
15+
16+
内测用户在课堂助手(回声定位)使用**精细采集**(Path A)看 B 站网课时,时间线出现三类异常:
17+
18+
- **A 视觉轨**:反复输出同一段浏览器页面元数据(视频标题/播放量/日期/URL/导航栏文字),而非教学内容
19+
- **B 音频轨**:大量"嗯嗯嗯嗯""。""是是是"及短句脏话("我操你妈的。")——用户观感极差("我要哭了,还会骂人")
20+
- **C**:一条视觉条目直接显示原始 JSON 片段(`"keyPoints": [...], "codeBlocks": []...}` ``` )
21+
22+
## 根因(三个独立缺陷,同场景集中暴露)
23+
24+
| 症状 | 根因 | 位置 |
25+
|---|---|---|
26+
| A | vision prompt 只说"提取所有可见的学习内容",未指示忽略浏览器 chrome/页面元数据;整窗口截图里标题栏/侧栏被模型忠实 OCR | `server/ai-gateway/chains/vision_extract_chain.py` VISION_MODE_PROMPTS |
27+
| B | Path A 音频链**无 VAD**:固定切片全部直送 ASR,静音/背景音乐段触发 ASR 模型典型幻觉(重复语气词、脏话短句是 Qwen/GLM-ASR 在静音段的高频幻觉形态);且输出端无幻觉过滤 | `client/src/lib/ai/asrWorker.ts`(对比:Path B smart 模式有 vadMarker,Path A 没有) |
28+
| C | 模型输出被 max_tokens(2048)截断 → 残缺 JSON 三段解析全失败 → 兜底分支把**原始 content 整段**当 text 返回 → 前端原样渲染 | `vision_extract_chain.py` `_parse_response` 兜底分支 |
29+
30+
## 解决方案
31+
32+
1. **A**:auto/full 两个 prompt 增加硬性指令——忽略浏览器/网站 UI 元数据,只提取教学画面内容,无教学内容时 text 返回空串
33+
2. **B**:新增 `client/src/lib/capture/asrFilters.ts` 双防线——送 ASR 前 RMS 静音门控(阈值 0.008 与 vadMarker loopback 预设一致)+ ASR 返回后幻觉过滤(纯标点/重复字符灌水/短句脏话,宁放过不误杀)
34+
3. **C**:`_parse_response` 兜底分支区分形态——形似 JSON(含 `"text":`、fence、`{` 开头)则正则抢救 `text` 字段值(还原转义),抢救不到返回空文本;纯文本形态才原样透传
35+
36+
验证:新增 vitest 10 例 + pytest 6 例全过(用例直接取自内测截图的真实幻觉文本);client 455 tests / gateway 164 tests 全绿。
37+
38+
## 教训
39+
40+
- **管线成对能力要对齐**:Path B 有 VAD、Path A 没有——同一功能的并行实现路径,防护能力不一致时薄弱路径必然先炸。新增采集路径时应有"能力清单"对照(VAD/去重/降级/过滤)。
41+
- **ASR 静音幻觉是已知模型行为不是玄学**:静音/音乐段送 ASR,输出重复语气词甚至脏话是大模型 ASR 的公开特性,任何 ASR 集成都必须有静音门控 + 输出过滤两道防线。
42+
- **LLM 结构化输出的兜底分支同样要"结构化"**:`解析失败→返回原文`看似安全,实际把内部协议(JSON)泄漏给了用户;兜底必须考虑"截断的半个 JSON"这一最常见失败形态。
43+
- **prompt 的"提取所有内容"在真实屏幕上是错的**:用户屏幕永远比教学内容多(浏览器 UI、弹幕、推荐位),提取类 prompt 必须显式声明忽略清单。
44+
45+
## 参考
46+
47+
- 修复文件:`server/ai-gateway/chains/vision_extract_chain.py`、`client/src/lib/capture/asrFilters.ts`(新增)、`client/src/lib/ai/asrWorker.ts`
48+
- 回归测试:`client/src/lib/capture/asrFilters.test.ts`、`server/ai-gateway/tests/test_vision_chain.py`
49+
- 后续跟进(未在本次范围):smart 路径的流式 ASR 输出可复用 `isLikelyHallucination`;`fine` 路径可考虑接入窗口区域裁剪只截视频区

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

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,8 @@
3636
' "concepts": ["概念1", "概念2"]\n'
3737
"}\n\n"
3838
"注意:\n"
39+
"- 忽略一切软件界面元素:浏览器标签页/地址栏/导航栏、视频网站的标题/播放量/发布日期/作者信息/推荐列表/弹幕评论等页面元数据\n"
40+
"- 只提取教学画面本身的内容(板书/PPT/字幕/讲解要点);若画面中没有教学内容,text 返回空字符串\n"
3941
"- 如果没有看到公式或图表,对应数组返回空 []\n"
4042
"- 数学公式使用 LaTeX 格式\n"
4143
"- 代码块保留原始格式\n"
@@ -114,7 +116,9 @@
114116
' "concepts": ["识别到的所有概念和术语"]\n'
115117
"}\n\n"
116118
"注意:\n"
117-
"- 提取所有文字内容,包括标题、正文、注释\n"
119+
"- 忽略一切软件界面元素:浏览器标签页/地址栏/导航栏、视频网站的标题/播放量/发布日期/作者信息/推荐列表/弹幕评论等页面元数据\n"
120+
"- 只提取教学画面本身的内容;若画面中没有教学内容,text 返回空字符串\n"
121+
"- 提取所有教学文字内容,包括标题、正文、注释\n"
118122
"- 识别所有数学公式并使用 LaTeX 格式\n"
119123
"- 详细描述所有图表和可视化内容\n"
120124
"- 提取所有代码块并标注语言\n"
@@ -189,8 +193,34 @@ def _parse_response(self, content: str) -> dict[str, Any]:
189193
except json.JSONDecodeError:
190194
pass
191195

192-
# 解析失败,返回默认结构
193-
logger.warning("视觉提取结果 JSON 解析失败,返回原始文本")
196+
# 解析失败。区分两类情况:
197+
# - 输出形似 JSON 但残缺(常见于 max_tokens 截断)→ 抢救 text 字段,
198+
# 绝不把原始 JSON 片段泄漏到 UI 时间线
199+
# - 输出本就是纯文本(未按要求返回 JSON)→ 原样作为 text
200+
looks_like_json = bool(re.search(r'"text"\s*:|^\s*```|^\s*\{', content))
201+
if looks_like_json:
202+
salvaged = ""
203+
text_match = re.search(r'"text"\s*:\s*"((?:[^"\\]|\\.)*)"', content, re.DOTALL)
204+
if text_match:
205+
try:
206+
# 经 json.loads 还原转义字符(\n、\" 等)
207+
salvaged = json.loads(f'"{text_match.group(1)}"')
208+
except json.JSONDecodeError:
209+
salvaged = text_match.group(1)
210+
logger.warning(
211+
"视觉提取 JSON 残缺(疑似截断),已抢救 text 字段(%d 字符)", len(salvaged),
212+
)
213+
return {
214+
"text": salvaged,
215+
"formulas": [],
216+
"diagrams": [],
217+
"keyPoints": [],
218+
"codeBlocks": [],
219+
"concepts": [],
220+
}
221+
222+
# 非 JSON 形态:视为模型直接输出了纯文本内容
223+
logger.warning("视觉提取结果非 JSON 格式,返回原始文本")
194224
return {
195225
"text": content,
196226
"formulas": [],
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
"""
2+
VisionExtractChain._parse_response 单元测试
3+
4+
@ai-context: 用例源自内测真实故障——模型输出被 max_tokens 截断产生残缺 JSON,
5+
旧逻辑将原始 JSON 片段直接返回为 text 并泄漏到 UI 时间线。
6+
"""
7+
8+
import pytest
9+
10+
from chains.vision_extract_chain import VisionExtractChain
11+
12+
13+
@pytest.fixture
14+
def chain() -> VisionExtractChain:
15+
# _parse_response 不触碰 provider,传 None 即可
16+
return VisionExtractChain(provider=None) # type: ignore[arg-type]
17+
18+
19+
class TestParseResponse:
20+
def test_valid_json(self, chain):
21+
content = '{"text": "牛顿第二定律", "formulas": ["$F=ma$"], "diagrams": [], "keyPoints": [], "codeBlocks": [], "concepts": []}'
22+
result = chain._parse_response(content)
23+
assert result["text"] == "牛顿第二定律"
24+
assert result["formulas"] == ["$F=ma$"]
25+
26+
def test_fenced_json(self, chain):
27+
content = '```json\n{"text": "板书内容", "formulas": [], "diagrams": [], "keyPoints": [], "codeBlocks": [], "concepts": []}\n```'
28+
result = chain._parse_response(content)
29+
assert result["text"] == "板书内容"
30+
31+
def test_truncated_json_salvages_text_field(self, chain):
32+
"""截断残缺 JSON:抢救 text 字段值,不泄漏 JSON 语法到 UI"""
33+
content = '{"text": "打鼾的危害讲解", "formulas": [], "keyPoints": ["打鼾虽正常'
34+
result = chain._parse_response(content)
35+
assert result["text"] == "打鼾的危害讲解"
36+
assert '"keyPoints"' not in result["text"]
37+
38+
def test_truncated_json_without_text_field_returns_empty(self, chain):
39+
"""残缺 JSON 连 text 字段都不完整时返回空文本,而非泄漏原文"""
40+
content = '```json\n{"keyPoints": ["要点1", "要'
41+
result = chain._parse_response(content)
42+
assert result["text"] == ""
43+
44+
def test_plain_text_passthrough(self, chain):
45+
"""非 JSON 形态的纯文本输出原样保留"""
46+
content = "这是一段普通的板书文字描述"
47+
result = chain._parse_response(content)
48+
assert result["text"] == content
49+
50+
def test_salvaged_text_unescapes(self, chain):
51+
"""抢救的 text 字段应还原转义字符"""
52+
content = '{"text": "第一行\\n第二行", "formulas": ['
53+
result = chain._parse_response(content)
54+
assert result["text"] == "第一行\n第二行"

0 commit comments

Comments
 (0)