Merge pull request 'Voice cannot accept a routine (V-367); the last three prompts are Russian (V-404)' (#89) from fix/367-voice-parks-routine-accept into master
This commit was merged in pull request #89.
This commit is contained in:
+12
-8
@@ -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 {
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
|
||||
+4
-7
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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"}.`
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user