diff --git a/openless-all/app/src-tauri/src/asr/whisper.rs b/openless-all/app/src-tauri/src/asr/whisper.rs index 465657a0..13c8264e 100644 --- a/openless-all/app/src-tauri/src/asr/whisper.rs +++ b/openless-all/app/src-tauri/src/asr/whisper.rs @@ -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, buffer: Mutex>, } 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, + ) -> 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 { + 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 = (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 = (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")); + } +} diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index fd85a4cf..c25f3c68 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -2094,7 +2094,21 @@ async fn begin_session(inner: &Arc) -> 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),