Let users repair recurring ASR classifier mistakes

Issue #372 reports repeated classifier errors such as 2粒 being intended as 2例. The existing vocabulary hotword path is probabilistic, so this adds a separate deterministic correction-rules store and applies supported literal/{num} rules in the coordinator before polish and before insertion.

The review follow-up keeps the UI refresh path consistent, handles remove failures, rejects rules that would otherwise save as enabled but never run, and fixes the Traditional Chinese wording.

Constraint: Keep correction rules separate from dictionary.json and vocab presets.

Constraint: Only support literal replacement and one {num} wildcard; do not expose arbitrary regex.

Rejected: Add import/export or a new top-level page | outside the approved scheme-C scope.

Rejected: Change history rawTranscript schema | product/data-model decision not approved in this review round.

Confidence: high

Scope-risk: moderate

Tested: cargo test --manifest-path openless-all/app/src-tauri/Cargo.toml --lib correction

Tested: cargo test --manifest-path openless-all/app/src-tauri/Cargo.toml --lib correction_rule_syntax_rejects_silent_noops

Tested: cargo check --manifest-path openless-all/app/src-tauri/Cargo.toml

Tested: cd openless-all/app && npm run build

Tested: git diff --check

Related: #372

Co-authored-by: OmX <omx@oh-my-codex.dev>
This commit is contained in:
H-Chris233andOmX committed 2026-05-10 10:59:07 +08:00
1 parent 225859e18d
commit 95237d2c49
15 files changed
+721 -29

No files matched your search

+71 -15
View File
@@ -19,9 +19,9 @@ use crate::persistence::{CredentialAccount, CredentialsSnapshot, CredentialsVaul
use crate::polish::{LLMError, OpenAICompatibleConfig, OpenAICompatibleLLMProvider};
use crate::recorder::{AudioConsumer, Recorder};
use crate::types::{
ChineseScriptPreference, ComboBinding, CredentialsStatus, DictationSession, DictionaryEntry,
HotkeyCapability, HotkeyStatus, OutputLanguagePreference, PolishMode, ShortcutBinding,
UpdateChannel, UserPreferences, VocabPresetStore, WindowsImeStatus,
ChineseScriptPreference, ComboBinding, CorrectionRule, CredentialsStatus, DictationSession,
DictionaryEntry, HotkeyCapability, HotkeyStatus, OutputLanguagePreference, PolishMode,
ShortcutBinding, UpdateChannel, UserPreferences, VocabPresetStore, WindowsImeStatus,
};
type CoordinatorState<'a> = State<'a, Arc<Coordinator>>;
@@ -160,7 +160,10 @@ pub fn set_settings(
if let Err(err) = crate::refresh_tray_microphone_menu(&app_for_main) {
log::warn!("[tray] refresh microphone menu after settings save failed: {err}");
let tray_state = app_for_main.state::<TrayMicrophoneMenuState>();
sync_tray_microphone_selection(&tray_state.lock(), &prefs_for_main.microphone_device_name);
sync_tray_microphone_selection(
&tray_state.lock(),
&prefs_for_main.microphone_device_name,
);
}
});
// 抑制 unused 警告:tray_microphones 现在改在闭包里通过 app.state 取,
@@ -246,7 +249,10 @@ pub async fn fetch_latest_beta_release() -> Result<Option<LatestBetaRelease>, St
/// 用 `/releases/tag/` 这个唯一锚点抓 tag。
fn parse_latest_beta_from_atom(body: &str) -> Option<LatestBetaRelease> {
for entry in body.split("<entry>").skip(1) {
let entry_body = entry.split_once("</entry>").map(|(b, _)| b).unwrap_or(entry);
let entry_body = entry
.split_once("</entry>")
.map(|(b, _)| b)
.unwrap_or(entry);
let needle = "/releases/tag/";
let tag_start = match entry_body.find(needle) {
Some(i) => i + needle.len(),
@@ -260,11 +266,9 @@ fn parse_latest_beta_from_atom(body: &str) -> Option<LatestBetaRelease> {
if !tag_name.ends_with("-beta-tauri") {
continue;
}
let html_url = format!(
"https://github.com/appergb/openless/releases/tag/{tag_name}"
);
let published_at = extract_between(entry_body, "<updated>", "</updated>")
.unwrap_or_default();
let html_url = format!("https://github.com/appergb/openless/releases/tag/{tag_name}");
let published_at =
extract_between(entry_body, "<updated>", "</updated>").unwrap_or_default();
return Some(LatestBetaRelease {
tag_name,
html_url,
@@ -926,6 +930,43 @@ pub fn set_vocab_enabled(
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn list_correction_rules(coord: CoordinatorState<'_>) -> Result<Vec<CorrectionRule>, String> {
coord.correction_rules().list().map_err(|e| e.to_string())
}
#[tauri::command]
pub fn add_correction_rule(
coord: CoordinatorState<'_>,
pattern: String,
replacement: String,
) -> Result<CorrectionRule, String> {
coord
.correction_rules()
.add(pattern, replacement)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn remove_correction_rule(coord: CoordinatorState<'_>, id: String) -> Result<(), String> {
coord
.correction_rules()
.remove(&id)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn set_correction_rule_enabled(
coord: CoordinatorState<'_>,
id: String,
enabled: bool,
) -> Result<(), String> {
coord
.correction_rules()
.set_enabled(&id, enabled)
.map_err(|e| e.to_string())
}
#[tauri::command]
pub fn list_vocab_presets() -> Result<VocabPresetStore, String> {
crate::persistence::list_vocab_presets().map_err(|e| e.to_string())
@@ -991,7 +1032,10 @@ pub fn set_default_polish_mode(
) -> Result<(), String> {
let mut prefs = coord.prefs().get();
prefs.default_mode = mode;
coord.prefs().set(prefs.clone()).map_err(|e| e.to_string())?;
coord
.prefs()
.set(prefs.clone())
.map_err(|e| e.to_string())?;
// 跟 set_settings 同样:refresh_tray_microphone_menu 里 tray.set_menu 改 NSStatusItem,
// 必须主线程;这里是同步 Tauri command 跑在 IPC 线程,直调会让 macOS 死锁。
let app_for_main = app.clone();
@@ -1975,14 +2019,20 @@ mod tests {
};
assert!(llm_configured_for_provider("custom", &keyless_ready));
assert!(llm_configured_for_provider("self-hosted", &keyless_ready));
assert!(llm_configured_for_provider("openrouterFree", &keyless_ready));
assert!(llm_configured_for_provider(
"openrouterFree",
&keyless_ready
));
let hosted_keyless = CredentialsSnapshot {
ark_endpoint: Some("https://openrouter.ai/api/v1".into()),
ark_model_id: Some("qwen/qwen3-coder:free".into()),
..snapshot()
};
assert!(!llm_configured_for_provider("openrouterFree", &hosted_keyless));
assert!(!llm_configured_for_provider(
"openrouterFree",
&hosted_keyless
));
let hosted_ready = CredentialsSnapshot {
ark_api_key: Some("key".into()),
@@ -1997,13 +2047,19 @@ mod tests {
ark_model_id: Some("qwen".into()),
..snapshot()
};
assert!(!llm_configured_for_provider("custom", &key_without_endpoint));
assert!(!llm_configured_for_provider(
"custom",
&key_without_endpoint
));
let endpoint_without_model = CredentialsSnapshot {
ark_endpoint: Some("http://localhost:11434/v1".into()),
..snapshot()
};
assert!(!llm_configured_for_provider("custom", &endpoint_without_model));
assert!(!llm_configured_for_provider(
"custom",
&endpoint_without_model
));
}
impl SettingsWriter for FakeSettingsWriter {
+12 -3
View File
@@ -32,20 +32,21 @@ use crate::coordinator_state::{
use crate::hotkey::{HotkeyEvent, HotkeyMonitor};
use crate::insertion::TextInserter;
use crate::persistence::{
CredentialAccount, CredentialsVault, DictionaryStore, HistoryStore, PreferencesStore,
CorrectionRuleStore, CredentialAccount, CredentialsVault, DictionaryStore, HistoryStore,
PreferencesStore,
};
use crate::polish::{OpenAICompatibleConfig, OpenAICompatibleLLMProvider};
use crate::qa_hotkey::{QaHotkeyError, QaHotkeyEvent, QaHotkeyMonitor};
use crate::recorder::{Recorder, RecorderError};
use crate::selection::capture_selection;
#[cfg(target_os = "windows")]
use crate::types::PasteShortcut;
use crate::types::{
CapsulePayload, CapsuleState, ChineseScriptPreference, DictationSession, HotkeyCapability,
HotkeyStatus, HotkeyStatusState, InsertStatus, OutputLanguagePreference, PolishMode,
};
#[cfg(target_os = "windows")]
use crate::types::PasteShortcut;
#[cfg(target_os = "windows")]
use crate::windows_ime_ipc::ImeSubmitTarget;
#[cfg(target_os = "windows")]
use crate::windows_ime_session::{PreparedWindowsImeSession, WindowsImeSessionController};
@@ -96,6 +97,7 @@ struct Inner {
history: HistoryStore,
prefs: PreferencesStore,
vocab: DictionaryStore,
correction_rules: CorrectionRuleStore,
inserter: TextInserter,
#[cfg(target_os = "windows")]
windows_ime: WindowsImeSessionController,
@@ -176,6 +178,7 @@ impl Coordinator {
});
let prefs = PreferencesStore::new().expect("preferences store init");
let vocab = DictionaryStore::new().expect("dictionary store init");
let correction_rules = CorrectionRuleStore::new().expect("correction rule store init");
Self {
inner: Arc::new(Inner {
@@ -183,6 +186,7 @@ impl Coordinator {
history,
prefs,
vocab,
correction_rules,
inserter: TextInserter::new(),
state: Mutex::new(SessionState::default()),
asr: Mutex::new(None),
@@ -218,6 +222,7 @@ impl Coordinator {
});
let prefs = PreferencesStore::new().expect("preferences store init");
let vocab = DictionaryStore::new().expect("dictionary store init");
let correction_rules = CorrectionRuleStore::new().expect("correction rule store init");
Self {
inner: Arc::new(Inner {
@@ -225,6 +230,7 @@ impl Coordinator {
history,
prefs,
vocab,
correction_rules,
inserter: TextInserter::new(),
windows_ime: WindowsImeSessionController::new(),
prepared_windows_ime_session: Arc::new(Mutex::new(Vec::new())),
@@ -642,6 +648,9 @@ impl Coordinator {
pub fn vocab(&self) -> &DictionaryStore {
&self.inner.vocab
}
pub fn correction_rules(&self) -> &CorrectionRuleStore {
&self.inner.correction_rules
}
pub fn update_hotkey_binding(&self) {
let prefs = self.inner.prefs.get();
@@ -2,6 +2,7 @@ use std::sync::atomic::Ordering;
use std::sync::Arc;
use crate::coordinator_state::request_stop_during_starting_state;
use crate::correction::apply_correction_rules;
use crate::types::HotkeyMode;
use super::qa::handle_qa_option_edge;
@@ -906,6 +907,25 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
return Err("ASR returned empty transcript".to_string());
}
let correction_rules = match inner.correction_rules.list() {
Ok(rules) => rules,
Err(e) => {
log::warn!("[coord] load correction rules failed: {e}; continue without correction");
Vec::new()
}
};
if !correction_rules.is_empty() {
let corrected = apply_correction_rules(&raw.text, &correction_rules);
if corrected != raw.text {
log::info!(
"[coord] correction rules adjusted raw transcript ({} → {} chars)",
raw.text.chars().count(),
corrected.chars().count()
);
raw.text = corrected;
}
}
emit_capsule(inner, CapsuleState::Polishing, 0.0, elapsed, None, None);
let prefs = inner.prefs.get();
@@ -988,6 +1008,19 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
} else {
polished
};
let polished = if correction_rules.is_empty() {
polished
} else {
let corrected = apply_correction_rules(&polished, &correction_rules);
if corrected != polished {
log::info!(
"[coord] correction rules adjusted final text ({} → {} chars)",
polished.chars().count(),
corrected.chars().count()
);
}
corrected
};
// 原子化最后一次 cancel 检查 + 转 Inserting:
// 在同一 lock 内决定「丢弃」还是「进入 Inserting」。一旦设到 Inserting,
@@ -1035,7 +1068,9 @@ pub(super) async fn end_session(inner: &Arc<Inner>) -> Result<(), String> {
}
#[cfg(not(target_os = "windows"))]
{
inner.inserter.insert(&polished, restore_clipboard, paste_shortcut)
inner
.inserter
.insert(&polished, restore_clipboard, paste_shortcut)
}
} else {
log::warn!(
@@ -0,0 +1,203 @@
//! 用户自定义纠正规则。
//!
//! 规则独立于词汇表:词汇表负责 ASR/LLM 热词提示,纠正规则负责在听写流水线里
//! 做确定性的文本替换。当前只支持一个保守通配符 `{num}`,避免把任意正则暴露给
//! 用户造成误替换。
use crate::types::CorrectionRule;
const NUM_TOKEN: &str = "{num}";
pub fn apply_correction_rules(text: &str, rules: &[CorrectionRule]) -> String {
let mut current = text.to_string();
for rule in rules {
if !rule.enabled {
continue;
}
let pattern = rule.pattern.trim();
if pattern.is_empty() {
continue;
}
current = apply_rule(&current, pattern, &rule.replacement);
}
current
}
fn apply_rule(text: &str, pattern: &str, replacement: &str) -> String {
let token_count = pattern.matches(NUM_TOKEN).count();
if token_count == 0 {
if replacement.contains(NUM_TOKEN) {
return text.to_string();
}
return text.replace(pattern, replacement);
}
if token_count != 1 {
return text.to_string();
}
apply_num_rule(text, pattern, replacement)
}
fn apply_num_rule(text: &str, pattern: &str, replacement: &str) -> String {
let Some((prefix, suffix)) = pattern.split_once(NUM_TOKEN) else {
return text.to_string();
};
if prefix.is_empty() && suffix.is_empty() {
return text.to_string();
}
let mut output = String::with_capacity(text.len());
let mut cursor = 0usize;
while cursor < text.len() {
let Some((match_start, token_start)) = next_prefix_match(text, cursor, prefix) else {
break;
};
let Some(token_end) = consume_number_token(text, token_start) else {
output.push_str(&text[cursor..next_char_boundary(text, match_start)]);
cursor = next_char_boundary(text, match_start);
continue;
};
let after_number = &text[token_end..];
if !after_number.starts_with(suffix) {
output.push_str(&text[cursor..next_char_boundary(text, match_start)]);
cursor = next_char_boundary(text, match_start);
continue;
}
let match_end = token_end + suffix.len();
output.push_str(&text[cursor..match_start]);
output.push_str(&replacement.replace(NUM_TOKEN, &text[token_start..token_end]));
cursor = match_end;
}
output.push_str(&text[cursor..]);
output
}
fn next_prefix_match(text: &str, cursor: usize, prefix: &str) -> Option<(usize, usize)> {
if prefix.is_empty() {
let match_start = next_number_start(text, cursor)?;
return Some((match_start, match_start));
}
let relative = text[cursor..].find(prefix)?;
let match_start = cursor + relative;
Some((match_start, match_start + prefix.len()))
}
fn next_number_start(text: &str, cursor: usize) -> Option<usize> {
text[cursor..]
.char_indices()
.find_map(|(offset, ch)| is_number_char(ch).then_some(cursor + offset))
}
fn consume_number_token(text: &str, start: usize) -> Option<usize> {
let mut end = start;
let mut consumed = false;
for (offset, ch) in text[start..].char_indices() {
if !is_number_char(ch) {
break;
}
consumed = true;
end = start + offset + ch.len_utf8();
}
consumed.then_some(end)
}
fn is_number_char(ch: char) -> bool {
ch.is_ascii_digit()
|| matches!(
ch,
'零' | '〇'
| '一'
| '二'
| '两'
| '兩'
| '三'
| '四'
| '五'
| '六'
| '七'
| '八'
| '九'
| '十'
| '百'
| '千'
| '万'
| '萬'
| '亿'
| '億'
| '几'
| '幾'
)
}
fn next_char_boundary(text: &str, start: usize) -> usize {
text[start..]
.chars()
.next()
.map(|ch| start + ch.len_utf8())
.unwrap_or(text.len())
}
#[cfg(test)]
mod tests {
use super::*;
fn rule(pattern: &str, replacement: &str) -> CorrectionRule {
CorrectionRule {
id: "rule".into(),
pattern: pattern.into(),
replacement: replacement.into(),
enabled: true,
created_at: String::new(),
}
}
#[test]
fn applies_literal_replacement() {
let rules = vec![rule("几粒", "几例")];
assert_eq!(
apply_correction_rules("这里有几粒样品", &rules),
"这里有几例样品"
);
}
#[test]
fn applies_num_wildcard_for_arabic_digits() {
let rules = vec![rule("{num}粒", "{num}例")];
assert_eq!(
apply_correction_rules("2粒样品和10粒对照", &rules),
"2例样品和10例对照"
);
}
#[test]
fn applies_num_wildcard_for_chinese_numbers() {
let rules = vec![rule("{num}粒", "{num}例")];
assert_eq!(
apply_correction_rules("两粒样品和幾粒对照", &rules),
"两例样品和幾例对照"
);
}
#[test]
fn disabled_rules_are_ignored() {
let mut disabled = rule("{num}粒", "{num}例");
disabled.enabled = false;
assert_eq!(apply_correction_rules("10粒样品", &[disabled]), "10粒样品");
}
#[test]
fn malformed_rules_are_inert() {
let rules = vec![
rule("{num}到{num}粒", "{num}例"),
rule("几粒", "{num}例"),
rule("{num}", "{num}例"),
];
assert_eq!(apply_correction_rules("几粒和10粒", &rules), "几粒和10粒");
}
#[test]
fn applies_rules_sequentially() {
let rules = vec![rule("{num}粒", "{num}例"), rule("样本", "样品")];
assert_eq!(apply_correction_rules("10粒样本", &rules), "10例样品");
}
}
+15 -4
View File
@@ -16,6 +16,7 @@ mod combo_hotkey;
mod commands;
mod coordinator;
mod coordinator_state;
mod correction;
mod global_hotkey_runtime;
mod hotkey;
mod insertion;
@@ -157,8 +158,8 @@ pub fn run() {
// 登录都被主窗口打扰。OPENLESS_SHOW_MAIN_ON_START=1 仍保留
// 老的强制 show 路径(手动 dispatch 测试 / dev 用),优先级高
// 于 prefs。
let force_show = std::env::var("OPENLESS_SHOW_MAIN_ON_START").ok().as_deref()
== Some("1");
let force_show =
std::env::var("OPENLESS_SHOW_MAIN_ON_START").ok().as_deref() == Some("1");
let suppress_show = !force_show && coordinator.prefs().get().start_minimized;
if suppress_show {
log::info!("[main] start_minimized=true → 跳过初始 show,等用户点托盘");
@@ -254,6 +255,10 @@ pub fn run() {
commands::add_vocab,
commands::remove_vocab,
commands::set_vocab_enabled,
commands::list_correction_rules,
commands::add_correction_rule,
commands::remove_correction_rule,
commands::set_correction_rule_enabled,
commands::list_vocab_presets,
commands::save_vocab_presets,
commands::start_dictation,
@@ -1197,8 +1202,14 @@ mod tests {
#[test]
fn tray_style_menu_id_parsing_accepts_only_style_items() {
assert_eq!(parse_tray_polish_mode_id("style-raw"), Some(PolishMode::Raw));
assert_eq!(parse_tray_polish_mode_id("style-light"), Some(PolishMode::Light));
assert_eq!(
parse_tray_polish_mode_id("style-raw"),
Some(PolishMode::Raw)
);
assert_eq!(
parse_tray_polish_mode_id("style-light"),
Some(PolishMode::Light)
);
assert_eq!(
parse_tray_polish_mode_id("style-structured"),
Some(PolishMode::Structured)
+117 -2
View File
@@ -21,7 +21,9 @@ use parking_lot::Mutex;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use crate::types::{DictationSession, DictionaryEntry, UserPreferences, VocabPresetStore};
use crate::types::{
CorrectionRule, DictationSession, DictionaryEntry, UserPreferences, VocabPresetStore,
};
const HISTORY_CAP: usize = 200;
const HISTORY_FILE: &str = "history.json";
@@ -29,6 +31,8 @@ const PREFERENCES_FILE: &str = "preferences.json";
/// 与 Swift `Sources/OpenLessPersistence/DictionaryStore.swift` 同名,
/// 让旧版词汇表在升级后无缝继承。**不要**改成 `vocab.json`,会丢用户数据。
const VOCAB_FILE: &str = "dictionary.json";
const CORRECTION_RULES_FILE: &str = "correction-rules.json";
const CORRECTION_NUM_TOKEN: &str = "{num}";
const VOCAB_PRESETS_FILE: &str = "vocab-presets.json";
/// 旧版 plaintext JSON 凭据路径。仅作为迁移来源;成功写入系统凭据库后会删除。
@@ -1041,6 +1045,106 @@ pub fn save_vocab_presets(store: &VocabPresetStore) -> Result<()> {
atomic_write(&path, &json)
}
// ───────────────────────── CorrectionRuleStore ─────────────────────────
pub struct CorrectionRuleStore {
path: PathBuf,
lock: Mutex<()>,
}
impl CorrectionRuleStore {
pub fn new() -> Result<Self> {
let dir = data_dir()?;
ensure_dir(&dir)?;
Ok(Self {
path: dir.join(CORRECTION_RULES_FILE),
lock: Mutex::new(()),
})
}
pub fn list(&self) -> Result<Vec<CorrectionRule>> {
let _guard = self.lock.lock();
self.read_locked()
}
pub fn add(&self, pattern: String, replacement: String) -> Result<CorrectionRule> {
let pattern = pattern.trim().to_string();
let replacement = replacement.trim().to_string();
validate_correction_rule_syntax(&pattern, &replacement)?;
let _guard = self.lock.lock();
let mut rules = self.read_locked()?;
let rule = CorrectionRule {
id: Uuid::new_v4().to_string(),
pattern,
replacement,
enabled: true,
created_at: Utc::now().to_rfc3339(),
};
rules.insert(0, rule.clone());
self.write_locked(&rules)?;
Ok(rule)
}
pub fn remove(&self, id: &str) -> Result<()> {
let _guard = self.lock.lock();
let mut rules = self.read_locked()?;
let before = rules.len();
rules.retain(|r| r.id != id);
if rules.len() == before {
return Ok(());
}
self.write_locked(&rules)
}
pub fn set_enabled(&self, id: &str, enabled: bool) -> Result<()> {
let _guard = self.lock.lock();
let mut rules = self.read_locked()?;
let mut found = false;
for rule in rules.iter_mut() {
if rule.id == id {
rule.enabled = enabled;
found = true;
break;
}
}
if !found {
return Err(anyhow!("correction rule {} not found", id));
}
self.write_locked(&rules)
}
fn read_locked(&self) -> Result<Vec<CorrectionRule>> {
read_or_default::<Vec<CorrectionRule>>(&self.path)
}
fn write_locked(&self, rules: &[CorrectionRule]) -> Result<()> {
let json = serde_json::to_vec_pretty(rules).context("encode correction rules failed")?;
atomic_write(&self.path, &json)
}
}
fn validate_correction_rule_syntax(pattern: &str, replacement: &str) -> Result<()> {
if pattern.is_empty() {
return Err(anyhow!("correction rule pattern is empty"));
}
let pattern_token_count = pattern.matches(CORRECTION_NUM_TOKEN).count();
if pattern_token_count > 1 {
return Err(anyhow!("unsupported correction rule syntax"));
}
if replacement.contains(CORRECTION_NUM_TOKEN) && pattern_token_count == 0 {
return Err(anyhow!("unsupported correction rule syntax"));
}
if pattern_token_count == 1 {
let Some((prefix, suffix)) = pattern.split_once(CORRECTION_NUM_TOKEN) else {
return Err(anyhow!("unsupported correction rule syntax"));
};
if prefix.is_empty() && suffix.is_empty() {
return Err(anyhow!("unsupported correction rule syntax"));
}
}
Ok(())
}
// ───────────────────────── CredentialsVault ─────────────────────────
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
@@ -1185,7 +1289,8 @@ impl CredentialsVault {
#[cfg(test)]
mod tests {
use super::{
chunk_json_payload, list_vocab_presets, save_vocab_presets, KEYRING_CHUNK_MAX_UTF16_UNITS,
chunk_json_payload, list_vocab_presets, save_vocab_presets,
validate_correction_rule_syntax, KEYRING_CHUNK_MAX_UTF16_UNITS,
};
use crate::types::{VocabPreset, VocabPresetStore};
use std::fs;
@@ -1236,4 +1341,14 @@ mod tests {
assert_eq!(loaded.disabled_builtin_preset_ids, vec!["chef".to_string()]);
let _ = fs::remove_dir_all(&tmp);
}
#[test]
fn correction_rule_syntax_rejects_silent_noops() {
assert!(validate_correction_rule_syntax("{num}粒", "{num}例").is_ok());
assert!(validate_correction_rule_syntax("几粒", "几例").is_ok());
assert!(validate_correction_rule_syntax("", "几例").is_err());
assert!(validate_correction_rule_syntax("{num}", "{num}例").is_err());
assert!(validate_correction_rule_syntax("{num}到{num}粒", "{num}例").is_err());
assert!(validate_correction_rule_syntax("几粒", "{num}例").is_err());
}
}
+12
View File
@@ -119,6 +119,18 @@ pub struct DictionaryEntry {
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct CorrectionRule {
pub id: String,
pub pattern: String,
pub replacement: String,
#[serde(default = "default_true")]
pub enabled: bool,
#[serde(default)]
pub created_at: String,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
pub struct VocabPreset {
+11
View File
@@ -178,6 +178,17 @@ export const en: typeof zhCN = {
tipDisabled: 'Click to disable this entry',
tipEnabled: 'Click to enable this entry',
removeAria: 'Remove',
corrections: {
title: 'Correction rules',
tip: 'Separate from hot words. Deterministically fixes common ASR mistakes; {num} matches numbers. Example: {num}粒 → {num}例.',
patternPlaceholder: 'Mistaken text, e.g. {num}粒',
replacementPlaceholder: 'Target text, e.g. {num}例',
empty: 'No correction rules yet.',
invalid: 'Only literal replacements or one {num} number wildcard are supported, for example {num}粒 → {num}例.',
tipDisabled: 'Click to disable this rule',
tipEnabled: 'Click to enable this rule',
removeAria: 'Remove correction rule',
},
presets: {
title: 'Scenario presets',
tip: 'Multi-select to apply in batch; supports edit/create and keeps local preset structure ready for future import/export.',
+11
View File
@@ -180,6 +180,17 @@ export const ja: typeof zhCN = {
tipDisabled: 'クリックで無効化',
tipEnabled: 'クリックで有効化',
removeAria: '削除',
corrections: {
title: '補正ルール',
tip: 'ホットワードとは別に、ASR のよくある誤認識を文字列として補正します。{num} は数字に一致します。例:{num}粒 → {num}例。',
patternPlaceholder: '誤認識された表記(例:{num}粒)',
replacementPlaceholder: '修正後の表記(例:{num}例)',
empty: '補正ルールはまだありません。',
invalid: '文字列の置換、または {num} 数字ワイルドカードを 1 つだけ含むルールに対応しています。例:{num}粒 → {num}例。',
tipDisabled: 'クリックしてこのルールを無効化',
tipEnabled: 'クリックしてこのルールを有効化',
removeAria: '補正ルールを削除',
},
presets: {
title: 'シーンプリセット',
tip: '複数選択して一括有効化できます。編集と新規作成にも対応し、将来のインポート/エクスポート用のローカル構造を予約しています。',
+11
View File
@@ -180,6 +180,17 @@ export const ko: typeof zhCN = {
tipDisabled: '클릭하여 비활성화',
tipEnabled: '클릭하여 활성화',
removeAria: '삭제',
corrections: {
title: '교정 규칙',
tip: '핫워드와 별도로 ASR의 흔한 오인식을 문자 그대로 교정합니다. {num}은 숫자와 일치합니다. 예: {num}粒 → {num}例.',
patternPlaceholder: '오인식 표현, 예: {num}粒',
replacementPlaceholder: '대상 표현, 예: {num}例',
empty: '아직 교정 규칙이 없습니다.',
invalid: '문자 그대로 바꾸기 또는 {num} 숫자 와일드카드 1개가 포함된 규칙만 지원합니다. 예: {num}粒 → {num}例.',
tipDisabled: '이 규칙 비활성화',
tipEnabled: '이 규칙 활성화',
removeAria: '교정 규칙 삭제',
},
presets: {
title: '시나리오 프리셋',
tip: '여러 개 선택 후 일괄 활성화 가능. 편집과 새로 만들기 지원, 향후 가져오기/내보내기를 위한 로컬 구조가 예약되어 있습니다.',
+11
View File
@@ -176,6 +176,17 @@ export const zhCN = {
tipDisabled: '点击禁用此词条',
tipEnabled: '点击启用此词条',
removeAria: '删除',
corrections: {
title: '纠正规则',
tip: '独立于热词,按字面修正 ASR 常见误识别;支持 {num} 通配数字。例如:{num}粒 → {num}例。',
patternPlaceholder: '误识别写法,如 {num}粒',
replacementPlaceholder: '目标写法,如 {num}例',
empty: '还没有纠正规则。',
invalid: '仅支持字面替换,或一个 {num} 通配数字的规则,例如 {num}粒 → {num}例。',
tipDisabled: '点击禁用此规则',
tipEnabled: '点击启用此规则',
removeAria: '删除纠正规则',
},
presets: {
title: '场景预设',
tip: '可多选后批量启用;支持编辑和新建,已为后续导入导出预留本地结构。',
+11
View File
@@ -178,6 +178,17 @@ export const zhTW: typeof zhCN = {
tipDisabled: '點擊禁用此詞條',
tipEnabled: '點擊啓用此詞條',
removeAria: '刪除',
corrections: {
title: '糾正規則',
tip: '獨立於熱詞,按字面修正 ASR 常見誤識別;支援 {num} 通配數字。例如:{num}粒 → {num}例。',
patternPlaceholder: '誤識別寫法,如 {num}粒',
replacementPlaceholder: '目標寫法,如 {num}例',
empty: '還沒有糾正規則。',
invalid: '僅支援字面替換,或一個 {num} 通配數字的規則,例如 {num}粒 → {num}例。',
tipDisabled: '點擊停用此規則',
tipEnabled: '點擊啟用此規則',
removeAria: '刪除糾正規則',
},
presets: {
title: '場景預設',
tip: '可多選後批量啓用;支持編輯和新建,已爲後續導入導出預留本地結構。',
+33
View File
@@ -4,6 +4,7 @@
import type {
ComboBinding,
CorrectionRule,
CredentialsStatus,
DictationSession,
DictionaryEntry,
@@ -151,6 +152,16 @@ const mockVocab: DictionaryEntry[] = OL_DATA.vocab.map((v, i) => ({
createdAt: new Date().toISOString(),
}));
const mockCorrectionRules: CorrectionRule[] = [
{
id: 'rule-quantity-classifier',
pattern: '{num}粒',
replacement: '{num}例',
enabled: true,
createdAt: new Date().toISOString(),
},
];
// ── Settings ───────────────────────────────────────────────────────────
export function getSettings(): Promise<UserPreferences> {
return invokeOrMock('get_settings', undefined, () => mockSettings);
@@ -274,6 +285,28 @@ export function setVocabEnabled(id: string, enabled: boolean): Promise<void> {
return invokeOrMock('set_vocab_enabled', { id, enabled }, () => undefined);
}
export function listCorrectionRules(): Promise<CorrectionRule[]> {
return invokeOrMock('list_correction_rules', undefined, () => mockCorrectionRules);
}
export function addCorrectionRule(pattern: string, replacement: string): Promise<CorrectionRule> {
return invokeOrMock('add_correction_rule', { pattern, replacement }, () => ({
id: `rule-new-${Date.now()}`,
pattern,
replacement,
enabled: true,
createdAt: new Date().toISOString(),
}));
}
export function removeCorrectionRule(id: string): Promise<void> {
return invokeOrMock('remove_correction_rule', { id }, () => undefined);
}
export function setCorrectionRuleEnabled(id: string, enabled: boolean): Promise<void> {
return invokeOrMock('set_correction_rule_enabled', { id, enabled }, () => undefined);
}
export function listVocabPresets(): Promise<VocabPresetStore> {
return invokeOrMock('list_vocab_presets', undefined, () => ({
custom: [],
+8
View File
@@ -29,6 +29,14 @@ export interface DictionaryEntry {
createdAt: string;
}
export interface CorrectionRule {
id: string;
pattern: string;
replacement: string;
enabled: boolean;
createdAt: string;
}
export interface VocabPreset {
id: string;
name: string;
+159 -4
View File
@@ -3,12 +3,35 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { addVocab, isTauri, listVocab, removeVocab, setVocabEnabled } from '../lib/ipc';
import type { DictionaryEntry, VocabPreset } from '../lib/types';
import {
addCorrectionRule,
addVocab,
isTauri,
listCorrectionRules,
listVocab,
removeCorrectionRule,
removeVocab,
setCorrectionRuleEnabled,
setVocabEnabled,
} from '../lib/ipc';
import type { CorrectionRule, DictionaryEntry, VocabPreset } from '../lib/types';
import { DEFAULT_VOCAB_PRESETS, loadVocabPresets, persistVocabPresets } from '../lib/vocabPresets';
import { Btn, Card, PageHeader } from './_atoms';
const NEW_PRESET_DRAFT_ID = '__new__';
const NUM_TOKEN = '{num}';
function isSupportedCorrectionRule(pattern: string, replacement: string) {
const tokenCount = pattern.split(NUM_TOKEN).length - 1;
if (!pattern) return false;
if (tokenCount > 1) return false;
if (replacement.includes(NUM_TOKEN) && tokenCount === 0) return false;
if (tokenCount === 1) {
const [prefix, suffix] = pattern.split(NUM_TOKEN);
return Boolean(prefix || suffix);
}
return true;
}
export function Vocab() {
const { t } = useTranslation();
@@ -22,6 +45,9 @@ export function Vocab() {
const [editingPresetId, setEditingPresetId] = useState<string | null>(null);
const [presetNameDraft, setPresetNameDraft] = useState('');
const [presetPhrasesDraft, setPresetPhrasesDraft] = useState('');
const [correctionRules, setCorrectionRules] = useState<CorrectionRule[]>([]);
const [rulePatternDraft, setRulePatternDraft] = useState('');
const [ruleReplacementDraft, setRuleReplacementDraft] = useState('');
const refresh = async () => {
try {
@@ -36,8 +62,22 @@ export function Vocab() {
}
};
const refreshCorrectionRules = async () => {
try {
const data = await listCorrectionRules();
setCorrectionRules(data);
} catch (e) {
setError(e instanceof Error ? e.message : String(e));
}
};
const refreshAll = () => {
void refresh();
void refreshCorrectionRules();
};
useEffect(() => {
refresh();
refreshAll();
void loadVocabPresets()
.then(setPresets)
.catch(err => setError(err instanceof Error ? err.message : String(err)));
@@ -75,6 +115,44 @@ export function Vocab() {
}
};
const onAddCorrectionRule = async () => {
const pattern = rulePatternDraft.trim();
if (!pattern) return;
const replacement = ruleReplacementDraft.trim();
if (!isSupportedCorrectionRule(pattern, replacement)) {
setError(t('vocab.corrections.invalid'));
return;
}
try {
const rule = await addCorrectionRule(pattern, replacement);
setCorrectionRules(prev => [rule, ...prev]);
setRulePatternDraft('');
setRuleReplacementDraft('');
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};
const onRemoveCorrectionRule = async (id: string) => {
try {
await removeCorrectionRule(id);
setCorrectionRules(prev => prev.filter(r => r.id !== id));
} catch (err) {
setError(err instanceof Error ? err.message : String(err));
}
};
const onToggleCorrectionRule = async (rule: CorrectionRule) => {
const next = !rule.enabled;
setCorrectionRules(prev => prev.map(r => (r.id === rule.id ? { ...r, enabled: next } : r)));
try {
await setCorrectionRuleEnabled(rule.id, next);
} catch (err) {
setCorrectionRules(prev => prev.map(r => (r.id === rule.id ? { ...r, enabled: rule.enabled } : r)));
setError(err instanceof Error ? err.message : String(err));
}
};
const onRemove = async (id: string) => {
await removeVocab(id);
setEntries(prev => prev.filter(e => e.id !== id));
@@ -184,7 +262,7 @@ export function Vocab() {
desc={t('vocab.desc')}
right={
<div style={{ display: 'flex', gap: 8 }}>
<Btn icon="refresh" variant="ghost" size="sm" onClick={refresh}>{t('common.refresh')}</Btn>
<Btn icon="refresh" variant="ghost" size="sm" onClick={refreshAll}>{t('common.refresh')}</Btn>
</div>
}
/>
@@ -231,6 +309,43 @@ export function Vocab() {
</div>
)}
</div>
<div style={{ padding: 18, borderBottom: '0.5px solid var(--ol-line)' }}>
<div style={{ display: 'grid', gap: 10 }}>
<div>
<strong style={{ fontSize: 12 }}>{t('vocab.corrections.title')}</strong>
<div style={{ fontSize: 11, color: 'var(--ol-ink-4)', marginTop: 6 }}>{t('vocab.corrections.tip')}</div>
</div>
<div style={{ display: 'grid', gridTemplateColumns: 'minmax(0, 1fr) auto minmax(0, 1fr) auto', gap: 8, alignItems: 'center' }}>
<input
value={rulePatternDraft}
onChange={e => setRulePatternDraft(e.target.value)}
placeholder={t('vocab.corrections.patternPlaceholder')}
style={{ height: 32, padding: '0 10px', border: '0.5px solid var(--ol-line-strong)', borderRadius: 8, background: 'var(--ol-surface-2)' }}
/>
<span style={{ color: 'var(--ol-ink-4)', fontSize: 12 }}>→</span>
<input
value={ruleReplacementDraft}
onChange={e => setRuleReplacementDraft(e.target.value)}
placeholder={t('vocab.corrections.replacementPlaceholder')}
style={{ height: 32, padding: '0 10px', border: '0.5px solid var(--ol-line-strong)', borderRadius: 8, background: 'var(--ol-surface-2)' }}
/>
<Btn size="sm" variant="primary" onClick={() => void onAddCorrectionRule()}>{t('common.add')}</Btn>
</div>
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 8, minHeight: correctionRules.length ? undefined : 20 }}>
{correctionRules.length === 0 && (
<span style={{ fontSize: 12, color: 'var(--ol-ink-4)' }}>{t('vocab.corrections.empty')}</span>
)}
{correctionRules.map(rule => (
<CorrectionRuleChip
key={rule.id}
rule={rule}
onToggle={() => void onToggleCorrectionRule(rule)}
onRemove={() => void onRemoveCorrectionRule(rule.id)}
/>
))}
</div>
</div>
</div>
<div style={{ padding: 18, borderBottom: '0.5px solid var(--ol-line)' }}>
<div style={{ display: 'flex', gap: 8 }}>
<input
@@ -279,6 +394,46 @@ export function Vocab() {
);
}
interface CorrectionRuleChipProps {
rule: CorrectionRule;
onToggle: () => void;
onRemove: () => void;
}
function CorrectionRuleChip({ rule, onToggle, onRemove }: CorrectionRuleChipProps) {
const { t } = useTranslation();
const enabled = rule.enabled;
return (
<span
style={{
display: 'inline-flex', alignItems: 'center', gap: 6,
padding: '5px 8px 5px 10px',
borderRadius: 999,
border: '0.5px solid var(--ol-line-strong)',
background: enabled ? 'var(--ol-surface)' : 'var(--ol-surface-2)',
opacity: enabled ? 1 : 0.55,
fontSize: 12,
fontFamily: 'var(--ol-font-mono)',
}}
>
<button
onClick={onToggle}
title={enabled ? t('vocab.corrections.tipDisabled') : t('vocab.corrections.tipEnabled')}
style={{ background: 'transparent', border: 0, padding: 0, color: 'inherit', fontFamily: 'inherit', cursor: 'default' }}
>
{rule.pattern} → {rule.replacement}
</button>
<button
onClick={onRemove}
aria-label={t('vocab.corrections.removeAria')}
style={{ width: 18, height: 18, borderRadius: 999, border: 0, background: 'rgba(0,0,0,0.06)', color: 'var(--ol-ink-4)', cursor: 'default' }}
>
×
</button>
</span>
);
}
interface VocabChipProps {
entry: DictionaryEntry;
onRemove: () => void;