diff --git a/cmd/mavend/actions.go b/cmd/mavend/actions.go index 13dbaf4..f6a9956 100644 --- a/cmd/mavend/actions.go +++ b/cmd/mavend/actions.go @@ -16,7 +16,7 @@ // intent identically. // - the destructive-act confirm gate (park / resolveConfirm / confirmTTL) // and the enabled-tool allowlist. Both live entirely inside -// actionAct/handleAct below, exactly where they lived in the old +// actionAct/handleAct in actions_act.go, exactly where they lived in the old // switch's IntentAct case — they are act-specific (a fact or a note // can't be destructive), not shared across intents, so they do not need // to move to a separate layer. The important invariant, preserved @@ -29,23 +29,18 @@ // followUpMerge) run in the callers (runTurn, // finishClarified), not per-intent, and are untouched by this slice. // -// Adding an intent: write its handler here, add one line to actionHandlers. -// Do not grow applyAction's switch back. +// Each handler lives in actions_.go; the small ones (chat, system) +// and the table itself stay here. +// +// Adding an intent: write its handler in its own file, add one line to +// actionHandlers. Do not grow applyAction's switch back. package main import ( "context" - "errors" - "fmt" "log" - "strconv" - "time" - "github.com/kami/maven/internal/ipc" - "github.com/kami/maven/internal/memory" "github.com/kami/maven/internal/router" - "github.com/kami/maven/internal/tool" - "github.com/kami/maven/internal/weather" ) // actionHandlers is the per-intent dispatch table used by applyAction. @@ -59,134 +54,6 @@ var actionHandlers = map[router.Intent]func(*reactiveHandler, context.Context, r router.IntentQuery: (*reactiveHandler).actionQuery, } -func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) string { - if !dec.Slots.HasKey { - return "не разобрала, что записать — попробуй иначе." - } - now := h.now() - req := ipc.WriteFactReq{ - Ts: now, - Kind: "self", - Key: dec.Slots.Key, - Value: dec.Slots.Value, - Source: "tap:voice", - Confidence: 1.0, - // Subject: the key doubles as the entity-resolution candidate — - // a voice-tapped fact's key is usually the thing/person it's - // about ("espresso_machine", "kate"), so queueing it for Nexus - // resolution costs one async lookup and is a no-op (not_found) - // for the abstract self-state keys (mood, water) that aren't - // entities at all. - Subject: dec.Slots.Key, - } - factID, err := h.api.WriteFact(ctx, req) - if err != nil { - log.Printf("voice: write fact: %v", err) - return "не получилось сохранить факт." - } - // Index the fact utterance in long-term memory (best-effort, must not - // fail the fact write). Facts aren't in the notes table, so this is the - // only recall path for them — "когда я пил воду?" reads back from here. - if h.memStore != nil { - if vec, err := router.EmbedPassage(ctx, h.embedder, dec.Utterance); err != nil { - log.Printf("voice: embed fact for memory: %v", err) - } else if err := h.memStore.Insert(ctx, "fact:"+dec.Slots.Key+":"+strconv.FormatInt(now.Unix(), 10), vec, map[string]string{ - "source": "voice", - "type": "fact", - "text": dec.Utterance, - "ts": strconv.FormatInt(now.Unix(), 10), - }); err != nil { - log.Printf("voice: memory insert fact: %v", err) - } - } - // Event extraction + pattern detection (best-effort, must not fail the - // fact write). If the fact describes a recognizable action, it becomes a - // normalized event; if ≥3 events for the same action+object show stable - // intervals, a proposed routine is created and parked for confirmation. - if h.dataStore != nil { - if phrase := h.detectPattern(ctx, factID, dec.Slots.Key, dec.Slots.Value, now); phrase != "" { - return phrase // "ты заправляешь ... напоминать?" - } - } - return "" // replier phrases the success reply -} - -func (h *reactiveHandler) actionReminder(ctx context.Context, dec router.Decision) string { - if !dec.Slots.HasTime { - // Stage-0 (reminder-wakeword grammar) skips the extractor, so the - // time wasn't parsed. Run the parser as a fallback. - if dec.Stage == 0 && h.timeParser != nil { - t, ok, err := h.timeParser.Parse(ctx, dec.Utterance, h.now()) - if err == nil && ok { - dec.Slots.Time = t - dec.Slots.HasTime = true - } - } - if !dec.Slots.HasTime { - return "не получилось разобрать время напоминания." - } - } - payload := `{"text":` + jsonString(dec.Utterance) + `}` - if _, err := h.api.CreateReminder(ctx, dec.Slots.Time, payload, ""); err != nil { - log.Printf("voice: create reminder: %v", err) - return "не получилось поставить напоминание." - } - return "" -} - -func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) string { - // tool executor: run the matched fn against the enabled allowlist. - // HasFn=false ⇒ try the matcher (for LLM-routed acts where the verb - // didn't go through the stage-0 act grammar). - if !dec.Slots.HasFn && dec.Slots.Text != "" && h.matcher != nil { - if fn, args, ok := h.matcher.Match(dec.Slots.Text); ok { - dec.Slots.Fn, dec.Slots.Args, dec.Slots.HasFn = fn, args, true - } - } - - // Praxis ecosystem tools: intercept before the system command executor. - if h.ecosystem != nil && h.ecosystem.praxis != nil && dec.Slots.HasFn { - if reply := h.handlePraxisAct(ctx, dec); reply != "" { - return reply - } - } - - // Hexis ecosystem action: if ecosystem is configured and we have a verb - // + entity text, try to resolve the entity and execute via Hexis. - if h.ecosystem != nil && h.ecosystem.hexis != nil && dec.Slots.Text != "" { - if reply := h.handleHexisAct(ctx, dec); reply != "" { - return reply - } - } - - // HasFn still false ⇒ no allowlist match: scaffold a 'proposed' tool - // the user can enable on the authed surface ("earn the right to ask"). - if !dec.Slots.HasFn { - return h.proposeGap(ctx, dec) - } - out, err := h.tools.Exec(ctx, dec.Slots.Fn, dec.Slots.Args, false) - if err != nil { - switch { - case errors.Is(err, tool.ErrNeedsConfirm): - // destructive: park it and ask. The next utterance answers. - phrase := actPhrase(dec.Slots.Fn, dec.Slots.Args) - h.park(dec.Slots.Fn, dec.Slots.Args, phrase) - return "выполнить «" + phrase + "»? скажи «да» или «нет»." - case errors.Is(err, tool.ErrNotEnabled): - return h.proposeGap(ctx, dec) - } - log.Printf("voice: tool %s: %v", dec.Slots.Fn, err) - if out != "" { - return "не получилось выполнить команду: " + firstLine(out) - } - return "не получилось выполнить команду." - } - if out != "" { - return "готово: " + firstLine(out) - } - return "готово." -} - func (h *reactiveHandler) actionChat(ctx context.Context, dec router.Decision) string { // Conversational: build history from dialogue session (prior user turns) // and let the LLM respond from general knowledge + context. @@ -202,161 +69,3 @@ func (h *reactiveHandler) actionChat(ctx context.Context, dec router.Decision) s func (h *reactiveHandler) actionSystem(ctx context.Context, dec router.Decision) string { return h.replySystem(ctx, dec) } - -func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) string { - // embed the note text with the same model the classifier uses, persist - // via CoreAPI (source=tap:voice). Semantic recall lives in `notes`, not - // facts — no predicate reads it (spec's two-memory split). - vec, err := router.EmbedPassage(ctx, h.embedder, dec.Utterance) - if err != nil { - log.Printf("voice: embed note: %v", err) - return "не получилось сохранить заметку." - } - noteTs := h.now() - noteID, err := h.api.WriteNote(ctx, noteTs, dec.Utterance, vec, "tap:voice") - if err != nil { - log.Printf("voice: write note: %v", err) - return "не получилось сохранить заметку." - } - // Insert into long-term memory (best-effort, must not fail the note write). - // text/ts in the meta make a Search hit self-describing (see bestRecall). - if h.memStore != nil { - if err := h.memStore.Insert(ctx, "note:"+strconv.FormatInt(noteID, 10), vec, map[string]string{ - "source": "voice", - "type": "note", - "text": dec.Utterance, - "ts": strconv.FormatInt(noteTs.Unix(), 10), - }); err != nil { - log.Printf("voice: memory insert: %v", err) - } - } - return "" // replier phrases the "saved" reply -} - -func (h *reactiveHandler) actionQuery(ctx context.Context, dec router.Decision) string { - // Fact-by-key lookup: when the dialogue layer resolved an anaphoric - // reference to a prior fact's key (e.g. "когда я это сделал?" after - // "запиши что я пил воду"), look up the fact's value directly. - if dec.Slots.HasKey && dec.Slots.Key != "" { - if f, err := h.api.LatestFact(ctx, dec.Slots.Key); err == nil { - 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. - reply := fmt.Sprintf("я записала это %s", formatTime(f.Ts)) - return reply - } - // General fact reference: describe what we know. - if dec.Utterance == "" { - return fmt.Sprintf("вот что я знаю: %s — %s", dec.Slots.Key, f.Value) - } - // The utterance still carries the question; fall through to - // normal RAG with the resolved key in context. - } - } - - // Calendar questions: "что у меня сегодня?", "планы на завтра?" - // h.now(), not time.Now(): the handler's clock is the injected one, so - // this arm can be tested at a fixed time like the rest. - if date, ok := router.ParseCalendarDate(dec.Utterance, h.now()); ok { - events, err := h.api.CalendarEvents(ctx, date, date.Add(24*time.Hour)) - if err != nil { - log.Printf("voice: calendar events: %v", err) - return "не получилось проверить календарь." - } - values := make([]string, len(events)) - for i, e := range events { - values[i] = e.Value - } - var f router.CalendarEventFormatter - return f.Format(values, date) - } - - // Weather questions - if isWeatherQuery(dec.Utterance) { - loc := extractWeatherLocation(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 "погода не настроена." - } - if err != nil { - log.Printf("voice: weather: %v", err) - return "не получилось узнать погоду." - } - return fmt.Sprintf("в %s сейчас %.0f градусов, %s.", w.Location, w.Temperature, w.Condition) - } - - vec, err := router.EmbedQuery(ctx, h.embedder, dec.Utterance) - if err != nil { - log.Printf("voice: embed query: %v", err) - return "не получилось найти ответ." - } - // 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 gate 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. - if h.memStore != nil { - if hits, herr := h.memStore.Search(ctx, vec, 3); herr == nil { - if hit, ok := bestRecall(hits, h.queryMinScore, h.queryMinMargin); ok { - 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, dec.Utterance, []string{text}); perr == nil && reply != "" { - return reply - } - } - return text - } - } else { - log.Printf("voice: memory search: %v", herr) - } - } - - notes, err := h.api.QueryNotes(ctx, vec, 5) - if err != nil { - log.Printf("voice: query notes: %v", err) - return "не получилось найти ответ." - } - // 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. - noteScores := make([]float64, len(notes)) - for i, n := range notes { - noteScores[i] = n.Score - } - if !memory.ConfidentScores(noteScores, h.queryMinScore, h.queryMinMargin) { - // Try general knowledge from the phraser before giving up - reply, err := h.phraser.PhraseQuery(ctx, dec.Utterance, nil) - if err != nil || reply == "" { - return "не знаю." - } - return reply - } - texts := make([]string, len(notes)) - for i, n := range notes { - texts[i] = n.Text - } - reply, err := h.phraser.PhraseQuery(ctx, dec.Utterance, texts) - if err != nil { - log.Printf("voice: phrase query: %v", err) - } - if reply == "" { - reply = "вот что я нашла: " + texts[0] - } - return reply -} diff --git a/cmd/mavend/actions_act.go b/cmd/mavend/actions_act.go new file mode 100644 index 0000000..d5a1dc1 --- /dev/null +++ b/cmd/mavend/actions_act.go @@ -0,0 +1,66 @@ +package main + +import ( + "context" + "errors" + "log" + + "github.com/kami/maven/internal/router" + "github.com/kami/maven/internal/tool" +) + +// actionAct handles router.IntentAct: match a verb to an enabled tool, offer +// it to the ecosystems first, and run it behind the confirm gate and the +// allowlist. proposeGap and the confirm gate itself live in confirm.go. +func (h *reactiveHandler) actionAct(ctx context.Context, dec router.Decision) string { + // tool executor: run the matched fn against the enabled allowlist. + // HasFn=false ⇒ try the matcher (for LLM-routed acts where the verb + // didn't go through the stage-0 act grammar). + if !dec.Slots.HasFn && dec.Slots.Text != "" && h.matcher != nil { + if fn, args, ok := h.matcher.Match(dec.Slots.Text); ok { + dec.Slots.Fn, dec.Slots.Args, dec.Slots.HasFn = fn, args, true + } + } + + // Praxis ecosystem tools: intercept before the system command executor. + if h.ecosystem != nil && h.ecosystem.praxis != nil && dec.Slots.HasFn { + if reply := h.handlePraxisAct(ctx, dec); reply != "" { + return reply + } + } + + // Hexis ecosystem action: if ecosystem is configured and we have a verb + // + entity text, try to resolve the entity and execute via Hexis. + if h.ecosystem != nil && h.ecosystem.hexis != nil && dec.Slots.Text != "" { + if reply := h.handleHexisAct(ctx, dec); reply != "" { + return reply + } + } + + // HasFn still false ⇒ no allowlist match: scaffold a 'proposed' tool + // the user can enable on the authed surface ("earn the right to ask"). + if !dec.Slots.HasFn { + return h.proposeGap(ctx, dec) + } + out, err := h.tools.Exec(ctx, dec.Slots.Fn, dec.Slots.Args, false) + if err != nil { + switch { + case errors.Is(err, tool.ErrNeedsConfirm): + // destructive: park it and ask. The next utterance answers. + phrase := actPhrase(dec.Slots.Fn, dec.Slots.Args) + h.park(dec.Slots.Fn, dec.Slots.Args, phrase) + return "выполнить «" + phrase + "»? скажи «да» или «нет»." + case errors.Is(err, tool.ErrNotEnabled): + return h.proposeGap(ctx, dec) + } + log.Printf("voice: tool %s: %v", dec.Slots.Fn, err) + if out != "" { + return "не получилось выполнить команду: " + firstLine(out) + } + return "не получилось выполнить команду." + } + if out != "" { + return "готово: " + firstLine(out) + } + return "готово." +} diff --git a/cmd/mavend/actions_fact.go b/cmd/mavend/actions_fact.go new file mode 100644 index 0000000..855bf4b --- /dev/null +++ b/cmd/mavend/actions_fact.go @@ -0,0 +1,64 @@ +package main + +import ( + "context" + "log" + "strconv" + + "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/router" +) + +// actionFact handles router.IntentFact: persist a tapped self-fact, index +// it for recall, and let pattern detection propose a routine. +func (h *reactiveHandler) actionFact(ctx context.Context, dec router.Decision) string { + if !dec.Slots.HasKey { + return "не разобрала, что записать — попробуй иначе." + } + now := h.now() + req := ipc.WriteFactReq{ + Ts: now, + Kind: "self", + Key: dec.Slots.Key, + Value: dec.Slots.Value, + Source: "tap:voice", + Confidence: 1.0, + // Subject: the key doubles as the entity-resolution candidate — + // a voice-tapped fact's key is usually the thing/person it's + // about ("espresso_machine", "kate"), so queueing it for Nexus + // resolution costs one async lookup and is a no-op (not_found) + // for the abstract self-state keys (mood, water) that aren't + // entities at all. + Subject: dec.Slots.Key, + } + factID, err := h.api.WriteFact(ctx, req) + if err != nil { + log.Printf("voice: write fact: %v", err) + return "не получилось сохранить факт." + } + // Index the fact utterance in long-term memory (best-effort, must not + // fail the fact write). Facts aren't in the notes table, so this is the + // only recall path for them — "когда я пил воду?" reads back from here. + if h.memStore != nil { + if vec, err := router.EmbedPassage(ctx, h.embedder, dec.Utterance); err != nil { + log.Printf("voice: embed fact for memory: %v", err) + } else if err := h.memStore.Insert(ctx, "fact:"+dec.Slots.Key+":"+strconv.FormatInt(now.Unix(), 10), vec, map[string]string{ + "source": "voice", + "type": "fact", + "text": dec.Utterance, + "ts": strconv.FormatInt(now.Unix(), 10), + }); err != nil { + log.Printf("voice: memory insert fact: %v", err) + } + } + // Event extraction + pattern detection (best-effort, must not fail the + // fact write). If the fact describes a recognizable action, it becomes a + // normalized event; if ≥3 events for the same action+object show stable + // intervals, a proposed routine is created and parked for confirmation. + if h.dataStore != nil { + if phrase := h.detectPattern(ctx, factID, dec.Slots.Key, dec.Slots.Value, now); phrase != "" { + return phrase // "ты заправляешь ... напоминать?" + } + } + return "" // replier phrases the success reply +} diff --git a/cmd/mavend/actions_note.go b/cmd/mavend/actions_note.go new file mode 100644 index 0000000..40e41cb --- /dev/null +++ b/cmd/mavend/actions_note.go @@ -0,0 +1,41 @@ +package main + +import ( + "context" + "log" + "strconv" + + "github.com/kami/maven/internal/router" +) + +// actionNote handles router.IntentNote: embed the note, persist it, and +// index it for recall. +func (h *reactiveHandler) actionNote(ctx context.Context, dec router.Decision) string { + // embed the note text with the same model the classifier uses, persist + // via CoreAPI (source=tap:voice). Semantic recall lives in `notes`, not + // facts — no predicate reads it (spec's two-memory split). + vec, err := router.EmbedPassage(ctx, h.embedder, dec.Utterance) + if err != nil { + log.Printf("voice: embed note: %v", err) + return "не получилось сохранить заметку." + } + noteTs := h.now() + noteID, err := h.api.WriteNote(ctx, noteTs, dec.Utterance, vec, "tap:voice") + if err != nil { + log.Printf("voice: write note: %v", err) + return "не получилось сохранить заметку." + } + // Insert into long-term memory (best-effort, must not fail the note write). + // text/ts in the meta make a Search hit self-describing (see bestRecall). + if h.memStore != nil { + if err := h.memStore.Insert(ctx, "note:"+strconv.FormatInt(noteID, 10), vec, map[string]string{ + "source": "voice", + "type": "note", + "text": dec.Utterance, + "ts": strconv.FormatInt(noteTs.Unix(), 10), + }); err != nil { + log.Printf("voice: memory insert: %v", err) + } + } + return "" // replier phrases the "saved" reply +} diff --git a/cmd/mavend/actions_query.go b/cmd/mavend/actions_query.go new file mode 100644 index 0000000..9ce9972 --- /dev/null +++ b/cmd/mavend/actions_query.go @@ -0,0 +1,226 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log" + "time" + + "github.com/kami/maven/internal/ipc" + "github.com/kami/maven/internal/memory" + "github.com/kami/maven/internal/router" + "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}, + {"calendar", (*reactiveHandler).queryCalendar}, + {"weather", (*reactiveHandler).queryWeather}, + {"embed", (*reactiveHandler).queryEmbed}, + {"memory", (*reactiveHandler).queryMemory}, + {"notes", (*reactiveHandler).queryNotes}, + {"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 +} + +// 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 + } + values := make([]string, len(events)) + for i, e := range events { + values[i] = e.Value + } + var f router.CalendarEventFormatter + return f.Format(values, date), true +} + +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 +} + +// 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 +} diff --git a/cmd/mavend/actions_reminder.go b/cmd/mavend/actions_reminder.go new file mode 100644 index 0000000..ce2a632 --- /dev/null +++ b/cmd/mavend/actions_reminder.go @@ -0,0 +1,33 @@ +package main + +import ( + "context" + "log" + + "github.com/kami/maven/internal/router" +) + +// actionReminder handles router.IntentReminder: parse the time when stage-0 +// skipped the extractor, then create the reminder. +func (h *reactiveHandler) actionReminder(ctx context.Context, dec router.Decision) string { + if !dec.Slots.HasTime { + // Stage-0 (reminder-wakeword grammar) skips the extractor, so the + // time wasn't parsed. Run the parser as a fallback. + if dec.Stage == 0 && h.timeParser != nil { + t, ok, err := h.timeParser.Parse(ctx, dec.Utterance, h.now()) + if err == nil && ok { + dec.Slots.Time = t + dec.Slots.HasTime = true + } + } + if !dec.Slots.HasTime { + return "не получилось разобрать время напоминания." + } + } + payload := `{"text":` + jsonString(dec.Utterance) + `}` + if _, err := h.api.CreateReminder(ctx, dec.Slots.Time, payload, ""); err != nil { + log.Printf("voice: create reminder: %v", err) + return "не получилось поставить напоминание." + } + return "" +}