Unify the voice and text turn pipelines into runTurn

HandlePushToTalk and handleText hand-wrote the same eight-step turn
sequence twice, comments in the latter saying "same as HandlePushToTalk"
four times. Extract it into runTurn(ctx, text) string: the voice path
wraps it in stt/tts, the text path returns it directly.

The two had drifted. The text path was missing the quiet-hours toggle
check entirely, so "тихий режим" over IPC/telegram fell through to the
classifier; unifying gives it the check. It also logged the route result
and applyAction return where the voice path did not — both logs are kept
for both paths.
This commit is contained in:
kami
2026-08-01 00:48:50 +04:00
parent c00e3003bf
commit 88d07b5175
2 changed files with 43 additions and 77 deletions
+1 -1
View File
@@ -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.
+42 -76
View File
@@ -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)
}