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) <noreply@anthropic.com>
This commit is contained in:
2026-04-25 00:02:12 +01:00
parent 1b6ad88ead
commit 7567bede52
2 changed files with 106 additions and 0 deletions

View File

@@ -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<prompts::ContentTags, EngineError> {
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(

View File

@@ -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:?}",
);
}