Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 99bb3526db | |||
| bb8cb8d014 |
+12
-8
@@ -107,14 +107,18 @@ func (h *reactiveHandler) confirmResolvers(ctx context.Context) []confirmResolve
|
|||||||
return pr != nil && !h.now().After(pr.expiry)
|
return pr != nil && !h.now().After(pr.expiry)
|
||||||
},
|
},
|
||||||
yes: func() string {
|
yes: func() string {
|
||||||
// Only record the acceptance. The tick loop reads accepted
|
// Voice does NOT accept (Vikunja #367). Accepting hands the
|
||||||
// routines and nudges on their own interval. Building a
|
// tick loop a standing new reason to speak, which is the same
|
||||||
// reminder here made a routine fire exactly once (Vikunja #366).
|
// tier as enabling a tool — and DESIGN.md § "surface caps
|
||||||
if err := h.dataStore.AcceptProposedRoutine(ctx, pr.routineID, h.now()); err != nil {
|
// authority" says a room mic, reachable by anyone present, is
|
||||||
log.Printf("voice: accept proposed routine: %v", err)
|
// structurally incapable of layer 3. So a spoken "да" leaves
|
||||||
return "не получилось запомнить рутину."
|
// the row 'proposed' and points at the authed page, where the
|
||||||
}
|
// accept button is gated at step-up. The convenience of
|
||||||
return "буду напоминать."
|
// 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 {
|
no: func() string {
|
||||||
if err := h.dataStore.DismissProposedRoutine(ctx, pr.routineID); err != nil {
|
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/config"
|
||||||
"github.com/kami/maven/internal/delivery"
|
"github.com/kami/maven/internal/delivery"
|
||||||
|
"github.com/kami/maven/internal/ipc"
|
||||||
"github.com/kami/maven/internal/loop"
|
"github.com/kami/maven/internal/loop"
|
||||||
"github.com/kami/maven/internal/pattern"
|
"github.com/kami/maven/internal/pattern"
|
||||||
"github.com/kami/maven/internal/store"
|
"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
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// acceptRoutine creates the recurring reminder for a proposal, then marks the
|
// acceptRoutine marks a proposal accepted. This page is the ONLY surface that
|
||||||
// proposal accepted and links the reminder to it. Weekly patterns get a cron
|
// may do it (Vikunja #367): accepting gives the tick loop a standing new
|
||||||
// expression; any other interval fires once.
|
// 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.
|
||||||
// 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.
|
|
||||||
func acceptRoutine(ctx context.Context, core ipc.CoreAPI, id int64) error {
|
func acceptRoutine(ctx context.Context, core ipc.CoreAPI, id int64) error {
|
||||||
proposed, err := core.ListProposedRoutines(ctx)
|
proposed, err := core.ListProposedRoutines(ctx)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -534,8 +534,14 @@ func (p *LLMPhraser) PhraseReminder(ctx context.Context, d loop.ReminderDecision
|
|||||||
text = "reminder"
|
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(
|
prompt := fmt.Sprintf(
|
||||||
`The user set a reminder: "%s". Rephrase it briefly as a gentle nudge. Respond as JSON: {"response": "...", "mood": "..."}`,
|
`Он поставил напоминание: "%s". Скажи это своими словами, коротко и мягко — одно предложение.`,
|
||||||
text,
|
text,
|
||||||
)
|
)
|
||||||
resp, err := p.chat(ctx, prompt)
|
resp, err := p.chat(ctx, prompt)
|
||||||
@@ -754,7 +760,7 @@ func (p *LLMPhraser) querySystemPrompt() string {
|
|||||||
base := "Ты отвечаешь ему по источникам, которые тебе дали. Отвечай ТОЛЬКО по ним: всё, что ты говоришь, должно быть написано в источниках. " +
|
base := "Ты отвечаешь ему по источникам, которые тебе дали. Отвечай ТОЛЬКО по ним: всё, что ты говоришь, должно быть написано в источниках. " +
|
||||||
"Если ответа в них нет — так и скажи и на этом остановись; не добавляй ничего из своих знаний и не догадывайся. " +
|
"Если ответа в них нет — так и скажи и на этом остановись; не добавляй ничего из своих знаний и не догадывайся. " +
|
||||||
"Не приплетай прошлые реплики разговора. " +
|
"Не приплетай прошлые реплики разговора. " +
|
||||||
"Отвечай по-русски, коротко и своими словами, начинай с \"вот что я нашла: \". О себе — в женском роде, глаголы в прошедшем времени с окончанием -ла. Он мужчина, обращайся к нему на \"ты\". Respond ONLY with valid JSON: {\"response\": \"...\", \"mood\": \"neutral\"}."
|
"Отвечай по-русски, коротко и своими словами, начинай с \"вот что я нашла: \". О себе — в женском роде, глаголы в прошедшем времени с окончанием -ла. Он мужчина, обращайся к нему на \"ты\". Отвечай ТОЛЬКО одним объектом JSON: {\"response\": \"...\", \"mood\": \"neutral\"}."
|
||||||
return persona.Prepend(p.cfg.ContextBlock, base)
|
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
|
// No self-introduction here: the shared persona block already says who she
|
||||||
// is, and this line used to disagree with it — a different name ("Мавена")
|
// is, and this line used to disagree with it — a different name ("Мавена")
|
||||||
// and a masculine noun ("ассистент") in front of a feminine persona.
|
// 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