Merge the clock invention fix (#231)

A parked clarify ate a foreign utterance and the clock answered for him.

Root cause was a step earlier than filed. ownContent("что у меня сегодня?")
returns empty, every token being frame, so needsRoute said no and no route was
computed at all. classifyTurnRole fell through to roleAnswer, the extractor read
сегодня, and the date parser answered a bare day word with that day at the
current minute.

needsRoute now routes a question shape even when every token is frame, and the
route decides when the utterance fills nothing she asked about. A frame match is
a hint, not a decision. roleAside is new: a note or fact stated mid-flow is
stored and the question comes back on the same reply.

router.NamesAnHour is the single gate on the reminder time slot, so a sentence
naming no hour never fills it. IsClockEcho is deleted; it could not catch на
завтра on the stub, which returns midnight rather than the clock. на joins в and
во as a frame around a spoken hour.

The owner's rule, ruled on 2026-08-06: a reminder commits only when what, what
time and what day are all answered, and every time question opens by stating the
clock. He confirmed both derived cases himself, so завтра в 15:00 and через час
commit with no question.

A global assertion in checkEnd now fails any trace whose reminder fires at the
current clock.

(V-577) (V-579)
This commit is contained in:
2026-08-06 02:22:09 +04:00
13 changed files with 851 additions and 55 deletions
+28 -17
View File
@@ -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: "что у меня сегодня"},