package main import ( "context" "errors" "fmt" "log" "strings" "time" "github.com/kami/maven/internal/crawl" "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/rss" "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}, // Before the recall sources and before general knowledge: "что нового?" is // a question about the feeds she reads, and general knowledge would answer // it by inventing news. Its matcher needs a feed noun plus an ask, so // "у меня новая лента в инстаграме" is untouched. {"feeds", (*reactiveHandler).queryFeeds}, // Before "calendar" and before the recall sources: "что включено дома?" is // a question about the house, and the notes pass would otherwise answer it // from whatever he once said about the lights. Its matcher needs a house // marker plus an ask plus a device word, and it bails out on weather // wording, so "какая температура на улице?" still reaches the weather // source. {"home", (*reactiveHandler).queryHome}, // Next to "home" and for the same reason: "какие устройства в сети?" is a // question about the LAN, and the recall pass would otherwise answer it // from an old note about the router. Its matcher needs a network word plus // an ask plus a device noun, so "интернет не работает" is untouched. {"network", (*reactiveHandler).queryNetwork}, {"calendar", (*reactiveHandler).queryCalendar}, {"weather", (*reactiveHandler).queryWeather}, {"embed", (*reactiveHandler).queryEmbed}, {"memory", (*reactiveHandler).queryMemory}, {"notes", (*reactiveHandler).queryNotes}, // LAST before the model answers from memory, and that position is the whole // design (Vikunja #259): local sources first. The model, his own notes and // facts, and — once internal/kiwix is wired into this chain — the offline // ZIMs all get their turn before anything touches the network. This source // only claims a turn where he named a URL out loud, so it never competes // with a local answer. {"web", (*reactiveHandler).queryWeb}, {"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 } // feedNoteWindow — how many recent notes are scanned for feed items, and // feedReadOut — how many headlines she actually reads back. She summarises the // top of the pile, she does not recite a river. const ( feedNoteWindow = 200 feedReadOut = 3 ) // queryFeeds — "что нового в лентах?", "что нового по технологиям?" // (Vikunja #258). // // This is the ONLY way a feed item reaches him. The poller writes notes and // never speaks; asking is the trigger. If that ever changes, the thing that // changed is "Maven is not a nag", not a detail of this file. func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string, bool) { q, ok := router.ParseFeedQuery(t.dec.Utterance) if !ok { return "", false } if !h.feedsOn { // Claim the turn rather than fall through: "не читаю ленты" is true, and // letting general knowledge answer "что нового?" would be an invented // news bulletin. return "я пока не читаю ленты — они не настроены.", true } notes, err := h.api.RecentNotes(ctx, feedNoteWindow) if err != nil { log.Printf("voice: feeds: recent notes: %v", err) return "не получилось посмотреть ленты.", true } var picked []string for _, n := range notes { if !strings.HasPrefix(n.Source, rss.SourcePrefix) { continue } if !router.CategoryMatches(n.Text, q.Category) { continue } // The note carries title, summary and link; she reads the title. title := n.Text if i := strings.IndexByte(title, '\n'); i > 0 { title = title[:i] } picked = append(picked, strings.TrimSpace(title)) if len(picked) == feedReadOut { break } } if len(picked) == 0 { if q.Category != "" { return "по этой теме в лентах пока ничего.", true } return "в лентах пока ничего нового.", true } return "вот что нового: " + strings.Join(picked, "; "), 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 } // 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. func (h *reactiveHandler) queryHome(ctx context.Context, t *queryTurn) (string, bool) { if !isHomeQuery(t.dec.Utterance) { return "", false } if h.home == nil { // Claim the turn rather than fall through: "дом не подключён" is true, // and letting general knowledge answer would be an invented house. return "дом не подключён — я его не вижу.", true } ctxH, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() return h.home.homeSummary(ctxH) } // queryNetwork answers a question about the LAN with a bounded scan. There is // no confirm turn because nothing is changed, and no way to widen the range // because Scan takes no target — the utterance selects the question, never the // subnet. func (h *reactiveHandler) queryNetwork(ctx context.Context, t *queryTurn) (string, bool) { if !isNetworkQuery(t.dec.Utterance) { return "", false } if h.netscan == nil { // Claim the turn: "сканирование не настроено" is true, and general // knowledge would answer with an invented list of devices. return "сканирование сети не настроено.", true } return h.netscan.scanSummary(ctx) } 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) if loc == "" { // He named no city and voice.weather.default_location is unset. Saying // so is the only honest answer; picking a city would be inventing one. return "не знаю, для какого города — задай voice.weather.default_location или назови город.", true } 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 } // webPageContextRunes — how much of a fetched page is handed to the phraser. // Less than the crawler keeps: the rest of the 4096-token window belongs to the // prompt, the persona block and the reply. const webPageContextRunes = 1500 // queryWeb — "посмотри https://example.org/x — что там?" (Vikunja #259). // // It claims a turn ONLY when he named a URL, which is what keeps a fallback from // becoming a habit: no URL, no fetch, and the model answers from what is local. // What leaves the box is the URL and nothing else — no note, no fact, no history // travels with it. func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, bool) { link, ok := router.FirstURL(t.dec.Utterance) if !ok { return "", false } if h.crawler == nil { // Claim rather than fall through: he asked about a specific page, and // letting the model answer from the URL's spelling alone is how a small // model invents a page's contents. return "я не читаю страницы — это не настроено.", true } ctxFetch, cancel := context.WithTimeout(ctx, 30*time.Second) defer cancel() page, err := h.crawler.Page(ctxFetch, link) if err != nil { if errors.Is(err, crawl.ErrRobots) { return "эта страница закрыта для чтения — robots.txt не разрешает.", true } log.Printf("voice: web: %v", err) return "не получилось прочитать страницу.", true } if page.Text == "" { return "страница открылась, но читать там нечего.", true } // The page is handed to the phraser the same way a note is: as context for // the question he actually asked. She answers the question, she does not // recite the page. snippet := page.Title + "\n" + crawl.TrimRunes(page.Text, webPageContextRunes) reply, perr := h.phraser.PhraseQuery(ctx, t.dec.Utterance, []string{snippet}) if perr != nil { log.Printf("voice: web: phrase: %v", perr) } if reply == "" { // No phraser (or it failed): read back the top of the page rather than // pretend the fetch did not happen. return "вот что на странице: " + crawl.TrimRunes(page.Text, 300), true } 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 }