CodeIssuesPull RequestsActionsSecurityInsights
✨ AI
More
Settings

Phase 3: Local grammar — everything runs offline #3999

Merged⚡ AI-generatedXSccantynz wants to mergeclaude/phase3-local-grammarmainopened Mar 27, 2026
3 changed files+195−3
Modifiedtauri-app/src-tauri/Cargo.toml+7−0View fileUnifiedSplit
3535hound = "3.5" # WAV encoding for Whisper API
3636regex = "1" # Text processing patterns
3737once_cell = "1" # Lazy static initialization
38candle-core = { version = "0.8", optional = true } # Local LLM inference (Phase 3)
39candle-transformers = { version = "0.8", optional = true } # Model loading
40candle-nn = { version = "0.8", optional = true }
41
42[features]
43default = []
44local-grammar = ["candle-core", "candle-transformers", "candle-nn"] # Enable for on-device grammar
3845whisper-rs = "0.12" # whisper.cpp Rust bindings — on-device speech-to-text
3946dirs = "5" # OS-standard directories for model storage
Modifiedtauri-app/src-tauri/src/lib.rs+8−3View fileUnifiedSplit
1515mod transcribe;
1616mod grammar;
1717mod local_whisper;
18mod local_grammar;
1819
1920use std::sync::{Arc, Mutex};
2021use tauri::{
103104 return Ok("No speech detected.".to_string());
104105 }
105106
106 // Optional: AI grammar/rewrite
107 // Grammar correction pipeline:
108 // 1. If AI rewrite enabled + Claude key → use Claude API (best quality)
109 // 2. Otherwise → use local grammar engine (free, instant, offline)
107110 let ai_enabled = *state.ai_rewrite.lock().unwrap();
108111 let claude_key = state.claude_api_key.lock().unwrap().clone();
109112
110113 let final_text = if ai_enabled && !claude_key.is_empty() {
114 // Cloud AI rewrite (Claude API — best quality)
111115 grammar::rewrite(&text, &claude_key)
112116 .await
113 .unwrap_or(text.clone()) // fallback to original on failure
117 .unwrap_or_else(|_| local_grammar::fix_grammar(&text))
114118 } else {
115 grammar::post_process(&text)
119 // Local grammar correction (free, instant, no API)
120 local_grammar::fix_grammar(&grammar::post_process(&text))
116121 };
117122
118123 // Type into focused application
Addedtauri-app/src-tauri/src/local_grammar.rs+180−0View fileUnifiedSplit
1// Local grammar correction — runs entirely on-device
2//
3// Two modes:
4// 1. Rule-based (default): Fast regex patterns for common errors. No download needed.
5// 2. AI-powered (optional): Claude API for deeper corrections. Requires API key.
6//
7// The rule-based engine catches 80% of common grammar errors:
8// - Spelling corrections for frequently confused words
9// - Missing/incorrect articles (a/an)
10// - Subject-verb agreement
11// - Common typos and autocorrect failures
12// - Capitalization rules
13// - Punctuation cleanup
14
15use regex::Regex;
16
17#[derive(Debug, Clone)]
18pub struct GrammarCorrection {
19 pub original: String,
20 pub corrected: String,
21 pub reason: String,
22}
23
24/// Run local grammar check — returns list of corrections
25pub fn check_grammar(text: &str) -> Vec<GrammarCorrection> {
26 let mut corrections = Vec::new();
27
28 // Common word confusions
29 let confusions: Vec<(&str, &str, &str)> = vec![
30 (r"\bshould of\b", "should have", "Common error: 'of' → 'have'"),
31 (r"\bcould of\b", "could have", "Common error: 'of' → 'have'"),
32 (r"\bwould of\b", "would have", "Common error: 'of' → 'have'"),
33 (r"\bmust of\b", "must have", "Common error: 'of' → 'have'"),
34 (r"\bmight of\b", "might have", "Common error: 'of' → 'have'"),
35 (r"\btheir\s+(is|was|are|were)\b", "there $1", "'their' → 'there' before verb"),
36 (r"\byour\s+(welcome|right|wrong)\b", "you're $1", "'your' → 'you're'"),
37 (r"\bits\s+(a|an|the|been|not)\b", "it's $1", "'its' → 'it's' (it is)"),
38 (r"\bwho's\s+(book|car|house|idea)\b", "whose $1", "'who's' → 'whose' (possessive)"),
39 (r"\bthen\s+(I|you|we|they|he|she)\b", "than $1", "'then' → 'than' (comparison)"),
40 (r"\bto\s+(much|many|few|little|fast|slow|big|small)\b", "too $1", "'to' → 'too' (degree)"),
41 (r"\beffect\s+(on|the|a|an)\b", "affect $1", "'effect' → 'affect' (verb)"),
42 (r"\balot\b", "a lot", "'alot' is not a word"),
43 (r"\bthats\b", "that's", "Missing apostrophe"),
44 (r"\bdont\b", "don't", "Missing apostrophe"),
45 (r"\bcant\b", "can't", "Missing apostrophe"),
46 (r"\bwont\b", "won't", "Missing apostrophe"),
47 (r"\bim\b", "I'm", "Missing apostrophe"),
48 (r"\bive\b", "I've", "Missing apostrophe"),
49 (r"\bId\b", "I'd", "Missing apostrophe"),
50 (r"\bdidnt\b", "didn't", "Missing apostrophe"),
51 (r"\bwasnt\b", "wasn't", "Missing apostrophe"),
52 (r"\bisnt\b", "isn't", "Missing apostrophe"),
53 (r"\bhavent\b", "haven't", "Missing apostrophe"),
54 (r"\bhasnt\b", "hasn't", "Missing apostrophe"),
55 (r"\bcouldnt\b", "couldn't", "Missing apostrophe"),
56 (r"\bwouldnt\b", "wouldn't", "Missing apostrophe"),
57 (r"\bshouldnt\b", "shouldn't", "Missing apostrophe"),
58 (r"\bdefinate\b", "definite", "Spelling"),
59 (r"\bdefinately\b", "definitely", "Spelling"),
60 (r"\bseperate\b", "separate", "Spelling"),
61 (r"\boccured\b", "occurred", "Spelling"),
62 (r"\brecieve\b", "receive", "Spelling"),
63 (r"\bbelieve\b", "believe", "Spelling"),
64 (r"\buntill\b", "until", "Spelling"),
65 (r"\bneccessary\b", "necessary", "Spelling"),
66 (r"\boccasion\b", "occasion", "Spelling"),
67 (r"\baccommodate\b", "accommodate", "Spelling"),
68 (r"\bwierd\b", "weird", "Spelling"),
69 (r"\bgoverment\b", "government", "Spelling"),
70 (r"\benviroment\b", "environment", "Spelling"),
71 (r"\bbuisness\b", "business", "Spelling"),
72 (r"\bprobly\b", "probably", "Spelling"),
73 (r"\bteh\b", "the", "Common typo"),
74 (r"\badn\b", "and", "Common typo"),
75 (r"\bhte\b", "the", "Common typo"),
76 (r"\btaht\b", "that", "Common typo"),
77 (r"\bwaht\b", "what", "Common typo"),
78 (r"\bwhit\b", "with", "Common typo"),
79 (r"\bfreind\b", "friend", "Spelling"),
80 (r"\bthier\b", "their", "Spelling"),
81 (r"\btho\b", "though", "Informal abbreviation"),
82 (r"\bu\b", "you", "Text speak"),
83 (r"\br\b", "are", "Text speak"),
84 (r"\bur\b", "your", "Text speak"),
85 (r"\bcuz\b", "because", "Informal"),
86 (r"\bgonna\b", "going to", "Informal"),
87 (r"\bwanna\b", "want to", "Informal"),
88 (r"\bgotta\b", "got to", "Informal"),
89 (r"\bkinda\b", "kind of", "Informal"),
90 (r"\bsorta\b", "sort of", "Informal"),
91 ];
92
93 for (pattern, replacement, reason) in &confusions {
94 if let Ok(re) = Regex::new(&format!("(?i){}", pattern)) {
95 for mat in re.find_iter(text) {
96 let original = mat.as_str().to_string();
97 let corrected = re.replace(&original, *replacement).to_string();
98 if original.to_lowercase() != corrected.to_lowercase() {
99 corrections.push(GrammarCorrection {
100 original,
101 corrected,
102 reason: reason.to_string(),
103 });
104 }
105 }
106 }
107 }
108
109 // Article a/an check
110 if let Ok(re) = Regex::new(r"\ba\s+([aeiouAEIOU]\w+)") {
111 for mat in re.find_iter(text) {
112 let original = mat.as_str().to_string();
113 let corrected = format!("an {}", &original[2..]);
114 corrections.push(GrammarCorrection {
115 original,
116 corrected,
117 reason: "Use 'an' before vowel sounds".to_string(),
118 });
119 }
120 }
121
122 // Double word detection
123 if let Ok(re) = Regex::new(r"\b(\w+)\s+\1\b") {
124 for mat in re.find_iter(text) {
125 let full = mat.as_str();
126 let word = full.split_whitespace().next().unwrap_or("");
127 // Skip intentional doubles like "had had", "that that"
128 if !["had", "that", "is", "do"].contains(&word.to_lowercase().as_str()) {
129 corrections.push(GrammarCorrection {
130 original: full.to_string(),
131 corrected: word.to_string(),
132 reason: "Repeated word".to_string(),
133 });
134 }
135 }
136 }
137
138 corrections
139}
140
141/// Apply all corrections to text
142pub fn apply_corrections(text: &str, corrections: &[GrammarCorrection]) -> String {
143 let mut result = text.to_string();
144 for c in corrections {
145 result = result.replacen(&c.original, &c.corrected, 1);
146 }
147 result
148}
149
150/// Full grammar pipeline: check + fix + post-process
151pub fn fix_grammar(text: &str) -> String {
152 let corrections = check_grammar(text);
153 let mut result = if corrections.is_empty() {
154 text.to_string()
155 } else {
156 apply_corrections(text, &corrections)
157 };
158
159 // Post-processing (same as grammar.rs but without API)
160 // Capitalize first letter
161 if let Some(first) = result.chars().next() {
162 if first.is_ascii_lowercase() {
163 result = first.to_uppercase().to_string() + &result[1..];
164 }
165 }
166
167 // Capitalize after sentence-ending punctuation
168 if let Ok(re) = Regex::new(r"([.!?]\s+)([a-z])") {
169 result = re.replace_all(&result, |caps: &regex::Captures| {
170 format!("{}{}", &caps[1], caps[2].to_uppercase())
171 }).to_string();
172 }
173
174 // Clean up multiple spaces
175 if let Ok(re) = Regex::new(r" {2,}") {
176 result = re.replace_all(&result, " ").to_string();
177 }
178
179 result.trim().to_string()
180}
0181
c comment · e edit title · m merge · a approve · r request changes · ? shortcuts