diff --git a/internal/router/intent.go b/internal/router/intent.go index df15fea..43b0b1c 100644 --- a/internal/router/intent.go +++ b/internal/router/intent.go @@ -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 diff --git a/internal/router/source.go b/internal/router/source.go new file mode 100644 index 0000000..13e9273 --- /dev/null +++ b/internal/router/source.go @@ -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 +} diff --git a/internal/router/stage0.go b/internal/router/stage0.go index 1a4c117..ef4a36a 100644 --- a/internal/router/stage0.go +++ b/internal/router/stage0.go @@ -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 } diff --git a/internal/router/worldquery.go b/internal/router/worldquery.go new file mode 100644 index 0000000..c458daf --- /dev/null +++ b/internal/router/worldquery.go @@ -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 + } +} diff --git a/internal/router/worldquery_test.go b/internal/router/worldquery_test.go new file mode 100644 index 0000000..bd353d0 --- /dev/null +++ b/internal/router/worldquery_test.go @@ -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 +}