b4a3867479
The six answer sources were hand-unrolled inside one 127-line function. The intent table is a closed set of 7, but this list is open-ended — Kiwix (#286), RSS (#258), the crawler (#259) and email (#246) each add one. Each is now a registry entry: a name plus a method on the handler, walked in order until one claims the question. Order is unchanged and still load-bearing (memory before the notes-only pass, #373), the confidence gate keeps its position and semantics, and every reply string, log line and best-effort failure is verbatim.
231 lines
9.3 KiB
Go
231 lines
9.3 KiB
Go
// 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"
|
|
"log"
|
|
"strconv"
|
|
|
|
"github.com/kami/maven/internal/ipc"
|
|
"github.com/kami/maven/internal/router"
|
|
"github.com/kami/maven/internal/tool"
|
|
)
|
|
|
|
// 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
|
|
}
|