Merge pull request #375 from katanumahotori/feat/whisper-prompt-vocabulary
feat(asr): forward dictionary entries to Whisper prompt
This commit is contained in:
2 files changed
+196
-3
No files matched your search
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user