diff --git a/cmd/mavend/actions.go b/cmd/mavend/actions.go new file mode 100644 index 0000000..83a874e --- /dev/null +++ b/cmd/mavend/actions.go @@ -0,0 +1,362 @@ +// actionTable dispatches applyAction's per-intent bodies. Each of the 7 +// intents (fact, reminder, note, query, act, chat, system) has one handler +// here with the signature: +// +// func(h *reactiveHandler, ctx context.Context, dec router.Decision) string +// +// same contract as applyAction itself: "" means "let the Replier phrase the +// reply", a non-empty string OVERRIDES it. This is a straight extraction of +// applyAction's old switch cases (formerly ~300 lines in voice.go) — no +// reordering of side effects, no new abstractions inside a handler. +// +// What does NOT belong in this table, because it is not per-intent: +// +// - the dec.Clarify short-circuit ("" when the router's stage-3 fired) — +// stays in applyAction, before dispatch, since it applies to every +// 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 +// 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 +// as-is: applyAction runs identically whether dec came from a fresh +// route or from a completed clarify answer (see finishClarified in +// clarify.go and its comment "filling in an argument never grants +// authority") — a handler must never special-case a clarify-completed +// decision to skip the confirm gate or the allowlist. +// - detectPattern and dialogue-session bookkeeping (rememberTurn, +// followUpMerge) run in the callers (handleText, HandlePushToTalk, +// 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. +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. +var actionHandlers = map[router.Intent]func(*reactiveHandler, context.Context, router.Decision) string{ + router.IntentFact: (*reactiveHandler).actionFact, + router.IntentReminder: (*reactiveHandler).actionReminder, + router.IntentAct: (*reactiveHandler).actionAct, + router.IntentChat: (*reactiveHandler).actionChat, + router.IntentSystem: (*reactiveHandler).actionSystem, + router.IntentNote: (*reactiveHandler).actionNote, + 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. + history := h.chatHistory() + reply, err := h.phraser.PhraseChat(ctx, dec.Utterance, history) + if err != nil { + log.Printf("voice: chat: %v", err) + return "поговорили." + } + return reply +} + +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/voice.go b/cmd/mavend/voice.go index e2e7381..5a9c955 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -51,7 +51,6 @@ import ( "log" "os" "path/filepath" - "strconv" "strings" "sync" "time" @@ -561,301 +560,8 @@ func (h *reactiveHandler) applyAction(ctx context.Context, dec router.Decision) if dec.Clarify { return "" // the Replier phrases clarify } - switch dec.Intent { - case router.IntentFact: - 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 - - case router.IntentReminder: - 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 "" - - case router.IntentAct: - // 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 "готово." - - case router.IntentChat: - // Conversational: build history from dialogue session (prior user turns) - // and let the LLM respond from general knowledge + context. - history := h.chatHistory() - reply, err := h.phraser.PhraseChat(ctx, dec.Utterance, history) - if err != nil { - log.Printf("voice: chat: %v", err) - return "поговорили." - } - return reply - - case router.IntentSystem: - return h.replySystem(ctx, dec) - - case router.IntentNote: - // 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 - - case router.IntentQuery: - // 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 + if handler, ok := actionHandlers[dec.Intent]; ok { + return handler(h, ctx, dec) } return "" }