28a940ebbe
- Add LLMRouter: grammar-constrained LFM call for intent classification after stage-0, before classifier cascade. Errors fall through gracefully. - Add IntentChat: conversational intent with no store side-effect, routed through LLM -> phraser chat endpoint. - Extract slots for Chat: no structured slots, full utterance is payload. - Extend stage-0 grammars to fire through Cyrillic wake-word spellings (Мэйвен/Мейвен/Майвен/etc.) produced by Russian STT model. - StripWakeToken helper strips leading wake in any script so time/date grammars still match when wake is present. - Add classifier examples for chat utterances (EN + RU). - Wire LLMRouter into Router.Config; optional, nil-safe.
100 lines
4.7 KiB
Go
100 lines
4.7 KiB
Go
// Package router is maven's reactive path — the cascade that turns a free-form
|
|
// utterance into a deterministic Decision.
|
|
//
|
|
// Spec contract (from maven.md § reactive path — router):
|
|
//
|
|
// - routing is a DECISION, and every decision in maven stays deterministic.
|
|
// a classifier owns the route; the SLM stays in its phrasing lane. same
|
|
// boundary as "rules decide, llm phrases," extended to the reactive path.
|
|
// - a CASCADE, not classifier-vs-deterministic — 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 vosk command path.
|
|
// stage 1 — intent classifier. embed utterance, nearest-centroid over
|
|
// labeled intents. one forward pass, ~30ms cpu, similarity score.
|
|
// 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 six save-where labels from the spec'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 → chroma
|
|
// - query: answer, don't store → slm reads sqlite or chroma (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
|
|
}
|