fix(asr): 过滤 GLM-ASR 偶发的纯井号占位转写(#787) (#916) (#920)

Co-authored-by: Chris233 <h-chris233@outlook.com>
This commit is contained in:
TRIPandChris233 authored and GitHub committed 2026-08-05 15:32:30 +08:00
1 parent b83b566cb4
commit d31bf5bbb7
1 file changed
+103 -2
+103 -2
View File
@@ -293,7 +293,15 @@ impl WhisperBatchASR {
// segments が無い応答では内部で従来どおり text にフォールバック。
Ok(extract_confident_text(&json))
} else {
Ok(json["text"].as_str().unwrap_or("").trim().to_string())
// GLM-ASR 等厂商在静音/弱音片段偶发返回仅含 `#`(或 `##`…)的占位
// 文本(issue #787)。归一化为空转写,走既有空转写护栏,避免把占位符
// 当有效内容插入用户输入。
let text = json["text"].as_str().unwrap_or("").trim();
if is_placeholder_heading(text) {
Ok(String::new())
} else {
Ok(text.to_string())
}
}
}
@@ -325,7 +333,11 @@ impl crate::recorder::AudioConsumer for WhisperBatchASR {
/// 「不丢弃」处理(unwrap_or 默认值),所以对不返回这些指标的 provider 是无害空转。
fn extract_confident_text(json: &serde_json::Value) -> String {
let Some(segments) = json.get("segments").and_then(|s| s.as_array()) else {
return json["text"].as_str().unwrap_or("").trim().to_string();
let text = json["text"].as_str().unwrap_or("").trim();
if is_placeholder_heading(text) {
return String::new();
}
return text.to_string();
};
let mut kept = String::new();
@@ -371,6 +383,17 @@ fn extract_confident_text(json: &serde_json::Value) -> String {
kept
}
/// 判定转写结果是否为「纯井号占位文本」。
///
/// GLM-ASR(zhipu)在静音 / 弱音片段偶发把整段识别为单个 `#`(或 `##`、`###`…),
/// 属于模型退化的占位输出,不是任何真实语音转写(issue #787)。判定只命中「整段
/// 仅由 1 个或多个 `#` 组成」的情况:`C#`、`# 你好` 等含其它字符的真实内容不受
/// 影响。输入先 `trim()`,空白与两侧空格不影响判定。
fn is_placeholder_heading(text: &str) -> bool {
let trimmed = text.trim();
!trimmed.is_empty() && trimmed.chars().all(|c| c == '#')
}
fn pcm_duration_ms(pcm: &[u8]) -> u64 {
super::pcm::pcm_duration_ms(pcm)
}
@@ -815,6 +838,84 @@ mod tests {
assert_eq!(extract_confident_text(&json), "保留される");
}
#[test]
fn placeholder_heading_detects_pure_hash_runs_only() {
// issue #787:GLM-ASR 偶发返回仅含 `#` 的占位文本。
assert!(is_placeholder_heading("#"));
assert!(is_placeholder_heading("##"));
assert!(is_placeholder_heading("###"));
assert!(is_placeholder_heading(" ## "));
// 含其它字符的真实内容不受影响。
assert!(!is_placeholder_heading("C#"));
assert!(!is_placeholder_heading("# 你好"));
assert!(!is_placeholder_heading("#hash"));
// 空 / 空白输入不命中。
assert!(!is_placeholder_heading(""));
assert!(!is_placeholder_heading(" "));
}
#[tokio::test]
async fn single_placeholder_chunk_transcribes_to_empty() {
// 单分片响应为 `#` → 归一化为空转写。
for text in ["#", "##", "###"] {
let (base_url, server) = start_whisper_test_server(vec![text]);
let asr = WhisperBatchASR::new(
"key".to_string(),
base_url,
"model".to_string(),
None,
None,
false,
);
let pcm = vec![0u8; 32_000 * 2];
asr.consume_pcm_chunk(&pcm);
let transcript = asr.transcribe().await.unwrap();
assert_eq!(transcript.text, "");
server.join().unwrap();
}
}
#[tokio::test]
async fn placeholder_chunk_is_dropped_when_joining() {
// 分片 ["你好", "#", "世界"] → 占位分片被丢弃,正常分片保留。
let (base_url, server) = start_whisper_test_server(vec!["你好", "#", "世界"]);
let asr = WhisperBatchASR::new(
"key".to_string(),
base_url,
"model".to_string(),
None,
Some(30_000),
false,
);
let pcm = vec![0u8; 32_000 * 65];
asr.consume_pcm_chunk(&pcm);
let transcript = asr.transcribe().await.unwrap();
assert_eq!(transcript.text, "你好世界");
server.join().unwrap();
}
#[tokio::test]
async fn all_placeholder_chunks_transcribe_to_empty() {
// 全部分片均为 `#` → 整体转写为空(走 coordinator 空转写护栏)。
let (base_url, server) = start_whisper_test_server(vec!["#", "##", "###"]);
let asr = WhisperBatchASR::new(
"key".to_string(),
base_url,
"model".to_string(),
None,
Some(30_000),
false,
);
let pcm = vec![0u8; 32_000 * 65];
asr.consume_pcm_chunk(&pcm);
let transcript = asr.transcribe().await.unwrap();
assert_eq!(transcript.text, "");
server.join().unwrap();
}
#[tokio::test]
async fn transcribe_posts_single_request_without_chunk_limit() {
let (base_url, server) = start_whisper_test_server(vec!["one"]);