refactor(llm): remove GBNF grammar, switch to JSON-envelope extractor
extract_content_tags now generates with grammar=None and parses the response via a manual brace-counting JSON envelope extractor that handles Qwen <think>...</think> prefixes and trailing stop tokens. Five new unit tests. Bumps llama-cpp-2 to 0.1.146. Explicit features=[] on tauri dependency (no-op). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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<usize> {
|
||||
.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<T: for<'de> Deserialize<'de>>(raw: &str) -> Result<T, EngineError> {
|
||||
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 =
|
||||
"<think>\n\n</think>\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();
|
||||
|
||||
Reference in New Issue
Block a user