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:
@@ -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
|
||||
}
|
||||
|
||||
+39
-8
@@ -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))
|
||||
|
||||
+28
-17
@@ -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: "что у меня сегодня"},
|
||||
|
||||
@@ -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"}}},
|
||||
},
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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: "ага",
|
||||
|
||||
+11
-1
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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'(?<![\w:])(в|во|at)\s+([01]?\d|2[0-3])(?:\s+час(?:а|ов)?)?(?![\d:.\w])',
|
||||
# "на 9" is the same hour said with the other preposition, and it was not
|
||||
# read at all until V-579: "в 9" set the reminder and "на 9" did not.
|
||||
text = re.sub(r'(?<![\w:])(в|во|на|at)\s+([01]?\d|2[0-3])(?:\s+час(?:а|ов)?)?(?![\d:.\w])',
|
||||
lambda m: '%s %02d:00' % (m.group(1), int(m.group(2))), text, flags=re.IGNORECASE)
|
||||
settings = {'PREFER_DATES_FROM': 'future', 'RELATIVE_BASE': now}
|
||||
# Two-step: search_dates finds the date substring in text,
|
||||
|
||||
@@ -55,7 +55,11 @@ func (e Extractor) Extract(ctx context.Context, intent Intent, utterance string,
|
||||
switch intent {
|
||||
case IntentReminder:
|
||||
if e.Time != nil {
|
||||
if t, ok, err := e.Time.Parse(ctx, utterance, now); err == nil && ok {
|
||||
// NamesAnHour is the gate, not the parser's ok (V-577, V-579). A
|
||||
// sentence that names a day and no hour parses to that day at the
|
||||
// current minute, and filling the slot with it invents the answer
|
||||
// she asked for. Left empty, the daemon asks.
|
||||
if t, ok, err := e.Time.Parse(ctx, utterance, now); err == nil && ok && NamesAnHour(utterance) {
|
||||
s.Time = t
|
||||
s.HasTime = true
|
||||
}
|
||||
@@ -171,6 +175,11 @@ func afterWord(s, w string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// hourPrepositions — the words a spoken hour sits behind. Three, and no more:
|
||||
// the lexicon's frame set is much wider, and a word goes in here only when the
|
||||
// number after it is an hour of the day rather than a count of anything.
|
||||
var hourPrepositions = map[string]bool{"в": true, "во": true, "на": true}
|
||||
|
||||
// StubDateTimeParser — a tiny relative/absolute parser standing in for
|
||||
// `dateparser` until the i18n module lands. Handles "in Nh"/"in Nm"/"in Ns" and
|
||||
// "at HH:MM" / "HH:MM". The production path replaces this wholesale; the
|
||||
@@ -210,18 +219,27 @@ func (StubDateTimeParser) Parse(_ context.Context, text string, now time.Time) (
|
||||
// after the hour moves it into the afternoon: "в 7 вечера" is 19:00, and
|
||||
// with SpellOutDigits in front of this that is what "в семь вечера" reads
|
||||
// as too (Vikunja #469).
|
||||
//
|
||||
// "на" and "во" frame a spoken hour the same way, and until V-579 only "в"
|
||||
// did: "в 9" set the reminder and "на 9" was not read at all.
|
||||
for i := 0; i+1 < len(toks); i++ {
|
||||
if toks[i] != "в" {
|
||||
if !hourPrepositions[toks[i]] {
|
||||
continue
|
||||
}
|
||||
t, ok := parseClock(toks[i+1], now)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if i+2 < len(toks) {
|
||||
t = applyRuQualifier(t, toks[i+2], now)
|
||||
// The qualifier is looked for anywhere in the sentence, not only right
|
||||
// after the hour. It arrives on its own turn when she asks which half of
|
||||
// the day he meant, and "на 9" plus "вечера" is one time (V-579).
|
||||
if qual := ruQualifierIn(toks); qual != "" {
|
||||
t = applyRuQualifier(t, qual, now)
|
||||
}
|
||||
return t, true, nil
|
||||
// A day word anywhere in the sentence moves the hour onto that day. This
|
||||
// scan runs before the calendar one below, so without this "напомни
|
||||
// завтра в 15:00" landed today and V-579 asks about exactly that gap.
|
||||
return applyRuDayShift(t, toks, now), true, nil
|
||||
}
|
||||
|
||||
// "через <N> <unit>" / "через <unit>" (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), ".,!?;:") {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user