From 173531be8cc1dc11ad250a49cd990c9dbebf3733 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 6 Aug 2026 02:20:23 +0400 Subject: [PATCH] a reminder commits on what, what time and what day (V-579) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The owner's rule of 2026-08-06. Anything of the three that is missing is asked for, and every ask states the current time so he can tell what she is reasoning from. A bare hour is asked which half of the day it is. A time with no day named is asked which day, because today being a valid reading is not him saying it. Two things go straight through, both his call: a time that already reads only one way, and an interval, which resolves to one instant and answers all three at once. An answer about the time is read against the whole request rather than alone. "вечера" says which nine and names no hour by itself, so the answers accumulate on the parked question and the newest statement wins. Co-Authored-By: Claude Opus 5 --- cmd/mavend/clarify.go | 47 +++++++++-- cmd/mavend/clarify_test.go | 45 ++++++---- cmd/mavend/reminderwhen.go | 158 +++++++++++++++++++++++++++++++++++ internal/dialogue/clarify.go | 7 +- 4 files changed, 231 insertions(+), 26 deletions(-) create mode 100644 cmd/mavend/reminderwhen.go diff --git a/cmd/mavend/clarify.go b/cmd/mavend/clarify.go index 413b219..d4dd1e5 100644 --- a/cmd/mavend/clarify.go +++ b/cmd/mavend/clarify.go @@ -200,7 +200,7 @@ func lowerFirst(s string) string { // missingFor returns the slots a decision still needs, most important first. // Empty ⇒ there is nothing identifiable to ask about. func missingFor(dec router.Decision) []dialogue.Slot { - return dialogue.StillMissing(wantedSlots[dec.Intent], toDialogueSlots(dec.Slots)) + return stillMissingFor(dec.Intent, dec.Utterance, toDialogueSlots(dec.Slots)) } // clarifyQuestion picks the one question to ask for a clarify decision. Returns @@ -209,18 +209,29 @@ func missingFor(dec router.Decision) []dialogue.Slot { // One question about one thing: if two slots are missing she asks about the // first only. Two questions in one breath is an interrogation. The second gap // is picked up on the turn after the first one is answered (askRemainingGap). -func clarifyQuestion(dec router.Decision) (dialogue.Slot, string, bool) { +func (h *reactiveHandler) clarifyQuestion(dec router.Decision) (dialogue.Slot, string, bool) { missing := missingFor(dec) if len(missing) == 0 { return "", "", false } - q, ok := clarifyQuestionFor(missing[0], 1) + q, ok := h.questionFor(missing[0], 1, dec.Utterance, toDialogueSlots(dec.Slots)) if !ok { return "", "", false } return missing[0], q, true } +// questionFor picks the wording for one gap. Every slot but the reminder's time +// reads its deck by attempt; the time asks about whichever of the hour, the half +// of the day and the day he has not said, and states the clock while it does +// (V-579). +func (h *reactiveHandler) questionFor(slot dialogue.Slot, attempt int, utterance string, s dialogue.Slots) (string, bool) { + if slot != dialogue.SlotTime { + return clarifyQuestionFor(slot, attempt) + } + return whenQuestion(whenGapOf(utterance, s.HasTime), attempt, h.now()) +} + // askClarify parks the request and returns the question to ask instead of the // canned "не поняла". Returns ("", false) when there is nothing to ask about, so // the caller falls back to the canned reply. @@ -228,7 +239,7 @@ func (h *reactiveHandler) askClarify(ctx context.Context, dec router.Decision) ( if h.clarifyStore == nil { return "", false } - slot, question, ok := clarifyQuestion(dec) + slot, question, ok := h.clarifyQuestion(dec) if !ok { return "", false } @@ -348,6 +359,15 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string) // the V-554 shape. h.noteSuspended(ctx, q) return "", false + case roleAside: + // He stated something in the middle of the flow. Same machinery as a + // side query and for the same reason: the words are answered as + // themselves, so the note or the fact is stored, and the question comes + // back on the end of the same reply (V-577 shape 2). Storing it in + // silence and dropping it in silence are both wrong, and dropping it is + // what she did. + h.noteSuspended(ctx, q) + return "", false case roleNewRequest: // He moved on. A parked question used to swallow whatever came next, so // one act she could not fulfil ate the following three turns (Vikunja @@ -364,7 +384,17 @@ func (h *reactiveHandler) resolveClarifyAnswer(ctx context.Context, text string) // as the reminder payload — so a reminder clarified out of a bare "напомни" // would fire at 11:00 saying "напомни" and nothing else. q.Utterance = foldAnswerIntoUtterance(q.Utterance, merged.Text) - if len(dialogue.StillMissing(q.Missing, merged)) > 0 { + // An answer about the time joins everything else he has said about the time, + // and the whole of it is re-read as one request (V-579). "завтра" names the + // day of an hour she is already holding, and read alone it names no hour at + // all, so the parser would have nothing and she would ask for ever. + if asksAboutTime(q.Missing) { + q.WhenText = strings.TrimSpace(q.WhenText + " " + text) + if t, ok := h.readWhen(ctx, intent, q, text); ok { + merged.Time, merged.HasTime = t, true + } + } + if stillOpen(q.Missing, whenTextOf(q), merged) { return h.reaskOrGiveUp(ctx, q, merged, text), true } h.clarifyStore.Delete(dialogueIDOf(ctx)) @@ -460,13 +490,13 @@ func foldAnswerIntoUtterance(utterance, subject string) string { // costs a question exactly like a second try at the first one does, so the cap // still bounds how many times she can speak before acting or letting go. func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.PendingQuestion, intent router.Intent, merged dialogue.Slots) (string, bool) { - remaining := dialogue.StillMissing(wantedSlots[intent], merged) + remaining := stillMissingFor(intent, whenTextOf(q), merged) if len(remaining) == 0 { return "", false } // Attempts+1 is the question she is about to ask, and the budget is shared // with the re-ask path, so the second gap is worded like a second try. - question, ok := clarifyQuestionFor(remaining[0], q.Attempts+1) + question, ok := h.questionFor(remaining[0], q.Attempts+1, whenTextOf(q), merged) if !ok || !q.CanAsk() { return "", false } @@ -475,6 +505,7 @@ func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.Pendi Slots: merged, Missing: []dialogue.Slot{remaining[0]}, Utterance: q.Utterance, + WhenText: q.WhenText, Asked: h.now(), TTL: clarifyTTL, Attempts: q.Attempts + 1, @@ -490,7 +521,7 @@ func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.Pendi func (h *reactiveHandler) reaskOrGiveUp(ctx context.Context, q *dialogue.PendingQuestion, merged dialogue.Slots, text string) string { question := "" if len(q.Missing) > 0 { - question, _ = clarifyQuestionFor(q.Missing[0], q.Attempts+1) + question, _ = h.questionFor(q.Missing[0], q.Attempts+1, whenTextOf(q), merged) } if question == "" || !q.CanAsk() { h.clarifyStore.Delete(dialogueIDOf(ctx)) diff --git a/cmd/mavend/clarify_test.go b/cmd/mavend/clarify_test.go index 09682e4..5aea9ab 100644 --- a/cmd/mavend/clarify_test.go +++ b/cmd/mavend/clarify_test.go @@ -58,18 +58,26 @@ func TestClarifyQuestionForMissingSlot(t *testing.T) { want string asked bool }{ - {"reminder without a time", clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"), "Когда?", true}, + {"reminder without a time", clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме"), "Сейчас 09:00. Когда?", true}, {"fact without a key", clarifyDec(router.IntentFact, router.Slots{Text: "запиши"}, "запиши"), "Что записать?", true}, {"act without a fn", clarifyDec(router.IntentAct, router.Slots{Text: "сделай это"}, "сделай это"), "Что сделать?", true}, // A time with nothing to say at that time is still half a reminder, so // the subject is what she asks about — not silence. {"reminder that has a time but no subject", clarifyDec(router.IntentReminder, router.Slots{HasTime: true}, "напомни в 11"), "О чём напомнить?", true}, - {"reminder that has both", clarifyDec(router.IntentReminder, router.Slots{Text: "позвонить маме", HasTime: true}, "напомни в 11 позвонить маме"), "", false}, + // A bare hour is half of a day away from being an answer, and she asks + // which half rather than picking one (V-579). + {"reminder whose hour could be either half of the day", clarifyDec(router.IntentReminder, router.Slots{Text: "позвонить маме", HasTime: true}, "напомни в 11 позвонить маме"), "Сейчас 09:00. Это утра или вечера?", true}, + {"reminder that has all three", clarifyDec(router.IntentReminder, router.Slots{Text: "позвонить маме", HasTime: true}, "напомни завтра в 15:00 позвонить маме"), "", false}, + // The owner's own two, confirmed 2026-08-06: an unambiguous time and a + // relative one are both complete and are never asked about. + {"an interval names the instant by itself", clarifyDec(router.IntentReminder, router.Slots{Text: "позвонить маме", HasTime: true}, "напомни через час позвонить маме"), "", false}, + {"half an hour is an interval too", clarifyDec(router.IntentReminder, router.Slots{Text: "выключить духовку", HasTime: true}, "напомни через полчаса выключить духовку"), "", false}, {"chat is never worth a question", clarifyDec(router.IntentChat, router.Slots{Text: "мгм"}, "мгм"), "", false}, {"query is never worth a question", clarifyDec(router.IntentQuery, router.Slots{Text: "а"}, "а"), "", false}, } + h, _, _ := newClarifyHandler(t) for _, tc := range cases { - _, got, asked := clarifyQuestion(tc.dec) + _, got, asked := h.clarifyQuestion(tc.dec) if asked != tc.asked || got != tc.want { t.Errorf("%s: got (%q, %v), want (%q, %v)", tc.name, got, asked, tc.want, tc.asked) } @@ -83,11 +91,13 @@ func TestClarifyReminderCompletesOnAnswer(t *testing.T) { h, st, _ := newClarifyHandler(t) question, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме")) - if !asked || question != "Когда?" { + if !asked || question != "Сейчас 09:00. Когда?" { t.Fatalf("expected the time question, got %q asked=%v", question, asked) } - reply, handled := h.resolveClarifyAnswer(ctx, "в 11:00") + // The answer names the day as well as the hour. A reminder commits on what, + // what time and what day, and a dayless hour is asked about (V-579). + reply, handled := h.resolveClarifyAnswer(ctx, "сегодня в 11:00") if !handled { t.Fatal("the answer to an open question must be consumed as an answer") } @@ -159,11 +169,11 @@ func TestClarifyAsksThreeTimesThenSaysSo(t *testing.T) { } // The wording changes with the attempt (Vikunja #457): repeating a // question he already failed to answer is the worst way to ask it. - want, _ := clarifyQuestionFor(dialogue.SlotTime, i) + want, _ := whenQuestion(whenNoHour, i, h.now()) if reply != want { t.Fatalf("attempt %d should ask again as %q, got %q", i, want, reply) } - if first, _ := clarifyQuestionFor(dialogue.SlotTime, 1); reply == first { + if first, _ := whenQuestion(whenNoHour, 1, h.now()); reply == first { t.Fatalf("attempt %d repeated the first wording: %q", i, reply) } if h.clarifyStore.Get(voiceDialogueID, h.now()) == nil { @@ -216,10 +226,11 @@ func TestClarifyRestatedAnswerWins(t *testing.T) { if q == nil { t.Fatal("expected an armed question") } - first := h.extractor.Extract(ctx, router.IntentReminder, "в 11:00", h.now()) - q.Slots = q.Answer("в 11:00", toDialogueSlots(first)) + first := h.extractor.Extract(ctx, router.IntentReminder, "сегодня в 11:00", h.now()) + q.Slots = q.Answer("сегодня в 11:00", toDialogueSlots(first)) + q.WhenText = "сегодня в 11:00" - if reply, handled := h.resolveClarifyAnswer(ctx, "нет, в 15:00"); !handled || reply == clarifyGaveUp { + if reply, handled := h.resolveClarifyAnswer(ctx, "нет, сегодня в 15:00"); !handled || reply == clarifyGaveUp { t.Fatalf("the restated answer should complete the request, handled=%v reply=%q", handled, reply) } reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)) @@ -357,7 +368,7 @@ func TestClarifyAsksAboutTheSecondGapToo(t *testing.T) { } // Second gap, second attempt, so it is the second wording of the time // question — the attempt budget is shared between the two paths. - want, _ := clarifyQuestionFor(dialogue.SlotTime, 2) + want, _ := whenQuestion(whenNoHour, 2, h.now()) if reply != want { t.Fatalf("a filled subject with no time must ask about the time as %q, got %q", want, reply) } @@ -369,7 +380,7 @@ func TestClarifyAsksAboutTheSecondGapToo(t *testing.T) { t.Fatalf("the re-parked question lost the answered subject: %+v", q.Slots) } - if reply, handled := h.resolveClarifyAnswer(ctx, "в 11:00"); !handled || reply == clarifyGaveUp { + if reply, handled := h.resolveClarifyAnswer(ctx, "сегодня в 11:00"); !handled || reply == clarifyGaveUp { t.Fatalf("the time answer must complete the reminder, handled=%v reply=%q", handled, reply) } reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)) @@ -487,7 +498,7 @@ func TestClarifySubjectAnswerFillsRatherThanClobbers(t *testing.T) { at := h.now().Add(2 * time.Hour) question, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, - router.Slots{Time: at, HasTime: true}, "напомни в 11")) + router.Slots{Time: at, HasTime: true}, "напомни сегодня в 11 утра")) if !asked || question != "О чём напомнить?" { t.Fatalf("expected the subject question, got %q asked=%v", question, asked) } @@ -623,7 +634,7 @@ func TestClarifyQuestionShapedAnswerThatFillsTheGapStillLands(t *testing.T) { if _, asked := h.askClarify(ctx, clarifyDec(router.IntentReminder, router.Slots{Text: "напомни позвонить маме"}, "напомни позвонить маме")); !asked { t.Fatal("expected the time question") } - if reply, handled := h.resolveClarifyAnswer(ctx, "а что если в 11:00"); !handled || reply == clarifyGaveUp { + if reply, handled := h.resolveClarifyAnswer(ctx, "а что если сегодня в 11:00"); !handled || reply == clarifyGaveUp { t.Fatalf("an answer that fills the gap must land, handled=%v reply=%q", handled, reply) } if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 1 { @@ -676,14 +687,14 @@ func TestIncompleteReminderAsksInsteadOfFailing(t *testing.T) { h, st := newRoutingClarifyHandler(t) reply := h.handleText(ctx, "web", "напомни позвонить маме") - want, _ := clarifyQuestionFor(dialogue.SlotTime, 1) + want, _ := whenQuestion(whenNoHour, 1, h.now()) if reply != want { t.Fatalf("reply = %q, want the time question %q", reply, want) } if h.clarifyStore.Get(dialogueIDFor(sourceText, "web"), h.now()) == nil { t.Fatal("the request must be parked, or the answer has nowhere to land") } - if reply := h.handleText(ctx, "web", "в семь вечера"); strings.Contains(reply, "нашла") { + if reply := h.handleText(ctx, "web", "сегодня в семь вечера"); strings.Contains(reply, "нашла") { t.Fatalf("the answer to her own question must not be looked up: %q", reply) } if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 1 { @@ -717,7 +728,7 @@ func TestACompleteTurnStillDoesNotAsk(t *testing.T) { h, _, _ := newClarifyHandler(t) complete := []router.Decision{ - {Intent: router.IntentReminder, Slots: router.Slots{Text: "позвонить маме", HasTime: true}, Utterance: "напомни в 11 позвонить маме"}, + {Intent: router.IntentReminder, Slots: router.Slots{Text: "позвонить маме", HasTime: true}, Utterance: "напомни завтра в 11 утра позвонить маме"}, {Intent: router.IntentFact, Slots: router.Slots{Key: "water", Value: "выпил", HasKey: true}, Utterance: "я выпил воды"}, {Intent: router.IntentNote, Slots: router.Slots{Text: "купить хлеб"}, Utterance: "запиши купить хлеб"}, {Intent: router.IntentQuery, Slots: router.Slots{Text: "что у меня сегодня"}, Utterance: "что у меня сегодня"}, diff --git a/cmd/mavend/reminderwhen.go b/cmd/mavend/reminderwhen.go new file mode 100644 index 0000000..2c28ee7 --- /dev/null +++ b/cmd/mavend/reminderwhen.go @@ -0,0 +1,158 @@ +package main + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/kami/maven/internal/dialogue" + "github.com/kami/maven/internal/router" +) + +// A reminder commits only when three things are answered: what to say, what +// time to say it, and what day (owner's rule, 2026-08-06, V-579). Anything +// missing is asked about, and nothing missing is filled from the clock. +// +// "напомни завтра в 3 заказать цветы" has the what and the day and an hour that +// could be either half of the day, so she asks which 3. "напомни в 9 вечера +// разгрузить стиралку" has the what and an unambiguous hour and no day, so she +// asks which day. Today being a valid reading is not the same as him saying it. +// +// Two things are already whole and are not asked about. A time that admits one +// reading is not queried for its half of the day, so "завтра в 15:00" commits. +// And an interval is an instant, so "через час" carries all three by itself. +type whenGap string + +const ( + whenComplete whenGap = "" + whenNoHour whenGap = "hour" + whenAmbiguousHour whenGap = "part_of_day" + whenNoDay whenGap = "day" +) + +// whenGapOf reads the request and names the first thing about its time that he +// has not said. hasTime is whether a parser could read an instant out of it, +// which is necessary and not sufficient: the parser answers a dayless "в 9" +// with a day it picked. +func whenGapOf(text string, hasTime bool) whenGap { + if !router.NamesAnHour(text) { + return whenNoHour + } + if router.NamesAnInterval(text) { + return whenComplete + } + if !hasTime { + return whenNoHour + } + if router.HourIsAmbiguous(text) { + return whenAmbiguousHour + } + if !router.NamesADay(text) { + return whenNoDay + } + return whenComplete +} + +// whenQuestion is what she asks for each gap. Every one of them opens with the +// current time, because she is reasoning from it and he cannot check that +// reasoning unless he hears it. The hour deck varies with the attempt, like +// every other slot; the other two say one thing and there is only one way to +// say it. +func whenQuestion(gap whenGap, attempt int, now time.Time) (string, bool) { + clock := fmt.Sprintf("Сейчас %s.", now.Format("15:04")) + switch gap { + case whenNoHour: + q, ok := clarifyQuestionFor(dialogue.SlotTime, attempt) + if !ok { + return "", false + } + return clock + " " + q, true + case whenAmbiguousHour: + return clock + " Это утра или вечера?", true + case whenNoDay: + return clock + " В какой день?", true + } + return "", false +} + +// whenTextOf is everything he has said about when, the original request plus +// every answer he has given to a question about it. +// +// The answers are kept apart from the utterance on purpose. The utterance is +// the reminder's payload, so folding "завтра" into it would have her read the +// day back to him at the time she says it. And a time answer has to be read +// against the request rather than alone: "завтра" names no hour, and the hour +// it belongs to is the one she is already holding. +func whenTextOf(q *dialogue.PendingQuestion) string { + if q.WhenText == "" { + return q.Utterance + } + return strings.TrimSpace(q.Utterance + " " + q.WhenText) +} + +// slotStillMissing reports whether a slot is still open. Every slot but the +// reminder's time is open when it is empty; the time is open until all three of +// what he must say about it are said. +func slotStillMissing(slot dialogue.Slot, utterance string, s dialogue.Slots) bool { + if len(dialogue.StillMissing([]dialogue.Slot{slot}, s)) > 0 { + return true + } + return slot == dialogue.SlotTime && whenGapOf(utterance, s.HasTime) != whenComplete +} + +// readWhen reads the instant out of what he has said about the time, newest +// statement first. +// +// The request plus his latest answer is tried before the whole history, and +// that order is what makes a correction win: "нет, сегодня в 15:00" after "в +// 11:00" must land on 15:00, and a parser reading left to right off the joined +// history would find the 11 he just took back. The history is the fallback, +// because an answer often completes an earlier one rather than replacing it - +// "вечера" says which 9, and alone it names no hour at all. +func (h *reactiveHandler) readWhen(ctx context.Context, intent router.Intent, q *dialogue.PendingQuestion, text string) (time.Time, bool) { + latest := strings.TrimSpace(q.Utterance + " " + text) + if router.NamesAnHour(text) { + if w := h.extractor.Extract(ctx, intent, latest, h.now()); w.HasTime { + return w.Time, true + } + } + if w := h.extractor.Extract(ctx, intent, whenTextOf(q), h.now()); w.HasTime { + return w.Time, true + } + return time.Time{}, false +} + +// asksAboutTime reports whether the parked question is one about when. +func asksAboutTime(missing []dialogue.Slot) bool { + for _, s := range missing { + if s == dialogue.SlotTime { + return true + } + } + return false +} + +// stillOpen reports whether any of the slots she asked about is still unsaid. +func stillOpen(missing []dialogue.Slot, utterance string, s dialogue.Slots) bool { + for _, slot := range missing { + if slotStillMissing(slot, utterance, s) { + return true + } + } + return false +} + +// stillMissingFor is missingFor's engine, in wantedSlots order. It reads the +// utterance as well as the slots, which plain StillMissing cannot: whether an +// hour is ambiguous is a fact about the words, not about the instant they +// parsed to. +func stillMissingFor(intent router.Intent, utterance string, s dialogue.Slots) []dialogue.Slot { + var out []dialogue.Slot + for _, want := range wantedSlots[intent] { + if slotStillMissing(want, utterance, s) { + out = append(out, want) + } + } + return out +} diff --git a/internal/dialogue/clarify.go b/internal/dialogue/clarify.go index cdab9a4..474cd67 100644 --- a/internal/dialogue/clarify.go +++ b/internal/dialogue/clarify.go @@ -31,7 +31,12 @@ type PendingQuestion struct { Slots Slots // what it already filled Missing []Slot // what is still empty, in the order to ask about Utterance string // the user's original raw words - Asked time.Time + // WhenText is every answer he has given about the time, joined in the order + // he gave them. Kept apart from Utterance because the utterance is the + // reminder's payload, and because a time answer has to be read against the + // request rather than alone: "завтра" names a day for an hour said earlier. + WhenText string + Asked time.Time TTL time.Duration Attempts int // questions already asked // MaxAttempts caps Attempts. 0 ⇒ DefaultMaxAttempts.