Files
Lumotia/docs/architecture-map/04-llm-formatting-mcp/llm-decompose-task.md
jars a1f3f3f134 docs: architecture map (initial 5-slice generation, 105 pages)
Five-slice navigable map of the entire codebase under
docs/architecture-map/. Each slice is a self-contained
breadcrumbed sub-tree:

  01-frontend (16)              Svelte/SvelteKit UI
  02-tauri-runtime (26)         src-tauri commands + lifecycle
  03-audio-transcription (16)   audio + transcription crates
  04-llm-formatting-mcp (19)    llm, ai-formatting, mcp, cloud
  05-core-storage-hotkey-build  core, storage, hotkey, workspace,
                          (26) CI, dev glue

Plus master README.md and data-flow-end-to-end.md tracing
audio bytes from microphone to FTS5 search to MCP read.

Generated by 5 parallel subagents on 2026/05/09 against
HEAD 3c47000. Each page has YAML frontmatter, file:line code
refs, sibling cross-links, plain-English summaries.

Aggregated debt surfaced (full lists in master README):
RB-08 macOS power assertion, schema head drift v14 vs v15,
VAD blocked on ort version conflict, streaming primitives
not wired into live.rs, no prompt versioning, MCP has no
auth, cloud-providers in-memory keystore, SettingsPage
2 484 LOC, commands/live.rs 1 737 LOC, dual theme system,
brand rename to Lumenote pending across the codebase.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:04:13 +01:00

90 lines
5.9 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
name: LLM decompose_task surface
type: architecture-map-page
slice: 04-llm-formatting-mcp
last_verified: 2026/05/09
---
# LLM `decompose_task` surface
> **Where you are:** [Architecture map](../README.md) → [LLM, Formatting, MCP](README.md) → decompose_task
**Plain English summary.** `decompose_task` takes a task description and returns 3 to 7 short imperative micro-steps. The output is a JSON array of strings, constrained at the GBNF level so the model literally cannot emit fewer than three or more than seven. A feedback-conditioned variant adds few-shot examples from the user's HITL corrections.
## At a glance
- Crate: `magnotia-llm`
- Path: `crates/llm/src/lib.rs:254` (`decompose_task`) and `:267` (`decompose_task_with_feedback`)
- LOC: ~40 across both methods
- Public surface:
- `pub fn decompose_task(&self, task_text: &str) -> Result<Vec<String>, EngineError>`
- `pub fn decompose_task_with_feedback(&self, task_text: &str, examples: &[prompts::FeedbackExample]) -> Result<Vec<String>, EngineError>`
- Re-export not exposed at crate root: callers get `prompts::FeedbackExample` via `magnotia_llm::prompts::FeedbackExample` (the `prompts` module is `pub mod prompts;`).
- External deps that matter: GBNF sampler from llama-cpp-2; `serde_json` for the array parse.
- Tauri command that calls this (slice 2, best guess): the only call site is `src-tauri/src/commands/tasks.rs:322``engine.decompose_task_with_feedback(&parent_text, &examples)` — invoked from a `decompose_task_*_cmd` (the file's helper name; see slice 2's tasks page when written).
## What's in here
### `decompose_task` (`crates/llm/src/lib.rs:254`)
Convenience wrapper that calls `decompose_task_with_feedback(task_text, &[])`. Behaviour identical to the conditioned variant with no examples.
### `decompose_task_with_feedback` (`crates/llm/src/lib.rs:267`)
Steps:
1. Borrow the loaded model via `loaded_model_arc()`. `EngineError::NotLoaded` if no model is loaded.
2. Build the system prompt: `prompts::build_conditioned_system_prompt(prompts::DECOMPOSE_TASK_SYSTEM, examples)`. With an empty `examples` slice, the base prompt is returned unchanged. With non-empty examples, a few-shot block is appended (see [`llm-prompts-and-grammars.md`](llm-prompts-and-grammars.md) for the rendering rules).
3. Render a chat prompt with two messages — `("system", system)` and `("user", &format!("Task: {task_text}"))`.
4. Call `generate` with:
- `max_tokens: 512`
- `temperature: 0.0`
- `stop_sequences: ["<|im_end|>", "<|im_end_of_text|>"]`
- `grammar: Some(grammars::TASK_ARRAY_GRAMMAR.to_string())`
5. Parse the raw output via `parse_string_array` (`crates/llm/src/lib.rs:489`): `serde_json::from_str::<Vec<String>>` then trim, drop empties, dedupe case-insensitively while preserving first-seen ordering.
The 3-to-7 lower and upper bound is enforced by the GBNF, not by Rust code. The `parse_string_array` helper is happy to return arrays of any size; `TASK_ARRAY_GRAMMAR` makes that hypothetical impossible.
### Feedback examples
`prompts::FeedbackExample` carries `(input: String, original_output: Option<String>, corrected_output: Option<String>)`. The renderer prefers `corrected_output` over `original_output` so a user's edits beat a thumbs-up on the original. Empty inputs are skipped. Examples without any usable output (no original, no correction) are skipped. See `crates/llm/src/prompts.rs:69` for the renderer and `:93` for the prompt-builder.
## Data flow
```
(task_text: &str, examples: &[FeedbackExample])
→ loaded_model_arc()
→ build_conditioned_system_prompt(DECOMPOSE_TASK_SYSTEM, examples)
(returns base prompt unchanged when examples is empty)
→ render_chat_prompt([(system, system), (user, "Task: {task_text}")])
→ generate(prompt, GenerationConfig {
max_tokens: 512, temp: 0.0,
stops: [<|im_end|>, <|im_end_of_text|>],
grammar: Some(TASK_ARRAY_GRAMMAR),
})
→ parse_string_array(raw)
→ serde_json::from_str::<Vec<String>>
→ trim + drop empties + dedupe (case-insensitive, first-seen)
→ Vec<String>
```
## Prompts and grammars
- System prompt: `prompts::DECOMPOSE_TASK_SYSTEM` at `crates/llm/src/prompts.rs:1`. See [`llm-prompts-and-grammars.md`](llm-prompts-and-grammars.md) for the full text.
- GBNF: `grammars::TASK_ARRAY_GRAMMAR` at `crates/llm/src/grammars.rs:16`. Encodes "open bracket, exactly three strings, then up to four optional more strings, then close bracket". Recursive `rest3..rest6` chain is what bounds the array length to 37.
## Watch-outs
- **The GBNF is the source of truth for 37.** The system prompt also says "between 3 and 7", but the model's only actual constraint is the grammar. If the GBNF is ever loosened (e.g. for a free-text variant), the prompt will silently lose its size guarantee.
- **`parse_string_array` dedupe is case-insensitive.** Two micro-steps that differ only in casing collapse to one. This is desirable for the typical "Buy milk" / "buy milk" failure mode, but a niche prompt that legitimately asks for case variations would lose data.
- **`EngineError::InvalidJson` surfaces malformed grammar output.** In practice the GBNF prevents this, but a `LlamaSampler::grammar` runtime error or a tokenisation edge case can still produce a parse-able-by-llama but not-by-serde string. The error includes the raw output for debugging.
- **Stop sequences fire after detokenisation.** A token boundary that splits `<|im_end|>` is fine — the running buffer accumulates raw bytes via the UTF-8 decoder.
- **`max_tokens: 512` is the array's total budget, not per item.** Seven long imperative sentences will hit the cap. If a real-world task produces output near the ceiling, the JSON will be cut off and `serde_json::from_str` will return `EngineError::InvalidJson`.
## See also
- [LLM engine](llm-engine.md)
- [LLM extract_tasks (sibling array surface)](llm-extract-tasks.md)
- [Prompts and grammars catalogue](llm-prompts-and-grammars.md)
- [Slice README](README.md)