From b4a3867479af51fd06ae964afa877c766a74e406 Mon Sep 17 00:00:00 2001 From: kami Date: Sat, 1 Aug 2026 00:51:44 +0400 Subject: [PATCH] Turn actionQuery into a chain of query sources MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The six answer sources were hand-unrolled inside one 127-line function. The intent table is a closed set of 7, but this list is open-ended — Kiwix (#286), RSS (#258), the crawler (#259) and email (#246) each add one. Each is now a registry entry: a name plus a method on the handler, walked in order until one claims the question. Order is unchanged and still load-bearing (memory before the notes-only pass, #373), the confidence gate keeps its position and semantics, and every reply string, log line and best-effort failure is verbatim. --- cmd/mavend/actions.go | 132 --------------------- cmd/mavend/actions_query.go | 226 ++++++++++++++++++++++++++++++++++++ 2 files changed, 226 insertions(+), 132 deletions(-) create mode 100644 cmd/mavend/actions_query.go diff --git a/cmd/mavend/actions.go b/cmd/mavend/actions.go index 83a874e..5a455a9 100644 --- a/cmd/mavend/actions.go +++ b/cmd/mavend/actions.go @@ -36,16 +36,12 @@ package main import ( "context" "errors" - "fmt" "log" "strconv" - "time" "github.com/kami/maven/internal/ipc" - "github.com/kami/maven/internal/memory" "github.com/kami/maven/internal/router" "github.com/kami/maven/internal/tool" - "github.com/kami/maven/internal/weather" ) // actionHandlers is the per-intent dispatch table used by applyAction. @@ -232,131 +228,3 @@ func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) s } return "" // replier phrases the "saved" reply } - -func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision) string { - // Fact-by-key lookup: when the dialogue layer resolved an anaphoric - // reference to a prior fact's key (e.g. "когда я это сделал?" after - // "запиши что я пил воду"), look up the fact's value directly. - if dec.Slots.HasKey && dec.Slots.Key != "" { - if f, err := h.api.LatestFact(ctx, dec.Slots.Key); err == nil { - if dec.Slots.HasTime { - // The query asks about timing — the fact's own timestamp - // is the answer it's looking for. Format as a natural reply. - reply := fmt.Sprintf("я записала это %s", formatTime(f.Ts)) - return reply - } - // General fact reference: describe what we know. - if dec.Utterance == "" { - return fmt.Sprintf("вот что я знаю: %s — %s", dec.Slots.Key, f.Value) - } - // The utterance still carries the question; fall through to - // normal RAG with the resolved key in context. - } - } - - // Calendar questions: "что у меня сегодня?", "планы на завтра?" - // h.now(), not time.Now(): the handler's clock is the injected one, so - // this arm can be tested at a fixed time like the rest. - if date, ok := router.ParseCalendarDate(dec.Utterance, h.now()); ok { - events, err := h.api.CalendarEvents(ctx, date, date.Add(24*time.Hour)) - if err != nil { - log.Printf("voice: calendar events: %v", err) - return "не получилось проверить календарь." - } - values := make([]string, len(events)) - for i, e := range events { - values[i] = e.Value - } - var f router.CalendarEventFormatter - return f.Format(values, date) - } - - // Weather questions - if isWeatherQuery(dec.Utterance) { - loc := extractWeatherLocation(dec.Utterance, h.weatherLocation) - ctxWT, cancel := context.WithTimeout(ctx, 5*time.Second) - defer cancel() - w, err := h.weatherProvider.CurrentWeather(ctxWT, loc) - if errors.Is(err, weather.ErrNotConfigured) { - return "погода не настроена." - } - if err != nil { - log.Printf("voice: weather: %v", err) - return "не получилось узнать погоду." - } - return fmt.Sprintf("в %s сейчас %.0f градусов, %s.", w.Location, w.Temperature, w.Condition) - } - - vec, err := router.EmbedQuery(ctx, h.embedder, dec.Utterance) - if err != nil { - log.Printf("voice: embed query: %v", err) - return "не получилось найти ответ." - } - // 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 a fact. - // - // This used to run only after the notes-only gate below had already - // rejected the same note at the same score, which no note could ever - // survive a second time: the branch could only return a fact (#373). - // Order, not the gate, was the bug — the set of questions Maven answers - // is unchanged, only which memory gets to answer them. - if h.memStore != nil { - if hits, herr := h.memStore.Search(ctx, vec, 3); herr == nil { - if hit, ok := bestRecall(hits, h.queryMinScore, h.queryMinMargin); ok { - text := hit.Meta["text"] - // A note is phrased in Maven's voice; a fact is read back - // as it was stored. - if hit.Meta["type"] == "note" { - if reply, perr := h.phraser.PhraseQuery(ctx, dec.Utterance, []string{text}); perr == nil && reply != "" { - return reply - } - } - return text - } - } else { - log.Printf("voice: memory search: %v", herr) - } - } - - notes, err := h.api.QueryNotes(ctx, vec, 5) - if err != nil { - log.Printf("voice: query notes: %v", err) - return "не получилось найти ответ." - } - // Notes-only pass, for notes the vector index above does not hold (an - // older note written before it existed). Same gate, notes-only - // candidates. - // - // Confidence gate: below it, say "I don't know" rather than read back - // the least-unrelated note — a confident wrong recall is worse than a - // gap (spec's "not a guesser-of-truth"). Same instinct as the loop's - // since(key)==null → don't fire. Two parts: an absolute cosine floor, - // and a margin over the runner-up, which is the part that works with - // the e5 embedder's narrow score band. See memory.Confident. - noteScores := make([]float64, len(notes)) - for i, n := range notes { - noteScores[i] = n.Score - } - if !memory.ConfidentScores(noteScores, h.queryMinScore, h.queryMinMargin) { - // Try general knowledge from the phraser before giving up - reply, err := h.phraser.PhraseQuery(ctx, dec.Utterance, nil) - if err != nil || reply == "" { - return "не знаю." - } - return reply - } - texts := make([]string, len(notes)) - for i, n := range notes { - texts[i] = n.Text - } - reply, err := h.phraser.PhraseQuery(ctx, dec.Utterance, texts) - if err != nil { - log.Printf("voice: phrase query: %v", err) - } - if reply == "" { - reply = "вот что я нашла: " + texts[0] - } - return reply -} diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go new file mode 100644 index 0000000..9ce9972 --- /dev/null +++ b/cmd/mavend/actions_query.go @@ -0,0 +1,226 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log" + "time" + + "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/memory" + "github.com/kami/maven/internal/router" + "github.com/kami/maven/internal/weather" +) + +// queryTurn is the per-turn scratch a chain of query sources shares: the +// decision being answered plus the work an earlier source already paid for +// (the query embedding, the notes it pulled). Sources read and fill it in +// order, so a later source never re-embeds. +type queryTurn struct { + dec router.Decision + vec []float32 + notes []ipc.Note +} + +// querySource — one answer source in the chain actionQuery walks. answer +// returns (reply, true) when this source claims the question, ("", false) +// when it passes to the next one. name is for reading the table, not logged. +// +// A struct of one func rather than an interface: every source is a plain +// method on *reactiveHandler with no state of its own (what state a turn has +// lives in queryTurn), so an interface would mean one empty type per source +// to satisfy it — ceremony for nothing. Same reasoning as confirmResolver in +// confirm.go, and the table then reads like actionHandlers: a flat list of +// method expressions you extend with one line. +type querySource struct { + name string + answer func(*reactiveHandler, context.Context, *queryTurn) (string, bool) +} + +// querySources is the ordered chain actionQuery walks; first source to claim +// answers the turn. THE ORDER IS LOAD-BEARING — see the memory-before-notes +// comment on queryMemory: running the notes-only pass first was #373, and the +// gate was never the bug. Adding a source (Kiwix, RSS, crawler, email) is one +// line here plus its method; where you put the line is the whole decision. +var querySources = []querySource{ + {"fact-by-key", (*reactiveHandler).queryFactByKey}, + {"calendar", (*reactiveHandler).queryCalendar}, + {"weather", (*reactiveHandler).queryWeather}, + {"embed", (*reactiveHandler).queryEmbed}, + {"memory", (*reactiveHandler).queryMemory}, + {"notes", (*reactiveHandler).queryNotes}, + {"general-knowledge", (*reactiveHandler).queryGeneral}, +} + +func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision) string { + t := &queryTurn{dec: dec} + for _, src := range querySources { + if reply, ok := src.answer(h, ctx, t); ok { + return reply + } + } + return "не знаю." +} + +// queryFactByKey — when the dialogue layer resolved an anaphoric reference to +// a prior fact's key (e.g. "когда я это сделал?" after "запиши что я пил +// воду"), look up the fact's value directly. +func (h *reactiveHandler) queryFactByKey(ctx context.Context, t *queryTurn) (string, bool) { + dec := t.dec + if !dec.Slots.HasKey || dec.Slots.Key == "" { + return "", false + } + f, err := h.api.LatestFact(ctx, dec.Slots.Key) + if err != nil { + return "", false + } + if dec.Slots.HasTime { + // The query asks about timing — the fact's own timestamp is the + // answer it's looking for. Format as a natural reply. + return fmt.Sprintf("я записала это %s", formatTime(f.Ts)), true + } + // General fact reference: describe what we know. + if dec.Utterance == "" { + return fmt.Sprintf("вот что я знаю: %s — %s", dec.Slots.Key, f.Value), true + } + // The utterance still carries the question; fall through to normal RAG + // with the resolved key in context. + return "", false +} + +// queryCalendar — "что у меня сегодня?", "планы на завтра?" +// h.now(), not time.Now(): the handler's clock is the injected one, so this +// source can be tested at a fixed time like the rest. +func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (string, bool) { + date, ok := router.ParseCalendarDate(t.dec.Utterance, h.now()) + if !ok { + return "", false + } + events, err := h.api.CalendarEvents(ctx, date, date.Add(24*time.Hour)) + if err != nil { + log.Printf("voice: calendar events: %v", err) + return "не получилось проверить календарь.", true + } + values := make([]string, len(events)) + for i, e := range events { + values[i] = e.Value + } + var f router.CalendarEventFormatter + return f.Format(values, date), true +} + +func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (string, bool) { + if !isWeatherQuery(t.dec.Utterance) { + return "", false + } + loc := extractWeatherLocation(t.dec.Utterance, h.weatherLocation) + ctxWT, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + w, err := h.weatherProvider.CurrentWeather(ctxWT, loc) + if errors.Is(err, weather.ErrNotConfigured) { + return "погода не настроена.", true + } + if err != nil { + log.Printf("voice: weather: %v", err) + return "не получилось узнать погоду.", true + } + return fmt.Sprintf("в %s сейчас %.0f градусов, %s.", w.Location, w.Temperature, w.Condition), true +} + +// queryEmbed isn't an answer source — it's the shared cost the two recall +// sources below both need, run once, in the position it always ran in. It +// only claims the turn when the embedder fails. +func (h *reactiveHandler) queryEmbed(ctx context.Context, t *queryTurn) (string, bool) { + vec, err := router.EmbedQuery(ctx, h.embedder, t.dec.Utterance) + if err != nil { + log.Printf("voice: embed query: %v", err) + return "не получилось найти ответ.", true + } + t.vec = vec + 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 +// a fact. +// +// This used to run only after the notes-only source below had already +// rejected the same note at the same score, which no note could ever survive +// a second time: the branch could only return a fact (#373). Order, not the +// gate, was the bug — the set of questions Maven answers is unchanged, only +// which memory gets to answer them. +func (h *reactiveHandler) queryMemory(ctx context.Context, t *queryTurn) (string, bool) { + if h.memStore == nil { + return "", false + } + hits, herr := h.memStore.Search(ctx, t.vec, 3) + if herr != nil { + log.Printf("voice: memory search: %v", herr) + return "", false + } + hit, ok := bestRecall(hits, h.queryMinScore, h.queryMinMargin) + if !ok { + return "", false + } + text := hit.Meta["text"] + // A note is phrased in Maven's voice; a fact is read back as it was + // stored. + if hit.Meta["type"] == "note" { + if reply, perr := h.phraser.PhraseQuery(ctx, t.dec.Utterance, []string{text}); perr == nil && reply != "" { + return reply, true + } + } + return text, true +} + +// queryNotes — notes-only pass, for notes the vector index above does not +// hold (an older note written before it existed). Same gate, notes-only +// candidates. +// +// Confidence gate: below it, say "I don't know" rather than read back the +// least-unrelated note — a confident wrong recall is worse than a gap (spec's +// "not a guesser-of-truth"). Same instinct as the loop's since(key)==null → +// don't fire. Two parts: an absolute cosine floor, and a margin over the +// runner-up, which is the part that works with the e5 embedder's narrow score +// band. See memory.Confident. Failing the gate passes the turn on to general +// knowledge, which is what "don't read back the runner-up" means here. +func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string, bool) { + notes, err := h.api.QueryNotes(ctx, t.vec, 5) + if err != nil { + log.Printf("voice: query notes: %v", err) + return "не получилось найти ответ.", true + } + t.notes = notes + noteScores := make([]float64, len(notes)) + for i, n := range notes { + noteScores[i] = n.Score + } + if !memory.ConfidentScores(noteScores, h.queryMinScore, h.queryMinMargin) { + return "", false + } + texts := make([]string, len(notes)) + for i, n := range notes { + texts[i] = n.Text + } + reply, err := h.phraser.PhraseQuery(ctx, t.dec.Utterance, texts) + if err != nil { + log.Printf("voice: phrase query: %v", err) + } + if reply == "" { + reply = "вот что я нашла: " + texts[0] + } + return reply, true +} + +// queryGeneral — general knowledge from the phraser, the last source before +// giving up. It always claims: either the model answers or Maven says she +// doesn't know. +func (h *reactiveHandler) queryGeneral(ctx context.Context, t *queryTurn) (string, bool) { + reply, err := h.phraser.PhraseQuery(ctx, t.dec.Utterance, nil) + if err != nil || reply == "" { + return "не знаю.", true + } + return reply, true +}