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/store" "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}, // The offline encyclopedia, after everything of his and before anything on // the network. A question he can be answered from his own notes is answered // from his own notes; only what is left over is looked up. {"kiwix", (*reactiveHandler).queryKiwix}, // LAST before the model answers from memory, and that position is the whole // design (Vikunja #259): local sources first. His memory, his notes and the // offline ZIMs all get their turn before anything touches the network. // The model does NOT: it // answers after this, because a URL he said out loud is an instruction and // a 1.7B guessing at a page it cannot read is how contents get invented. // This source only claims a turn where he named a URL, 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 every morning checklist item today still has no evidence for, // including the ones whose window has closed. // // 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. // // What surface this belongs on is still open, tracked as Vikunja #431 ("Board // surface: Maven holds the work board, runs the intake form, never argues"). // The spoken recital here is the current answer, not the decided one. 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 !router.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 } // habitFactWindow — how many recent SELF 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. // // The read is kind-filtered in SQL, and that is the load-bearing part. When this // was a plain recent-facts read the window was a row budget over every writer, // and the machine writers dwarf the taps: mavpoll writes a wg_handshake row // whenever a peer rehandshakes, which is roughly every two minutes per peer, so // 2000 rows was under three days of history. A weekday habit needs // memory.MinHabitDays distinct Tuesdays, which such a window can never hold, so // she answered "по вторникам у меня пока нет ничего постоянного" forever on a // store with a year of taps in it. Self facts come from voice taps, and he does // not tap seven hundred times a day. 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.RecentActiveFactsByKind(ctx, string(store.KindSelf), 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 } if q.Weekend { return profile.FormatWeekendRU(), true } return profile.FormatOverallRU(), true } // feedNoteWindow — how many recent FEED notes are scanned, 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 } // By source, not the last 200 notes of any kind: a busy day of voice notes // used to push the newest headline out of the window, and she answered "в // лентах пока ничего нового" while the poller was working fine. notes, err := h.api.RecentNotesFromSource(ctx, rss.SourcePrefix, feedNoteWindow) if err != nil { log.Printf("voice: feeds: recent notes: %v", err) return "не получилось посмотреть ленты.", true } var picked []string for _, n := range notes { if !router.CategoryMatches(rss.NoteCategory(n.Text), q.Category) { continue } // The note carries title, summary, category tag and link; she reads the // title alone. The tag is for the match above, and piper reads brackets // out loud. picked = append(picked, rss.NoteHeadline(n.Text)) 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 { // Fall through rather than claim the turn. A capability that is off // must not change what an unconfigured box answers: "какая температура // в доме?" on a Maven with no smarthome block reached recall before // this source existed, and a stored fact is a better answer than // "дом не подключён" from a house that was never configured. The // unreachable case is different and homeSummary covers it. return "", false } 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 { // Fall through, same as queryHome: an unconfigured scanner must not // swallow "сколько устройств в сети?" before recall has looked. return "", false } 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 { // Fall through. Reading pages is off unless configured, and on a daemon // where it was never turned on the older behaviour is right: the model // answers the question as if the URL had not been said. Announcing a // configuration status is for a capability that exists and failed, not // for one he never asked for. return "", false } 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 } // kiwixTimeout — the whole ZIM source, rewrite included. The rewrite is one // short constrained completion and the search is a LAN request; if the pair // takes longer than this something is wrong and he is better served by the // model's own answer than by more waiting. const kiwixTimeout = 20 * time.Second // queryKiwix — the offline encyclopedia. The last local source: everything of // his has already had its turn and found nothing, so the question is a world // question, and reading beats recalling for a 1.7B. // // What leaves this process is the search query and nothing else. His notes, // his facts, the persona block and the history do not travel with it — the // kiwix package cannot read the store. That holds even though the server is on // the LAN, because "local sources first" is not a licence to widen what a // lookup is allowed to see. // // It claims the turn only when the search returns something. No results is not // a failure worth announcing: it means the ZIM does not cover this, and the // model answering next is the better outcome than "ничего не нашла". func (h *reactiveHandler) queryKiwix(ctx context.Context, t *queryTurn) (string, bool) { if h.kiwix == nil { // Off unless configured, same as the crawler and the weather. Nothing // is said about it: he never asked for a capability he did not enable. return "", false } ctxK, cancel := context.WithTimeout(ctx, kiwixTimeout) defer cancel() // The ZIMs are English and kiwix ranks by keyword overlap, not meaning, so // a Russian sentence matches nothing at all. The rewriter turns it into a // handful of English keywords with the resident model. pattern := t.dec.Utterance if h.kiwix.rewriter != nil { q, err := h.kiwix.rewriter.Rewrite(ctxK, t.dec.Utterance) if err != nil { // Fall through to the verbatim question rather than give up. It // will usually miss, and missing is a fall-through too. log.Printf("voice: kiwix: rewrite: %v", err) } else if q != "" { pattern = q } } hits, err := h.kiwix.client.Search(ctxK, pattern, h.kiwix.book, h.kiwix.max) if err != nil { log.Printf("voice: kiwix: search %q: %v", pattern, err) return "", false } if len(hits) == 0 { return "", false } top := hits[0] // Logged on the way through, not only on failure. Without this there is no // way to tell from the outside whether an answer came off a ZIM or out of // the model's weights, and those are the two cases worth telling apart. log.Printf("voice: kiwix: %q → %d hits, top %q", pattern, len(hits), top.Title) // The top hit only, read as an article rather than as a snippet. Kiwix // builds its snippet from wherever the keyword matched, which on Wikipedia // is usually the navigation box at the foot of the page — the first version // of this joined three of those and she recited "Ecological economics // Ecological footprint …" at him. The head of the article is the lead // paragraph, which is the definition the snippet was meant to be. page, aerr := h.kiwix.client.Article(ctxK, top.Path, h.kiwix.runes) if aerr != nil || page.Text == "" { if aerr != nil { log.Printf("voice: kiwix: article %s: %v", top.Path, aerr) } // The search did find something, so fall back to its snippet rather // than throw the hit away. if top.Snippet == "" { return "", false } page = crawl.Page{Title: top.Title, Text: top.Snippet} } // Handed over the same way a note or a page is: context for the question he // asked, not something to recite. snippet := top.Title + "\n" + crawl.TrimRunes(page.Text, h.kiwix.runes) var reply string if h.phraser != nil { var perr error reply, perr = h.phraser.PhraseQuery(ctx, t.dec.Utterance, []string{snippet}) if perr != nil { log.Printf("voice: kiwix: phrase: %v", perr) } } if reply == "" { // No phraser, or it failed. Read back the best hit rather than pretend // the search did not happen. return "вот что я нашла: " + crawl.TrimRunes(top.Title+" — "+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 }