c938148619
Naming a destination takes the guessing query sources off a turn, and the personal boundary is one of them. Every other guesser costs an answer when it is wrongly dropped. This one costs the rule that a question about him never reaches an upstream engine. Three deciders name a destination now and two of them infer it: the routing heads and the resident model. Decision.SourceAnchored says a stage 0 grammar read the words instead. queryWalk honours it for the source marked boundary: true and for no other, so the rest of the table is unchanged. Owner's call of 2026-08-09. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
136 lines
6.6 KiB
Go
136 lines
6.6 KiB
Go
// 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
|
||
|
||
// 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
|
||
}
|