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 2f00dff..be7e5eb 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 { diff --git a/internal/phraser/llmphraser.go b/internal/phraser/llmphraser.go index ba690b1..b1a3e16 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"}.` }