6c24e19b83
V-564. One decision.Record per turn: the utterance, the winner, and a Claim per claimant carrying its stage, name, the intent it would have made the turn, the score it reported, the outcome and the reason. HasScore is separate from the score so a real 0.0 is not read as no score. Outcomes are won, declined, lost_on_order, lost_on_score, thinned, merged, never_asked. Every stage declares its roster up front, so Finish names everyone who never reported. NEVER ASKED is explicit rather than an absence, which is the fact the hardcoded ordering hides. Covered: the seven pre-route resolvers, eleven stage 0 grammar sets, the LLM router and the classifier with which arm of gateLLMDecision thinned a route, the classifier runners-up, the follow-up merge, 27 query sources, and a terminal action-handler or clarify-ask claim. On by default, no flag. It rides the context like querysource.go and is installed in runTurn, so mic, telegram and web leave the same trail. Storage is a 25-turn in-memory ring: no write on the answer path, no migration, and none of his words outlive the diagnosis. Readable on /trace. TestRecordingDoesNotChangeTheReply answers the same utterances with and without the ring.
566 lines
26 KiB
Go
566 lines
26 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
|
||
|
||
// 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 → 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) 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() { h.decisions.Push(rec.Finish(h.now())) }()
|
||
}
|
||
|
||
// 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. 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)
|
||
}
|
||
|
||
// 4. 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)
|
||
}
|
||
|
||
// 4b. 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)
|
||
}
|
||
|
||
// 4c. 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)
|
||
}
|
||
|
||
// 4d. spoken correction — "нет, это была заметка" points at the previous
|
||
// turn and names what it should have been (repair.go). Before routing,
|
||
// like the confirm and clarify turns: routing the correction as a fresh
|
||
// utterance files the correction itself instead of fixing anything.
|
||
if reply, handled := h.resolveRepair(ctx, text); notePreRoute(ctx, "repair", handled) {
|
||
return withNotice(expiredNotice, reply)
|
||
}
|
||
|
||
// 4e. 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)
|
||
}
|
||
|
||
// 5. 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.
|
||
var (
|
||
dec router.Decision
|
||
err error
|
||
prev *dialogue.Session
|
||
)
|
||
now := h.now()
|
||
if h.dialogueSessions != nil {
|
||
prev = h.dialogueSessions.Get(dialogueIDOf(ctx), now)
|
||
}
|
||
cont := false
|
||
if dec, cont = continuationDecision(prev, text, now); cont {
|
||
log.Printf("voice: continuation of %s from the previous turn", dec.Intent)
|
||
}
|
||
if !cont {
|
||
dec, err = h.router.Route(ctx, text, now)
|
||
}
|
||
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)
|
||
|
||
// 6. 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)
|
||
}
|
||
}
|
||
|
||
// 7. 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)
|
||
}
|
||
|
||
// 8. 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, "")
|
||
|
||
// 8b. 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)
|
||
|
||
// 9. replier — phrase the reply across the router decision.
|
||
if replyText == "" {
|
||
replyText = h.replier.Reply(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 "пока не умею отвечать на этот вопрос."
|
||
}
|
||
}
|
||
|
||
// 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
|
||
}
|
||
// History already includes the immediate prior turn (set by the dialogue
|
||
// merge at lines 373-395), plus up to 3 more from deeper history.
|
||
out := make([]dialogue.Turn, 0, 1+len(prev.History))
|
||
out = append(out, dialogue.Turn{
|
||
Intent: prev.Intent,
|
||
Slots: prev.Slots,
|
||
Text: prev.Slots.Text,
|
||
})
|
||
out = append(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
|
||
}
|