8015fdbb79
Replace nearest-neighbour personal routing with a frozen class-balanced linear head measured on historical, stratified, cross-validation, holdout, and fresh challenge gates (V-702). Close the four repair handoff holes, preserve nested clarification flows, and route Russian possession statements through structural grammar rather than lexical exceptions (V-573). Owner explicitly requested direct commits to master.
608 lines
29 KiB
Go
608 lines
29 KiB
Go
// Package main is mavend's voice wiring + reactive handler.
|
||
//
|
||
// Two responsibilities for the audio path:
|
||
//
|
||
// 1. CONSTRUCTION: read cfg.Voice, build the stt/tts/transcribers (Stub
|
||
// in-process by default, Remote via worker socket when configured),
|
||
// the router (stage-0 grammar + HashEmbedder classifier seeded with
|
||
// floor examples — production swaps in the ONNX multilingual model
|
||
// later), the voice TCP listener, the sessions registry, the
|
||
// voicesink, and wire the voicesink into the dispatcher's Voice slot.
|
||
//
|
||
// 2. HANDLER: a concrete voice.Handler that processes PushToTalk
|
||
// requests: stt → router → action → replier → tts → reply. The
|
||
// handler is what makes the audio round-trip "live". It wires to the
|
||
// CoreAPI in-process (the daemon already has it as ipc.NewStoreAPI(st)
|
||
// for module-IPC — the reactive path uses the same CoreAPI off the
|
||
// same store; both are the "core = the only key-holder" path through
|
||
// the daemon-embedded adapter).
|
||
//
|
||
// The "actions" handled today (per spec order; some deferred):
|
||
//
|
||
// - IntentFact: WriteFact via CoreAPI. The router's Slots.Key/Value feed
|
||
// the write; Source = "tap:voice" (the voice path is a tap, value=1.0
|
||
// confidence — the user said it out loud, maven trusts the capture).
|
||
// - IntentReminder: CreateReminder via CoreAPI. The router already
|
||
// resolved relative→absolute at capture ("in 4h" → fire_ts); the
|
||
// CoreAPI stores it as-is.
|
||
// - IntentAct: the tool executor runs the matched fn against the store's
|
||
// ENABLED allowlist (internal/tool). A verb not on it is scaffolded as a
|
||
// 'proposed' tool a human enables on the authed mavweb surface (never
|
||
// voice). Destructive tools run only after a spoken confirm turn.
|
||
// - IntentNote: chroma/vector-store deferred. The handler replies
|
||
// "saved" without persisting — a stub on the way to chroma.
|
||
// - IntentQuery: RAG-over-chroma deferred. The handler replies "I'll
|
||
// look that up later" — same shape as the other deferred slots.
|
||
// - Clarify: the router's stage-3 confidence gate fired; reply "didn't
|
||
// catch that, can you rephrase?"
|
||
//
|
||
// The Replier (voice.StubReplier today) renders the reply TEXT across all
|
||
// these branches. The TTS synthesiser (tts.Stub today) renders that text
|
||
// to audio. The PushToTalkResp carries BOTH so the client can play (audio)
|
||
// AND log (text) for tests asserting the round-trip.
|
||
package main
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"fmt"
|
||
"log"
|
||
"strings"
|
||
"sync"
|
||
"time"
|
||
|
||
"github.com/kami/maven/internal/audio"
|
||
"github.com/kami/maven/internal/crawl"
|
||
"github.com/kami/maven/internal/decision"
|
||
"github.com/kami/maven/internal/dialogue"
|
||
"github.com/kami/maven/internal/ipc"
|
||
"github.com/kami/maven/internal/lexicon"
|
||
"github.com/kami/maven/internal/phraser"
|
||
"github.com/kami/maven/internal/router"
|
||
"github.com/kami/maven/internal/store"
|
||
"github.com/kami/maven/internal/stt"
|
||
"github.com/kami/maven/internal/tool"
|
||
"github.com/kami/maven/internal/tts"
|
||
"github.com/kami/maven/internal/ttsnorm"
|
||
"github.com/kami/maven/internal/voice"
|
||
"github.com/kami/maven/internal/weather"
|
||
)
|
||
|
||
// reactiveHandler — voice.Handler implementation. One method: turn a
|
||
// PushToTalkReq into a reply (audio + text). The handler is concurrency-
|
||
// safe (the wired stt/tts/router/api all are); called from per-conn
|
||
// goroutines on the voice.Server.
|
||
type reactiveHandler struct {
|
||
stt stt.Transcriber
|
||
tts tts.Synthesizer
|
||
router *router.Router
|
||
|
||
// recall — the note-and-fact recall subsystem: the embedder, the vector
|
||
// store it writes into, the personal boundary, and the two numbers that
|
||
// gate an answer. Grouped rather than spread across the handler because a
|
||
// handler that recalls needs all five and a handler that does not needs
|
||
// none of them (Vikunja #433, docs/handler-wiring.md).
|
||
recall recallWiring
|
||
// api — the CoreAPI the handler reads and writes through. Wired with the
|
||
// bare store adapter and UPGRADED by main once the daemonAPI exists; see
|
||
// upgradeAPI.
|
||
api ipc.CoreAPI
|
||
tools *tool.Executor
|
||
matcher *tool.Matcher
|
||
phraser phraser.Phraser
|
||
replier voice.Replier
|
||
now func() time.Time
|
||
|
||
// crawler reads a web page he names out loud (queryWeb). nil ⇒ on-demand
|
||
// page reading is off, which is the default: no `crawl` block, no fetch.
|
||
crawler *crawl.Crawler
|
||
|
||
// search asks a self-hosted SearXNG (querySearch), the first world source
|
||
// once his own data has had its turn. nil ⇒ off, the default: no `search`
|
||
// block, no query ever leaves the LAN.
|
||
search *searchWiring
|
||
|
||
// kiwix searches the offline ZIMs (queryKiwix), the fallback behind the
|
||
// live search and the last source before the model answers from its own
|
||
// weights. nil ⇒ off, the default.
|
||
kiwix *kiwixWiring
|
||
|
||
// feedsOn — whether any RSS feed is configured (config.Feeds). It changes
|
||
// only what she SAYS when asked and nothing is there: "ленты не настроены"
|
||
// instead of "ничего нового", which are different truths.
|
||
feedsOn bool
|
||
|
||
// home — the Home Assistant client (Vikunja #256). nil ⇒ the house is not
|
||
// configured, which is the default: no `smarthome` block, no reads, no
|
||
// switches. Control does not go through this field — it goes through the
|
||
// act allowlist and tool.Executor, like every other mutating act.
|
||
home *homeWiring
|
||
|
||
// netscan — the LAN scanner (Vikunja #257). nil ⇒ off, which is the
|
||
// default. A scan is a read, so it has no allowlist row; what keeps it
|
||
// safe is that its range comes from config and from nowhere else.
|
||
netscan *netWiring
|
||
|
||
weatherProvider weather.Provider
|
||
weatherLocation string // default location for weather queries
|
||
|
||
dataStore *store.Store // direct store access for event extraction + pattern detection
|
||
|
||
// timeParser — used as a fallback for stage-0 reminder grammar matches
|
||
// (where the extractor didn't run). Shared with the router's extractor.
|
||
// The production dateparser will replace StubDateTimeParser here too.
|
||
timeParser router.DateTimeParser
|
||
|
||
// dialogueSessions carries slots across turns for follow-ups. Keyed by the
|
||
// reach the turn arrived on (dialogueIDOf), like the clarify store: one
|
||
// slot per reach, not one for the box. nil ⇒ no carry-over.
|
||
dialogueSessions *dialogue.SessionStore
|
||
|
||
// decisions holds the last few turns' arbitration records (V-564): who
|
||
// claimed the turn, who lost it and who was never asked. In memory and
|
||
// bounded, because a turn record is read minutes later or never, and none
|
||
// of his words belong in a table that outlives the diagnosis. nil ⇒ nothing
|
||
// is recorded, which is what a test that did not ask for one gets.
|
||
decisions *decision.Ring
|
||
|
||
// traces persists those same records (V-629, routingtrace.go). The ring is
|
||
// still what /trace reads; this is the second sink, and it exists because the
|
||
// routing heads cannot be fitted without real utterances. nil ⇒ the ring
|
||
// alone, which is the behaviour every box had before 06-08-2026.
|
||
traces traceWriter
|
||
|
||
// encoderID names the encoder body live on this box, stored beside each
|
||
// trace: a fitted distance means nothing under another body. Empty ⇒ no
|
||
// embedder, so the classifier was the keyword floor.
|
||
encoderID string
|
||
|
||
// clarifyStore parks the request behind an open question she asked (see
|
||
// clarify.go). nil ⇒ she falls back to the canned "не поняла" reply.
|
||
clarifyStore *dialogue.ClarifyStore
|
||
|
||
// clarifyMaxAttempts — questions per request before she gives up out loud.
|
||
// 0 ⇒ dialogue.DefaultMaxAttempts (3). Set from VoiceConfig.
|
||
clarifyMaxAttempts int
|
||
|
||
// extractor parses the answer to an open question, with the same parsers
|
||
// the router's own stage-2 uses.
|
||
extractor router.Extractor
|
||
|
||
// pending destructive-act confirmation. A destructive act replies with a
|
||
// "выполнить X? да/нет" prompt and parks here; the NEXT utterance is read as
|
||
// the y/n answer. ponytail: single slot, single-user box — a second act
|
||
// while one waits overwrites it (last-asked wins); expires after confirmTTL.
|
||
mu sync.Mutex
|
||
lastRouted *routedTurn // the previous acted turn, for a spoken correction (repair.go)
|
||
pending *pendingAct
|
||
pendingRoutine *pendingRoutineConfirm // routine proposal awaiting y/n
|
||
pendingHexis *pendingHexisExec // mutating Hexis capability awaiting y/n
|
||
|
||
// surfacedItems — the Praxis item ids she last read out, in the order she
|
||
// read them, so "отметь второй пункт" has a second pункт to mean (Vikunja
|
||
// #516). Same single-slot posture as pending above: the next attention digest
|
||
// replaces the list, because a position only refers to the last one spoken.
|
||
// No TTL — a stale position resolves to an item that Praxis will report as
|
||
// already acknowledged, which is a harmless answer, unlike a stale
|
||
// confirmation that would execute something.
|
||
surfacedItems []string
|
||
|
||
ecosystem *ecosystemWiring // nexus + hexis + praxis clients
|
||
}
|
||
|
||
// HandlePushToTalk — the full reactive round-trip. Each step's failure
|
||
// surfaces as a short reply text + empty audio OR an error; the voice
|
||
// server translates an error into a wire RpcError. Today the handler
|
||
// prefers a canned error-reply over an error return (a user-facing "didn't
|
||
// catch that" is better than a wire error the client surfaces as
|
||
// "internal"); the only error returned is a synthesizer fault (no audio
|
||
// to ship back).
|
||
func (h *reactiveHandler) HandlePushToTalk(ctx context.Context, req voice.PushToTalkReq, _ uint64) (voice.PushToTalkResp, error) {
|
||
// 1. stt — transcribe the audio.
|
||
text, _, err := h.stt.Transcribe(ctx, req.Audio)
|
||
if err != nil {
|
||
log.Printf("voice: stt error: %v", err)
|
||
return h.reply(ctx, "не получилось разобрать речь — попробуй ещё раз.", nil)
|
||
}
|
||
if text == "" {
|
||
return h.reply(ctx, "ничего не услышала — попробуй ещё раз.", nil)
|
||
}
|
||
log.Printf("voice: stt → %q", text)
|
||
|
||
// 2-5. the shared turn pipeline (confirm → clarify → route → dialogue →
|
||
// action → replier), identical to the text path.
|
||
replyText := h.runTurn(ctx, text, sourceVoice)
|
||
|
||
// 6. tts — synthesise the reply text; return to the voice server which
|
||
// ships it back on the conn.
|
||
return h.reply(ctx, replyText, nil)
|
||
}
|
||
|
||
// upgradeAPI points the handler at the daemon's own CoreAPI once main has
|
||
// built it.
|
||
//
|
||
// Wiring order forces this. wireVoice runs before the tick loop exists, so it
|
||
// can only be handed the bare store adapter — and that adapter answers DayPlan
|
||
// (and TickTrace, and MorningStatus) with "not available via direct store
|
||
// API", because a day plan is assembled by the tick loop and is not a table to
|
||
// read. So queryDayPlan, which the query chain reaches for "какие у меня планы
|
||
// на сегодня", failed on the deployed daemon for every caller. main already
|
||
// back-patches the other direction (daemonAPI.chatFn = handler.handleText);
|
||
// this is the same seam in reverse.
|
||
//
|
||
// Safe against the obvious loop: nothing in the voice path calls api.Chat, so
|
||
// pointing the handler at an API whose Chat IS the handler cannot recurse.
|
||
func (h *reactiveHandler) upgradeAPI(api ipc.CoreAPI) {
|
||
if h == nil || api == nil {
|
||
return
|
||
}
|
||
h.api = api
|
||
}
|
||
|
||
// handleText — the core reactive path without stt/tts. Used by the IPC Chat
|
||
// endpoint (and eventually by telegram). Splits out the audio bookends from
|
||
// HandlePushToTalk so text channels share the same routing logic.
|
||
func (h *reactiveHandler) handleText(ctx context.Context, conversation, text string) string {
|
||
log.Printf("voice: handleText: %q", text)
|
||
return h.runTurn(withDialogueID(ctx, dialogueIDFor(sourceText, conversation)), text, sourceText)
|
||
}
|
||
|
||
// turnSource — which channel this utterance arrived on, in the same provenance
|
||
// vocabulary facts use (internal/event). It is threaded through runTurn because
|
||
// a turn can write a fact, and a fact that lies about where it came from is
|
||
// worse than no fact: provenance is the first column read when asking why a
|
||
// daemon-wide setting is the way it is.
|
||
type turnSource string
|
||
|
||
const (
|
||
sourceVoice turnSource = "tap:voice" // HandlePushToTalk, a real microphone
|
||
sourceText turnSource = "tap:text" // handleText: mavweb /api/chat, telegram
|
||
)
|
||
|
||
// runTurn — the reactive turn pipeline shared by the voice and text entry
|
||
// points: expired-clarify notice → confirm answer → explicit correction →
|
||
// clarify answer → quiet toggle → route → dialogue merge → clarify question →
|
||
// action → replier.
|
||
// Takes the already-transcribed utterance, returns the reply text; the voice
|
||
// path wraps it in stt/tts, the text path returns it as-is.
|
||
//
|
||
// The ordering is load-bearing — see the step comments.
|
||
func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSource) (reply string) {
|
||
// 0. the decision record (V-564). Installed here rather than in the IPC
|
||
// entry point, so the mic, telegram and the web all leave the same trail —
|
||
// a record only the web produced would be missing exactly the turns that
|
||
// are hardest to reproduce. It rides the context, costs a few dozen structs
|
||
// on a human-rate path, and no claim site can change a route with it.
|
||
if h.decisions != nil {
|
||
var rec *decision.Record
|
||
ctx, rec = decision.With(ctx, text)
|
||
decision.Expect(ctx, decision.StagePreRoute, preRouteLadder)
|
||
defer func() {
|
||
done := rec.Finish(h.now())
|
||
h.decisions.Push(done)
|
||
h.persistDecision(ctx, done, src)
|
||
}()
|
||
}
|
||
|
||
// 0b. the turn's routing, computed at most once and shared (Vikunja #560).
|
||
// The clarify resolver reads it to decide what this utterance IS before
|
||
// claiming it, and step 5 acts on the same decision — routing twice would
|
||
// cost a second on the resident model and could disagree with itself.
|
||
now := h.now()
|
||
rt := h.newTurnRoute(text, now)
|
||
ctx = withTurnRoute(ctx, rt)
|
||
// A resolver may suspend an older clarify flow even when it handles this
|
||
// turn itself. Finalise that state at one choke point so early returns from
|
||
// confirm/repair/clarify cannot leave a live question parked without saying
|
||
// it again, or silently drop one when the suspension bound is reached.
|
||
defer func() {
|
||
reply = withNotice(rt.dropped, reply)
|
||
reply = withResumed(reply, rt.resume)
|
||
}()
|
||
|
||
// 1. expired clarify — a question was parked but its TTL ran out, so the
|
||
// request behind it is gone. Say that out loud (see clarify.go) and carry
|
||
// on: these words are still routed as a fresh utterance below, with the
|
||
// notice glued in front of whatever the fresh routing answers. Checked
|
||
// BEFORE the answer path: reading a parked question drops an expired one.
|
||
//
|
||
// Taken before the confirm check, not after, because a confirm turn returns
|
||
// early. He can be asked a question, walk off, come back and say "да" to a
|
||
// confirm that is still parked; computing the notice after that return meant
|
||
// he answered the confirm and never heard that the older request was let go.
|
||
expiredNotice := h.clarifyExpiredNotice(ctx)
|
||
|
||
// 2. confirm turn — if a destructive act is parked, this utterance is its
|
||
// y/n answer, not a fresh command. Handled before routing so "да" doesn't
|
||
// get classified as some other intent.
|
||
if reply, handled := h.resolveConfirm(ctx, text); notePreRoute(ctx, "confirm", handled) {
|
||
return withNotice(expiredNotice, reply)
|
||
}
|
||
|
||
// 3. spoken correction — an explicit "нет, это был вопрос" names both the
|
||
// prior mistake and its replacement. It is narrower evidence than a parked
|
||
// question merely being present, so it gets first refusal. Otherwise the
|
||
// clarify resolver treats the correction as a bad slot value and spends a
|
||
// retry on a turn that was never an answer (V-573).
|
||
if reply, handled := h.resolveRepair(ctx, text); notePreRoute(ctx, "repair", handled) {
|
||
return withNotice(expiredNotice, reply)
|
||
}
|
||
|
||
// 3b. The same correction without a target — "нет, не так". It cannot redo
|
||
// the turn, but it can durably label the previous decision as wrong. Like a
|
||
// targeted repair, it is not an answer to a parked slot question.
|
||
if reply, handled := h.resolveUntargetedRepair(ctx, text); notePreRoute(ctx, "repair-negative", handled) {
|
||
return withNotice(expiredNotice, reply)
|
||
}
|
||
|
||
// 4. clarify answer — if she asked a live question last turn, this
|
||
// utterance is its answer, not a fresh command. After the confirm check: a
|
||
// y/n gate is armed by her own prompt and is the narrower claim on the
|
||
// utterance.
|
||
// A live question and an expired one cannot both exist for one dialogue id,
|
||
// so the notice is empty here in practice. withNotice anyway: every exit
|
||
// from runTurn carries it, and that is what stops the next one from
|
||
// forgetting.
|
||
if reply, handled := h.resolveClarifyAnswer(ctx, text); notePreRoute(ctx, "clarify-answer", handled) {
|
||
return withNotice(expiredNotice, reply)
|
||
}
|
||
// It did not claim the turn. Any drop notice or resumed question recorded on
|
||
// rt is attached by the turn finaliser above, including on an early return.
|
||
|
||
// 5. quiet-hours toggle — keyword match, not classifier-dependent.
|
||
// "тихий режим" / "quiet on" would route through the classifier
|
||
// unreliably (it's a command, not a free-form query), so we match it
|
||
// before routing. Same pattern as the confirm turn above.
|
||
if reply, handled := h.resolveQuietToggle(ctx, text, src); notePreRoute(ctx, "quiet-toggle", handled) {
|
||
return withNotice(expiredNotice, reply)
|
||
}
|
||
|
||
// 5b. spoken snooze — "не сейчас" / "потом" answers the nudge she just
|
||
// sent. Only handled when a pending nudge is actually inside the window
|
||
// (snooze.go); otherwise the words route normally, because "потом" is an
|
||
// ordinary word and eating every one of them would break real sentences.
|
||
if reply, handled := h.resolveSnooze(ctx, text, src); notePreRoute(ctx, "snooze", handled) {
|
||
return withNotice(expiredNotice, reply)
|
||
}
|
||
|
||
// 5c. spoken ack — "готово" closes that same nudge as `acted`. Only the
|
||
// contentless form is intercepted here; "выпил воды" keeps routing and
|
||
// closes the nudge after its fact lands (ackFromFact, step 8b).
|
||
if reply, handled := h.resolveAck(ctx, text, src); notePreRoute(ctx, "ack", handled) {
|
||
return withNotice(expiredNotice, reply)
|
||
}
|
||
|
||
// 5d. ordinal selection — "второй", "первую сделал" pick from the list she
|
||
// just read (ordinal.go). Before routing, and only when a list is actually
|
||
// bound to the session: with nothing offered, "второй" is an ordinary word
|
||
// and keeps routing.
|
||
if reply, handled := h.resolveCandidate(ctx, text, src); notePreRoute(ctx, "ordinal", handled) {
|
||
return withNotice(expiredNotice, reply)
|
||
}
|
||
|
||
// 6. route. An elliptical follow-up — "а завтра?" — is answered from the
|
||
// previous turn instead (continuation.go): the intent is the part it is
|
||
// missing, so no amount of routing recovers it, and the model's guess
|
||
// costs seconds to obtain and is close to a coin flip. Everything else
|
||
// goes to the router.
|
||
dec, cont, prev, err := rt.resolve(ctx)
|
||
if err != nil {
|
||
// ErrNoIntents ⇒ classifier unseeded (cold boot). reply with a
|
||
// "still warming up" rather than a wire error.
|
||
if errors.Is(err, router.ErrNoIntents) {
|
||
return withNotice(expiredNotice, "я ещё не понимаю свободную речь — скоро научусь.")
|
||
}
|
||
log.Printf("voice: router error: %v", err)
|
||
return withNotice(expiredNotice, "не получилось разобрать команду.")
|
||
}
|
||
log.Printf("voice: route result: intent=%s slots=%+v", dec.Intent, dec.Slots)
|
||
|
||
// 7. dialogue — fill this turn's missing slots from a prior same-intent
|
||
// turn (follow-ups like «напомни завтра» → «…позвонить маме»), then remember
|
||
// this turn for the next follow-up. Only same-intent, non-expired, non-
|
||
// clarify turns carry (see followUpMerge). Best-effort: nil store ⇒ skipped.
|
||
// A continuation already carries the previous turn's slots, so there is
|
||
// nothing left to inherit — but it is still remembered, so a chain of them
|
||
// ("а завтра?" … "а послезавтра?") keeps working.
|
||
if h.dialogueSessions != nil {
|
||
if !cont {
|
||
merged := followUpMerge(prev, dec, now)
|
||
noteMerge(ctx, dec, merged)
|
||
dec = merged
|
||
}
|
||
if !dec.Clarify {
|
||
h.rememberTurn(ctx, prev, dec, now)
|
||
}
|
||
}
|
||
|
||
// 8. clarify — something she needs is missing. If one named thing is missing,
|
||
// ask about it and park the request (clarify.go); otherwise the replier's
|
||
// canned reply stands.
|
||
//
|
||
// Not gated on dec.Clarify alone (Vikunja #557). A turn the cascade routed
|
||
// confidently but incompletely skipped this entirely: "напомни позвонить"
|
||
// reached applyAction, failed on the missing time, parked nothing, and the
|
||
// "в семь вечера" that followed was web-searched as a world question. A
|
||
// required slot that missingFor names is a gap whatever the confidence.
|
||
if dec.Clarify || len(missingFor(dec)) > 0 {
|
||
if reply := h.hexisBeforeClarify(ctx, dec); reply != "" {
|
||
return withNotice(expiredNotice, reply)
|
||
}
|
||
if question, asked := h.askClarify(ctx, dec); asked {
|
||
noteTerminal(ctx, "clarify-ask", dec.Intent,
|
||
"the route was below the threshold, so she asked instead of acting")
|
||
return withNotice(expiredNotice, question)
|
||
}
|
||
}
|
||
|
||
// Remember what this turn was routed as, so the next utterance can correct
|
||
// it. Only turns she acts on: a clarify asked instead of acting, so there
|
||
// is nothing yet to be wrong about.
|
||
if !dec.Clarify {
|
||
h.recordTurn(text, dec.Intent)
|
||
}
|
||
|
||
// 9. action — execute the decision's intent. errors here surface as
|
||
// short reply text (the user wants to know the action didn't land);
|
||
// the round-trip stays alive.
|
||
replyText := h.applyAction(ctx, dec)
|
||
log.Printf("voice: applyAction returned: %q", replyText)
|
||
// A query turn was already claimed by a source inside the chain; every other
|
||
// intent has no chain and no scoreboard, so the handler is the winner.
|
||
noteTerminal(ctx, "action-handler", dec.Intent, "")
|
||
|
||
// 9b. a fact that answers a live nudge closes it as `acted` (ack.go).
|
||
// Silent: the fact reply stands, she does not congratulate him for it.
|
||
h.ackFromFact(ctx, dec)
|
||
|
||
// 10. replier — phrase the reply across the router decision.
|
||
if replyText == "" {
|
||
replyText = h.replier.Reply(ctx, dec)
|
||
}
|
||
return withNotice(expiredNotice, replyText)
|
||
}
|
||
|
||
// applyAction — executes the router's Decision. Intent-by-intent:
|
||
//
|
||
// - IntentFact: WriteFact via CoreAPI. Source = "tap:voice" (a voice
|
||
// capture is a tap; confidence 1.0).
|
||
// - IntentReminder: CreateReminder via CoreAPI.
|
||
// - IntentAct: tool-executor deferred (no-op today; the reply says so).
|
||
// - IntentNote / IntentQuery: chroma/RAG deferred (no-op; reply says so).
|
||
// - Clarify: the router's stage-3 fired; no action.
|
||
//
|
||
// Returns "" when the Replier should phrase the reply (the default path);
|
||
// returns a non-empty string when the action path wants to OVERRIDE the
|
||
// reply text (e.g. an action error the user should hear SPECIFICALLY, not
|
||
// a generic "ok"). Errors surface as a short reply text the user hears.
|
||
func (h *reactiveHandler) applyAction(ctx context.Context, dec router.Decision) string {
|
||
if dec.Clarify {
|
||
return "" // the Replier phrases clarify
|
||
}
|
||
if handler, ok := actionHandlers[dec.Intent]; ok {
|
||
return handler(h, ctx, dec)
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// replySystem answers system-observable queries using the handler's clock
|
||
// and (in future) system interfaces. The decision's utterance is parsed
|
||
// for keywords to determine what the user is asking about.
|
||
func (h *reactiveHandler) replySystem(ctx context.Context, dec router.Decision) string {
|
||
u := strings.ToLower(dec.Utterance)
|
||
now := h.now()
|
||
|
||
// The topic and the day come from different places on a continuation.
|
||
// "а завтра?" names the day and nothing else; what he is asking ABOUT
|
||
// lives in the previous turn, which continuation.go copied into
|
||
// Slots.Text. Dates keep parsing from the utterance — that is the part
|
||
// the ellipsis actually restates — and only the keyword match widens.
|
||
//
|
||
// Gated on Continued, and that gate is load-bearing. followUpMerge fills
|
||
// an empty Text from the previous same-intent turn, so without it a plain
|
||
// "привет" after "какой сегодня день" inherited the old topic and got
|
||
// answered with the date. Seen on the deployed daemon, 01-08-2026.
|
||
topic := u
|
||
if dec.Continued {
|
||
if t := strings.ToLower(dec.Slots.Text); t != "" && t != u {
|
||
topic = u + " " + t
|
||
}
|
||
}
|
||
|
||
// stage-0 grammars catch the exact time/date patterns, but duration
|
||
// queries ("сколько времени прошло") bypass the grammar's build filter
|
||
// and can reach replySystem via the classifier path. Guard against them.
|
||
if hasDurationWords(u) {
|
||
return "пока не умею отвечать на этот вопрос."
|
||
}
|
||
|
||
switch {
|
||
case strings.Contains(topic, "час") || strings.Contains(topic, "врем"):
|
||
// "который час в киеве" — she keeps one clock, so any named place gets
|
||
// the honest answer. Never local time dressed up as the city's.
|
||
if mentionsUnknownPlace(u) {
|
||
return onlyLocalTimeReply
|
||
}
|
||
return "сейчас " + ruClock(now)
|
||
case strings.Contains(topic, "день") || strings.Contains(topic, "числ"):
|
||
// "какое число завтра" — answer for the day the user asked about,
|
||
// not today. Reuses the router's calendar day-word parser.
|
||
day := now
|
||
prefix := "сегодня"
|
||
if d, ok := router.ParseCalendarDate(u, now); ok {
|
||
day = d
|
||
prefix = dayPrefix(now, d)
|
||
} else if mentionsUnknownDay(u) {
|
||
// He named a day she cannot work out ("в пятницу", "через неделю").
|
||
// Answering today's date here would be the same silent wrong answer
|
||
// this arm was fixed for, so say what she can do instead.
|
||
return onlyNearDaysReply
|
||
}
|
||
dow := lexicon.Weekday(int(day.Weekday()))
|
||
month := lexicon.MonthGenitive(int(day.Month()))
|
||
return fmt.Sprintf("%s %s, %d %s %d года", prefix, dow, day.Day(), month, day.Year())
|
||
case strings.Contains(u, "кто дома") || strings.Contains(u, "человек дома"):
|
||
return "присутствие пока не подключено к голосовому запросу."
|
||
case strings.Contains(u, "памят") || strings.Contains(u, "процессор") || strings.Contains(u, "загрузк") || strings.Contains(u, "статус") || strings.Contains(u, "работа") || strings.Contains(u, "сервис") || strings.Contains(u, "диск") || strings.Contains(u, "ip") || strings.Contains(u, "аптайм") || strings.Contains(u, "трафик") || strings.Contains(u, "интернет"):
|
||
return "системная статистика пока не подключена."
|
||
default:
|
||
return "пока не умею отвечать на этот вопрос."
|
||
}
|
||
}
|
||
|
||
// sessionAsTurn projects a stored session onto the dialogue.Turn shape used in
|
||
// history lists. Shared by chatHistory and rememberTurn (clarify.go) so the
|
||
// same session is described the same way in both places.
|
||
func sessionAsTurn(s *dialogue.Session) dialogue.Turn {
|
||
text := s.Utterance
|
||
if text == "" {
|
||
// Compatibility with a session blob written before Utterance became a
|
||
// first-class field. Slots.Text was the old transcript by convention
|
||
// for query/chat/system turns, and is still better than dropping it.
|
||
text = s.Slots.Text
|
||
}
|
||
return dialogue.Turn{
|
||
Intent: s.Intent,
|
||
Slots: s.Slots,
|
||
Text: text,
|
||
}
|
||
}
|
||
|
||
// chatHistory collects dialogue turns from the session store for the current
|
||
// conversation. Returns prior user utterances (newest last) up to a depth of
|
||
// 4 turns. Returns nil when there's no session or no history.
|
||
func (h *reactiveHandler) chatHistory(ctx context.Context) []dialogue.Turn {
|
||
if h.dialogueSessions == nil {
|
||
return nil
|
||
}
|
||
now := h.now()
|
||
prev := h.dialogueSessions.Get(dialogueIDOf(ctx), now)
|
||
if prev == nil {
|
||
return nil
|
||
}
|
||
// rememberTurn runs before the action so query handlers can bind candidate
|
||
// lists to the current session. Therefore prev is the CURRENT turn here;
|
||
// its History is precisely the prior transcript. Adding sessionAsTurn(prev)
|
||
// would hand the model the current utterance twice.
|
||
out := make([]dialogue.Turn, len(prev.History))
|
||
copy(out, prev.History)
|
||
return out
|
||
}
|
||
|
||
// reply wraps a text reply through TTS to produce a PushToTalkResp. If TTS
|
||
// fails, the response carries an empty audio + the text — the client can
|
||
// still display text if it can't play. The routedChannels field is
|
||
// reserved for a future "the dispatcher also forwarded to ntfy/telegram"
|
||
// reply (today the reactive path doesn't dispatch nudges; that's the loop
|
||
// tick's job).
|
||
func (h *reactiveHandler) reply(ctx context.Context, text string, _ []string) (voice.PushToTalkResp, error) {
|
||
spoken := ttsnorm.Speakable(text)
|
||
log.Printf("voice: reply → %q", text)
|
||
audioOut, err := h.tts.Synthesize(ctx, spoken)
|
||
if err != nil {
|
||
log.Printf("voice: tts error: %v", err)
|
||
return voice.PushToTalkResp{ReplyText: text, ReplyAudio: audio.Audio{}}, nil
|
||
}
|
||
return voice.PushToTalkResp{ReplyText: text, ReplyAudio: audioOut}, nil
|
||
}
|