---
name: LLM extract_content_tags surface
type: architecture-map-page
slice: 04-llm-formatting-mcp
last_verified: 2026/05/09
---

# LLM `extract_content_tags` surface

> **Where you are:** [Architecture map](../README.md) → [LLM, Formatting, MCP](README.md) → extract_content_tags

**Plain English summary.** Phase 9 addition. Given a transcript, returns a single `{topic, intent}` pair. Topic is a lowercase hyphen-joined slug. Intent is one of six fixed values: planning, reflection, venting, capture, decision, question. Both the GBNF and a redundant Rust check enforce the closed set.

## At a glance

- Crate: `lumotia-llm`
- Path: `crates/llm/src/lib.rs:306`
- LOC: ~50 for the method
- Public surface:
  - `pub fn extract_content_tags(&self, transcript: &str) -> Result<prompts::ContentTags, EngineError>`
  - Companion types and constants (re-exported at crate root): `pub struct ContentTags { topic: String, intent: String }` (`crates/llm/src/prompts.rs:21`), `pub const INTENT_CLOSED_SET: &[&str]` (`:27`), `pub fn is_valid_intent(s: &str) -> bool` (`:36`), `pub const CONTENT_TAGS_SYSTEM: &str` (`:11`), `pub const CONTENT_TAGS_GRAMMAR: &str` (`crates/llm/src/grammars.rs:7`).
- External deps that matter: GBNF sampler from llama-cpp-2; `serde_json` for the typed deserialise.
- Tauri command that calls this (slice 2, best guess): `commands::llm::extract_content_tags_cmd` (`src-tauri/src/commands/llm.rs:408`), wired via `src-tauri/src/lib.rs:426`. The actual call is at `llm.rs:418` — `engine.extract_content_tags(&transcript)`.

## What's in here

### `extract_content_tags` (`crates/llm/src/lib.rs:306`)

Steps:

1. If `transcript.trim().is_empty()`, return `EngineError::Inference("empty transcript".into())`. Note this is an error, not a default — the caller is expected not to ask for tags on nothing. Distinct from `extract_tasks` which returns an empty vec, and from `cleanup_text` which returns an empty string.
2. **Truncate to the trailing 2000 chars on a UTF-8 char boundary.** A transcript longer than 2000 chars is sliced from the end, with the start position bumped forward until `is_char_boundary` returns true so we never split a multi-byte sequence. The intent here is "the dominant topic at the end is what the user is talking about now"; older content is discarded.
3. Borrow the loaded model via `loaded_model_arc()`. `EngineError::NotLoaded` if no model is loaded.
4. Render a chat prompt with two messages — `("system", prompts::CONTENT_TAGS_SYSTEM)` and `("user", &format!("Transcript:\n{tail}"))`.
5. Call `generate` with:
   - `max_tokens: 96` (small — the output is a single line)
   - `temperature: 0.0`
   - `stop_sequences: ["<|im_end|>", "<|im_end_of_text|>"]`
   - `grammar: Some(grammars::CONTENT_TAGS_GRAMMAR.to_string())`
6. **Typed deserialise.** `serde_json::from_str::<prompts::ContentTags>(raw.trim())`. Failure surfaces as `EngineError::InvalidJson(format!("{e}: raw={raw:?}"))`.
7. **Closed-set re-validation.** `if !prompts::is_valid_intent(&tags.intent)` returns `EngineError::InvalidJson(format!("intent out of closed set: {}", tags.intent))`.

### Why the redundant intent check?

The GBNF rule `intent ::= "\"planning\"" | "\"reflection\"" | ...` already restricts the model output to one of the six values, so `is_valid_intent` is logically reachable only when the GBNF and the closed set drift apart in source. The check is kept deliberately:

- Defense in depth against grammar / closed-set drift (see slice README's debt note).
- Clear error message for the frontend toast — we surface the actual offending value, which a stack trace from `serde_json::from_str` would not.

The check is also why the slice README flags the schema-drift gap: a value added to one and not the other silently becomes a hard failure or a hard escape.

## Data flow

```
(transcript: &str)
  → empty-transcript guard (returns EngineError::Inference)
  → tail = trailing 2000 chars (UTF-8 boundary-safe)
  → loaded_model_arc()
  → render_chat_prompt([(system, CONTENT_TAGS_SYSTEM), (user, "Transcript:\n{tail}")])
  → generate(prompt, GenerationConfig {
       max_tokens: 96, temp: 0.0,
       stops: [<|im_end|>, <|im_end_of_text|>],
       grammar: Some(CONTENT_TAGS_GRAMMAR),
     })
  → serde_json::from_str::<ContentTags>(raw.trim())
       (InvalidJson on parse failure)
  → is_valid_intent(tags.intent)
       (InvalidJson on closed-set violation)
  → ContentTags { topic, intent }
```

## Prompts and grammars

- System prompt: `prompts::CONTENT_TAGS_SYSTEM` at `crates/llm/src/prompts.rs:11`. Specifies "1 to 3 token lowercase hyphen-joined noun phrase" for topic and lists the six intent values verbatim.
- GBNF: `grammars::CONTENT_TAGS_GRAMMAR` at `crates/llm/src/grammars.rs:7`. Encodes:
  - `root` is a `{ "topic": "...", "intent": "..." }` JSON object with optional whitespace
  - `topic-str` is a quoted string of at least 3 lowercase-alphanumeric-or-hyphen characters
  - `intent` is one of the six closed-set values
  - The 3-character minimum on topic is a deliberate floor; there is no upper bound — `max_tokens: 96` is the practical cap.
- Closed set: `INTENT_CLOSED_SET = &["planning", "reflection", "venting", "capture", "decision", "question"]` at `crates/llm/src/prompts.rs:27`.

See [`llm-prompts-and-grammars.md`](llm-prompts-and-grammars.md) for full text.

## Watch-outs

- **Trivial-true cross-check observability gap.** The GBNF prevents an out-of-set intent; the Rust `is_valid_intent` check therefore "cannot fail" today. The redundancy is intentional. Per commit `052265b` ("document trivial-true cross-check as observability gap"), the intent is to make a future drift loud. The slice README tracks this as one of the documented gaps. If the GBNF is regenerated from the closed set or vice-versa as part of a future refactor, this redundancy becomes meaningful protection.
- **2000-char tail is "trailing", not a sliding window.** The model sees only the end of the transcript. A long discussion that pivoted topic in the last paragraph will be tagged on that pivot, not on the full conversation. Behaviour-by-design for the Phase 9 use-case (live tagging of an in-progress recording), but worth knowing for the file-import path.
- **`max_tokens: 96` is intentionally small.** The output is at most ~30 chars of topic plus an ~12-char intent value plus quoting. Anything bigger means the model went off-rail before the GBNF could catch it; we want that to fail fast.
- **Empty transcript is an error, not an empty result.** Callers must screen empty inputs upstream. The Tauri command at `src-tauri/src/commands/llm.rs:412` checks `is_loaded()` first but does not check transcript emptiness — the engine returns the error and the frontend toasts it.
- **Phase 9 feature, no `_with_feedback` variant.** Unlike `decompose_task` and `extract_tasks`, content-tag extraction does not take HITL examples. The closed-set intent makes few-shot conditioning largely meaningless.

## See also

- [LLM engine](llm-engine.md)
- [Prompts and grammars catalogue](llm-prompts-and-grammars.md)
- [Tests — content_tags_smoke](llm-tests.md)
- [Slice README — observability gap entry](README.md)
