Query source is a routing decision made outside the router #199
@@ -105,6 +105,12 @@ type Decision struct {
|
||||
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
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
package router
|
||||
|
||||
// Source — where the answer to a query lives. It is the second half of a
|
||||
// routing decision and it used to be made outside the router entirely (V-655).
|
||||
//
|
||||
// The cascade sorted an utterance into one of seven intents with stage 0 rules,
|
||||
// the resident model and the classifier behind it, a fixture measuring it and
|
||||
// the decision trace recording it. Then IntentQuery handed the turn to
|
||||
// querySources in the daemon, a chain of twenty-two branches deciding by seed
|
||||
// similarity in a fixed order, with none of that. So the careful sorter did the
|
||||
// easy half and the sloppy one did the hard half: on 2026-08-07 weather claimed
|
||||
// "что такое TCP?" and answered "для какого города?", because weather read one
|
||||
// percent closer to the turn than the pile of leftover seeds did, and one
|
||||
// percent was enough. Search would have answered it and search was never asked.
|
||||
//
|
||||
// "query" is not a destination. It is a shrug. This is the field that says
|
||||
// where to look.
|
||||
//
|
||||
// # Why twelve and not twenty-two
|
||||
//
|
||||
// A destination is what a decider can plausibly name from the utterance alone,
|
||||
// not one entry per source. Three of the daemon's sources are successive passes
|
||||
// over his own words and a fourth reads the facts by key: which of them lands
|
||||
// the hit is an ordering detail inside the chain, and no utterance says. They
|
||||
// are SourceRecall together. The same goes for the metasearch, the offline
|
||||
// encyclopedia and a page he named by URL, which are SourceWorld.
|
||||
//
|
||||
// # Empty is a real value and it is the floor
|
||||
//
|
||||
// SourceUnknown means nobody decided. The daemon then walks the whole chain in
|
||||
// its original order, which is the behaviour that shipped before this field
|
||||
// existed. So the classifier arm sets nothing and costs nothing, and a box
|
||||
// whose model is down routes queries exactly as it did.
|
||||
type Source string
|
||||
|
||||
const (
|
||||
// SourceUnknown — no decider named a destination. Walk the chain.
|
||||
SourceUnknown Source = ""
|
||||
|
||||
// His own data.
|
||||
SourceRecall Source = "recall" // notes, facts and what he has said before
|
||||
SourceCalendar Source = "calendar" // events, and the only date-aware destination
|
||||
SourceTasks Source = "tasks" // the task list
|
||||
SourceList Source = "list" // the shopping and other named lists
|
||||
SourceMoney Source = "money" // the spending facts the poller writes
|
||||
|
||||
// The surroundings.
|
||||
SourceWeather Source = "weather" // the forecast for a place
|
||||
SourceHome Source = "home" // lights, devices, the house
|
||||
SourceNetwork Source = "network" // the LAN and what is on it
|
||||
SourceFeeds Source = "feeds" // the RSS she reads
|
||||
SourceAttention Source = "attention" // what Praxis says needs looking at
|
||||
|
||||
// Everything else.
|
||||
SourceSelf Source = "self" // a question about Maven herself
|
||||
SourceWorld Source = "world" // search, the ZIMs, a page he named
|
||||
)
|
||||
|
||||
// Sources — every destination a decider may name, in a fixed order so a prompt,
|
||||
// a grammar table and a test all read the same list. SourceUnknown is not a
|
||||
// member: it is the absence of a choice, not one of the choices.
|
||||
var Sources = []Source{
|
||||
SourceRecall, SourceCalendar, SourceTasks, SourceList, SourceMoney,
|
||||
SourceWeather, SourceHome, SourceNetwork, SourceFeeds, SourceAttention,
|
||||
SourceSelf, SourceWorld,
|
||||
}
|
||||
|
||||
// ValidSource reports whether s is one a decider may name. Anything else,
|
||||
// including a destination invented by a model, is dropped back to
|
||||
// SourceUnknown by the caller rather than trusted.
|
||||
func ValidSource(s Source) bool {
|
||||
for _, known := range Sources {
|
||||
if s == known {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -187,9 +187,11 @@ func SystemTimeDateGrammars() []Grammar {
|
||||
// written ("the clock/date system rule must not swallow it"); the daemon
|
||||
// disagreed with the fixture and the daemon was wrong.
|
||||
//
|
||||
// Routing, not answering. These set the intent and nothing else — which source
|
||||
// in the query chain claims the turn stays the chain's decision, and a
|
||||
// question with no date still falls through queryCalendar to recall.
|
||||
// Routing, not answering. Two of the five also name the calendar as the
|
||||
// destination (V-655), which narrows who may GUESS their way onto the turn and
|
||||
// claims nothing. Every source that looks something up still runs, in the order
|
||||
// it always did, so a question with no date still falls through queryCalendar
|
||||
// to recall.
|
||||
//
|
||||
// Deliberately not folded into SystemTimeDateGrammars: those exist to send
|
||||
// utterances TO system, these exist to keep utterances OUT of it, and one
|
||||
@@ -199,9 +201,15 @@ func AgendaQueryGrammars() []Grammar {
|
||||
{
|
||||
// An explicit calendar noun is unambiguous wherever it appears:
|
||||
// "что в календаре на завтра", "покажи расписание на среду".
|
||||
//
|
||||
// The one agenda rule that names its destination, because an
|
||||
// explicit calendar noun leaves nothing to weigh (V-655). The
|
||||
// possessive rules below deliberately do not: "что у меня в списке
|
||||
// покупок" matches agenda-query, and naming the calendar there
|
||||
// would take the list source off the turn.
|
||||
Name: "calendar-query",
|
||||
Pattern: regexp.MustCompile(`(?i)(календар|расписани|повестк)`),
|
||||
Build: agendaQueryBuild,
|
||||
Build: queryTo(SourceCalendar),
|
||||
},
|
||||
{
|
||||
// The agenda phrasing with no calendar noun. Anchored at the start
|
||||
@@ -251,9 +259,11 @@ func AgendaQueryGrammars() []Grammar {
|
||||
// "во сколько созвон". He is asking when something on his calendar
|
||||
// happens, and the noun is the only signal. Closed list, so "когда
|
||||
// битва при Ватерлоо" is still a world question.
|
||||
// Names the calendar (V-655): the noun list is closed and every
|
||||
// member of it is an event, so there is nothing else to weigh.
|
||||
Name: "event-time-query",
|
||||
Pattern: regexp.MustCompile(`(?i)^\s*(когда|во\s+сколько|в\s+котором\s+часу)\s+(будет\s+|у\s+нас\s+)?(планёрк|планерк|встреч|созвон|митинг|совещани|звонок|созвон|приём|прием|интервью|собеседовани|тренировк|урок|занятие|пара)[а-я]*(\s|[?!.]|$)`),
|
||||
Build: agendaQueryBuild,
|
||||
Build: queryTo(SourceCalendar),
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -340,6 +350,11 @@ func narrativeQueryBuild(m []string) (Decision, bool) {
|
||||
Intent: IntentQuery,
|
||||
Confidence: 1.0,
|
||||
Slots: Slots{Text: topic},
|
||||
// The world, because that is the shape this asks for and the rule has
|
||||
// already declined the two cases where it is not: entertainment, and
|
||||
// questions about her (V-655). His own notes are still read first — a
|
||||
// destination narrows who may guess and reorders nothing.
|
||||
Source: SourceWorld,
|
||||
}, true
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package router
|
||||
|
||||
import "regexp"
|
||||
|
||||
// WorldQueryGrammars — stage-0 rules for the two question shapes that name the
|
||||
// world in their own words, and say so plainly enough that no scorer is needed
|
||||
// (V-655).
|
||||
//
|
||||
// They exist because of what happens when nothing deterministic claims these.
|
||||
// Measured on the box on 2026-08-07 (docs/evals/2026-08-07-week-of-usage.md,
|
||||
// section 4): "что такое TCP?" and "сколько будет 17 на 23?" were both answered
|
||||
// "для какого города?", and "кто такой Линус Торвальдс?" was answered "не знаю —
|
||||
// не нашла у тебя такой записи". None of those three is about him, about the
|
||||
// weather, or about anything on this box.
|
||||
//
|
||||
// The mechanism is the destination, not the answer. Naming SourceWorld does not
|
||||
// send the turn outside and does not skip a single source that looks something
|
||||
// up: his notes, his facts and the personal boundary all still run first, in the
|
||||
// order they always did. What it does is stop the sources that claim on seed
|
||||
// similarity from taking the turn on the way past. Weather cannot claim a
|
||||
// question about a protocol once the utterance has said which side it is on.
|
||||
//
|
||||
// Both patterns are spelled out here rather than drawn from internal/lexicon,
|
||||
// which is the same call the agenda rules made: these are interrogative FRAMES
|
||||
// of two words, not a closed class of single words, and the lexicon holds
|
||||
// classes. Nothing here is a stem pattern over open vocabulary — the variable
|
||||
// part of each rule is the topic, and the rule reads none of it.
|
||||
func WorldQueryGrammars() []Grammar {
|
||||
return []Grammar{
|
||||
{
|
||||
// "что такое X", "кто такой X". A request for what a thing or a
|
||||
// person IS, which his own data can answer and usually cannot.
|
||||
//
|
||||
// The topic is deliberately not captured into Slots.Text. Every
|
||||
// source below reads the utterance, "что такое TCP?" is already the
|
||||
// best query string for it, and the agenda rules make the same call
|
||||
// for the same reason.
|
||||
Name: "definition-query",
|
||||
Pattern: definitionQueryPattern,
|
||||
Build: queryTo(SourceWorld),
|
||||
},
|
||||
{
|
||||
// "сколько будет 17 на 23", "сколько будет 2+2". Arithmetic, which
|
||||
// the metasearch answers and no local source holds. The digits are
|
||||
// what make it arithmetic: "сколько будет гостей" names no number
|
||||
// and is a question about his evening.
|
||||
Name: "arithmetic-query",
|
||||
Pattern: arithmeticQueryPattern,
|
||||
Build: queryTo(SourceWorld),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// definitionQueryPattern — anchored at the start, because "напомни узнать что
|
||||
// такое TCP" is a reminder that happens to contain the frame.
|
||||
//
|
||||
// (\s|[?!.]|$) and not \b: Go's \b is ASCII-only and never fires after a
|
||||
// Cyrillic letter, so the ASCII form silently matches nothing. The agenda rules
|
||||
// carry the same note.
|
||||
var definitionQueryPattern = regexp.MustCompile(
|
||||
`(?i)^\s*(что\s+так(ое|ая)|кто\s+так(ой|ая|ие)|what\s+is|who\s+is)(\s|[?!.]|$)`)
|
||||
|
||||
// arithmeticQueryPattern — the ask, then a digit somewhere after it. Loose on
|
||||
// what sits between them on purpose: the operator is spoken half a dozen ways
|
||||
// ("на", "умножить на", "плюс", "+") and reading them is the calculator's job,
|
||||
// not this rule's. All this decides is which side of the boundary the turn is
|
||||
// on.
|
||||
var arithmeticQueryPattern = regexp.MustCompile(
|
||||
`(?i)^\s*(сколько\s+будет|посчитай|вычисли|how\s+much\s+is)\s.*\d`)
|
||||
|
||||
// queryTo builds a stage-0 query Decision that names where the answer lives.
|
||||
//
|
||||
// The utterance travels intact and no slot is filled, which is the same
|
||||
// contract agendaQueryBuild has: confidence 1.0 on the intent and the
|
||||
// destination, and every source below still decides for itself whether it has
|
||||
// an answer. Naming a destination narrows who may guess. It promises nothing.
|
||||
func queryTo(dest Source) func([]string) (Decision, bool) {
|
||||
return func([]string) (Decision, bool) {
|
||||
return Decision{
|
||||
Stage: 0,
|
||||
Intent: IntentQuery,
|
||||
Confidence: 1.0,
|
||||
Source: dest,
|
||||
}, true
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
package router
|
||||
|
||||
import "testing"
|
||||
|
||||
// The three utterances from the 2026-08-07 week on the box that no local source
|
||||
// could answer and three different local sources claimed anyway. Stage 0 has to
|
||||
// say which side of the boundary they are on, because by the time the chain is
|
||||
// walking, the only thing separating them from a weather forecast is a cosine.
|
||||
func TestAWorldQuestionNamesTheWorld(t *testing.T) {
|
||||
cases := []struct {
|
||||
utterance string
|
||||
rule string
|
||||
}{
|
||||
{"что такое TCP?", "definition-query"},
|
||||
{"кто такой Линус Торвальдс?", "definition-query"},
|
||||
{"что такая мембрана", "definition-query"},
|
||||
{"кто такая Ада Лавлейс?", "definition-query"},
|
||||
{"what is TCP?", "definition-query"},
|
||||
{"сколько будет 17 на 23?", "arithmetic-query"},
|
||||
{"посчитай 2+2", "arithmetic-query"},
|
||||
{"сколько будет 5 умножить на 6", "arithmetic-query"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
dec, rule, ok := matchWorldQuery(c.utterance)
|
||||
if !ok {
|
||||
t.Errorf("%q: no world rule claimed it", c.utterance)
|
||||
continue
|
||||
}
|
||||
if rule != c.rule {
|
||||
t.Errorf("%q: claimed by %q, want %q", c.utterance, rule, c.rule)
|
||||
}
|
||||
if dec.Intent != IntentQuery {
|
||||
t.Errorf("%q: intent %q, want query", c.utterance, dec.Intent)
|
||||
}
|
||||
if dec.Source != SourceWorld {
|
||||
t.Errorf("%q: source %q, want %q", c.utterance, dec.Source, SourceWorld)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The frame has to be the whole opening or the rule is reading somebody else's
|
||||
// sentence. Every case here contains a world-question shape and is not one.
|
||||
func TestAWorldRuleDeclinesWhatIsNotItsShape(t *testing.T) {
|
||||
cases := []struct {
|
||||
utterance string
|
||||
why string
|
||||
}{
|
||||
{"напомни узнать что такое TCP", "a reminder that happens to quote the frame"},
|
||||
{"запиши что такое TCP", "a capture that happens to quote the frame"},
|
||||
{"сколько будет гостей", "an ask with no number is not arithmetic"},
|
||||
{"что у меня сегодня?", "his agenda, and the agenda rules own it"},
|
||||
{"кто там?", "not the frame"},
|
||||
{"посчитай расходы", "no number, so the money source keeps it"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if _, rule, ok := matchWorldQuery(c.utterance); ok {
|
||||
t.Errorf("%q: claimed by %q, want no claim — %s", c.utterance, rule, c.why)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The destination is advice about who may guess, never a filled slot. A rule
|
||||
// that quietly captured the topic would change what every source below reads.
|
||||
func TestNamingTheWorldFillsNoSlot(t *testing.T) {
|
||||
dec, _, ok := matchWorldQuery("что такое TCP?")
|
||||
if !ok {
|
||||
t.Fatal("definition-query did not claim it")
|
||||
}
|
||||
if dec.Slots.Text != "" || dec.Slots.HasTime || dec.Slots.HasFn || dec.Slots.HasKey {
|
||||
t.Errorf("slots = %+v, want none filled", dec.Slots)
|
||||
}
|
||||
if dec.Confidence != 1.0 {
|
||||
t.Errorf("confidence = %v, want 1.0 for a stage-0 match", dec.Confidence)
|
||||
}
|
||||
}
|
||||
|
||||
func matchWorldQuery(utterance string) (Decision, string, bool) {
|
||||
for _, g := range WorldQueryGrammars() {
|
||||
m := g.Pattern.FindStringSubmatch(utterance)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
if dec, ok := g.Build(m); ok {
|
||||
return dec, g.Name, true
|
||||
}
|
||||
}
|
||||
return Decision{}, "", false
|
||||
}
|
||||
Reference in New Issue
Block a user