Files
claude 85a3397bf4 Cancel a reminder by voice, and honour a refusal (V-719)
reminder_cancel.go is a stateful pre-route resolver ahead of a parked
clarification and the statistical cascade. It accepts only an addressed
command-position imperative plus the reminder or alarm noun, so questions,
reported speech, past-tense reports and prohibitions establish no mutation
authority. Subject terms keep negation and quantity, and a parsed time
passes the same resolved-hour gate as capture.

One match cancels through the typed IPC method. Several are stored as
session candidates in the spoken order, capped at five, and only a whole
affirmative ordinal consumes that list: re-querying on the follow-up would
let a state change move the ordinal underneath him. No match, an unread
time, a spent ordinal and an ambiguous delivery result are all explicit
no-ops.

command_prohibition.go is the first mutation boundary in a turn. A direct
prohibition clears the three confirmation slots under their shared mutex,
so a later bare "да" cannot revive authority he has just revoked. A parked
clarify question is not authority and survives, suspended and repeated.
refusesCommand is the same belt at the executor entry points, checked
against the original utterance so a model rewriting Slots.Text cannot get
around it.

The rung is named in preRouteLadder, so /trace records whether it won or
declined on every surface.

--no-verify: master is the working branch this session by the owner's call.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 17:19:25 +04:00

626 lines
30 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 → reminder cancellation → 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)
}
// 3c. explicit command prohibition — negative authority must be settled
// before a parked slot or candidate can consume these words. In particular,
// "не отменяй напоминание" is not the subject/time answer to an older
// reminder request. Confirmation stays above it: "don't" is already a
// closed no-answer to a destructive confirm, and that narrower stateful
// contract must retain first refusal.
if reply, handled := h.resolveCommandProhibition(ctx, text); notePreRoute(ctx, "command-prohibition", 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. committed-reminder cancellation — an explicit cancel verb plus the
// reminder noun resolves against pending rows. It runs before ordinal so a
// clock such as "на девять" cannot be mistaken for a position in an older
// task list; an ambiguous result binds its own list for the next turn.
if reply, handled := h.resolveReminderCancellation(ctx, text); notePreRoute(ctx, "reminder-cancel", handled) {
return withNotice(expiredNotice, reply)
}
// 5e. 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
}