diff --git a/cmd/mavend/actions_reminder.go b/cmd/mavend/actions_reminder.go index 7ff97ed..4f58862 100644 --- a/cmd/mavend/actions_reminder.go +++ b/cmd/mavend/actions_reminder.go @@ -19,7 +19,9 @@ func (h *reactiveHandler) actionReminder(ctx context.Context, dec router.Decisio // time wasn't parsed. Run the parser as a fallback. if dec.Stage == 0 && h.timeParser != nil { t, ok, err := h.timeParser.Parse(ctx, dec.Utterance, h.now()) - if err == nil && ok { + // Same gate as the extractor (V-577, V-579): a request that named + // no hour gets asked about, never completed from the clock. + if err == nil && ok && router.NamesAnHour(dec.Utterance) { dec.Slots.Time = t dec.Slots.HasTime = true } diff --git a/cmd/mavend/clarify.go b/cmd/mavend/clarify.go index 41e0b49..4b0a5e5 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 a5739ed..d59741f 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 { @@ -674,14 +685,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 { @@ -715,7 +726,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/dialogue_contract_test.go b/cmd/mavend/dialogue_contract_test.go index b4c7533..23187ba 100644 --- a/cmd/mavend/dialogue_contract_test.go +++ b/cmd/mavend/dialogue_contract_test.go @@ -114,8 +114,11 @@ type turn struct { wait time.Duration // question — the reply must be exactly this clarify question, worded for // this attempt. Zero slot ⇒ not checked. - question dialogue.Slot - attempt int + question dialogue.Slot + attempt int + // gap — which part of the time she is asking about, for a SlotTime question + // (V-579). Zero value is the missing hour, which is what she asks first. + gap whenGap contains []string notContain []string // noQuestion — the reply must not be any clarify question. Used where the @@ -157,11 +160,29 @@ type trace struct { func newDialogueHandler(t *testing.T) (*reactiveHandler, *store.Store, *time.Time) { t.Helper() h, st, now := newClarifyHandler(t) + // A minute no trace ever says, so "fires at the current clock" is a defect + // and never a coincidence (V-577, V-579). checkEnd refuses any reminder + // landing on it, and at 09:00 the row that answers "на 9" would trip that. + *now = time.Date(2026, 7, 31, 9, 17, 0, 0, time.UTC) h.router = buildRouter(router.NewHashEmbedder(1024), h.matcher, 0.55, nil) h.recall = recallWiring{embedder: router.NewHashEmbedder(1024), memStore: memory.NewInMemoryStore()} return h, st, now } +// wantedQuestion builds the question a turn must be answered with, from the +// same code the daemon asks through. A time question is built from the gap, +// because she names the clock and asks about the part he left out (V-579). +func wantedQuestion(tn turn, now time.Time) (string, bool) { + if tn.question == dialogue.SlotTime { + gap := tn.gap + if gap == whenComplete { + gap = whenNoHour + } + return whenQuestion(gap, tn.attempt, now) + } + return clarifyQuestionFor(tn.question, tn.attempt) +} + // runTrace drives one trace through handleText and checks every turn, then the // end state. Every failure carries the decision trace so far, so a wrong // claimant reads differently from wrong copy. @@ -217,7 +238,7 @@ func runTrace(t *testing.T, tr trace) { fail(i, "reply %q announced an expiry nothing asked for", reply) } if tn.question != "" { - want, ok := clarifyQuestionFor(tn.question, tn.attempt) + want, ok := wantedQuestion(tn, h.now()) if !ok { fail(i, "no question exists for slot %s attempt %d", tn.question, tn.attempt) } @@ -248,12 +269,16 @@ func runTrace(t *testing.T, tr trace) { func isAnyClarifyQuestion(reply string) bool { for _, variants := range clarifyQuestionVariants { for _, v := range variants { - if reply == v { + // HasSuffix, not equality: a question about the time opens with the + // clock she is reasoning from (V-579). + if strings.HasSuffix(reply, v) { return true } } } - return false + // The two questions with no deck behind them, asked when the hour is said + // and its half of the day or its day is not. + return strings.HasSuffix(reply, "утра или вечера?") || strings.HasSuffix(reply, "В какой день?") } func checkParked(t *testing.T, fail func(int, string, ...any), i int, got *dialogue.PendingQuestion, want *parkedWant) { @@ -294,6 +319,17 @@ func checkEnd(t *testing.T, ctx context.Context, st *store.Store, h *reactiveHan if len(reminders) != len(want.reminders) { t.Fatalf("end state: %d reminder(s), want %d: %+v%s", len(reminders), len(want.reminders), reminders, trace) } + // No trace may leave a reminder at the current clock, whatever else it + // asserts (V-577, V-579). Twice on the box a sentence naming a day and no + // hour was completed from time.Now(): "что у меня сегодня?" became 01:28 and + // "на завтра" became 01:38. Neither minute was ever spoken, and a row that + // only checked the payload would have passed both. + for _, r := range reminders { + if r.FireTs.In(h.now().Location()).Format("15:04") == h.now().Format("15:04") { + t.Fatalf("end state: reminder %q fires at %s, which is the clock — a time slot naming no hour is asked about, never filled from now()%s", + r.Payload, r.FireTs.Format("15:04"), trace) + } + } for i, w := range want.reminders { if !strings.Contains(reminders[i].Payload, w.payload) { t.Fatalf("end state: reminder %d payload %q does not carry %q%s", i, reminders[i].Payload, w.payload, trace) @@ -355,11 +391,18 @@ func dialogueTraces() []trace { // from: she asks for the time, he gives it, the reminder lands with the // subject he said in the FIRST turn. { - name: "reminder completed over two turns", + // Three turns since V-579, not two. An hour with no day named is + // not an answer she can act on: 11:00 today has passed as often as + // not, and picking one for him is the invention the whole rule is + // against. So she says the clock she is reasoning from and asks + // which day. + name: "reminder completed over three turns", turns: []turn{ {say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1, parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "маме"}}, - {say: "в 11:00", contains: []string{"11:00"}, notContain: []string{"?"}}, + {say: "в 11:00", question: dialogue.SlotTime, attempt: 2, gap: whenNoDay, + parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2, carries: "маме"}}, + {say: "сегодня", contains: []string{"11:00"}, notContain: []string{"?"}}, }, end: endState{reminders: []reminderWant{{payload: "позвонить маме", fireAt: "2026-07-31 11:00"}}}, }, @@ -438,8 +481,147 @@ func dialogueTraces() []trace { end: endState{tasks: []string{"купить молоко"}}, }, + // V-577 shape 1, the worst of the nine claimants measured on 2026-08-06. + // Every token of "что у меня сегодня?" is frame — an interrogative, a + // preposition, a particle and a day word — so the role classifier never + // looked at the route, the parked reminder read "сегодня" as its time, + // and the hour came from the clock. He got a reminder he never asked for + // at a minute he never said, and his question was answered nowhere. + // + // Two claims: the calendar answers, and nothing is written. The flow + // survives underneath, because a question of his own is not a request to + // abandon the one he was making. + { + name: "an agenda question mid-flow is answered, not eaten", + turns: []turn{ + {say: "напомни забрать посылку", question: dialogue.SlotTime, attempt: 1, + parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "посылку"}}, + {say: "что у меня сегодня?", contains: []string{"31.07.2026"}, + parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "посылку"}}, + }, + end: endState{}, + }, + // V-577 shape 2. He states something in the middle of the flow. It is + // neither a slot value nor a cancel, and it was scored as a failed + // answer and dropped in silence: alone the same sentence is stored. + // Silence is the one option that is wrong, so it is stored, no retry is + // spent, and the question comes back on the end of the same reply. + // + // The words are a fact and not the owner's note, because the fact parser + // is deterministic and the offline floor marks every classifier route + // Clarify. The row below carries his own sentence and needs the model. + // + // What this floor can prove is the arbitration: no retry is spent, the + // flow survives on the same attempt, and the words are answered as + // themselves with the question coming back after them. Whether the fact + // is then WRITTEN is the routing engine's business — the hash embedder + // is unsure of every sentence it sees, and an unsure fact has never been + // stored. + { + name: "a fact stated mid-flow steps aside without spending a retry", + turns: []turn{ + {say: "напомни позвонить врачу", question: dialogue.SlotTime, attempt: 1, + parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "врачу"}}, + {say: "я выпил воды", contains: []string{"напоминание?"}, + parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "врачу"}}, + }, + end: endState{}, + }, + // V-579 turn 3: the preposition decided whether the hour was read. "в 9" + // set the reminder and "на 9" was not read at all, on the same build and + // with the same cardinal. + { + // It is read, and being read is not the same as being enough: nine is + // either half of the day, so she asks which and then which day + // (V-579). Both answers are frame words and neither carries an hour + // of its own, so this row is also the proof that an answer is read + // against the whole request rather than alone. + name: "на 9 answers the time question like в 9", + turns: []turn{ + {say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1, + parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}}, + {say: "на 9", question: dialogue.SlotTime, attempt: 2, gap: whenAmbiguousHour, + parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2}}, + {say: "утра", question: dialogue.SlotTime, attempt: 3, gap: whenNoDay, + parked: &parkedWant{slot: dialogue.SlotTime, attempt: 3}}, + {say: "завтра", contains: []string{"09:00"}}, + }, + end: endState{reminders: []reminderWant{{payload: "позвонить маме", fireAt: "2026-08-01 09:00"}}}, + }, + // The owner's own four, ruled 2026-08-06 (V-579). A reminder commits + // when what, what time and what day are all answered, and every ask + // states the clock she is reasoning from. + { + name: "his first example: a bare 3 is asked about", + turns: []turn{ + {say: "напомни завтра в 3 заказать цветы", + question: dialogue.SlotTime, attempt: 1, gap: whenAmbiguousHour, + parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "цветы"}}, + }, + end: endState{}, + }, + { + // The hour is unambiguous and the day is still missing, so she asks. + // Today being a valid reading is not the same as him saying it. + name: "his second example: nine in the evening of which day", + turns: []turn{ + {say: "напомни в 9 вечера разгрузить стиралку", + question: dialogue.SlotTime, attempt: 1, gap: whenNoDay, + parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "стиралку"}}, + {say: "завтра", contains: []string{"21:00"}}, + }, + end: endState{reminders: []reminderWant{{payload: "стиралку", fireAt: "2026-08-01 21:00"}}}, + }, + { + // All three answered in one breath, so she does not ask at all. + name: "his third example: a full time commits", + turns: []turn{ + {say: "напомни завтра в 15:00 заказать цветы", notContain: []string{"?"}}, + }, + end: endState{reminders: []reminderWant{{payload: "цветы", fireAt: "2026-08-01 15:00"}}}, + }, + { + // An interval is one instant, so it answers the hour and the day + // together. Confirmed by the owner: "через час is fine as is". + name: "an interval commits without a question", + turns: []turn{ + {say: "напомни через час позвонить маме", notContain: []string{"?"}}, + }, + end: endState{reminders: []reminderWant{{payload: "маме", fireAt: "2026-07-31 10:17"}}}, + }, + // V-579 turn 4: he named a day and no hour, and got the day at the + // current minute. She has to ask instead, and the global check in + // checkEnd refuses the invented minute for every row at once. + { + name: "a day with no hour is asked about, not taken from the clock", + turns: []turn{ + {say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1, + parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}}, + {say: "на завтра", question: dialogue.SlotTime, attempt: 2, + parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2}}, + }, + end: endState{}, + }, + // ---- rows below carry the CORRECT expectation and fail today ---- + // The owner's own sentence from V-577 shape 2, in his words. It needs + // an engine that can route it: the hash embedder marks it note with + // Clarify set, and a route she is not sure of is not evidence that he + // stated anything. The row above is the same contract in words the + // floor's deterministic fact parser reads. + { + name: "a note stated mid-flow is stored, not dropped", + skip: "the offline floor cannot route «у меня новый ноутбук» confidently; needs the resident model", + turns: []turn{ + {say: "напомни позвонить врачу", question: dialogue.SlotTime, attempt: 1, + parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "врачу"}}, + {say: "у меня новый ноутбук", + parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "врачу"}}, + }, + end: endState{notes: 1}, + }, + // The owner's target transcript, V-561. He asks for a reminder, she asks // when, he asks something else entirely, and then comes back to her // question. On the box this created a reminder at 00:12 and never @@ -452,26 +634,35 @@ func dialogueTraces() []trace { // still standing, on the same attempt — a side query is not a failed // answer and must not spend a retry. // - // Unskipping this needs more than V-561, and V-561 landing did not change - // that. The suspend and resume it asked for is done — the row below is - // the same shape in words the floor can parse and is green. What is left - // here is the parser: StubDateTimeParser does not read "на 9" or "на - // завтра", so turn 3 lands as an answer that filled nothing and spends a - // retry, which is what this row now fails on. V-562 and V-543 own the - // ambiguous hour and the day correction behind those two words. + // The skip came off with V-579. What held it was the parser, not the + // arbitration: neither the stub nor the production one read "на 9", + // because only "в" framed a spoken hour, and "на завтра" was completed + // from the clock. + // + // Turn 3 now closes the flow, where the transcript has one more exchange + // in it. That is the 12-hour question — the owner's turn 4 answers "на + // 9" with "сейчас 15:23, на 9 сегодня вечером?" — and it is a decision of + // its own, not one to invent here. Nine o'clock is read as nine and, at + // 09:17, as tomorrow's, which is where the transcript ends up anyway. + // Turn 4 then has nothing to answer and must not write anything. { name: "the owner's transcript from V-561", - skip: "V-543/V-562: the floor's date parser reads neither «на 9» nor «на завтра»", turns: []turn{ {say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1, parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}}, {say: "какая сейчас погода в Риме?", parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "маме"}}, - {say: "а, да, прости - на 9.", - parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "маме"}}, - {say: "на завтра."}, + // His words, unchanged. What changed under V-579 is that "на 9" + // is a question and not a commit: nine could be either half of + // the day, so she says the clock she is reading from and asks. + // "на завтра." then answers the day and leaves the half open, so + // she asks that one again. + {say: "а, да, прости - на 9.", question: dialogue.SlotTime, attempt: 2, gap: whenAmbiguousHour, + parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2}}, + {say: "на завтра.", question: dialogue.SlotTime, attempt: 3, gap: whenAmbiguousHour, + parked: &parkedWant{slot: dialogue.SlotTime, attempt: 3}}, }, - end: endState{reminders: []reminderWant{{payload: "позвонить маме", fireAt: "2026-08-01 09:00"}}}, + end: endState{}, }, // The same shape said in words StubDateTimeParser reads. GREEN since // V-561. Same three claims: Rome is answered, the question survives the @@ -488,7 +679,9 @@ func dialogueTraces() []trace { parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}}, {say: "какая сейчас погода в Риме?", parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "маме"}}, - {say: "в 11:00", contains: []string{"11:00"}}, + {say: "в 11:00", question: dialogue.SlotTime, attempt: 2, gap: whenNoDay, + parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2, carries: "маме"}}, + {say: "сегодня", contains: []string{"11:00"}}, }, end: endState{reminders: []reminderWant{{payload: "позвонить маме", fireAt: "2026-07-31 11:00"}}}, }, 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/cmd/mavend/turnrole.go b/cmd/mavend/turnrole.go index 7ca5a19..677b1d8 100644 --- a/cmd/mavend/turnrole.go +++ b/cmd/mavend/turnrole.go @@ -31,6 +31,7 @@ const ( roleCorrection turnRole = "correction" // it replaces a value she already had roleSideQuery turnRole = "side_query" // a question of its own, asked mid-flow roleNewRequest turnRole = "new_request" // a different request entirely + roleAside turnRole = "aside" // something he stated, not an answer roleCancel turnRole = "cancel" // call the pending action off roleNotApplicable turnRole = "not_applicable" // nothing is pending; not our turn ) @@ -212,11 +213,28 @@ func classifyTurnRole(q *dialogue.PendingQuestion, text string, answer dialogue. // tests are the floor and answer for free; the route is what sees a request // with no shape to it — "погода в риме" asks a question and carries neither // a question mark nor an interrogative, and only the router knows that. + // + // An utterance of pure frame gets one more chance, and V-577 is why. Every + // token of "что у меня сегодня?" is frame, so the content gate called it an + // answer, the parked reminder took "сегодня" for its time, and the question + // he asked was answered nowhere. A routed intent beats a frame match, + // because the frame is a hint and the route is a decision. + // + // The condition is that it fills nothing she asked about. That keeps the + // hedged "а что если в 11:00" an answer, which is what it is: it carries the + // hour, and no route saying "question" changes that. It works because the + // extractor no longer reads a day word as the current clock, so a sentence + // that names no hour now fills nothing to weigh. own := false if len(ownContent(text)) > 0 { own = offlineOwnRequest(text) || (ok && carriesOwnRequest(routed, text)) + } else if ok && fillsNothingAsked(q, answer) { + own = carriesOwnRequest(routed, text) } if !own { + if isAside(q, text, answer, routed, ok) { + return roleAside + } if replacesFilledSlot(q, answer) { return roleCorrection } @@ -228,6 +246,59 @@ func classifyTurnRole(q *dialogue.PendingQuestion, text string, answer dialogue. return roleNewRequest } +// isAside reports whether the utterance is something he STATED while she was +// waiting on a question (V-577 shape 2). +// +// "у меня новый ноутбук" said into a parked reminder was dropped in silence: it +// carries no capture verb, so it is not a request of its own, and it fills no +// slot, so it is not an answer either. Neither storing it nor saying it was +// ignored is the one behaviour that is wrong, and it was the behaviour. +// +// Three conditions, and all three are needed. The route has to call it a +// statement AND stand behind that, so a bare time is never an aside. It has to +// fill none of what she asked about, so an answer she can use stays an answer. +// And it has to say something, so a shrug is still a failed answer and still +// spends a retry. +func isAside(q *dialogue.PendingQuestion, text string, answer dialogue.Slots, routed router.Decision, ok bool) bool { + if !ok || q == nil { + return false + } + if !statesSomething(routed) { + return false + } + if len(ownContent(text)) == 0 { + return false + } + return fillsNothingAsked(q, answer) +} + +// statesSomething reports whether the route is evidence that these words state +// a thing, rather than a guess she has to interrupt a flow over. +// +// Two kinds of evidence, and the second one exists because the classifier floor +// marks nearly everything Clarify. A parsed fact key comes from the +// deterministic fact parser and not from a similarity score, so "я выпил воды" +// is a statement on any engine. A confident note or fact is the other kind, and +// that is the one the resident model gives for "у меня новый ноутбук". +func statesSomething(routed router.Decision) bool { + switch routed.Intent { + case router.IntentFact: + return routed.Slots.HasKey || !routed.Clarify + case router.IntentNote: + return !routed.Clarify + } + return false +} + +// fillsNothingAsked reports whether the utterance gave her none of what she +// asked for. Nothing is pending counts as nothing filled. +func fillsNothingAsked(q *dialogue.PendingQuestion, answer dialogue.Slots) bool { + if q == nil { + return true + } + return len(dialogue.StillMissing(q.Missing, answer)) == len(q.Missing) +} + // replacesFilledSlot reports whether the utterance overwrites something the // pending action already had, rather than filling the gap she asked about — // "нет, на девять" while she is waiting for the subject. Both are handled the diff --git a/cmd/mavend/turnrole_test.go b/cmd/mavend/turnrole_test.go index 4d85b68..c230519 100644 --- a/cmd/mavend/turnrole_test.go +++ b/cmd/mavend/turnrole_test.go @@ -123,6 +123,37 @@ func TestTurnRoleReadsTheRoutedDecision(t *testing.T) { ok: true, want: roleAnswer, }, + { + // V-577 shape 1. Every token is frame, so the content gate called + // this an answer and the reminder took "сегодня" for its time. It + // fills nothing she asked about, so the route decides, and the route + // says the calendar answers it. + name: "an agenda question of pure frame words is a side query", + text: "что у меня сегодня?", + routed: dec(router.IntentQuery, router.Slots{}), + ok: true, + want: roleSideQuery, + }, + { + // V-577 shape 2. Neither a slot value nor a request nor a cancel. + // It was dropped in silence; it is an aside, and an aside is stored + // and re-asked. + name: "a fact stated mid-flow is an aside", + text: "у меня новый ноутбук", + routed: dec(router.IntentNote, router.Slots{Text: "у меня новый ноутбук"}), + ok: true, + want: roleAside, + }, + { + // A route she is not sure of is not evidence that he stated + // anything, and "позвонить маме" is the answer to the other half of + // a reminder. + name: "an unsure note is not an aside", + text: "позвонить маме", + routed: router.Decision{Intent: router.IntentNote, Clarify: true}, + ok: true, + want: roleAnswer, + }, { name: "a bare noun that answers nothing is still an answer", text: "ага", diff --git a/cmd/mavend/turnroute.go b/cmd/mavend/turnroute.go index d852ad7..a2018b7 100644 --- a/cmd/mavend/turnroute.go +++ b/cmd/mavend/turnroute.go @@ -110,6 +110,16 @@ func (h *reactiveHandler) routeForRole(ctx context.Context, text string) (router // This is a fast path to the SAME answer and must stay one. If it ever needs a // rule the classifier does not have, it has become a second decision procedure // and it is the thing V-560 deleted. +// +// A question shape is the exception and V-577 is why (measured 2026-08-06). +// "что у меня сегодня?" is an interrogative, a preposition, a particle and a day +// word, so every token of it is frame and it left no content of its own. The +// fast path called it an answer, the parked reminder read "сегодня" as its time, +// and the question he asked was never answered. Asked alone the same sentence +// routes to query at stage 0, so the route knew and was never consulted. func needsRoute(text string) bool { - return !isCancel(text) && len(ownContent(text)) > 0 + if isCancel(text) { + return false + } + return len(ownContent(text)) > 0 || router.IsQuestionShaped(text) } 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. diff --git a/internal/router/dateparser.go b/internal/router/dateparser.go index fe13e1b..15cd364 100644 --- a/internal/router/dateparser.go +++ b/internal/router/dateparser.go @@ -54,7 +54,9 @@ try: # часов" is read as seven hours from now. Only a qualifier (already an # am/pm above) or a colon makes it read the hour, so give it the colon. # English "at 7" fails identically, so both prepositions are rewritten. - text = re.sub(r'(? " / "через " (bare = 1) / "через полчаса". @@ -307,6 +325,10 @@ func sortDescByLen(ss []string) { // parseClock — "7", "7:30" → today at that time; if already past today, roll // to tomorrow (a "wake me 7" at 8pm fires tomorrow 7). Used by the stub scan. func parseClock(clock string, now time.Time) (time.Time, bool) { + // Speech arrives with its punctuation attached: "на 9." ends a sentence and + // still names nine o'clock (V-579). The colon is kept, since it is the one + // mark that is part of a clock. + clock = strings.Trim(clock, ".,!?;") parts := strings.SplitN(clock, ":", 2) h, err := strconv.Atoi(parts[0]) if err != nil || h < 0 || h > 23 { @@ -480,6 +502,39 @@ func midnight(now time.Time, days int) time.Time { // // The date is recomputed rather than shifted, so an hour that parseClock // already pushed to tomorrow does not land two days out. +// applyRuDayShift moves an hour onto the day the sentence names, if it names +// one. The hour is kept exactly as read: the day word says which day and says +// nothing about when in it. +// ruQualifierIn returns the first part-of-day word in the sentence, or "". +func ruQualifierIn(toks []string) string { + for _, tok := range toks { + switch cleanWord(tok) { + case "утра", "вечера", "дня", "ночи": + return cleanWord(tok) + } + } + return "" +} + +func applyRuDayShift(t time.Time, toks []string, now time.Time) time.Time { + for _, tok := range toks { + days := 0 + switch cleanWord(tok) { + case "сегодня": + days = 0 + case "завтра": + days = 1 + case "послезавтра": + days = 2 + default: + continue + } + base := now.AddDate(0, 0, days) + return time.Date(base.Year(), base.Month(), base.Day(), t.Hour(), t.Minute(), 0, 0, now.Location()) + } + return t +} + func applyRuQualifier(t time.Time, qualifier string, now time.Time) time.Time { h := t.Hour() switch strings.Trim(strings.ToLower(qualifier), ".,!?;:") { diff --git a/internal/router/timementions.go b/internal/router/timementions.go index 9c2e59c..017c746 100644 --- a/internal/router/timementions.go +++ b/internal/router/timementions.go @@ -81,6 +81,148 @@ func NamesADay(text string) bool { return false } +// NamesAnHour reports whether the sentence names a time of day or an interval +// away from now: a written clock, a numeral, a half or quarter past, or one of +// the words an interval is built from. A day word alone is not one — "завтра" +// says which day and says nothing about when in it. +// +// It is the gate on the reminder's time slot (V-577, V-579). A time slot that +// names no hour is never filled, it is asked about. Both parsers answer a bare +// day word with that day at the current minute, so "что у меня сегодня?" set a +// reminder at 01:28 and "на завтра" set one at 01:38 — the minute he happened +// to be speaking, in a request that never named one. The stub answers with +// midnight instead, which is a different invented hour and no better. +// +// Same discipline as MentionsTime above: every signal is a closed lexicon class +// or a digit, so this reads data and decides nothing about meaning. +func NamesAnHour(text string) bool { + toks := strings.Fields(strings.ToLower(text)) + for i, raw := range toks { + tok := cleanWord(raw) + if isDigitClock(tok) || isAllDigits(tok) { + return true + } + if _, ok := numeralDigit(tok); ok { + return true + } + if hourMarkers[tok] { + return true + } + if _, _, ok := halfPastAt(toks, i); ok { + return true + } + if _, _, _, ok := quarterToAt(toks, i); ok { + return true + } + } + return false +} + +// NamesAnInterval reports whether the sentence measures the time from now +// instead of naming it: "через час", "через 10 минут", "in 30 minutes". +// +// An interval resolves to one instant, so it answers the hour and the day +// together and nothing about it is ambiguous. Callers that ask which day or +// which nine o'clock have to skip it (V-579). +func NamesAnInterval(text string) bool { + for _, raw := range strings.Fields(strings.ToLower(text)) { + switch cleanWord(raw) { + case "через", "спустя", "in": + return true + } + } + return false +} + +// HourIsAmbiguous reports whether the hour named could be either half of the +// day: "в 3" is three in the afternoon or three at night, and only he knows +// which (V-579, owner's rule of 2026-08-06). +// +// Three things settle it and any one is enough. A qualifier - "вечера", "pm", +// "полдень" - says which half. A written clock says it by being written. An +// hour above twelve says it by arithmetic. An interval names no hour at all. +func HourIsAmbiguous(text string) bool { + if NamesAnInterval(text) { + return false + } + toks := strings.Fields(strings.ToLower(text)) + for _, raw := range toks { + tok := cleanWord(raw) + if hourQualifiers[tok] || isDigitClock(tok) { + return false + } + } + for _, raw := range toks { + tok := cleanWord(raw) + d, ok := numeralDigit(tok) + if !ok && isAllDigits(tok) { + d, ok = tok, true + } + if !ok { + continue + } + n, err := strconv.Atoi(d) + if err == nil && n >= 1 && n <= 12 { + return true + } + } + return false +} + +// hourQualifiers — the words that pin an hour to one half of the day. Closed, +// and every member is a lexicon class or the two English markers. +var hourQualifiers = buildHourQualifiers() + +func buildHourQualifiers() map[string]bool { + m := map[string]bool{ + "утра": true, "вечера": true, "дня": true, "ночи": true, + "полдень": true, "полночь": true, "полудня": true, + "am": true, "pm": true, "noon": true, "midnight": true, + } + for _, w := range lexicon.PartsOfDay() { + m[w] = true + } + return m +} + +func isAllDigits(tok string) bool { + if tok == "" { + return false + } + for _, r := range tok { + if r < '0' || r > '9' { + return false + } + } + return true +} + +// hourMarkers — timeMarkers minus the day words and the weekdays, which name a +// day and not an hour, and minus "сейчас", which IS the clock and so can never +// be the evidence that the clock was meant. +var hourMarkers = buildHourMarkers() + +func buildHourMarkers() map[string]bool { + m := map[string]bool{ + "утра": true, "вечера": true, "дня": true, "ночи": true, + "часа": true, "часов": true, "час": true, "часу": true, + "минут": true, "минуты": true, "минуту": true, + "через": true, "спустя": true, "полчаса": true, + "полдень": true, "полночь": true, + "am": true, "pm": true, "noon": true, "midnight": true, "in": true, + } + for _, w := range lexicon.PartsOfDay() { + m[w] = true + } + for _, w := range lexicon.HalfHourWords() { + m[w] = true + } + for w := range minutesTo { + m[w] = true + } + return m +} + // isMonth reports whether the token is a month name. The lexicon holds the // genitive, which is the form a spoken date uses: "10 июля". func isMonth(tok string) bool { diff --git a/internal/router/timementions_test.go b/internal/router/timementions_test.go index c6d4988..06aaa77 100644 --- a/internal/router/timementions_test.go +++ b/internal/router/timementions_test.go @@ -1,6 +1,10 @@ package router -import "testing" +import ( + "context" + "testing" + "time" +) func TestMentionsTime(t *testing.T) { for _, s := range []string{ @@ -21,6 +25,87 @@ func TestMentionsTime(t *testing.T) { } } +// TestNamesAnHour — the gate on the reminder's time slot (V-577, V-579). A day +// word is not an hour, and a sentence that names no hour is asked about rather +// than completed from the clock. +func TestNamesAnHour(t *testing.T) { + for _, s := range []string{ + "в 11:00", "на 9", "в 9", "в девять", "в 9 утра", "завтра в 9", + "через час", "через двадцать минут", "в половине восьмого", + "без четверти восемь", "remind me at noon", "вечером", + } { + if !NamesAnHour(s) { + t.Errorf("NamesAnHour(%q) = false; this names an hour or an interval", s) + } + } + for _, s := range []string{ + "на завтра", "что у меня сегодня?", "напомни завтра позвонить маме", + "в пятницу", "позвонить маме", "", + } { + if NamesAnHour(s) { + t.Errorf("NamesAnHour(%q) = true; no hour was spoken, so she has to ask", s) + } + } +} + +// TestHourIsAmbiguous — the owner's rule of 2026-08-06. A bare hour is either +// half of the day and gets asked about; a qualifier, a written clock, an hour +// above twelve or an interval settles it and goes straight through. +func TestHourIsAmbiguous(t *testing.T) { + for _, s := range []string{ + "напомни завтра в 3 заказать цветы", "в 9", "на 9", "в девять", "в 11 позвонить маме", + } { + if !HourIsAmbiguous(s) { + t.Errorf("HourIsAmbiguous(%q) = false; the hour could be either half of the day", s) + } + } + for _, s := range []string{ + "напомни в 9 вечера разгрузить стиралку", "завтра в 15:00", "в 21", "в 11:00", + "через час", "через 10 минут", "remind me at noon", "напомни позвонить маме", + } { + if HourIsAmbiguous(s) { + t.Errorf("HourIsAmbiguous(%q) = true; this time reads only one way", s) + } + } +} + +// TestNamesAnInterval — an interval resolves to one instant, so it answers the +// hour and the day at once and is never asked about. +func TestNamesAnInterval(t *testing.T) { + for _, s := range []string{"через час", "через 10 минут", "через полчаса", "in 30 minutes"} { + if !NamesAnInterval(s) { + t.Errorf("NamesAnInterval(%q) = false", s) + } + } + for _, s := range []string{"завтра в 15:00", "в 9 вечера", ""} { + if NamesAnInterval(s) { + t.Errorf("NamesAnInterval(%q) = true", s) + } + } +} + +// TestReminderSlotRefusesAnHourNobodySaid — the same rule where it bites. The +// parser answers a bare day word with that day at the current minute, and the +// slot must stay empty so the daemon asks. +func TestReminderSlotRefusesAnHourNobodySaid(t *testing.T) { + now := time.Date(2026, 8, 6, 1, 38, 0, 0, time.UTC) + ex := Extractor{Time: clockEchoParser{}} + if got := ex.Extract(context.Background(), IntentReminder, "на завтра", now); got.HasTime { + t.Errorf("«на завтра» filled the time slot with %s, which is the clock", got.Time.Format("15:04")) + } + if got := ex.Extract(context.Background(), IntentReminder, "на 9", now); !got.HasTime { + t.Error("«на 9» names an hour and must still fill the slot") + } +} + +// clockEchoParser stands in for what both real parsers do with a bare day word: +// it answers with the current time of day. +type clockEchoParser struct{} + +func (clockEchoParser) Parse(_ context.Context, _ string, now time.Time) (time.Time, bool, error) { + return now.AddDate(0, 0, 1), true, nil +} + // A sentence with no time in it must not read as one, or a real follow-up stops // inheriting the hour it meant. func TestMentionsTimeIgnoresSentencesWithoutOne(t *testing.T) {