diff --git a/cmd/mavend/actions.go b/cmd/mavend/actions.go index 83a874e..13dbaf4 100644 --- a/cmd/mavend/actions.go +++ b/cmd/mavend/actions.go @@ -26,7 +26,7 @@ // authority") — a handler must never special-case a clarify-completed // decision to skip the confirm gate or the allowlist. // - detectPattern and dialogue-session bookkeeping (rememberTurn, -// followUpMerge) run in the callers (handleText, HandlePushToTalk, +// followUpMerge) run in the callers (runTurn, // finishClarified), not per-intent, and are untouched by this slice. // // Adding an intent: write its handler here, add one line to actionHandlers. diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index 2abff05..53c849f 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -150,49 +150,75 @@ func (h *reactiveHandler) HandlePushToTalk(ctx context.Context, req voice.PushTo } log.Printf("voice: stt → %q", text) - // 1b. confirm turn — if a destructive act is parked, this utterance is its + // 2-5. the shared turn pipeline (confirm → clarify → route → dialogue → + // action → replier), identical to the text path. + replyText := h.runTurn(ctx, text) + + // 6. tts — synthesise the reply text; return to the voice server which + // ships it back on the conn. + return h.reply(ctx, replyText, nil) +} + +// 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, text string) string { + log.Printf("voice: handleText: %q", text) + return h.runTurn(ctx, text) +} + +// runTurn — the reactive turn pipeline shared by the voice and text entry +// points: confirm answer → expired-clarify notice → 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) string { + // 1. 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); handled { - return h.reply(ctx, reply, nil) + return reply } - // 1b2. expired clarify — a question was parked but its TTL ran out, so the + // 2. 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. expiredNotice := h.clarifyExpiredNotice() - // 1b3. clarify answer — if she asked a live question last turn, this + // 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. if reply, handled := h.resolveClarifyAnswer(ctx, text); handled { - return h.reply(ctx, reply, nil) + return reply } - // 1c. quiet-hours toggle — keyword match, not classifier-dependent. + // 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); handled { - return h.reply(ctx, withNotice(expiredNotice, reply), nil) + return withNotice(expiredNotice, reply) } - // 2. router — classify the utterance. + // 5. router — classify the utterance. dec, err := h.router.Route(ctx, text, h.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 h.reply(ctx, withNotice(expiredNotice, "я ещё не понимаю свободную речь — скоро научусь."), nil) + return withNotice(expiredNotice, "я ещё не понимаю свободную речь — скоро научусь.") } log.Printf("voice: router error: %v", err) - return h.reply(ctx, withNotice(expiredNotice, "не получилось разобрать команду."), nil) + return withNotice(expiredNotice, "не получилось разобрать команду.") } + log.Printf("voice: route result: intent=%s slots=%+v", dec.Intent, dec.Slots) - // 2b. dialogue — fill this turn's missing slots from a prior same-intent + // 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. @@ -205,82 +231,22 @@ func (h *reactiveHandler) HandlePushToTalk(ctx context.Context, req voice.PushTo } } - // 2c. clarify — she is not sure. If one named thing is missing, ask about it + // 7. clarify — she is not sure. If one named thing is missing, ask about it // and park the request (clarify.go); otherwise the replier's canned reply // stands. - if dec.Clarify { - if question, asked := h.askClarify(dec); asked { - return h.reply(ctx, withNotice(expiredNotice, question), nil) - } - } - - // 3. 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) - - // 4. replier — phrase the reply across the router decision. - if replyText == "" { - replyText = h.replier.Reply(dec) - } - - // 5. tts — synthesise the reply text; return to the voice server which - // ships it back on the conn. - return h.reply(ctx, withNotice(expiredNotice, replyText), nil) -} - -// handleText — the core reactive path without stt/tts: confirm check → -// route → dialogue → action → replier. 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, text string) string { - log.Printf("voice: handleText: %q", text) - // 1b. confirm turn — if a destructive act is parked, this utterance is its - // y/n answer. Same check as HandlePushToTalk. - if reply, handled := h.resolveConfirm(ctx, text); handled { - return reply - } - - // 1b2/1b3. expired clarify then clarify answer — same order and reasons as - // HandlePushToTalk. - expiredNotice := h.clarifyExpiredNotice() - if reply, handled := h.resolveClarifyAnswer(ctx, text); handled { - return reply - } - - // 2. router — classify the utterance. - dec, err := h.router.Route(ctx, text, h.now()) - if err != nil { - if errors.Is(err, router.ErrNoIntents) { - return withNotice(expiredNotice, "я ещё не понимаю свободную речь — скоро научусь.") - } - log.Printf("voice: handleText router error: %v", err) - return withNotice(expiredNotice, "не получилось разобрать команду.") - } - log.Printf("voice: route result: intent=%s slots=%+v", dec.Intent, dec.Slots) - - // 2b. dialogue — same as HandlePushToTalk. - if h.dialogueSessions != nil { - now := h.now() - prev := h.dialogueSessions.Get(voiceDialogueID, now) - dec = followUpMerge(prev, dec, now) - if !dec.Clarify { - h.rememberTurn(prev, dec, now) - } - } - - // 2c. clarify — same as HandlePushToTalk: ask about the one missing thing. if dec.Clarify { if question, asked := h.askClarify(dec); asked { return withNotice(expiredNotice, question) } } - // 3. action — execute the decision's 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) - // 4. replier — phrase the reply when applyAction returned "". + // 9. replier — phrase the reply across the router decision. if replyText == "" { replyText = h.replier.Reply(dec) }