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.