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 }