Files
Maven/internal/router/intent.go
T
claude 1d02ba8936 router: add ActionResolutionMethod type and ResolvedBy field to Slots
Five disjoint values tracking which component selected the exact function:
grammar_fixed, grammar_matcher, extractor_raw, extractor_llm_text,
fallback_matcher. Slots.ResolvedBy carries provenance at the selection
point.
2026-09-06 13:49:50 +04:00

185 lines
8.8 KiB
Go
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.
// 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"
// RouteProducer — which stage of the cascade produced the routing decision.
// Recorded for observability so a trace can name the winning component without
// re-deriving it from the stage number and surrounding claims.
type RouteProducer string
const (
RouteProducerGrammar RouteProducer = "grammar"
RouteProducerHeads RouteProducer = "heads"
RouteProducerLLM RouteProducer = "llm"
RouteProducerClassifier RouteProducer = "classifier"
)
// 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"
)
// ActionResolutionMethod — which component actually selected the exact
// function. Recorded for observability so a trace can name the selection
// mechanism without re-deriving it from the route producer and surrounding
// claims. Five disjoint values; empty means no function was resolved.
type ActionResolutionMethod string
const (
// ActionResolutionGrammarFixed — a stage-0 grammar hardcodes a fixed
// canonical fn (praxis lifecycle, task-status). No matcher involved.
ActionResolutionGrammarFixed ActionResolutionMethod = "grammar_fixed"
// ActionResolutionGrammarMatcher — a stage-0 grammar invokes the
// ActMatcher to select fn (wakeword-act fast path).
ActionResolutionGrammarMatcher ActionResolutionMethod = "grammar_matcher"
// ActionResolutionExtractorRaw — post-route Extractor.Acts.Match over
// the original/raw routed utterance.
ActionResolutionExtractorRaw ActionResolutionMethod = "extractor_raw"
// ActionResolutionExtractorLLMText — the LLM produced cleaned
// Slots.Text, then Extractor.Acts.Match selected fn from it.
ActionResolutionExtractorLLMText ActionResolutionMethod = "extractor_llm_text"
// ActionResolutionFallbackMatcher — ResolveActionCandidate ran the
// fallback matcher because routing/extraction left HasFn=false.
ActionResolutionFallbackMatcher ActionResolutionMethod = "fallback_matcher"
)
// 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
// ResolvedBy — which component actually selected the exact function.
// Set when Fn is set; empty when HasFn is false. Travels with the slot
// so provenance is known at the selection point, not reconstructed later.
ResolvedBy ActionResolutionMethod
// 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
// Producer — which cascade stage produced this decision. Recorded for
// observability so a trace can name the winning component directly.
Producer RouteProducer
// 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
// SourceAnchored — a stage 0 grammar named that destination, matching a
// literal pattern to do it. Only the router sets this, and only there.
//
// It exists because one thing downstream is not reversible by evidence
// (V-666). Naming a destination normally takes guessing sources off a turn,
// and one of those is the personal boundary, which is what stops a question
// about him from reaching the world. A grammar that read "что такое X" may
// take it off. A model or a softmax may not, because a wrong destination
// there widens what leaves the box rather than costing an answer.
//
// Read Stage instead and the two decisions get coupled: stage 0 also means
// confidence 1.0 and an anchored claim band, and a later cascade change
// could make one true where the other is not.
SourceAnchored bool
// 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
}