From 7567bede52e76ca4666990c67014ca9bd4f69101 Mon Sep 17 00:00:00 2001 From: Jake Date: Sat, 25 Apr 2026 00:02:12 +0100 Subject: [PATCH] feat(phase9): LlmEngine::extract_content_tags + smoke test Added as a method on LlmEngine alongside cleanup_text and extract_tasks; same render_chat_prompt -> generate -> parse pattern. Truncates the transcript to its trailing 2000 chars on a UTF-8 char boundary, runs at temperature 0.0 with the CONTENT_TAGS_GRAMMAR GBNF, and re-validates intent against INTENT_CLOSED_SET to catch the unlikely grammar bypass case. max_tokens 96 is enough for the JSON envelope. Smoke test gated on KON_LLM_TEST_MODEL like the existing smoke.rs. Co-Authored-By: Claude Opus 4.7 (1M context) --- crates/llm/src/lib.rs | 58 ++++++++++++++++++++++++++ crates/llm/tests/content_tags_smoke.rs | 48 +++++++++++++++++++++ 2 files changed, 106 insertions(+) create mode 100644 crates/llm/tests/content_tags_smoke.rs diff --git a/crates/llm/src/lib.rs b/crates/llm/src/lib.rs index 99c9585..16f9c7c 100644 --- a/crates/llm/src/lib.rs +++ b/crates/llm/src/lib.rs @@ -285,6 +285,64 @@ impl LlmEngine { self.extract_tasks_with_feedback(transcript, &[]) } + /// 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. + pub fn extract_content_tags( + &self, + transcript: &str, + ) -> Result { + if transcript.trim().is_empty() { + return Err(EngineError::Inference("empty transcript".into())); + } + + // Truncate to the last 2000 chars on a UTF-8 char boundary so + // we don't slice through a multi-byte sequence. + const MAX_CHARS: usize = 2000; + let tail = if transcript.len() > MAX_CHARS { + let mut adj = transcript.len() - MAX_CHARS; + while adj < transcript.len() && !transcript.is_char_boundary(adj) { + adj += 1; + } + &transcript[adj..] + } else { + transcript + }; + + let model = self.loaded_model_arc()?; + let prompt = render_chat_prompt( + &model, + &[ + ("system", prompts::CONTENT_TAGS_SYSTEM), + ("user", &format!("Transcript:\n{tail}")), + ], + )?; + let raw = self.generate( + &prompt, + &GenerationConfig { + 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()), + }, + )?; + + let tags: prompts::ContentTags = serde_json::from_str(raw.trim()) + .map_err(|e| EngineError::InvalidJson(format!("{e}: raw={raw:?}")))?; + if !prompts::is_valid_intent(&tags.intent) { + return Err(EngineError::InvalidJson(format!( + "intent out of closed set: {}", + tags.intent, + ))); + } + Ok(tags) + } + /// Feedback-conditioned variant of `extract_tasks`. See /// `decompose_task_with_feedback` for the `examples` semantics. pub fn extract_tasks_with_feedback( diff --git a/crates/llm/tests/content_tags_smoke.rs b/crates/llm/tests/content_tags_smoke.rs new file mode 100644 index 0000000..4402495 --- /dev/null +++ b/crates/llm/tests/content_tags_smoke.rs @@ -0,0 +1,48 @@ +//! Smoke test for Phase 9 LlmEngine::extract_content_tags. +//! +//! Gated behind the same `KON_LLM_TEST_MODEL` env var as the existing +//! smoke.rs test so neither runs in default `cargo test` runs (model +//! load is heavy). Run explicitly with: +//! +//! KON_LLM_TEST_MODEL=/path/to/model.gguf cargo test -p kon-llm \ +//! --test content_tags_smoke -- --nocapture + +use std::env; +use std::path::PathBuf; + +use kon_llm::{is_valid_intent, LlmEngine, LlmModelId}; + +#[test] +fn extract_content_tags_returns_valid_pair() { + let model_path = match env::var("KON_LLM_TEST_MODEL") { + Ok(path) => PathBuf::from(path), + Err(_) => { + eprintln!("KON_LLM_TEST_MODEL not set — skipping"); + return; + } + }; + + let engine = LlmEngine::new(); + engine + .load_model(LlmModelId::Qwen3_1_7B_Q4, &model_path, true) + .expect("load model"); + + let transcript = "Tomorrow I need to run through the grant application one more time \ + and make sure the figures add up. I also need to book a slot with \ + Rachmann for the Mac test and email Andrew about the meeting window."; + let tags = engine + .extract_content_tags(transcript) + .expect("extract_content_tags"); + + assert!(tags.topic.len() >= 3, "topic present: {tags:?}"); + assert!( + tags.topic + .chars() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-'), + "topic lowercase + slugged: {tags:?}", + ); + assert!( + is_valid_intent(&tags.intent), + "intent in closed set: {tags:?}", + ); +}