diff --git a/crates/llm/Cargo.toml b/crates/llm/Cargo.toml index b3215bf..4895077 100644 --- a/crates/llm/Cargo.toml +++ b/crates/llm/Cargo.toml @@ -19,7 +19,7 @@ openmp = ["llama-cpp-2/openmp"] magnotia-core = { path = "../core" } encoding_rs = "0.8" futures-util = "0.3" -llama-cpp-2 = { version = "0.1.144", default-features = false } +llama-cpp-2 = { version = "0.1.146", default-features = false } reqwest = { version = "0.12", default-features = false, features = ["rustls-tls", "stream"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/crates/llm/src/lib.rs b/crates/llm/src/lib.rs index 1453ab9..65c92ad 100644 --- a/crates/llm/src/lib.rs +++ b/crates/llm/src/lib.rs @@ -212,6 +212,10 @@ impl LlmEngine { generated.push_str(&piece); sampler.accept(next); + if config.grammar.is_some() && json_envelope_complete(&generated) { + break; + } + if let Some(stop_index) = first_stop_index(&generated, &config.stop_sequences) { generated.truncate(stop_index); break; @@ -296,13 +300,12 @@ impl LlmEngine { } /// Phase 9 content-tag extraction. Emits a single (topic, intent) - /// pair under the `CONTENT_TAGS_GRAMMAR` GBNF. Truncates to the - /// trailing 2000 chars of the transcript so the prompt budget - /// stays well under any model's context window. Determinism is - /// enforced by temperature 0.0 and the closed-set intent grammar - /// rule; on the rare case the model emits a parse-able-but-out-of- - /// set intent, we re-validate with `is_valid_intent` and bubble - /// `InvalidJson` so the frontend toasts a clear error. + /// pair as JSON. Truncates to the trailing 2000 chars of the + /// transcript so the prompt budget stays well under any model's + /// context window. Determinism is enforced by temperature 0.0; + /// the parsed intent is re-validated with `is_valid_intent` and + /// invalid JSON bubbles as `InvalidJson` so the frontend toasts a + /// clear error. pub fn extract_content_tags( &self, transcript: &str, @@ -338,12 +341,11 @@ impl LlmEngine { max_tokens: 96, temperature: 0.0, stop_sequences: vec!["<|im_end|>".to_string(), "<|im_end_of_text|>".to_string()], - grammar: Some(grammars::CONTENT_TAGS_GRAMMAR.to_string()), + grammar: None, }, )?; - let tags: prompts::ContentTags = serde_json::from_str(raw.trim()) - .map_err(|e| EngineError::InvalidJson(format!("{e}: raw={raw:?}")))?; + let tags: prompts::ContentTags = parse_json_payload(&raw)?; if !prompts::is_valid_intent(&tags.intent) { return Err(EngineError::InvalidJson(format!( "intent out of closed set: {}", @@ -459,6 +461,62 @@ fn first_stop_index(text: &str, stop_sequences: &[String]) -> Option { .min() } +fn json_envelope_complete(text: &str) -> bool { + extract_json_envelope(text) == Some(text.trim()) +} + +fn extract_json_envelope(text: &str) -> Option<&str> { + let start = text + .char_indices() + .find_map(|(idx, ch)| (ch == '{' || ch == '[').then_some(idx))?; + let mut chars = text[start..].char_indices(); + let (_, first) = chars.next()?; + + let mut stack = vec![match first { + '{' => '}', + '[' => ']', + _ => unreachable!(), + }]; + let mut in_string = false; + let mut escaped = false; + + while let Some((offset, ch)) = chars.next() { + if in_string { + if escaped { + escaped = false; + } else if ch == '\\' { + escaped = true; + } else if ch == '"' { + in_string = false; + } + continue; + } + + match ch { + '"' => in_string = true, + '{' => stack.push('}'), + '[' => stack.push(']'), + '}' | ']' => { + if stack.pop() != Some(ch) { + return None; + } + if stack.is_empty() { + let end = start + offset + ch.len_utf8(); + return Some(&text[start..end]); + } + } + _ => {} + } + } + + None +} + +fn parse_json_payload Deserialize<'de>>(raw: &str) -> Result { + let payload = extract_json_envelope(raw).unwrap_or(raw.trim()); + serde_json::from_str(payload).map_err(|e| EngineError::InvalidJson(format!("{e}: raw={raw:?}"))) +} + fn render_chat_prompt( model: &LlamaModel, messages: &[(&str, &str)], @@ -548,6 +606,47 @@ mod tests { assert_eq!(index, Some(5)); } + #[test] + fn json_envelope_complete_detects_finished_object() { + assert!(json_envelope_complete( + r#"{"topic":"meeting","intent":"planning"}"# + )); + } + + #[test] + fn json_envelope_complete_detects_finished_array() { + assert!(json_envelope_complete(r#"["Call plumber","Buy milk"]"#)); + } + + #[test] + fn json_envelope_complete_ignores_braces_inside_strings() { + assert!(!json_envelope_complete(r#"{"topic":"literal } brace""#)); + } + + #[test] + fn json_envelope_complete_rejects_prefixes_and_trailing_text() { + assert!(!json_envelope_complete(r#"{"topic":"meeting""#)); + assert!(!json_envelope_complete(r#"{"topic":"meeting"} extra"#)); + } + + #[test] + fn extract_json_envelope_skips_qwen_thinking_prefix() { + let raw = + "\n\n\n\n{\"topic\":\"grant-application\",\"intent\":\"planning\"}"; + assert_eq!( + extract_json_envelope(raw), + Some("{\"topic\":\"grant-application\",\"intent\":\"planning\"}"), + ); + } + + #[test] + fn extract_json_envelope_handles_arrays_and_trailing_stop_text() { + assert_eq!( + extract_json_envelope("prefix [\"Call plumber\",\"Buy milk\"]<|im_end|>"), + Some("[\"Call plumber\",\"Buy milk\"]"), + ); + } + #[test] fn prompt_preflight_rejects_oversized_prompt_tokens() { let err = preflight_context_window(7_105, 1_024).unwrap_err(); diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 95075d9..ce58e81 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -41,7 +41,7 @@ magnotia-llm = { path = "../crates/llm" } # autostart plugins are desktop-only — gated below under # cfg(not(target_os = "android")). The dialog, opener, and notification # plugins all support Android natively so live in the unconditional list. -tauri = { version = "2" } +tauri = { version = "2", features = [] } tauri-plugin-opener = "2" tauri-plugin-dialog = "2" # Phase 6 nudges: OS-native notifications via the frontend-owned nudge