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:
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user