From b72665869201cd02870ee1775fcbefc144a2c553 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 6 Aug 2026 01:52:08 +0400 Subject: [PATCH] query chain: name the magic values, one topic veto (V-581) The two recall sources ran the same topic veto with the same log line spelled two ways. It is one helper now. Timeouts and read-back budgets that were literals get names beside the source that spends them. The money and list comments in querySources sat above the wrong entries, so the ordering argument read backwards. No source moved, no gate changed. --- cmd/mavend/actions_query.go | 85 +++++++++++++++++++++++++++---------- 1 file changed, 63 insertions(+), 22 deletions(-) diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go index b68f0ad..c41984b 100644 --- a/cmd/mavend/actions_query.go +++ b/cmd/mavend/actions_query.go @@ -88,15 +88,15 @@ var querySources = []querySource{ // through every source to the web search (Vikunja #475). Its matcher needs // an attention marker, and it falls through when Praxis is not configured. {name: "attention", answer: (*reactiveHandler).queryAttention}, - // Before the recall sources too: "сколько я потратил?" is a question about - // the money facts the poller wrote, and the notes pass would otherwise - // answer it from whatever he once said about spending. Its matcher needs a - // money noun plus an actual ask, so "я потратил весь день" is untouched. // Next to "tasks" and for the same reason: "что мне купить?" is a question // about the shopping list, and the recall pass would otherwise answer it // from an old note about the shop. Its matcher needs an explicit list // marker, so "надо бы съездить в магазин" is untouched. {name: "list", answer: (*reactiveHandler).queryList}, + // Before the recall sources too: "сколько я потратил?" is a question about + // the money facts the poller wrote, and the notes pass would otherwise + // answer it from whatever he once said about spending. Its matcher needs a + // money noun plus an actual ask, so "я потратил весь день" is untouched. {name: "money", answer: (*reactiveHandler).queryMoney}, // Also above the recall sources: "что я тебе говорил?" is a question about // the facts he tapped in, and the notes pass would answer it with whatever @@ -428,6 +428,10 @@ func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (stri return f.FormatEntries(entries, date), true } +// homeTimeout — the whole house read. Longer than the weather call because the +// hub is polled over the LAN and answers for every device at once. +const homeTimeout = 10 * time.Second + // queryHome answers a question about the house. Read-only by construction: it // calls States and nothing else, so there is no confirm turn here — the only // way to CHANGE something is an enabled allowlist row through tool.Executor. @@ -444,7 +448,7 @@ func (h *reactiveHandler) queryHome(ctx context.Context, t *queryTurn) (string, // unreachable case is different and homeSummary covers it. return "", false } - ctxH, cancel := context.WithTimeout(ctx, 10*time.Second) + ctxH, cancel := context.WithTimeout(ctx, homeTimeout) defer cancel() return h.home.homeSummary(ctxH) } @@ -468,6 +472,10 @@ func (h *reactiveHandler) queryNetwork(ctx context.Context, t *queryTurn) (strin return h.netscan.scanSummary(ctx) } +// weatherTimeout — one geocode plus one forecast read. He asked a question with +// a one-line answer, so a provider that is slower than this is a failure. +const weatherTimeout = 5 * time.Second + func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (string, bool) { if !h.turnIsAbout(ctx, t, topicWeather, isWeatherQuery) { return "", false @@ -478,7 +486,7 @@ func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (strin // so is the only honest answer; picking a city would be inventing one. return phraser.Q(phraser.QueryWeatherWhere, nil), true } - ctxWT, cancel := context.WithTimeout(ctx, 5*time.Second) + ctxWT, cancel := context.WithTimeout(ctx, weatherTimeout) defer cancel() w, err := h.weatherProvider.CurrentWeather(ctxWT, loc) if errors.Is(err, weather.ErrNotConfigured) { @@ -529,6 +537,27 @@ func (h *reactiveHandler) queryEmbed(ctx context.Context, t *queryTurn) (string, return "", false } +// memoryRecallWidth and noteRecallWidth — how many candidates each recall pass +// pulls before the gate reads them. Both are small on purpose: the gate wants a +// best hit and its runner-up, and every further row is a margin the top match +// has to beat. +const ( + memoryRecallWidth = 3 + noteRecallWidth = 5 +) + +// recallOnTopic — the topic veto both recall sources apply after the score gate +// (#470). A memory about his slow network scored high enough to answer "почему +// небо синее?", because the right-note and must-be-silent score ranges overlap +// and no threshold sits between them. +func recallOnTopic(utterance, text string) bool { + if memory.RecallAllowed(utterance, text) { + return true + } + log.Printf("voice: recall %q rejected for %q: a world question and no shared topic word", text, utterance) + return false +} + // queryMemory — long-term memory first: ONE search over everything Maven // remembers (notes and facts share this index) and ONE confidence gate, so // the memory that is clearly the best match answers — a note just as much as @@ -549,7 +578,7 @@ func (h *reactiveHandler) queryMemory(ctx context.Context, t *queryTurn) (string // a "there is nothing" answer — pass rather than gate the chain. return "", false } - hits, herr := h.recall.memStore.Search(ctx, t.vec, 3) + hits, herr := h.recall.memStore.Search(ctx, t.vec, memoryRecallWidth) if herr != nil { log.Printf("voice: memory search: %v", herr) return "", false @@ -559,12 +588,8 @@ func (h *reactiveHandler) queryMemory(ctx context.Context, t *queryTurn) (string return "", false } text := hit.Meta["text"] - // The score cleared the gate and the topic still has to match (#470). A - // note about his slow network scored high enough to answer "почему небо - // синее?", because the right-note and must-be-silent score ranges overlap - // and no threshold sits between them. - if !memory.RecallAllowed(t.dec.Utterance, text) { - log.Printf("voice: recall %q rejected for %q: a world question and no shared topic word", text, t.dec.Utterance) + // The score cleared the gate and the topic still has to match. + if !recallOnTopic(t.dec.Utterance, text) { return "", false } // A note is phrased in Maven's voice; a fact is read back as it was @@ -600,7 +625,7 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string, // source could not look, and could-not-look passes. return "", false } - notes, err := h.api.QueryNotes(ctx, t.vec, 5) + notes, err := h.api.QueryNotes(ctx, t.vec, noteRecallWidth) if err != nil { // The store failed, so this source could not look either. It used to // claim here, which stopped the search, the ZIMs and the model from @@ -616,10 +641,9 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string, if !memory.ConfidentScores(noteScores, h.recall.minScore, h.recall.minMargin) { return "", false } - // Same topic veto as queryMemory above: the best note must be about what - // he asked, not merely the nearest vector in the index. - if !memory.RecallAllowed(t.dec.Utterance, notes[0].Text) { - log.Printf("voice: note %q rejected for %q: a world question and no shared topic word", notes[0].Text, t.dec.Utterance) + // The best note must be about what he asked, not merely the nearest vector + // in the index. + if !recallOnTopic(t.dec.Utterance, notes[0].Text) { return "", false } texts := make([]string, len(notes)) @@ -641,6 +665,23 @@ func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string, // prompt, the persona block and the reply. const webPageContextRunes = 1500 +// webFetchTimeout — the whole named-page source. Longer than the other outside +// sources because he named this page himself, so waiting for it is what he asked +// for, and there is nothing below that can answer instead. +const webFetchTimeout = 30 * time.Second + +// readBackRunes — how much of the evidence is read out when the phraser gave +// nothing back. It is spoken aloud, so it is a couple of sentences and not a +// page. +const readBackRunes = 300 + +// readBack — what an outside source says when the phraser gave nothing back. +// The evidence is read out plainly rather than dropped, because the fetch did +// happen and its result is a better answer than silence. +func readBack(evidence string) string { + return phraser.Q(phraser.QueryFound, map[string]string{"text": crawl.TrimRunes(evidence, readBackRunes)}) +} + // queryWeb — "посмотри https://example.org/x — что там?" (Vikunja #259). // // It claims a turn ONLY when he named a URL, which is what keeps a fallback from @@ -659,7 +700,7 @@ func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, b // guess dressed as an answer (Vikunja #479). return phraser.Q(phraser.QueryPageOff, nil), true } - ctxFetch, cancel := context.WithTimeout(ctx, 30*time.Second) + ctxFetch, cancel := context.WithTimeout(ctx, webFetchTimeout) defer cancel() page, err := h.crawler.Page(ctxFetch, link) if err != nil { @@ -680,7 +721,7 @@ func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, b if reply == "" { // No phraser (or it failed): read back the top of the page rather than // pretend the fetch did not happen. - return phraser.Q(phraser.QueryPageText, map[string]string{"text": crawl.TrimRunes(page.Text, 300)}), true + return phraser.Q(phraser.QueryPageText, map[string]string{"text": crawl.TrimRunes(page.Text, readBackRunes)}), true } return reply, true } @@ -746,7 +787,7 @@ func (h *reactiveHandler) querySearch(ctx context.Context, t *queryTurn) (string if reply == "" { // No phraser, or it failed. Read back the best evidence rather than // pretend the search did not happen. - return phraser.Q(phraser.QueryFound, map[string]string{"text": crawl.TrimRunes(resp.Snippets()[0], 300)}), true + return readBack(resp.Snippets()[0]), true } return reply, true } @@ -835,7 +876,7 @@ func (h *reactiveHandler) queryKiwix(ctx context.Context, t *queryTurn) (string, if reply == "" { // No phraser, or it failed. Read back the best hit rather than pretend // the search did not happen. - return phraser.Q(phraser.QueryFound, map[string]string{"text": crawl.TrimRunes(top.Title+" — "+page.Text, 300)}), true + return readBack(top.Title + " — " + page.Text), true } return reply, true }