diff --git a/cmd/mavend/quiet_toggle.go b/cmd/mavend/quiet_toggle.go index 142706e..13979cf 100644 --- a/cmd/mavend/quiet_toggle.go +++ b/cmd/mavend/quiet_toggle.go @@ -19,7 +19,14 @@ import ( // 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 // 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) if !on && !off { return "", false @@ -35,7 +42,7 @@ func (h *reactiveHandler) resolveQuietToggle(ctx context.Context, text string) ( Kind: "config", Key: "quiet_hours", Value: val, - Source: "tap:voice", + Source: string(src), Confidence: 1.0, }); err != nil { log.Printf("voice: write quiet_hours: %v", err) diff --git a/cmd/mavend/quiet_toggle_test.go b/cmd/mavend/quiet_toggle_test.go index cad56a9..c450b95 100644 --- a/cmd/mavend/quiet_toggle_test.go +++ b/cmd/mavend/quiet_toggle_test.go @@ -79,7 +79,7 @@ func TestResolveQuietToggle(t *testing.T) { t.Run(tc.text, func(t *testing.T) { api := &quietFakeAPI{} 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 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) + } + }) + } +} diff --git a/cmd/mavend/simulator_test.go b/cmd/mavend/simulator_test.go index df00c2f..37c6f8c 100644 --- a/cmd/mavend/simulator_test.go +++ b/cmd/mavend/simulator_test.go @@ -466,7 +466,7 @@ func (w *simWorld) stimulate(ctx context.Context, s step) { switch { 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.logf("он: %s", s.Say) w.logf("она: %s", reply) diff --git a/cmd/mavend/voice.go b/cmd/mavend/voice.go index 8223103..7bc2190 100644 --- a/cmd/mavend/voice.go +++ b/cmd/mavend/voice.go @@ -172,7 +172,7 @@ func (h *reactiveHandler) HandlePushToTalk(ctx context.Context, req voice.PushTo // 2-5. the shared turn pipeline (confirm → clarify → route → dialogue → // 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 // 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. func (h *reactiveHandler) handleText(ctx context.Context, text string) string { 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 // points: expired-clarify notice → confirm answer → clarify answer → quiet // 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. // // 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 // 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 @@ -230,7 +242,7 @@ func (h *reactiveHandler) runTurn(ctx context.Context, text string) string { // "тихий режим" / "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 { + if reply, handled := h.resolveQuietToggle(ctx, text, src); handled { return withNotice(expiredNotice, reply) }