Merge branch 'refactor/query-sources' into integration/small-batch
This commit is contained in:
+6
-297
@@ -16,7 +16,7 @@
|
|||||||
// intent identically.
|
// intent identically.
|
||||||
// - the destructive-act confirm gate (park / resolveConfirm / confirmTTL)
|
// - the destructive-act confirm gate (park / resolveConfirm / confirmTTL)
|
||||||
// and the enabled-tool allowlist. Both live entirely inside
|
// 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
|
// 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
|
// can't be destructive), not shared across intents, so they do not need
|
||||||
// to move to a separate layer. The important invariant, preserved
|
// to move to a separate layer. The important invariant, preserved
|
||||||
@@ -29,23 +29,18 @@
|
|||||||
// followUpMerge) run in the callers (runTurn,
|
// followUpMerge) run in the callers (runTurn,
|
||||||
// finishClarified), not per-intent, and are untouched by this slice.
|
// finishClarified), not per-intent, and are untouched by this slice.
|
||||||
//
|
//
|
||||||
// Adding an intent: write its handler here, add one line to actionHandlers.
|
// Each handler lives in actions_<intent>.go; the small ones (chat, system)
|
||||||
// Do not grow applyAction's switch back.
|
// 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
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
"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/router"
|
||||||
"github.com/kami/maven/internal/tool"
|
|
||||||
"github.com/kami/maven/internal/weather"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// actionHandlers is the per-intent dispatch table used by applyAction.
|
// 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,
|
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 {
|
func (h *reactiveHandler) actionChat(ctx context.Context, dec router.Decision) string {
|
||||||
// Conversational: build history from dialogue session (prior user turns)
|
// Conversational: build history from dialogue session (prior user turns)
|
||||||
// and let the LLM respond from general knowledge + context.
|
// 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 {
|
func (h *reactiveHandler) actionSystem(ctx context.Context, dec router.Decision) string {
|
||||||
return h.replySystem(ctx, dec)
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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 "готово."
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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 ""
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user