96d97e8964
A ZIM title carries a leading capital and the utterance does not: /A/фотосинтез is a 404 and /A/Фотосинтез is a 200. TitleCandidates tries the spoken form first, so a title that begins lowercase on purpose keeps its chance. That takes the measurement from four right to five, and the fifth is the one that mattered. "столица Франции" returned "Список столиц Олимпийских игр" and now returns Париж, through a title redirect the ZIM already held. The 2026-08-05 measurement named that case as the one no lexical signal could reach. Retrieval by title reaches it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
1117 lines
52 KiB
Go
1117 lines
52 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"log"
|
||
"regexp"
|
||
"strings"
|
||
"time"
|
||
"unicode"
|
||
|
||
"github.com/kami/maven/internal/crawl"
|
||
"github.com/kami/maven/internal/decision"
|
||
"github.com/kami/maven/internal/ipc"
|
||
"github.com/kami/maven/internal/kiwix"
|
||
"github.com/kami/maven/internal/memory"
|
||
"github.com/kami/maven/internal/morning"
|
||
"github.com/kami/maven/internal/phraser"
|
||
"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)
|
||
// dateAware — this source reads the day out of the turn and answers for
|
||
// THAT day. Only such a source may claim a continuation ("а завтра?"),
|
||
// because a continuation is a question about a different day and nothing
|
||
// else. A date-blind source claiming one would answer with today's data
|
||
// under tomorrow's question, which is a wrong answer delivered in a
|
||
// confident voice — the failure mode that took reminder out of
|
||
// continuableIntents (continuation.go).
|
||
//
|
||
// Exactly one source qualifies today, and that is not an oversight in the
|
||
// table: CalendarEvents is the only CoreAPI call that takes a date at all.
|
||
// DayPlan is today-only, CurrentWeather is now-only, and the recall
|
||
// sources search text with no notion of a day. When one of them grows a
|
||
// date parameter, flip its flag here.
|
||
dateAware bool
|
||
|
||
// dest — the destination this source serves, when the cascade named one
|
||
// (V-655). Several sources share a destination: the three recall passes and
|
||
// the fact-by-key lookup are all SourceRecall, because which of them lands
|
||
// the hit is an ordering detail no utterance can name. A source with no
|
||
// dest is reachable only by walking the chain.
|
||
dest router.Source
|
||
|
||
// guesses — this source decides whether the turn is its own by scoring the
|
||
// utterance against frozen seeds, rather than by looking something up and
|
||
// coming back empty.
|
||
//
|
||
// The distinction is the whole point of the field. A source that looks can
|
||
// be wrong about relevance and still harmless, because the miss shows up as
|
||
// no rows. A source that guesses answers whatever it claims: weather has no
|
||
// local table to miss against, so "что такое TCP?" became "для какого
|
||
// города?". So when the cascade names a destination, the guessers that were
|
||
// not named do not get to try. The lookups still run, because a named
|
||
// destination is evidence and not a promise.
|
||
guesses bool
|
||
|
||
// boundary — dropping this source widens what leaves the box, so only a
|
||
// literal pattern may do it (V-666, owner's call of 2026-08-09).
|
||
//
|
||
// Every other guesser costs an answer when it is wrongly taken off a turn.
|
||
// This one costs the rule that a question about him never reaches an
|
||
// upstream engine. A grammar read the words to name a destination. A model
|
||
// and a softmax both inferred one, and neither may spend that.
|
||
boundary 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{
|
||
{name: "fact-by-key", answer: (*reactiveHandler).queryFactByKey, dest: router.SourceRecall},
|
||
// 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.
|
||
{name: "day-plan", answer: (*reactiveHandler).queryDayPlan, dest: router.SourceCalendar},
|
||
// 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.
|
||
{name: "habits", answer: (*reactiveHandler).queryHabits, dest: router.SourceCalendar},
|
||
// 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.
|
||
{name: "tasks", answer: (*reactiveHandler).queryTasks, dest: router.SourceTasks},
|
||
// Next to "tasks" and for the same reason: "что требует внимания?" is a
|
||
// question about the operational state Praxis holds, and it used to fall
|
||
// 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, dest: router.SourceAttention, guesses: true},
|
||
// 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, dest: router.SourceList, guesses: true},
|
||
// 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, dest: router.SourceMoney},
|
||
// Also above the recall sources: "что я тебе говорил?" is a question about
|
||
// the facts he tapped in, and the notes pass would answer it with whatever
|
||
// note is nearest (Vikunja #456). Its matcher needs both halves of a
|
||
// history phrase and bails out when he names a topic, so "что я говорил
|
||
// про сервер" is still recall.
|
||
{name: "history", answer: (*reactiveHandler).queryHistory, dest: router.SourceRecall},
|
||
// 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.
|
||
{name: "feeds", answer: (*reactiveHandler).queryFeeds, dest: router.SourceFeeds, guesses: true},
|
||
// 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.
|
||
{name: "home", answer: (*reactiveHandler).queryHome, dest: router.SourceHome, guesses: true},
|
||
// 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.
|
||
{name: "network", answer: (*reactiveHandler).queryNetwork, dest: router.SourceNetwork, guesses: true},
|
||
{name: "calendar", answer: (*reactiveHandler).queryCalendar, dateAware: true, dest: router.SourceCalendar},
|
||
{name: "weather", answer: (*reactiveHandler).queryWeather, dest: router.SourceWeather, guesses: true},
|
||
// A question about her, above the three sources that search his own data
|
||
// (Vikunja #555). It has no answer anywhere else: below the boundary
|
||
// SearXNG answers about somebody else's assistant, and above it his notes
|
||
// answer by proximity — "кто ты" came back from a note of his, measured on
|
||
// the box, because the recall index has no idea the subject is her.
|
||
{name: "self", answer: (*reactiveHandler).querySelf, dest: router.SourceSelf, guesses: true},
|
||
{name: "embed", answer: (*reactiveHandler).queryEmbed, dest: router.SourceRecall},
|
||
{name: "memory", answer: (*reactiveHandler).queryMemory, dest: router.SourceRecall},
|
||
{name: "notes", answer: (*reactiveHandler).queryNotes, dest: router.SourceRecall},
|
||
// THE BOUNDARY. Everything above answers from his own data; everything
|
||
// below answers from the world's. A question about him that got this far
|
||
// has no answer in his data, and no outside source can supply one, so this
|
||
// stops the walk rather than let the encyclopedia and the model guess.
|
||
{name: "personal", answer: (*reactiveHandler).queryPersonal, dest: router.SourceRecall, guesses: true, boundary: true},
|
||
// The world, read live. Owner's ruling of 2026-08-02: a metasearch hit beats
|
||
// a frozen ZIM, so SearXNG asks before Kiwix does. Nothing of his is at
|
||
// stake by this point — the boundary above already stopped every question
|
||
// about him, and only the query string leaves the box.
|
||
{name: "search", answer: (*reactiveHandler).querySearch, dest: router.SourceWorld},
|
||
// The offline encyclopedia, now the fallback for when the line is down or
|
||
// the search comes back empty. It reads the way it always did; what changed
|
||
// is that it no longer gets first refusal on a world question.
|
||
{name: "kiwix", answer: (*reactiveHandler).queryKiwix, dest: router.SourceWorld},
|
||
// LAST before the model answers from memory, and that position is the whole
|
||
// design (Vikunja #259): everything of his, then the search, then the ZIMs,
|
||
// and only then a page he named. The model does NOT come first: 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.
|
||
{name: "web", answer: (*reactiveHandler).queryWeb, dest: router.SourceWorld},
|
||
{name: "general-knowledge", answer: (*reactiveHandler).queryGeneral, dest: router.SourceWorld},
|
||
}
|
||
|
||
// queryWalk narrows the chain for one turn against the destination the cascade
|
||
// named, and says which sources were left out (V-655).
|
||
//
|
||
// It takes sources OUT and never moves one, which is the whole safety argument.
|
||
// The table's order is load-bearing and every comment on it argues a reason
|
||
// between two sources; none of those reasons is about this. Above all, the
|
||
// order carries "his data first, then the world", and a destination named by a
|
||
// model must not be able to reverse that. Naming SourceWorld does not send the
|
||
// turn outside — it stops the guessers from claiming it on the way.
|
||
//
|
||
// What comes out is exactly the sources that guess. Those decide whether a turn
|
||
// is theirs by scoring it against frozen seeds, and then answer whatever they
|
||
// claimed, because they have no lookup that can come back empty. That is the
|
||
// whole of the 2026-08-07 defect: weather claiming "что такое TCP?", the feed
|
||
// claiming "какой у меня любимый язык?", the personal boundary claiming "кто
|
||
// такой Линус Торвальдс?". The sources that look are all still asked, so a
|
||
// wrong destination costs nothing but the guess it prevented.
|
||
//
|
||
// No destination named ⇒ the table exactly as written, which is what shipped
|
||
// before the field existed. That is the floor. The classifier arm names
|
||
// nothing, so a box whose model is down routes queries the way it always did.
|
||
// The personal boundary is the one exception, and anchored is what buys it
|
||
// (V-666). A grammar matched a literal pattern to name the destination. The
|
||
// routing heads and the resident model inferred one, and an inferred SourceWorld
|
||
// takes the boundary off a question about him. That widens what is asked
|
||
// upstream rather than costing a local answer, so those two keep it.
|
||
func queryWalk(dest router.Source, anchored bool) (walk, skipped []querySource) {
|
||
if dest == router.SourceUnknown {
|
||
return querySources, nil
|
||
}
|
||
for _, s := range querySources {
|
||
if s.guesses && s.dest != dest && (anchored || !s.boundary) {
|
||
skipped = append(skipped, s)
|
||
continue
|
||
}
|
||
walk = append(walk, s)
|
||
}
|
||
return walk, skipped
|
||
}
|
||
|
||
func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision) string {
|
||
t := &queryTurn{dec: dec}
|
||
// The roster, so the record can say which sources were never reached rather
|
||
// than leaving them out and letting a reader assume they looked and passed
|
||
// (V-564). Finish names everyone below the winner.
|
||
decision.Expect(ctx, decision.StageQuery, querySourceNames())
|
||
rec := decision.From(ctx)
|
||
walk, skipped := queryWalk(dec.Source, dec.SourceAnchored)
|
||
for _, src := range skipped {
|
||
rec.Note(decision.Claim{
|
||
Stage: decision.StageQuery, Claimant: src.name, Outcome: decision.NeverAsked,
|
||
Reason: "it decides by similarity and the cascade named " + string(dec.Source),
|
||
})
|
||
}
|
||
for _, src := range walk {
|
||
if dec.Continued && !src.dateAware {
|
||
rec.Note(decision.Claim{
|
||
Stage: decision.StageQuery, Claimant: src.name, Outcome: decision.NeverAsked,
|
||
Reason: "a continuation turn only asks the date-aware sources",
|
||
})
|
||
continue
|
||
}
|
||
if reply, ok := src.answer(h, ctx, t); ok {
|
||
// Which source claimed is the one thing about a query turn that was
|
||
// invisible from outside: /trace is the nudge-rule trace and carries
|
||
// no query-source field, so a wrong answer could not be told from a
|
||
// wrongly-ordered chain (Vikunja #474). Only the name is logged —
|
||
// the utterance and the answer are already on the voice lines above
|
||
// and below this one. The same name goes to the turn's sink when the
|
||
// caller asked for one, so /chat can show it (V-539).
|
||
log.Printf("voice: query claimed by source %q", src.name)
|
||
noteQuerySource(ctx, src.name)
|
||
rec.Note(decision.Claim{
|
||
Stage: decision.StageQuery, Claimant: src.name,
|
||
Intent: string(dec.Intent), Outcome: decision.Won,
|
||
})
|
||
return reply
|
||
}
|
||
rec.Note(decision.Claim{
|
||
Stage: decision.StageQuery, Claimant: src.name, Outcome: decision.Declined,
|
||
Reason: "it had no answer for this turn",
|
||
})
|
||
}
|
||
if dec.Continued {
|
||
// The previous question cannot be re-asked for another day. Saying so
|
||
// beats "не знаю", which reads as "no data for tomorrow" when the
|
||
// truth is that she never looked.
|
||
return phraser.Q(phraser.QueryOtherDay, nil)
|
||
}
|
||
return phraser.Q(phraser.QueryUnknown, nil)
|
||
}
|
||
|
||
// 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 phraser.Q(phraser.QueryFactWhen, map[string]string{"when": formatTime(f.Ts)}), true
|
||
}
|
||
// General fact reference: describe what we know.
|
||
if dec.Utterance == "" {
|
||
return phraser.Q(phraser.QueryFactValue, map[string]string{"key": dec.Slots.Key, "value": 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 and reads only the next
|
||
// morning.NextSpoken entries. Trimming alone was not enough: asked early it cuts
|
||
// nothing, and she read 43 entries aloud in one sentence (V-618).
|
||
//
|
||
// "что у меня сегодня?" is a different question and is not narrowed here — it
|
||
// carries no plan word, so IsDayPlanQuery declines it and the calendar source
|
||
// answers the whole day.
|
||
//
|
||
// 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 phraser.Q(phraser.QueryFailPlan, nil), 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.
|
||
//
|
||
// The instants are put back in the asking clock's zone on the way in. They
|
||
// arrive carrying whatever zone the core read them in — a calendar fact's Ts
|
||
// and a reminder's FireTs are UTC out of the store — and FormatRU reads the
|
||
// hours in the plan's own frame, so setting that frame here is what makes
|
||
// the recital name his clock rather than the store's (V-614).
|
||
zone := h.now().Location()
|
||
p := morning.Plan{Date: plan.Date.In(zone)}
|
||
for _, it := range plan.Items {
|
||
p.Items = append(p.Items, morning.PlanEntry{
|
||
At: it.At.In(zone),
|
||
Text: it.Text,
|
||
Kind: morning.PlanKind(it.Kind),
|
||
Uncertain: it.Uncertain,
|
||
})
|
||
}
|
||
return p.Next(h.now(), morning.NextSpoken).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 phraser.Q(phraser.QueryFailNotes, nil), 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
|
||
)
|
||
|
||
// feedFloor — the keyword test behind topicFeed, in the one-string shape
|
||
// turnIsAbout takes. router.ParseFeedQuery returns the category too, which the
|
||
// gate has no use for; the caller reads it separately.
|
||
func feedFloor(u string) bool {
|
||
_, ok := router.ParseFeedQuery(u)
|
||
return ok
|
||
}
|
||
|
||
// 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) {
|
||
// The seeds decide the subject; router.ParseFeedQuery is the floor behind
|
||
// them (V-522). The category still comes from the utterance either way,
|
||
// because a topic is marked by a preposition and needs no recogniser.
|
||
if !h.turnIsAbout(ctx, t, topicFeed, feedFloor) {
|
||
return "", false
|
||
}
|
||
category := router.FeedCategoryOf(t.dec.Utterance)
|
||
if !h.feedsOn {
|
||
// Claim only when nothing below can read the world. The reason this
|
||
// source used to claim unconditionally was that general knowledge would
|
||
// answer "что нового?" with an invented news bulletin — true, and it
|
||
// stopped being the only alternative on 2026-08-02, when live search
|
||
// took the lead. With SearXNG or the ZIMs configured, "что происходит
|
||
// в новостях про искусственный интеллект?" has a real answer below,
|
||
// and a configuration status is the wrong thing to say instead
|
||
// (Vikunja #474).
|
||
if h.search != nil || h.kiwix != nil {
|
||
return "", false
|
||
}
|
||
return phraser.Q(phraser.QueryFeedsOff, nil), 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 phraser.Q(phraser.QueryFailFeeds, nil), true
|
||
}
|
||
var picked []string
|
||
for _, n := range notes {
|
||
if !router.CategoryMatches(rss.NoteCategory(n.Text), 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 category != "" {
|
||
return phraser.Q(phraser.QueryFeedsTopic, nil), true
|
||
}
|
||
return phraser.Q(phraser.QueryFeedsEmpty, nil), true
|
||
}
|
||
return phraser.Q(phraser.QueryFeedsNew, map[string]string{"items": 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) {
|
||
// A day word is all this source matches on, so any question that merely
|
||
// names a day reached it first. "какая сегодня погода в Москве?" answered
|
||
// "на 02.08.2026 ничего нет." (Vikunja #474). Weather is asked about a day
|
||
// far more often than the calendar is, and the weather source sits right
|
||
// below, so the calendar steps aside on weather wording — the same bail-out
|
||
// queryHome already does for the same reason.
|
||
if isWeatherQuery(t.dec.Utterance) {
|
||
return "", false
|
||
}
|
||
// Weather was one instance of a wider class (Vikunja #552). Naming a day
|
||
// does not make a question his agenda: "какой сегодня курс доллара" and
|
||
// "во сколько закат сегодня" both answered "ничего нет", which reads as an
|
||
// answer about a subject she never looked at. All of them have an answer
|
||
// in search, and search sits below this source. So the question must ask
|
||
// about his schedule, not merely name a day.
|
||
//
|
||
// A continuation is exempt. "а завтра?" names no agenda and cannot: the
|
||
// subject was in the turn before it, and this is the only date-aware
|
||
// source there is.
|
||
if !t.dec.Continued && !router.IsAgendaQuestion(t.dec.Utterance) {
|
||
return "", false
|
||
}
|
||
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 phraser.Q(phraser.QueryFailCalendar, nil), 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
|
||
}
|
||
|
||
// 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.
|
||
func (h *reactiveHandler) queryHome(ctx context.Context, t *queryTurn) (string, bool) {
|
||
if !h.turnIsAbout(ctx, t, topicHome, isHomeQuery) {
|
||
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, homeTimeout)
|
||
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 !h.turnIsAbout(ctx, t, topicNetwork, isNetworkQuery) {
|
||
return "", false
|
||
}
|
||
if h.netscan == nil {
|
||
// The recogniser already matched, so this is a question about HIS LAN
|
||
// and there is no scanner to answer it. Falling through sent it to the
|
||
// search leg, which answered with a paragraph about routers in general
|
||
// and put his network question on an upstream engine (Vikunja #479).
|
||
// A missing capability names itself.
|
||
return phraser.Q(phraser.QueryNetOff, nil), true
|
||
}
|
||
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
|
||
}
|
||
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 phraser.Q(phraser.QueryWeatherWhere, nil), true
|
||
}
|
||
ctxWT, cancel := context.WithTimeout(ctx, weatherTimeout)
|
||
defer cancel()
|
||
w, err := h.weatherProvider.CurrentWeather(ctxWT, loc)
|
||
if errors.Is(err, weather.ErrNotConfigured) {
|
||
return phraser.Q(phraser.QueryWeatherOff, nil), true
|
||
}
|
||
if errors.Is(err, weather.ErrLocationUnknown) {
|
||
// He named a place and the geocoder does not have it. Saying so beats
|
||
// reading out the default city's temperature (Vikunja #421).
|
||
return "не знаю такого города — " + loc + ".", true
|
||
}
|
||
if err != nil {
|
||
log.Printf("voice: weather: %v", err)
|
||
return phraser.Q(phraser.QueryFailWeather, nil), true
|
||
}
|
||
return phraser.Q(phraser.QueryWeatherNow, map[string]string{
|
||
"location": w.Location,
|
||
"temp": fmt.Sprintf("%.0f", w.Temperature),
|
||
"word": phraser.Degrees(w.Temperature),
|
||
"condition": 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
|
||
// never claims the turn.
|
||
//
|
||
// It used to claim on an embedder error, and that made a RAG hint a hard gate
|
||
// over everything below it (V-568): one failing EmbedQuery and the memory, the
|
||
// notes, the boundary, the search, the ZIMs, the named page and the model all
|
||
// answered "не смогла ответить", including the questions search and Kiwix
|
||
// would have answered without ever touching the embedder. A failed embed means
|
||
// this source cannot claim, not that the turn is over — same shape as
|
||
// turnVector in topics.go, which had it right.
|
||
func (h *reactiveHandler) queryEmbed(ctx context.Context, t *queryTurn) (string, bool) {
|
||
// A topic source above already paid for this one; see turnVector.
|
||
if len(t.vec) > 0 {
|
||
return "", false
|
||
}
|
||
vec, err := router.EmbedQuery(ctx, h.recall.embedder, t.dec.Utterance)
|
||
if err != nil {
|
||
// Logged once, here, and the chain walks on. The two recall sources
|
||
// below read the empty vector and pass; the boundary drops to its
|
||
// offline floor.
|
||
log.Printf("voice: embed query: %v", err)
|
||
return "", false
|
||
}
|
||
t.vec = vec
|
||
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
|
||
// 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.recall.memStore == nil {
|
||
return "", false
|
||
}
|
||
if len(t.vec) == 0 {
|
||
// No query vector: the embed above failed or there is no embedder.
|
||
// Searching on an empty vector is not a search, and its scores are not
|
||
// a "there is nothing" answer — pass rather than gate the chain.
|
||
return "", false
|
||
}
|
||
hits, herr := h.recall.memStore.Search(ctx, t.vec, memoryRecallWidth)
|
||
if herr != nil {
|
||
log.Printf("voice: memory search: %v", herr)
|
||
return "", false
|
||
}
|
||
hit, ok := bestRecall(hits, h.recall.minScore, h.recall.minMargin)
|
||
if !ok {
|
||
return "", false
|
||
}
|
||
text := hit.Meta["text"]
|
||
// 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
|
||
// stored.
|
||
if hit.Meta["type"] == "note" {
|
||
reply, perr := h.phraser.PhraseQuery(ctx, t.dec.Utterance, []string{text})
|
||
switch {
|
||
case perr != nil:
|
||
// Reading the note back verbatim beats the phraser's own fallback,
|
||
// which only wraps the same text in "вот что я нашла:".
|
||
log.Printf("voice: recall phrase: %v", perr)
|
||
case 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) {
|
||
if len(t.vec) == 0 {
|
||
// Same reason as queryMemory above (V-568): with no query vector this
|
||
// source could not look, and could-not-look passes.
|
||
return "", false
|
||
}
|
||
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
|
||
// answering a question that never needed a note (V-568).
|
||
log.Printf("voice: query notes: %v", err)
|
||
return "", false
|
||
}
|
||
t.notes = notes
|
||
noteScores := make([]float64, len(notes))
|
||
for i, n := range notes {
|
||
noteScores[i] = n.Score
|
||
}
|
||
if !memory.ConfidentScores(noteScores, h.recall.minScore, h.recall.minMargin) {
|
||
return "", false
|
||
}
|
||
// 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))
|
||
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 = phraser.Q(phraser.QueryFound, map[string]string{"text": 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
|
||
|
||
// 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
|
||
// 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 {
|
||
// He named a URL, so the question is about that page and nothing else
|
||
// can answer it. The older comment here argued for falling through and
|
||
// letting the model answer as if the URL had not been said; that is a
|
||
// guess dressed as an answer (Vikunja #479).
|
||
return phraser.Q(phraser.QueryPageOff, nil), true
|
||
}
|
||
ctxFetch, cancel := context.WithTimeout(ctx, webFetchTimeout)
|
||
defer cancel()
|
||
page, err := h.crawler.Page(ctxFetch, link)
|
||
if err != nil {
|
||
if errors.Is(err, crawl.ErrRobots) {
|
||
return phraser.Q(phraser.QueryPageBlocked, nil), true
|
||
}
|
||
log.Printf("voice: web: %v", err)
|
||
return phraser.Q(phraser.QueryFailPage, nil), true
|
||
}
|
||
if page.Text == "" {
|
||
return phraser.Q(phraser.QueryPageEmpty, nil), 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 := h.phraseSource(ctx, "web", t.dec.Utterance, []string{snippet})
|
||
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, readBackRunes)}), 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
|
||
|
||
// searchTimeout — the whole metasearch source. websearch.Client already holds a
|
||
// per-request timeout from config; this is the outer bound on the turn, so a
|
||
// hung dial cannot outlive it either. Shorter than kiwixTimeout because there
|
||
// is no rewrite call in front of it: the question goes out verbatim.
|
||
const searchTimeout = 12 * time.Second
|
||
|
||
// querySearch — the live web, through a self-hosted SearXNG.
|
||
//
|
||
// Ahead of Kiwix by the owner's ruling of 2026-08-02: a search reads what is
|
||
// true today, a ZIM reads what was true when it was built, and the ZIM is the
|
||
// fallback for a box with no line out. Everything of his still answers first —
|
||
// the personal boundary is directly above this source, so a question ABOUT him
|
||
// never becomes a query.
|
||
//
|
||
// What leaves this process is the query string and nothing else. His notes, his
|
||
// facts, the persona block and the history do not travel with it: the websearch
|
||
// package cannot read the store. That is the CLAUDE.md rule made mechanical,
|
||
// not a promise about how the prompt is assembled.
|
||
//
|
||
// It claims the turn only when the search returns something. An empty result,
|
||
// an unreachable instance and a 403 from an instance without the JSON format
|
||
// all fall through to Kiwix, which is the point of the ordering.
|
||
func (h *reactiveHandler) querySearch(ctx context.Context, t *queryTurn) (string, bool) {
|
||
if h.search == nil {
|
||
// Off unless configured, same as the crawler and the ZIMs. Nothing is
|
||
// said about it: he never asked for a capability he did not enable.
|
||
return "", false
|
||
}
|
||
ctxS, cancel := context.WithTimeout(ctx, searchTimeout)
|
||
defer cancel()
|
||
|
||
// Verbatim. No rewriter: SearXNG ranks by meaning through real engines, and
|
||
// reducing "почему небо голубое" to English keywords would throw away the
|
||
// language he asked in along with the ranking that handles it.
|
||
resp, err := h.search.client.Search(ctxS, t.dec.Utterance, h.search.max)
|
||
if err != nil {
|
||
log.Printf("voice: search %q: %v", t.dec.Utterance, err)
|
||
return "", false
|
||
}
|
||
if resp.Empty() {
|
||
return "", false
|
||
}
|
||
// Logged on the way through, not only on failure. Without this there is no
|
||
// telling from the outside whether an answer came off the web, off a ZIM or
|
||
// out of the model's weights, and those are the cases worth telling apart.
|
||
log.Printf("voice: search: %q → %d answers, %d results", t.dec.Utterance, len(resp.Answers), len(resp.Results))
|
||
|
||
// Handed over the same way a note, a page or an article is: evidence for the
|
||
// question he asked, not something to recite. The trim is one budget over the
|
||
// joined block, so a long first snippet cannot crowd out the rest.
|
||
evidence := crawl.TrimRunes(strings.Join(resp.Snippets(), "\n"), h.search.runes)
|
||
reply := h.phraseSource(ctx, "search", t.dec.Utterance, []string{evidence})
|
||
if reply == "" {
|
||
// No phraser, or it failed. Read back the best evidence rather than
|
||
// pretend the search did not happen.
|
||
return readBack(resp.Snippets()[0]), true
|
||
}
|
||
return reply, true
|
||
}
|
||
|
||
// queryKiwix — the offline encyclopedia, and the fallback behind querySearch:
|
||
// everything of his has already had its turn and the live search found nothing
|
||
// or could not be reached. Reading beats recalling for a 1.7B either way.
|
||
//
|
||
// 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()
|
||
|
||
// A Russian question reads the Russian ZIM verbatim when there is one
|
||
// (V-508). Kiwix ranks by keyword overlap rather than meaning, so an English
|
||
// book matches a Russian sentence not at all, and the rewriter exists to
|
||
// turn the question into English keywords with the resident model. Against a
|
||
// Russian book that is a translation of his own words back at him: it costs
|
||
// a model call and drops whatever the keywords do not carry.
|
||
book, verbatim := h.kiwix.book, false
|
||
if h.kiwix.bookRU != "" && hasCyrillic(t.dec.Utterance) {
|
||
book, verbatim = h.kiwix.bookRU, true
|
||
}
|
||
|
||
pattern := t.dec.Utterance
|
||
if h.kiwix.rewriter != nil && !verbatim {
|
||
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
|
||
}
|
||
}
|
||
|
||
// The topic, not the sentence (V-668). Kiwix ranks by keyword overlap, so
|
||
// the question words outrank the one word that names the article: measured
|
||
// on 2026-08-09, "что такое TCP" returns "Перехват TCP-соединения" and
|
||
// "TCP" returns TCP. Only the verbatim path needs this. The rewriter
|
||
// already reduces a question to English keywords, and reducing twice would
|
||
// take the topic off the input it reads.
|
||
if verbatim {
|
||
if topic := kiwix.Topic(pattern); topic != "" {
|
||
// The article named exactly, before any ranking runs. A ZIM is
|
||
// addressable by title and a wrong title is a 404, so this either
|
||
// answers or costs one request that says nothing.
|
||
for _, cand := range kiwix.TitleCandidates(topic) {
|
||
page, err := h.kiwix.client.Article(ctxK, kiwix.TitlePath(book, cand), h.kiwix.runes)
|
||
if err == nil && page.Text != "" {
|
||
log.Printf("voice: kiwix: %q in %q → title hit %q", topic, book, page.Title)
|
||
return h.kiwixReply(ctx, t, page.Title, page.Text)
|
||
}
|
||
}
|
||
pattern = topic
|
||
}
|
||
}
|
||
|
||
hits, err := h.kiwix.client.Search(ctxK, pattern, 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 in %q → %d hits, top %q", pattern, book, 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}
|
||
}
|
||
return h.kiwixReply(ctx, t, top.Title, page.Text)
|
||
}
|
||
|
||
// kiwixReply hands one article over the same way a note or a page is handed
|
||
// over: context for the question he asked, not something to recite.
|
||
func (h *reactiveHandler) kiwixReply(ctx context.Context, t *queryTurn, title, text string) (string, bool) {
|
||
snippet := title + "\n" + crawl.TrimRunes(text, h.kiwix.runes)
|
||
reply := h.phraseSource(ctx, "kiwix", t.dec.Utterance, []string{snippet})
|
||
if reply == "" {
|
||
// No phraser, or it failed. Read back the best hit rather than pretend
|
||
// the search did not happen.
|
||
return readBack(title + " — " + text), true
|
||
}
|
||
return reply, true
|
||
}
|
||
|
||
// hasCyrillic reports whether the text carries a Cyrillic letter, which is the
|
||
// whole test for "he asked this in Russian". A question mixing a Latin proper
|
||
// noun into a Russian sentence is still Russian, so one letter is enough.
|
||
func hasCyrillic(s string) bool {
|
||
for _, r := range s {
|
||
if unicode.Is(unicode.Cyrillic, r) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// queryPersonal — stop the walk on a question about him that his own data did
|
||
// not answer.
|
||
//
|
||
// Every source above this one reads something of his: his facts, his calendar,
|
||
// his tasks, his house, his notes. Everything below reads the world: an offline
|
||
// Wikipedia, a page he named, the model's own weights. The world does not know
|
||
// when his meeting is, and asked anyway it will produce something.
|
||
//
|
||
// It did. "во сколько у меня встреча" reached Kiwix on the deployed daemon,
|
||
// 01-08-2026; Wikipedia matched an article on the 2015 CPISRA World Games, and
|
||
// the phraser rendered it as "встреча у тебя в 2015 CPISRA World Games, где
|
||
// были соревнования по плаванию". Fluent, confident, and about a swimming
|
||
// competition in Nottingham. Saying "не знаю" is not a worse answer than that
|
||
// one — it is the only true one.
|
||
//
|
||
// Note this is also the privacy edge. The rule in CLAUDE.md is that only the
|
||
// utterance may leave the box, never his notes; a question that is ABOUT him
|
||
// carries his life in the utterance itself, so it is the one class that should
|
||
// not be sent to an upstream engine at all. The guard closes both holes with
|
||
// the same test.
|
||
func (h *reactiveHandler) queryPersonal(ctx context.Context, t *queryTurn) (string, bool) {
|
||
if !h.isPersonalTurn(ctx, t) {
|
||
return "", false
|
||
}
|
||
log.Printf("voice: %q is about him and his own data did not answer it; not asking the world", t.dec.Utterance)
|
||
return phraser.Q(phraser.QueryPersonalNone, nil), true
|
||
}
|
||
|
||
// personalMarkers — first-person POSSESSION, not first person generally.
|
||
//
|
||
// "у меня" and "мой" attach to a thing that is his, which is what makes the
|
||
// question unanswerable from outside. A bare "мне" or "я" does not: "как мне
|
||
// сварить борщ" and "что я могу посмотреть" are ordinary questions about the
|
||
// world that happen to mention the asker, and refusing those would be the
|
||
// opposite mistake. The narrow test is the point.
|
||
// Go's \b is ASCII-only and never fires next to a Cyrillic letter, so the
|
||
// Russian patterns spell the boundary out as "not a letter or a digit". The
|
||
// English ones keep \b, where it works.
|
||
var personalMarkers = []*regexp.Regexp{
|
||
regexp.MustCompile(`(?i)(^|[^\p{L}\p{N}])у\s+меня([^\p{L}\p{N}]|$)`),
|
||
regexp.MustCompile(`(?i)(^|[^\p{L}\p{N}])мо(й|я|ё|е|и|его|ей|их|им|ими|ем|ю|ею)([^\p{L}\p{N}]|$)`),
|
||
regexp.MustCompile(`(?i)\bmy\b`),
|
||
regexp.MustCompile(`(?i)\bdo\s+i\s+have\b`),
|
||
regexp.MustCompile(`(?i)\bdid\s+i\b`),
|
||
}
|
||
|
||
// isPersonalQuery — the offline floor under the boundary. Possession only, and
|
||
// deliberately still narrow: it answers when there is no embedder to ask, and a
|
||
// broad guess made blind is worse than a narrow one.
|
||
func isPersonalQuery(utterance string) bool {
|
||
if utterance == "" {
|
||
return false
|
||
}
|
||
for _, re := range personalMarkers {
|
||
if re.MatchString(utterance) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
// isPersonalTurn — the boundary test. The seeds decide when the embedder is
|
||
// there, which is every deployed box; the possession markers are the floor
|
||
// underneath, for a handler with no embedder or a turn whose vector never got
|
||
// computed. Same shape as the cascade: the better test leads, the offline one
|
||
// always answers.
|
||
func (h *reactiveHandler) isPersonalTurn(ctx context.Context, t *queryTurn) bool {
|
||
h.recall.boundary.load(ctx, h.recall.embedder)
|
||
if personal, world, ok := h.recall.boundary.score(t.vec); ok {
|
||
if personal > world {
|
||
log.Printf("voice: %q scores personal %.4f vs world %.4f", t.dec.Utterance, personal, world)
|
||
return true
|
||
}
|
||
return false
|
||
}
|
||
return isPersonalQuery(t.dec.Utterance)
|
||
}
|
||
|
||
// queryGeneral — general knowledge, the last source before giving up. It always
|
||
// claims: either a model answers, or Maven names the gap, or she says she does
|
||
// not know.
|
||
//
|
||
// This is the sharpest case for the naming half. Nothing has been fetched, so
|
||
// there is no passage to fall back on and no floor under the answer except the
|
||
// model's weights — and a 1.7B's weights are where the invented answers come
|
||
// from. With a workstation configured and asleep he is told that, rather than
|
||
// told something false in a confident voice. With no workstation configured at
|
||
// all the resident model answers exactly as it does today: naming a gap requires
|
||
// a gap, and on that box the 1.7B is the whole product.
|
||
func (h *reactiveHandler) queryGeneral(ctx context.Context, t *queryTurn) (string, bool) {
|
||
if h.phraser == nil {
|
||
// No model of any size. That is not the workstation being asleep, so it
|
||
// is not that gap: it is simply not knowing.
|
||
return phraser.Q(phraser.QueryUnknown, nil), true
|
||
}
|
||
reply, err := h.phraseWorld(ctx, t.dec.Utterance, nil)
|
||
if errors.Is(err, phraser.ErrNoWorldModel) {
|
||
log.Printf("voice: %q needs the world model and it is not available", t.dec.Utterance)
|
||
return worldGap(), true
|
||
}
|
||
if err != nil || reply == "" {
|
||
return phraser.Q(phraser.QueryUnknown, nil), true
|
||
}
|
||
return reply, true
|
||
}
|