mavend: record which channel a quiet toggle arrived on
resolveQuietToggle runs inside runTurn, so mavweb /api/chat and telegram reach it as well as the microphone. Every toggle was written with Source "tap:voice" regardless, which left the facts table claiming a mic flipped a setting nobody spoke to. This is the one function whose own doc comment calls it a network-reachable way to change a daemon-wide setting, and provenance is the first column read when asking why quiet mode is on. runTurn now takes the channel it was entered from and the toggle writes it: "tap:voice" from HandlePushToTalk, "tap:text" from handleText. Found in review of #53. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TrVSBKe3RFDF4fGYKWYQnX
This commit is contained in:
@@ -19,7 +19,14 @@ import (
|
|||||||
// both the voice path and the text path (mavweb /api/chat, telegram) reach it,
|
// both the voice path and the text path (mavweb /api/chat, telegram) reach it,
|
||||||
// so a false positive here is a network-reachable way to flip a daemon-wide
|
// so a false positive here is a network-reachable way to flip a daemon-wide
|
||||||
// setting. See classifyQuietToggle for the matching rule.
|
// setting. See classifyQuietToggle for the matching rule.
|
||||||
func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string) (string, bool) {
|
//
|
||||||
|
// src is the channel the utterance arrived on, and it is written straight into
|
||||||
|
// the fact. Every toggle used to be stored as "tap:voice", including the ones
|
||||||
|
// typed into the web UI, which left the facts table claiming a microphone flipped
|
||||||
|
// a setting nobody spoke to. This is the one function where that matters most:
|
||||||
|
// when he goes looking at why quiet mode is on, provenance is the first column
|
||||||
|
// he reads.
|
||||||
|
func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string, src turnSource) (string, bool) {
|
||||||
on, off := classifyQuietToggle(text)
|
on, off := classifyQuietToggle(text)
|
||||||
if !on && !off {
|
if !on && !off {
|
||||||
return "", false
|
return "", false
|
||||||
@@ -35,7 +42,7 @@ func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string) (
|
|||||||
Kind: "config",
|
Kind: "config",
|
||||||
Key: "quiet_hours",
|
Key: "quiet_hours",
|
||||||
Value: val,
|
Value: val,
|
||||||
Source: "tap:voice",
|
Source: string(src),
|
||||||
Confidence: 1.0,
|
Confidence: 1.0,
|
||||||
}); err != nil {
|
}); err != nil {
|
||||||
log.Printf("voice: write quiet_hours: %v", err)
|
log.Printf("voice: write quiet_hours: %v", err)
|
||||||
|
|||||||
@@ -79,7 +79,7 @@ func TestResolveQuietToggle(t *testing.T) {
|
|||||||
t.Run(tc.text, func(t *testing.T) {
|
t.Run(tc.text, func(t *testing.T) {
|
||||||
api := &quietFakeAPI{}
|
api := &quietFakeAPI{}
|
||||||
h := &reactiveHandler{api: api, now: func() time.Time { return time.Unix(0, 0).UTC() }}
|
h := &reactiveHandler{api: api, now: func() time.Time { return time.Unix(0, 0).UTC() }}
|
||||||
reply, handled := h.resolveQuietToggle(context.Background(), tc.text)
|
reply, handled := h.resolveQuietToggle(context.Background(), tc.text, sourceVoice)
|
||||||
|
|
||||||
if tc.want == quietNone {
|
if tc.want == quietNone {
|
||||||
if handled || reply != "" {
|
if handled || reply != "" {
|
||||||
@@ -147,3 +147,22 @@ func TestQuietToggleNegationIsNotAdjacency(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestQuietToggleRecordsTheChannelItArrivedOn — the toggle is reachable from
|
||||||
|
// mavweb /api/chat and telegram, not only the microphone. Every write used to
|
||||||
|
// be stamped "tap:voice", so a toggle typed into the web UI claimed a mic wrote
|
||||||
|
// it and the provenance column lied about a daemon-wide setting.
|
||||||
|
func TestQuietToggleRecordsTheChannelItArrivedOn(t *testing.T) {
|
||||||
|
for _, src := range []turnSource{sourceVoice, sourceText} {
|
||||||
|
t.Run(string(src), func(t *testing.T) {
|
||||||
|
api := &quietFakeAPI{}
|
||||||
|
h := &reactiveHandler{api: api, now: func() time.Time { return time.Unix(0, 0).UTC() }}
|
||||||
|
if _, handled := h.resolveQuietToggle(context.Background(), "тихий режим", src); !handled {
|
||||||
|
t.Fatal("expected the toggle to match")
|
||||||
|
}
|
||||||
|
if api.got.Source != string(src) {
|
||||||
|
t.Errorf("source = %q, want %q", api.got.Source, src)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -466,7 +466,7 @@ func (w *simWorld) stimulate(ctx context.Context, s step) {
|
|||||||
|
|
||||||
switch {
|
switch {
|
||||||
case s.Say != "":
|
case s.Say != "":
|
||||||
reply := w.handler.runTurn(ctx, s.Say)
|
reply := w.handler.runTurn(ctx, s.Say, sourceText)
|
||||||
w.replies = append(w.replies, reply)
|
w.replies = append(w.replies, reply)
|
||||||
w.logf("он: %s", s.Say)
|
w.logf("он: %s", s.Say)
|
||||||
w.logf("она: %s", reply)
|
w.logf("она: %s", reply)
|
||||||
|
|||||||
+16
-4
@@ -172,7 +172,7 @@ func (h *reactiveHandler) HandlePushToTalk(ctx context.Context, req voice.PushTo
|
|||||||
|
|
||||||
// 2-5. the shared turn pipeline (confirm → clarify → route → dialogue →
|
// 2-5. the shared turn pipeline (confirm → clarify → route → dialogue →
|
||||||
// action → replier), identical to the text path.
|
// action → replier), identical to the text path.
|
||||||
replyText := h.runTurn(ctx, text)
|
replyText := h.runTurn(ctx, text, sourceVoice)
|
||||||
|
|
||||||
// 6. tts — synthesise the reply text; return to the voice server which
|
// 6. tts — synthesise the reply text; return to the voice server which
|
||||||
// ships it back on the conn.
|
// ships it back on the conn.
|
||||||
@@ -184,9 +184,21 @@ func (h *reactiveHandler) HandlePushToTalk(ctx context.Context, req voice.PushTo
|
|||||||
// HandlePushToTalk so text channels share the same routing logic.
|
// HandlePushToTalk so text channels share the same routing logic.
|
||||||
func (h *reactiveHandler) handleText(ctx context.Context, text string) string {
|
func (h *reactiveHandler) handleText(ctx context.Context, text string) string {
|
||||||
log.Printf("voice: handleText: %q", text)
|
log.Printf("voice: handleText: %q", text)
|
||||||
return h.runTurn(ctx, text)
|
return h.runTurn(ctx, 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
|
// runTurn — the reactive turn pipeline shared by the voice and text entry
|
||||||
// points: expired-clarify notice → confirm answer → clarify answer → quiet
|
// points: expired-clarify notice → confirm answer → clarify answer → quiet
|
||||||
// toggle → route → dialogue merge → clarify question → action → replier.
|
// toggle → route → dialogue merge → clarify question → action → replier.
|
||||||
@@ -194,7 +206,7 @@ func (h *reactiveHandler) handleText(ctx context.Context, text string) string {
|
|||||||
// path wraps it in stt/tts, the text path returns it as-is.
|
// path wraps it in stt/tts, the text path returns it as-is.
|
||||||
//
|
//
|
||||||
// The ordering is load-bearing — see the step comments.
|
// The ordering is load-bearing — see the step comments.
|
||||||
func (h *reactiveHandler) runTurn(ctx context.Context, text string) string {
|
func (h *reactiveHandler) runTurn(ctx context.Context, text string, src turnSource) string {
|
||||||
// 1. expired clarify — a question was parked but its TTL ran out, so the
|
// 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
|
// 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
|
// on: these words are still routed as a fresh utterance below, with the
|
||||||
@@ -230,7 +242,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string) string {
|
|||||||
// "тихий режим" / "quiet on" would route through the classifier
|
// "тихий режим" / "quiet on" would route through the classifier
|
||||||
// unreliably (it's a command, not a free-form query), so we match it
|
// unreliably (it's a command, not a free-form query), so we match it
|
||||||
// before routing. Same pattern as the confirm turn above.
|
// before routing. Same pattern as the confirm turn above.
|
||||||
if reply, handled := h.resolveQuietToggle(ctx, text); handled {
|
if reply, handled := h.resolveQuietToggle(ctx, text, src); handled {
|
||||||
return withNotice(expiredNotice, reply)
|
return withNotice(expiredNotice, reply)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user