Merge pull request #375 from katanumahotori/feat/whisper-prompt-vocabulary

feat(asr): forward dictionary entries to Whisper prompt
This commit is contained in:
Chris233 authored and GitHub committed 2026-05-09 12:06:15 +08:00
commit 9db6de34dc
2 files changed
+196 -3

No files matched your search

+180 -2
View File
@@ -7,19 +7,39 @@ use parking_lot::Mutex;
use crate::asr::wav::encode_wav_16k_mono;
use crate::asr::RawTranscript;
/// Whisper の `prompt` パラメータの安全側上限(文字数)。
///
/// OpenAI / Groq の Audio Transcriptions API は `prompt` を 244 トークンまで
/// 受け付ける。トークナイザは BPE で言語によって 1 token あたりの文字数が
/// 異なる:英語は ~4 chars/token、日本語・中国語は最悪 ~1 char/token。
/// CJK ユーザーが安全に収まるよう、文字数で 240 を上限にする。
pub const PROMPT_CHAR_BUDGET: usize = 240;
/// 区切り文字(ASCII)。Whisper のトークナイザはどの言語でも安定して扱える。
const PROMPT_SEPARATOR: &str = ", ";
pub struct WhisperBatchASR {
api_key: String,
base_url: String,
model: String,
/// 任意のプロンプト(語彙ヒント等)。空文字や空白のみは送信しない。
/// `None` = プロンプト無し(既存挙動)。
prompt: Option<String>,
buffer: Mutex<Vec<u8>>,
}
impl WhisperBatchASR {
pub fn new(api_key: String, base_url: String, model: String) -> Self {
pub fn new(
api_key: String,
base_url: String,
model: String,
prompt: Option<String>,
) -> Self {
Self {
api_key,
base_url,
model,
prompt,
buffer: Mutex::new(Vec::new()),
}
}
@@ -69,10 +89,20 @@ impl WhisperBatchASR {
.file_name("audio.wav")
.mime_str("audio/wav")
.context("set MIME type")?;
let form = reqwest::multipart::Form::new()
let mut form = reqwest::multipart::Form::new()
.part("file", wav_part)
.text("model", self.model.clone());
// `prompt` は空文字を送らない:OpenAI 互換実装によっては空文字でエラーに
// なるリスクがある(Groq は許容するが防御的にスキップ)。`trim()` で
// 空白のみのケースも除外。
if let Some(prompt) = self.prompt.as_ref() {
let trimmed = prompt.trim();
if !trimmed.is_empty() {
form = form.text("prompt", trimmed.to_string());
}
}
let client = reqwest::Client::new();
let resp = client
.post(&url)
@@ -104,3 +134,151 @@ impl crate::recorder::AudioConsumer for WhisperBatchASR {
self.buffer.lock().extend_from_slice(pcm);
}
}
/// 用户辞書の有効フレーズから Whisper の `prompt` パラメータを組み立てる。
///
/// Whisper は `prompt` で語彙ヒント / スタイル文脈を渡せる:固有名詞・専門
/// 用語の表記揺れを抑え、ASR 段階で正しい綴り(漢字選択を含む)に偏らせる。
/// 既存の dictionary 機能はこれまで Volcengine ASR と Polish LLM のみに渡って
/// いて、Whisper 互換プロバイダ(whisper / siliconflow / zhipu / groq)には
/// 流れていなかった。本関数で同じエントリを Whisper にも届ける。
///
/// # 仕様
///
/// - 空白のみのフレーズは除外
/// - 区切りは `, `
/// - 末尾に `.` を付与して「文の終わり」を Whisper に明示(モデルがプロンプト
/// を続きと誤解して書き起こし冒頭に混入するのを抑える)
/// - 文字数が `PROMPT_CHAR_BUDGET` を超えるエントリは**スキップ**して次に
/// 進む(途中で打ち切らない)。これにより「先頭に長文 1 件があると残りが
/// 全部捨てられる」現象を回避でき、登録順を保ちつつ収まるエントリを最大化
/// できる。
/// - 入力が空、または有効フレーズが 0 件の場合は `None` を返す。Optional に
/// することで「プロンプト無し」と「空文字プロンプト」を呼び出し側で区別
/// する必要をなくす。
pub fn build_prompt_from_phrases(phrases: &[String]) -> Option<String> {
let mut included: Vec<&str> = Vec::new();
let mut total_chars: usize = 0;
for phrase in phrases {
let trimmed = phrase.trim();
if trimmed.is_empty() {
continue;
}
let phrase_chars = trimmed.chars().count();
let added = if included.is_empty() {
phrase_chars
} else {
PROMPT_SEPARATOR.chars().count() + phrase_chars
};
// 末尾の "." 1 文字も予約。
if total_chars + added + 1 > PROMPT_CHAR_BUDGET {
continue;
}
included.push(trimmed);
total_chars += added;
}
if included.is_empty() {
return None;
}
let mut s = included.join(PROMPT_SEPARATOR);
s.push('.');
Some(s)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn build_prompt_returns_none_for_empty_input() {
assert_eq!(build_prompt_from_phrases(&[]), None);
}
#[test]
fn build_prompt_returns_none_when_all_phrases_blank() {
let phrases = vec!["".to_string(), " ".to_string(), "\t\n".to_string()];
assert_eq!(build_prompt_from_phrases(&phrases), None);
}
#[test]
fn build_prompt_single_phrase() {
let phrases = vec!["梁山泊".to_string()];
assert_eq!(build_prompt_from_phrases(&phrases), Some("梁山泊.".to_string()));
}
#[test]
fn build_prompt_joins_with_comma_and_appends_period() {
let phrases = vec![
"梁山泊".to_string(),
"片沼ほとり".to_string(),
"TRC".to_string(),
];
assert_eq!(
build_prompt_from_phrases(&phrases),
Some("梁山泊, 片沼ほとり, TRC.".to_string())
);
}
#[test]
fn build_prompt_trims_each_phrase() {
let phrases = vec![" 梁山泊 ".to_string(), "\tTRC\n".to_string()];
assert_eq!(
build_prompt_from_phrases(&phrases),
Some("梁山泊, TRC.".to_string())
);
}
#[test]
fn build_prompt_skips_blank_entries_in_middle() {
let phrases = vec![
"alpha".to_string(),
"".to_string(),
" ".to_string(),
"beta".to_string(),
];
assert_eq!(
build_prompt_from_phrases(&phrases),
Some("alpha, beta.".to_string())
);
}
#[test]
fn build_prompt_truncates_overflow_but_keeps_short_entries_after_long_one() {
// 先頭に 250 文字の長文 → 単独で予算超過 → スキップ。続く短いエントリは
// 採用される。「途中で break しない」契約の検証。
let long = "あ".repeat(250);
let phrases = vec![long.clone(), "梁山泊".to_string(), "TRC".to_string()];
let prompt = build_prompt_from_phrases(&phrases).expect("non-empty");
assert!(!prompt.contains(&long), "long phrase must be dropped");
assert!(prompt.contains("梁山泊"));
assert!(prompt.contains("TRC"));
assert!(prompt.ends_with('.'));
}
#[test]
fn build_prompt_respects_char_budget() {
// 6 文字 × 50 件 = 300 文字(区切り込みでさらに増える)→ 予算超過分は捨てる。
let phrases: Vec<String> = (0..50).map(|i| format!("word{:02}", i)).collect();
let prompt = build_prompt_from_phrases(&phrases).expect("non-empty");
assert!(
prompt.chars().count() <= PROMPT_CHAR_BUDGET,
"prompt length {} exceeds budget {}",
prompt.chars().count(),
PROMPT_CHAR_BUDGET
);
assert!(prompt.ends_with('.'));
}
#[test]
fn build_prompt_includes_first_entries_when_truncating_in_order() {
// 順序保証:登録順の早いものから入る。後続が落ちる。
let phrases: Vec<String> = (0..100).map(|i| format!("entry{:03}", i)).collect();
let prompt = build_prompt_from_phrases(&phrases).expect("non-empty");
assert!(prompt.contains("entry000"));
assert!(prompt.contains("entry001"));
// 100 件 × 8 文字以上は確実に予算超過 → 末尾は入らない
assert!(!prompt.contains("entry099"));
}
}
+16 -1
View File
@@ -2094,7 +2094,21 @@ async fn begin_session(inner: &Arc<Inner>) -> Result<(), String> {
if is_whisper_compatible_provider(&active_asr) {
let (api_key, base_url, model) = read_whisper_credentials();
let whisper = Arc::new(WhisperBatchASR::new(api_key, base_url, model));
// 用户辞書の有効フレーズを Whisper の `prompt` に流し込む。固有名詞や
// 専門用語の同音・近形誤認識を ASR 段階で抑える。Polish LLM 側には
// 既に system prompt として注入済みだが、Whisper 出力が大きく崩れる
// と Polish でも救えない(特に CJK で顕著)。Volcengine ASR は元々
// hotword を受け取っており、UI 説明文も「ASR ホットワードと後処理
// モデルのコンテキスト両方に渡される」と明示しているので、Whisper
// 互換プロバイダにも揃えるのが筋。
let whisper_prompt =
crate::asr::whisper::build_prompt_from_phrases(&enabled_phrases(inner));
let whisper = Arc::new(WhisperBatchASR::new(
api_key,
base_url,
model,
whisper_prompt,
));
store_asr_for_session(
inner,
current_session_id,
@@ -4652,6 +4666,7 @@ mod tests {
"key".to_string(),
"http://localhost".to_string(),
"model".to_string(),
None,
));
*coordinator.inner.asr.lock() = Some(SessionResource::new(
session_id(2),