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:
kami
2026-08-01 14:05:17 +04:00
parent b2eb08bb51
commit 4757ff6d7b
4 changed files with 46 additions and 8 deletions
+9 -2
View File
@@ -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)
+20 -1
View File
@@ -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)
}
})
}
}
+1 -1
View File
@@ -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)
+16 -4
View File
@@ -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)
}