96b474223d
Netscan and the crawler both declined their own turn when the wiring was nil, and the question fell through to the search leg. "какие устройства в сети?" came back as a paragraph about routers in general, and a question about his own LAN went to an upstream engine — the personal boundary exists to stop exactly that. A URL he named came back answered as though he had not named it. Both now claim the turn once their own recogniser has matched, and say which capability is missing: net_off and page_off in the query family. TestQueryWebPassesWhenNotConfigured encoded the old decision, that announcing a configuration status is only for a capability that exists and failed. It is rewritten, not deleted: the gap is the answer now.
814 lines
36 KiB
Go
814 lines
36 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"log"
|
||
"regexp"
|
||
"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/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
|
||
}
|
||
|
||
// 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},
|
||
// 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},
|
||
// 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},
|
||
// 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},
|
||
// 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},
|
||
// 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},
|
||
// 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},
|
||
// 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},
|
||
{name: "calendar", answer: (*reactiveHandler).queryCalendar, dateAware: true},
|
||
{name: "weather", answer: (*reactiveHandler).queryWeather},
|
||
{name: "embed", answer: (*reactiveHandler).queryEmbed},
|
||
{name: "memory", answer: (*reactiveHandler).queryMemory},
|
||
{name: "notes", answer: (*reactiveHandler).queryNotes},
|
||
// 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},
|
||
// 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},
|
||
// 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},
|
||
// 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},
|
||
{name: "general-knowledge", answer: (*reactiveHandler).queryGeneral},
|
||
}
|
||
|
||
func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision) string {
|
||
t := &queryTurn{dec: dec}
|
||
for _, src := range querySources {
|
||
if dec.Continued && !src.dateAware {
|
||
continue
|
||
}
|
||
if reply, ok := src.answer(h, ctx, t); ok {
|
||
return reply
|
||
}
|
||
}
|
||
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.
|
||
//
|
||
// 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.
|
||
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 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
|
||
)
|
||
|
||
// 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 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), 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 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) {
|
||
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
|
||
}
|
||
|
||
// 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 {
|
||
// 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)
|
||
}
|
||
|
||
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 phraser.Q(phraser.QueryWeatherWhere, nil), 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 phraser.Q(phraser.QueryWeatherOff, nil), 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),
|
||
"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
|
||
// 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 phraser.Q(phraser.QueryFailAnswer, nil), 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"]
|
||
// The score cleared the gate and the topic still has to match (#470). A
|
||
// note 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.
|
||
if !memory.RecallAllowed(t.dec.Utterance, text) {
|
||
log.Printf("voice: recall %q rejected for %q: a world question and no shared topic word", text, t.dec.Utterance)
|
||
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) {
|
||
notes, err := h.api.QueryNotes(ctx, t.vec, 5)
|
||
if err != nil {
|
||
log.Printf("voice: query notes: %v", err)
|
||
return phraser.Q(phraser.QueryFailAnswer, nil), 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
|
||
}
|
||
// Same topic veto as queryMemory above: the best note must be about what
|
||
// he asked, not merely the nearest vector in the index.
|
||
if !memory.RecallAllowed(t.dec.Utterance, notes[0].Text) {
|
||
log.Printf("voice: note %q rejected for %q: a world question and no shared topic word", notes[0].Text, t.dec.Utterance)
|
||
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
|
||
|
||
// 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, 30*time.Second)
|
||
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, 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
|
||
|
||
// 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 phraser.Q(phraser.QueryFound, map[string]string{"text": crawl.TrimRunes(resp.Snippets()[0], 300)}), 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()
|
||
|
||
// 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)
|
||
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 phraser.Q(phraser.QueryFound, map[string]string{"text": crawl.TrimRunes(top.Title+" — "+page.Text, 300)}), true
|
||
}
|
||
return reply, true
|
||
}
|
||
|
||
// 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.boundary.load(ctx, h.embedder)
|
||
if personal, world, ok := h.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
|
||
}
|