// Package router is maven's reactive path — the cascade that turns a free-form // utterance into a deterministic Decision. // // Spec contract (from docs/design.md § Reactive path — routing): // // - the TARGET design is LLM-as-router: the resident model (Qwen3-1.7B) // emits GBNF-constrained structured JSON for the route, and the same // model phrases replies; the embedder is a RAG hint, not a routing gate. // the classifier/embedder cascade below is the committed default today, // but it is an interim stopgap (docs/design.md § Superseded, "classifier-owns- // the-route") and the known cause of weak RU query handling — not a // design to extend. // - a CASCADE, not one decider — layers: // stage 0 — exact match (regex/grammar). wake-word + known command // grammar. "maven, restart nginx" hits the allowlist directly, // skips the classifier. lowest latency — the client-side wake-word/ // command-grammar path (cmd/mavwaked). // stage 1 — route decision. the resident model (target), else the // nearest-centroid classifier over embedded labeled intents (today's // stopgap: one forward pass, ~30ms cpu, similarity score). any LLM // error falls through to the classifier so a turn never breaks. // stage 2 — slot extraction, per intent. classification gives *what kind*, // not *the args*. reminders need a datetime, acts need fn+params. // stage 3 — confidence gate. below threshold → clarify, don't guess. // same pattern as since(key)==null → don't fire: a misrouted // fact is a confident wrong write — worse than a gap. // - save-where is the routing axis: act | reminder | fact | note | query. // - misroute correction = new centroid example — append-only, grows the // classifier as used. same shape as nudges.outcome tuning cooldowns. // // The Embedder is the one impure seam (the ONNX int8 model is a later module). // Given a deterministic embedder, the classifier + cascade are pure and unit- // testable with zero infra — same instinct as the loop's gather/pure split. package router import "time" // Intent — the seven save-where labels from docs/design.md's routing table. The // discriminator is "does the loop evaluate a predicate against it?": // // - act: command now, not stored (function call into the allowlist) // - reminder: has a fire-time → reminders table (sqlite). bypasses the gate // - fact: structured state the loop reasons over → facts (sqlite) // - note: recall/preference, no predicate touches it → semantic store // - query: answer, don't store → the resident model reads sqlite or the // semantic store (RAG) // - chat: conversational, no store side-effect — LLM replies from // dialogue history + general knowledge // // fact-vs-note is the whole line: predicate will read it → structured facts // row; just "recall when relevant" → semantic store. reminder splits off by // future timestamp; act splits off by being imperative-now. type Intent string const ( IntentAct Intent = "act" IntentReminder Intent = "reminder" IntentFact Intent = "fact" IntentNote Intent = "note" IntentQuery Intent = "query" IntentChat Intent = "chat" IntentSystem Intent = "system" ) // Slots — per-intent extracted arguments (stage 2). Not every field is set for // every intent; the Intent decides which matter. A slot that doesn't parse // leaves its Has* flag false — the daemon's SLM last-resort lane picks it up // for free-form notes the parsers choke on. Never an error to be missing. type Slots struct { // Reminder: absolute fire time. The router resolves relative→absolute AT // CAPTURE ("in 4h" → now+4h, never the string) per spec — the reminders // table stores only absolute fire_ts. HasTime=false ⇒ no datetime parsed. Time time.Time HasTime bool // Act: the function name from the allowlist + positional args. The router // fuzzy-matches the verb against the allowlist; not on the list → refuse, // don't improvise. Fn empty ⇒ no allowlist match. Destructive acts still // gate behind confirm at the daemon layer, not here. Fn string Args []string HasFn bool // Fact: structured (key,value) the loop will evaluate predicates against. // "drank water" → key=water; "slept 6h" → key=sleep, value=6h. The value // is the raw string the daemon json-encodes before WriteFact. Key string Value string HasKey bool // Note/Query: free-form payload (chroma-bound for note, RAG input for query). // Always set to the utterance for those intents. Text string } // Decision — the router's output. The cascade is deterministic: stage 0 wins // outright; otherwise classify (1) → extract (2) → gate (3). Clarify=true ⇒ // the daemon asks instead of guessing — "shuts up when uncertain" for routing, // same shape as since(key)==null → don't fire for the loop. type Decision struct { Utterance string Stage int // 0 exact-match, 1 classified, 2 slots-extracted, 3 clarify-gated Intent Intent Confidence float64 // 1.0 for stage-0; classifier cosine similarity for 1+ Slots Slots Clarify bool // stage 3: below threshold — ask, don't guess // Source — where the answer lives, for a query. The second half of the // route, and empty on every other intent. SourceUnknown means no decider // named one and the daemon walks its whole chain, which is what shipped // before this field existed. See source.go for why it is twelve values. Source Source // Continued — this decision was rebuilt from the previous turn rather // than routed, because the utterance was an ellipsis ("а завтра?"). // Handlers use it to know that Slots.Text is the PREVIOUS turn's topic // and not something the current utterance said. Nothing in the router // sets it; the daemon's continuation path does. Continued bool }