From bb8cb8d014de7f28c26d415f10eeea57859f9453 Mon Sep 17 00:00:00 2001 From: claude Date: Sun, 2 Aug 2026 09:58:07 +0400 Subject: [PATCH 1/2] Voice parks a routine proposal, it never accepts it (V-367) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Accepting a proposed routine gives the tick loop a standing new reason to speak. DESIGN.md § "surface caps authority" puts that at layer 3, and says voice is structurally incapable of layer 3 because a room mic is reachable by anyone in the room. The /routines button was gated at step-up; the voice path accepted outright. The two surfaces disagreed, so one of them was wrong. A spoken "да" now leaves the row 'proposed' and sends him to /routines, where the gated button is. A spoken "нет" still dismisses: declining does not move the boundary outward, so voice keeps it. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018CotYKycuio1GwLbYh9jfc --- cmd/mavend/confirm.go | 20 ++++++---- cmd/mavend/patterns_test.go | 79 +++++++++++++++++++++++++++++++++++++ cmd/mavweb/main.go | 11 ++---- 3 files changed, 95 insertions(+), 15 deletions(-) diff --git a/cmd/mavend/confirm.go b/cmd/mavend/confirm.go index 83171fb..0fa21e1 100644 --- a/cmd/mavend/confirm.go +++ b/cmd/mavend/confirm.go @@ -107,14 +107,18 @@ func (h *reactiveHandler) confirmResolvers(ctx context.Context) []confirmResolve return pr != nil && !h.now().After(pr.expiry) }, yes: func() string { - // Only record the acceptance. The tick loop reads accepted - // routines and nudges on their own interval. Building a - // reminder here made a routine fire exactly once (Vikunja #366). - if err := h.dataStore.AcceptProposedRoutine(ctx, pr.routineID, h.now()); err != nil { - log.Printf("voice: accept proposed routine: %v", err) - return "не получилось запомнить рутину." - } - return "буду напоминать." + // Voice does NOT accept (Vikunja #367). Accepting hands the + // tick loop a standing new reason to speak, which is the same + // tier as enabling a tool — and DESIGN.md § "surface caps + // authority" says a room mic, reachable by anyone present, is + // structurally incapable of layer 3. So a spoken "да" leaves + // the row 'proposed' and points at the authed page, where the + // accept button is gated at step-up. The convenience of + // answering out loud stays; the authority does not move. + // + // Acceptance itself is recorded by /routines, and the tick + // loop nudges on the interval from there (Vikunja #366). + return "поняла — подтверди на странице рутин, и начну напоминать." }, no: func() string { if err := h.dataStore.DismissProposedRoutine(ctx, pr.routineID); err != nil { diff --git a/cmd/mavend/patterns_test.go b/cmd/mavend/patterns_test.go index 1b14524..0809f73 100644 --- a/cmd/mavend/patterns_test.go +++ b/cmd/mavend/patterns_test.go @@ -9,6 +9,7 @@ import ( "github.com/kami/maven/internal/config" "github.com/kami/maven/internal/delivery" + "github.com/kami/maven/internal/ipc" "github.com/kami/maven/internal/loop" "github.com/kami/maven/internal/pattern" "github.com/kami/maven/internal/store" @@ -283,3 +284,81 @@ func TestTickProposalCooldownSpacesAnnouncements(t *testing.T) { } } } + +// TestVoiceYesDoesNotAcceptRoutine — Vikunja #367. Accepting a routine hands +// the tick loop a standing new reason to speak, which DESIGN.md puts at layer +// 3, and voice is structurally incapable of layer 3. A spoken "да" must park +// the decision for the authed page, not flip the row itself. +func TestVoiceYesDoesNotAcceptRoutine(t *testing.T) { + st := newTestStore(t) + ctx := context.Background() + now := refNow() + seedRefillEvents(t, st, ctx, now, pattern.MinEvents-1) + + h := &reactiveHandler{api: ipc.NewStoreAPI(st), dataStore: st, now: func() time.Time { return now }} + + // The MinEvents'th event is the one that makes the pattern detectable, and + // it goes through the voice path so the proposal is parked for a y/n. + last := now.Add(time.Duration(pattern.MinEvents-1) * 7 * 24 * time.Hour) + factID, err := st.WriteFact(ctx, last, store.KindSelf, "cat_water", "refill", "voice", 1.0, sql.NullInt64{}) + if err != nil { + t.Fatalf("write fact: %v", err) + } + if phrase := h.detectPattern(ctx, factID, "cat_water", "refill", last); phrase == "" { + t.Fatal("expected a parked routine proposal") + } + + reply, handled := h.resolveConfirm(ctx, "да") + if !handled { + t.Fatal("the spoken yes should be consumed by the routine confirm") + } + if !strings.Contains(reply, "рутин") { + t.Fatalf("reply should send him to the routines page, got %q", reply) + } + + rows, err := st.ListProposedRoutinesByStatus(ctx, store.RoutineAccepted) + if err != nil { + t.Fatalf("list accepted: %v", err) + } + if len(rows) != 0 { + t.Fatalf("voice accepted a routine: %+v", rows) + } + proposed, err := st.ListProposedRoutinesByStatus(ctx, store.RoutineProposed) + if err != nil { + t.Fatalf("list proposed: %v", err) + } + if len(proposed) != 1 { + t.Fatalf("proposed routines = %d, want 1 (still waiting for the page)", len(proposed)) + } +} + +// TestVoiceNoStillDismissesRoutine — declining does not move the boundary +// outward, so voice keeps it. Only acceptance is gated. +func TestVoiceNoStillDismissesRoutine(t *testing.T) { + st := newTestStore(t) + ctx := context.Background() + now := refNow() + seedRefillEvents(t, st, ctx, now, pattern.MinEvents-1) + + h := &reactiveHandler{api: ipc.NewStoreAPI(st), dataStore: st, now: func() time.Time { return now }} + + last := now.Add(time.Duration(pattern.MinEvents-1) * 7 * 24 * time.Hour) + factID, err := st.WriteFact(ctx, last, store.KindSelf, "cat_water", "refill", "voice", 1.0, sql.NullInt64{}) + if err != nil { + t.Fatalf("write fact: %v", err) + } + if phrase := h.detectPattern(ctx, factID, "cat_water", "refill", last); phrase == "" { + t.Fatal("expected a parked routine proposal") + } + + if _, handled := h.resolveConfirm(ctx, "нет"); !handled { + t.Fatal("the spoken no should be consumed by the routine confirm") + } + rows, err := st.ListProposedRoutinesByStatus(ctx, store.RoutineDismissed) + if err != nil { + t.Fatalf("list dismissed: %v", err) + } + if len(rows) != 1 { + t.Fatalf("dismissed routines = %d, want 1", len(rows)) + } +} diff --git a/cmd/mavweb/main.go b/cmd/mavweb/main.go index b39823e..8711f01 100644 --- a/cmd/mavweb/main.go +++ b/cmd/mavweb/main.go @@ -1210,13 +1210,10 @@ func routineRows(rs []ipc.ProposedRoutine) []routineRow { return out } -// acceptRoutine creates the recurring reminder for a proposal, then marks the -// proposal accepted and links the reminder to it. Weekly patterns get a cron -// expression; any other interval fires once. -// -// TODO(vikunja#46): this mirrors the voice accept path in cmd/mavend/voice.go. -// When the tick loop learns to read accepted proposals directly, both callers -// should hand off to one place in core instead of each building a reminder. +// acceptRoutine marks a proposal accepted. This page is the ONLY surface that +// may do it (Vikunja #367): accepting gives the tick loop a standing new +// reason to speak, which DESIGN.md puts at layer 3, and the button here is +// behind step-up. Voice can park the question and dismiss, never accept. func acceptRoutine(ctx context.Context, core ipc.CoreAPI, id int64) error { proposed, err := core.ListProposedRoutines(ctx) if err != nil { From 99bb3526dbb2563cc5edd53947e0a095361db79e Mon Sep 17 00:00:00 2001 From: claude Date: Sun, 2 Aug 2026 10:01:58 +0400 Subject: [PATCH 2/2] Stop asking for Russian in English on the last three prompts (V-404) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #400 rewrote the chat and query prompts in Russian and left three pieces of English prose behind. PhraseReminder's user prompt was fully English. It is Russian now, and it no longer restates the JSON contract or the persona rules: the call goes through chat(), so nudgeSystem already states both, and a second copy of a contract is one more thing that can drift out of step with the first. querySystemPrompt and router.KnowledgePrompt both closed with the English "Respond ONLY with valid JSON:". That sentence is prose instruction, not wire format — the JSON skeleton after it is the wire format, and it is unchanged. Kept rather than deleted: the GBNF grammar makes it close to redundant, but the grammar is switchable off (phraser NoGrammar), and the sentence is the floor when it is. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_018CotYKycuio1GwLbYh9jfc --- internal/phraser/llmphraser.go | 10 ++++++++-- internal/router/knowledge.go | 2 +- 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/internal/phraser/llmphraser.go b/internal/phraser/llmphraser.go index fd34770..c82d45e 100644 --- a/internal/phraser/llmphraser.go +++ b/internal/phraser/llmphraser.go @@ -534,8 +534,14 @@ func (p *LLMPhraser) PhraseReminder(ctx context.Context, d loop.ReminderDecision text = "reminder" } + // Russian, like the other two prompts (Vikunja #404). Asking a model for a + // Russian reply in English is asking it to switch languages mid-prompt, + // and a 1.7B sometimes answers in the language it was asked in. The + // persona rules and the JSON contract are not repeated here: this call + // goes through chat(), so nudgeSystem already states both, and a second + // statement of the same contract is one more thing that can drift. prompt := fmt.Sprintf( - `The user set a reminder: "%s". Rephrase it briefly as a gentle nudge. Respond as JSON: {"response": "...", "mood": "..."}`, + `Он поставил напоминание: "%s". Скажи это своими словами, коротко и мягко — одно предложение.`, text, ) resp, err := p.chat(ctx, prompt) @@ -754,7 +760,7 @@ func (p *LLMPhraser) querySystemPrompt() string { base := "Ты отвечаешь ему по источникам, которые тебе дали. Отвечай ТОЛЬКО по ним: всё, что ты говоришь, должно быть написано в источниках. " + "Если ответа в них нет — так и скажи и на этом остановись; не добавляй ничего из своих знаний и не догадывайся. " + "Не приплетай прошлые реплики разговора. " + - "Отвечай по-русски, коротко и своими словами, начинай с \"вот что я нашла: \". О себе — в женском роде, глаголы в прошедшем времени с окончанием -ла. Он мужчина, обращайся к нему на \"ты\". Respond ONLY with valid JSON: {\"response\": \"...\", \"mood\": \"neutral\"}." + "Отвечай по-русски, коротко и своими словами, начинай с \"вот что я нашла: \". О себе — в женском роде, глаголы в прошедшем времени с окончанием -ла. Он мужчина, обращайся к нему на \"ты\". Отвечай ТОЛЬКО одним объектом JSON: {\"response\": \"...\", \"mood\": \"neutral\"}." return persona.Prepend(p.cfg.ContextBlock, base) } diff --git a/internal/router/knowledge.go b/internal/router/knowledge.go index d425e95..0d3d7cc 100644 --- a/internal/router/knowledge.go +++ b/internal/router/knowledge.go @@ -6,5 +6,5 @@ func KnowledgePrompt() string { // No self-introduction here: the shared persona block already says who she // is, and this line used to disagree with it — a different name ("Мавена") // and a masculine noun ("ассистент") in front of a feminine persona. - return `Ответь кратко из своих знаний. Если не знаешь — скажи "не знаю". Не выдумывай. Respond ONLY with valid JSON: {"response": "...", "mood": "neutral"}.` + return `Ответь кратко из своих знаний. Если не знаешь — скажи "не знаю". Не выдумывай. Отвечай ТОЛЬКО одним объектом JSON: {"response": "...", "mood": "neutral"}.` }