package main import ( "context" "errors" "fmt" "log" "strings" "time" "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/memory" "github.com/kami/maven/internal/morning" "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}, // Before "calendar" on purpose: both match "…на сегодня", and the plan is // the more specific ask (its matcher requires a plan word), so the calendar // listing would otherwise swallow it. {"day-plan", (*reactiveHandler).queryDayPlan}, // Also before "calendar": "что я обычно делаю по средам?" names a weekday, // and the habit question is the more specific one. Its matcher requires a // habit marker ("обычно", "каждый", …), so a question about this coming // Wednesday still reaches the calendar. {"habits", (*reactiveHandler).queryHabits}, // Before "calendar" and before the recall sources: "что мне нужно // сделать?" is a question about the task list, and the notes pass would // otherwise answer it with whatever note happens to be nearest. Its // matcher requires a task noun or an explicit "что … сделать", so a // date-bearing question still reaches the calendar. {"tasks", (*reactiveHandler).queryTasks}, // 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. {"money", (*reactiveHandler).queryMoney}, {"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 } // queryDayPlan — "какие планы на сегодня?", "что у меня по плану?", "что // дальше?" (Vikunja #128). Recites the day: calendar events, pending // reminders, and any morning checklist still outstanding. // // Read-only by construction — the plan is assembled and rendered core-side and // nothing here schedules or announces. "что дальше?" asks for the rest of the // day, so that phrasing trims what has already passed. func (h *reactiveHandler) queryDayPlan(ctx context.Context, t *queryTurn) (string, bool) { if !router.IsDayPlanQuery(t.dec.Utterance) { return "", false } plan, err := h.api.DayPlan(ctx) if err != nil { log.Printf("voice: day plan: %v", err) return "не получилось собрать план.", true } if !isRestOfDayQuery(t.dec.Utterance) { return plan.Spoken, true } // Rebuild the pure plan so the rest-of-day rendering is the same code that // rendered the whole day — one formatter, one persona. p := morning.Plan{Date: plan.Date} for _, it := range plan.Items { p.Items = append(p.Items, morning.PlanEntry{ At: it.At, Text: it.Text, Kind: morning.PlanKind(it.Kind), Uncertain: it.Uncertain, }) } return p.After(h.now()).FormatRU(), true } // isRestOfDayQuery — "что дальше?" and its English form, the only plan phrasing // that means "from now on" rather than "the whole day". func isRestOfDayQuery(text string) bool { s := strings.ToLower(text) return strings.Contains(s, "дальше") || strings.Contains(s, "next") } // habitFactWindow — how many recent facts the behaviour profile is counted // over. Enough for a season of habits without scanning the whole store on every // question; the profile is recomputed on read, so the bound is the cost control. const habitFactWindow = 2000 // queryHabits — "что я обычно делаю по вторникам?" (Vikunja #254). Counts the // answer out of the fact log rather than asking the model to summarise a life: // see internal/memory/behavior.go for why nothing here is generated. func (h *reactiveHandler) queryHabits(ctx context.Context, t *queryTurn) (string, bool) { q, ok := router.ParseHabitQuery(t.dec.Utterance) if !ok { return "", false } facts, err := h.api.RecentFacts(ctx, habitFactWindow) if err != nil { log.Printf("voice: habits: recent facts: %v", err) return "не получилось посмотреть записи.", true } obs := make([]memory.Observation, 0, len(facts)) for _, f := range facts { obs = append(obs, memory.Observation{At: f.Ts, Key: f.Key, Kind: f.Kind}) } profile := memory.BuildProfile(obs, h.now()) if q.HasWeekday { return profile.FormatWeekdayRU(q.Weekday), true } return profile.FormatOverallRU(), true } // 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 } // Provenance travels with each event. A work meeting relayed off a phone // notification (source ambient:notif, #126) is stored below full confidence // and gets hedged; a CalDAV read is recited plainly. entries := make([]router.CalendarEntry, len(events)) for i, e := range events { entries[i] = router.CalendarEntry{Text: e.Value, Uncertain: e.Confidence < 1.0} } var f router.CalendarEventFormatter return f.FormatEntries(entries, 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 }