dc4c5b7841
A `smarthome` block points Maven at a Home Assistant instance. She reads its entity states to answer "что включено дома?", and every controllable device becomes a PROPOSED row in the existing act allowlist — cmd ["smarthome",<entity_id>,<service>], scope smarthome:<domain> — so nothing new had to be invented for the mutating half. ProposeTool/EnableTool/DisableTool, tool.Matcher and the confirm turn are untouched; one branch in Executor.Exec routes such a row to the client instead of exec, and "smarthome" is never run as a binary. This is the same trick overnight/mcp-tools used for #251, on purpose. Discovery only ever PROPOSES, and every control row is destructive=true: there is no read-only way to turn the heating off, so flipping something in his flat always costs a confirm turn and always had to be enabled by hand on /tools, behind step-up. The entity and the service come from the row he enabled, never from the utterance — Exec drops the spoken tail for a house row. A router that misheard can pick the wrong lamp; it cannot compose a target of its own. The service is checked against the domain's table on the way out too, so a hand-edited cmd column cannot reach an arbitrary Home Assistant service. set_brightness and set_temperature are deliberately absent: a spoken number the router got wrong is a wrong act on real hardware, and on/off is the whole of what a voice turn can defend. The read side is a query source ("home", before calendar and the recall passes) so "что нового дома?" is not answered from an old note. Its matcher needs a house marker plus an ask plus a device word and bails out on weather wording, because "какая температура на улице?" belongs to the weather source. Off unless configured: the block is dark without "enabled": true, and applyDefaults normalises a disabled block to nil so "off" stays in one place. deploy/mavend.json carries it disabled, with the token as ${HA_TOKEN}. NOT shipped, and not faked: MQTT / Zigbee2MQTT (plan steps 2 and 5) and the sensor-to-fact and presence-probe pipelines. There is no broker and no Home Assistant anywhere on this network — 8123 and 1883 are closed on every host in 192.168.1.0/24 — the module tree is vendored so a paho dependency cannot be added offline, and Home Assistant already fronts Zigbee2MQTT where it exists. Writing a sensor pipeline with no sensor to test it against would be a guess. Vikunja #256
467 lines
19 KiB
Go
467 lines
19 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"log"
|
||
"strings"
|
||
"time"
|
||
|
||
"github.com/kami/maven/internal/crawl"
|
||
"github.com/kami/maven/internal/ipc"
|
||
"github.com/kami/maven/internal/memory"
|
||
"github.com/kami/maven/internal/morning"
|
||
"github.com/kami/maven/internal/router"
|
||
"github.com/kami/maven/internal/rss"
|
||
"github.com/kami/maven/internal/weather"
|
||
)
|
||
|
||
// queryTurn is the per-turn scratch a chain of query sources shares: the
|
||
// decision being answered plus the work an earlier source already paid for
|
||
// (the query embedding, the notes it pulled). Sources read and fill it in
|
||
// order, so a later source never re-embeds.
|
||
type queryTurn struct {
|
||
dec router.Decision
|
||
vec []float32
|
||
notes []ipc.Note
|
||
}
|
||
|
||
// querySource — one answer source in the chain actionQuery walks. answer
|
||
// returns (reply, true) when this source claims the question, ("", false)
|
||
// when it passes to the next one. name is for reading the table, not logged.
|
||
//
|
||
// A struct of one func rather than an interface: every source is a plain
|
||
// method on *reactiveHandler with no state of its own (what state a turn has
|
||
// lives in queryTurn), so an interface would mean one empty type per source
|
||
// to satisfy it — ceremony for nothing. Same reasoning as confirmResolver in
|
||
// confirm.go, and the table then reads like actionHandlers: a flat list of
|
||
// method expressions you extend with one line.
|
||
type querySource struct {
|
||
name string
|
||
answer func(*reactiveHandler, context.Context, *queryTurn) (string, bool)
|
||
}
|
||
|
||
// querySources is the ordered chain actionQuery walks; first source to claim
|
||
// answers the turn. THE ORDER IS LOAD-BEARING — see the memory-before-notes
|
||
// comment on queryMemory: running the notes-only pass first was #373, and the
|
||
// gate was never the bug. Adding a source (Kiwix, RSS, crawler, email) is one
|
||
// line here plus its method; where you put the line is the whole decision.
|
||
var querySources = []querySource{
|
||
{"fact-by-key", (*reactiveHandler).queryFactByKey},
|
||
// Before "calendar" on purpose: both match "…на сегодня", and the plan is
|
||
// the more specific ask (its matcher requires a plan word), so the calendar
|
||
// listing would otherwise swallow it.
|
||
{"day-plan", (*reactiveHandler).queryDayPlan},
|
||
// Also before "calendar": "что я обычно делаю по средам?" names a weekday,
|
||
// and the habit question is the more specific one. Its matcher requires a
|
||
// habit marker ("обычно", "каждый", …), so a question about this coming
|
||
// Wednesday still reaches the calendar.
|
||
{"habits", (*reactiveHandler).queryHabits},
|
||
// Before "calendar" and before the recall sources: "что мне нужно
|
||
// сделать?" is a question about the task list, and the notes pass would
|
||
// otherwise answer it with whatever note happens to be nearest. Its
|
||
// matcher requires a task noun or an explicit "что … сделать", so a
|
||
// date-bearing question still reaches the calendar.
|
||
{"tasks", (*reactiveHandler).queryTasks},
|
||
// Before the recall sources too: "сколько я потратил?" is a question about
|
||
// the money facts the poller wrote, and the notes pass would otherwise
|
||
// answer it from whatever he once said about spending. Its matcher needs a
|
||
// money noun plus an actual ask, so "я потратил весь день" is untouched.
|
||
{"money", (*reactiveHandler).queryMoney},
|
||
// Before the recall sources and before general knowledge: "что нового?" is
|
||
// a question about the feeds she reads, and general knowledge would answer
|
||
// it by inventing news. Its matcher needs a feed noun plus an ask, so
|
||
// "у меня новая лента в инстаграме" is untouched.
|
||
{"feeds", (*reactiveHandler).queryFeeds},
|
||
// Before "calendar" and before the recall sources: "что включено дома?" is
|
||
// a question about the house, and the notes pass would otherwise answer it
|
||
// from whatever he once said about the lights. Its matcher needs a house
|
||
// marker plus an ask plus a device word, and it bails out on weather
|
||
// wording, so "какая температура на улице?" still reaches the weather
|
||
// source.
|
||
{"home", (*reactiveHandler).queryHome},
|
||
{"calendar", (*reactiveHandler).queryCalendar},
|
||
{"weather", (*reactiveHandler).queryWeather},
|
||
{"embed", (*reactiveHandler).queryEmbed},
|
||
{"memory", (*reactiveHandler).queryMemory},
|
||
{"notes", (*reactiveHandler).queryNotes},
|
||
// LAST before the model answers from memory, and that position is the whole
|
||
// design (Vikunja #259): local sources first. The model, his own notes and
|
||
// facts, and — once internal/kiwix is wired into this chain — the offline
|
||
// ZIMs all get their turn before anything touches the network. This source
|
||
// only claims a turn where he named a URL out loud, so it never competes
|
||
// with a local answer.
|
||
{"web", (*reactiveHandler).queryWeb},
|
||
{"general-knowledge", (*reactiveHandler).queryGeneral},
|
||
}
|
||
|
||
func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision) string {
|
||
t := &queryTurn{dec: dec}
|
||
for _, src := range querySources {
|
||
if reply, ok := src.answer(h, ctx, t); ok {
|
||
return reply
|
||
}
|
||
}
|
||
return "не знаю."
|
||
}
|
||
|
||
// queryFactByKey — when the dialogue layer resolved an anaphoric reference to
|
||
// a prior fact's key (e.g. "когда я это сделал?" after "запиши что я пил
|
||
// воду"), look up the fact's value directly.
|
||
func (h *reactiveHandler) queryFactByKey(ctx context.Context, t *queryTurn) (string, bool) {
|
||
dec := t.dec
|
||
if !dec.Slots.HasKey || dec.Slots.Key == "" {
|
||
return "", false
|
||
}
|
||
f, err := h.api.LatestFact(ctx, dec.Slots.Key)
|
||
if err != nil {
|
||
return "", false
|
||
}
|
||
if dec.Slots.HasTime {
|
||
// The query asks about timing — the fact's own timestamp is the
|
||
// answer it's looking for. Format as a natural reply.
|
||
return fmt.Sprintf("я записала это %s", formatTime(f.Ts)), true
|
||
}
|
||
// General fact reference: describe what we know.
|
||
if dec.Utterance == "" {
|
||
return fmt.Sprintf("вот что я знаю: %s — %s", dec.Slots.Key, f.Value), true
|
||
}
|
||
// The utterance still carries the question; fall through to normal RAG
|
||
// with the resolved key in context.
|
||
return "", false
|
||
}
|
||
|
||
// queryDayPlan — "какие планы на сегодня?", "что у меня по плану?", "что
|
||
// дальше?" (Vikunja #128). Recites the day: calendar events, pending
|
||
// reminders, and any morning checklist still outstanding.
|
||
//
|
||
// Read-only by construction — the plan is assembled and rendered core-side and
|
||
// nothing here schedules or announces. "что дальше?" asks for the rest of the
|
||
// day, so that phrasing trims what has already passed.
|
||
func (h *reactiveHandler) queryDayPlan(ctx context.Context, t *queryTurn) (string, bool) {
|
||
if !router.IsDayPlanQuery(t.dec.Utterance) {
|
||
return "", false
|
||
}
|
||
plan, err := h.api.DayPlan(ctx)
|
||
if err != nil {
|
||
log.Printf("voice: day plan: %v", err)
|
||
return "не получилось собрать план.", true
|
||
}
|
||
if !isRestOfDayQuery(t.dec.Utterance) {
|
||
return plan.Spoken, true
|
||
}
|
||
// Rebuild the pure plan so the rest-of-day rendering is the same code that
|
||
// rendered the whole day — one formatter, one persona.
|
||
p := morning.Plan{Date: plan.Date}
|
||
for _, it := range plan.Items {
|
||
p.Items = append(p.Items, morning.PlanEntry{
|
||
At: it.At,
|
||
Text: it.Text,
|
||
Kind: morning.PlanKind(it.Kind),
|
||
Uncertain: it.Uncertain,
|
||
})
|
||
}
|
||
return p.After(h.now()).FormatRU(), true
|
||
}
|
||
|
||
// isRestOfDayQuery — "что дальше?" and its English form, the only plan phrasing
|
||
// that means "from now on" rather than "the whole day".
|
||
func isRestOfDayQuery(text string) bool {
|
||
s := strings.ToLower(text)
|
||
return strings.Contains(s, "дальше") || strings.Contains(s, "next")
|
||
}
|
||
|
||
// habitFactWindow — how many recent facts the behaviour profile is counted
|
||
// over. Enough for a season of habits without scanning the whole store on every
|
||
// question; the profile is recomputed on read, so the bound is the cost control.
|
||
const habitFactWindow = 2000
|
||
|
||
// queryHabits — "что я обычно делаю по вторникам?" (Vikunja #254). Counts the
|
||
// answer out of the fact log rather than asking the model to summarise a life:
|
||
// see internal/memory/behavior.go for why nothing here is generated.
|
||
func (h *reactiveHandler) queryHabits(ctx context.Context, t *queryTurn) (string, bool) {
|
||
q, ok := router.ParseHabitQuery(t.dec.Utterance)
|
||
if !ok {
|
||
return "", false
|
||
}
|
||
facts, err := h.api.RecentFacts(ctx, habitFactWindow)
|
||
if err != nil {
|
||
log.Printf("voice: habits: recent facts: %v", err)
|
||
return "не получилось посмотреть записи.", true
|
||
}
|
||
obs := make([]memory.Observation, 0, len(facts))
|
||
for _, f := range facts {
|
||
obs = append(obs, memory.Observation{At: f.Ts, Key: f.Key, Kind: f.Kind})
|
||
}
|
||
profile := memory.BuildProfile(obs, h.now())
|
||
if q.HasWeekday {
|
||
return profile.FormatWeekdayRU(q.Weekday), true
|
||
}
|
||
return profile.FormatOverallRU(), true
|
||
}
|
||
|
||
// feedNoteWindow — how many recent notes are scanned for feed items, and
|
||
// feedReadOut — how many headlines she actually reads back. She summarises the
|
||
// top of the pile, she does not recite a river.
|
||
const (
|
||
feedNoteWindow = 200
|
||
feedReadOut = 3
|
||
)
|
||
|
||
// queryFeeds — "что нового в лентах?", "что нового по технологиям?"
|
||
// (Vikunja #258).
|
||
//
|
||
// This is the ONLY way a feed item reaches him. The poller writes notes and
|
||
// never speaks; asking is the trigger. If that ever changes, the thing that
|
||
// changed is "Maven is not a nag", not a detail of this file.
|
||
func (h *reactiveHandler) queryFeeds(ctx context.Context, t *queryTurn) (string, bool) {
|
||
q, ok := router.ParseFeedQuery(t.dec.Utterance)
|
||
if !ok {
|
||
return "", false
|
||
}
|
||
if !h.feedsOn {
|
||
// Claim the turn rather than fall through: "не читаю ленты" is true, and
|
||
// letting general knowledge answer "что нового?" would be an invented
|
||
// news bulletin.
|
||
return "я пока не читаю ленты — они не настроены.", true
|
||
}
|
||
notes, err := h.api.RecentNotes(ctx, feedNoteWindow)
|
||
if err != nil {
|
||
log.Printf("voice: feeds: recent notes: %v", err)
|
||
return "не получилось посмотреть ленты.", true
|
||
}
|
||
var picked []string
|
||
for _, n := range notes {
|
||
if !strings.HasPrefix(n.Source, rss.SourcePrefix) {
|
||
continue
|
||
}
|
||
if !router.CategoryMatches(n.Text, q.Category) {
|
||
continue
|
||
}
|
||
// The note carries title, summary and link; she reads the title.
|
||
title := n.Text
|
||
if i := strings.IndexByte(title, '\n'); i > 0 {
|
||
title = title[:i]
|
||
}
|
||
picked = append(picked, strings.TrimSpace(title))
|
||
if len(picked) == feedReadOut {
|
||
break
|
||
}
|
||
}
|
||
if len(picked) == 0 {
|
||
if q.Category != "" {
|
||
return "по этой теме в лентах пока ничего.", true
|
||
}
|
||
return "в лентах пока ничего нового.", true
|
||
}
|
||
return "вот что нового: " + strings.Join(picked, "; "), true
|
||
}
|
||
|
||
// queryCalendar — "что у меня сегодня?", "планы на завтра?"
|
||
// h.now(), not time.Now(): the handler's clock is the injected one, so this
|
||
// source can be tested at a fixed time like the rest.
|
||
func (h *reactiveHandler) queryCalendar(ctx context.Context, t *queryTurn) (string, bool) {
|
||
date, ok := router.ParseCalendarDate(t.dec.Utterance, h.now())
|
||
if !ok {
|
||
return "", false
|
||
}
|
||
events, err := h.api.CalendarEvents(ctx, date, date.Add(24*time.Hour))
|
||
if err != nil {
|
||
log.Printf("voice: calendar events: %v", err)
|
||
return "не получилось проверить календарь.", true
|
||
}
|
||
// Provenance travels with each event. A work meeting relayed off a phone
|
||
// notification (source ambient:notif, #126) is stored below full confidence
|
||
// and gets hedged; a CalDAV read is recited plainly.
|
||
entries := make([]router.CalendarEntry, len(events))
|
||
for i, e := range events {
|
||
entries[i] = router.CalendarEntry{Text: e.Value, Uncertain: e.Confidence < 1.0}
|
||
}
|
||
var f router.CalendarEventFormatter
|
||
return f.FormatEntries(entries, date), true
|
||
}
|
||
|
||
// queryHome answers a question about the house. Read-only by construction: it
|
||
// calls States and nothing else, so there is no confirm turn here — the only
|
||
// way to CHANGE something is an enabled allowlist row through tool.Executor.
|
||
func (h *reactiveHandler) queryHome(ctx context.Context, t *queryTurn) (string, bool) {
|
||
if !isHomeQuery(t.dec.Utterance) {
|
||
return "", false
|
||
}
|
||
if h.home == nil {
|
||
// Claim the turn rather than fall through: "дом не подключён" is true,
|
||
// and letting general knowledge answer would be an invented house.
|
||
return "дом не подключён — я его не вижу.", true
|
||
}
|
||
ctxH, cancel := context.WithTimeout(ctx, 10*time.Second)
|
||
defer cancel()
|
||
return h.home.homeSummary(ctxH)
|
||
}
|
||
|
||
func (h *reactiveHandler) queryWeather(ctx context.Context, t *queryTurn) (string, bool) {
|
||
if !isWeatherQuery(t.dec.Utterance) {
|
||
return "", false
|
||
}
|
||
loc := extractWeatherLocation(t.dec.Utterance, h.weatherLocation)
|
||
ctxWT, cancel := context.WithTimeout(ctx, 5*time.Second)
|
||
defer cancel()
|
||
w, err := h.weatherProvider.CurrentWeather(ctxWT, loc)
|
||
if errors.Is(err, weather.ErrNotConfigured) {
|
||
return "погода не настроена.", true
|
||
}
|
||
if err != nil {
|
||
log.Printf("voice: weather: %v", err)
|
||
return "не получилось узнать погоду.", true
|
||
}
|
||
return fmt.Sprintf("в %s сейчас %.0f градусов, %s.", w.Location, w.Temperature, w.Condition), true
|
||
}
|
||
|
||
// queryEmbed isn't an answer source — it's the shared cost the two recall
|
||
// sources below both need, run once, in the position it always ran in. It
|
||
// only claims the turn when the embedder fails.
|
||
func (h *reactiveHandler) queryEmbed(ctx context.Context, t *queryTurn) (string, bool) {
|
||
vec, err := router.EmbedQuery(ctx, h.embedder, t.dec.Utterance)
|
||
if err != nil {
|
||
log.Printf("voice: embed query: %v", err)
|
||
return "не получилось найти ответ.", true
|
||
}
|
||
t.vec = vec
|
||
return "", false
|
||
}
|
||
|
||
// queryMemory — long-term memory first: ONE search over everything Maven
|
||
// remembers (notes and facts share this index) and ONE confidence gate, so
|
||
// the memory that is clearly the best match answers — a note just as much as
|
||
// a fact.
|
||
//
|
||
// This used to run only after the notes-only source below had already
|
||
// rejected the same note at the same score, which no note could ever survive
|
||
// a second time: the branch could only return a fact (#373). Order, not the
|
||
// gate, was the bug — the set of questions Maven answers is unchanged, only
|
||
// which memory gets to answer them.
|
||
func (h *reactiveHandler) queryMemory(ctx context.Context, t *queryTurn) (string, bool) {
|
||
if h.memStore == nil {
|
||
return "", false
|
||
}
|
||
hits, herr := h.memStore.Search(ctx, t.vec, 3)
|
||
if herr != nil {
|
||
log.Printf("voice: memory search: %v", herr)
|
||
return "", false
|
||
}
|
||
hit, ok := bestRecall(hits, h.queryMinScore, h.queryMinMargin)
|
||
if !ok {
|
||
return "", false
|
||
}
|
||
text := hit.Meta["text"]
|
||
// A note is phrased in Maven's voice; a fact is read back as it was
|
||
// stored.
|
||
if hit.Meta["type"] == "note" {
|
||
if reply, perr := h.phraser.PhraseQuery(ctx, t.dec.Utterance, []string{text}); perr == nil && reply != "" {
|
||
return reply, true
|
||
}
|
||
}
|
||
return text, true
|
||
}
|
||
|
||
// queryNotes — notes-only pass, for notes the vector index above does not
|
||
// hold (an older note written before it existed). Same gate, notes-only
|
||
// candidates.
|
||
//
|
||
// Confidence gate: below it, say "I don't know" rather than read back the
|
||
// least-unrelated note — a confident wrong recall is worse than a gap (spec's
|
||
// "not a guesser-of-truth"). Same instinct as the loop's since(key)==null →
|
||
// don't fire. Two parts: an absolute cosine floor, and a margin over the
|
||
// runner-up, which is the part that works with the e5 embedder's narrow score
|
||
// band. See memory.Confident. Failing the gate passes the turn on to general
|
||
// knowledge, which is what "don't read back the runner-up" means here.
|
||
func (h *reactiveHandler) queryNotes(ctx context.Context, t *queryTurn) (string, bool) {
|
||
notes, err := h.api.QueryNotes(ctx, t.vec, 5)
|
||
if err != nil {
|
||
log.Printf("voice: query notes: %v", err)
|
||
return "не получилось найти ответ.", true
|
||
}
|
||
t.notes = notes
|
||
noteScores := make([]float64, len(notes))
|
||
for i, n := range notes {
|
||
noteScores[i] = n.Score
|
||
}
|
||
if !memory.ConfidentScores(noteScores, h.queryMinScore, h.queryMinMargin) {
|
||
return "", false
|
||
}
|
||
texts := make([]string, len(notes))
|
||
for i, n := range notes {
|
||
texts[i] = n.Text
|
||
}
|
||
reply, err := h.phraser.PhraseQuery(ctx, t.dec.Utterance, texts)
|
||
if err != nil {
|
||
log.Printf("voice: phrase query: %v", err)
|
||
}
|
||
if reply == "" {
|
||
reply = "вот что я нашла: " + texts[0]
|
||
}
|
||
return reply, true
|
||
}
|
||
|
||
// webPageContextRunes — how much of a fetched page is handed to the phraser.
|
||
// Less than the crawler keeps: the rest of the 4096-token window belongs to the
|
||
// prompt, the persona block and the reply.
|
||
const webPageContextRunes = 1500
|
||
|
||
// queryWeb — "посмотри https://example.org/x — что там?" (Vikunja #259).
|
||
//
|
||
// It claims a turn ONLY when he named a URL, which is what keeps a fallback from
|
||
// becoming a habit: no URL, no fetch, and the model answers from what is local.
|
||
// What leaves the box is the URL and nothing else — no note, no fact, no history
|
||
// travels with it.
|
||
func (h *reactiveHandler) queryWeb(ctx context.Context, t *queryTurn) (string, bool) {
|
||
link, ok := router.FirstURL(t.dec.Utterance)
|
||
if !ok {
|
||
return "", false
|
||
}
|
||
if h.crawler == nil {
|
||
// Claim rather than fall through: he asked about a specific page, and
|
||
// letting the model answer from the URL's spelling alone is how a small
|
||
// model invents a page's contents.
|
||
return "я не читаю страницы — это не настроено.", true
|
||
}
|
||
ctxFetch, cancel := context.WithTimeout(ctx, 30*time.Second)
|
||
defer cancel()
|
||
page, err := h.crawler.Page(ctxFetch, link)
|
||
if err != nil {
|
||
if errors.Is(err, crawl.ErrRobots) {
|
||
return "эта страница закрыта для чтения — robots.txt не разрешает.", true
|
||
}
|
||
log.Printf("voice: web: %v", err)
|
||
return "не получилось прочитать страницу.", true
|
||
}
|
||
if page.Text == "" {
|
||
return "страница открылась, но читать там нечего.", true
|
||
}
|
||
// The page is handed to the phraser the same way a note is: as context for
|
||
// the question he actually asked. She answers the question, she does not
|
||
// recite the page.
|
||
snippet := page.Title + "\n" + crawl.TrimRunes(page.Text, webPageContextRunes)
|
||
reply, perr := h.phraser.PhraseQuery(ctx, t.dec.Utterance, []string{snippet})
|
||
if perr != nil {
|
||
log.Printf("voice: web: phrase: %v", perr)
|
||
}
|
||
if reply == "" {
|
||
// No phraser (or it failed): read back the top of the page rather than
|
||
// pretend the fetch did not happen.
|
||
return "вот что на странице: " + crawl.TrimRunes(page.Text, 300), true
|
||
}
|
||
return reply, true
|
||
}
|
||
|
||
// queryGeneral — general knowledge from the phraser, the last source before
|
||
// giving up. It always claims: either the model answers or Maven says she
|
||
// doesn't know.
|
||
func (h *reactiveHandler) queryGeneral(ctx context.Context, t *queryTurn) (string, bool) {
|
||
reply, err := h.phraser.PhraseQuery(ctx, t.dec.Utterance, nil)
|
||
if err != nil || reply == "" {
|
||
return "не знаю.", true
|
||
}
|
||
return reply, true
|
||
}
|