diff --git a/cmd/mavend/clarify.go b/cmd/mavend/clarify.go index 4b0a5e5..bb87835 100644 --- a/cmd/mavend/clarify.go +++ b/cmd/mavend/clarify.go @@ -214,7 +214,7 @@ func (h *reactiveHandler) clarifyQuestion(dec router.Decision) (dialogue.Slot, s if len(missing) == 0 { return "", "", false } - q, ok := h.questionFor(missing[0], 1, dec.Utterance, toDialogueSlots(dec.Slots)) + q, ok := h.questionFor(missing[0], 1, dec.Utterance, toDialogueSlots(dec.Slots), "") if !ok { return "", "", false } @@ -225,11 +225,14 @@ func (h *reactiveHandler) clarifyQuestion(dec router.Decision) (dialogue.Slot, s // 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) { +// +// taken is the acknowledgement of what his last turn added, empty when it added +// nothing and empty for a first ask, which has no turn behind it (V-593). +func (h *reactiveHandler) questionFor(slot dialogue.Slot, attempt int, utterance string, s dialogue.Slots, taken string) (string, bool) { if slot != dialogue.SlotTime { return clarifyQuestionFor(slot, attempt) } - return whenQuestion(whenGapOf(utterance, s.HasTime), attempt, h.now()) + return whenQuestion(whenGapOf(utterance, s.HasTime), attempt, h.now(), taken) } // askClarify parks the request and returns the question to ask instead of the @@ -384,18 +387,35 @@ 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) + // A fact answers with a key and a value and fills no Text slot at all, so + // the fold above leaves the utterance at the bare "запиши" — and that is + // what the confirmation now reads back to him (V-592). His raw words are the + // only record of what he said, so they are what is folded. Never for a time + // question: what he says about when is kept apart in WhenText on purpose, + // or the reminder would read the day back at him when it fires. + if merged.Text == "" && !asksAboutTime(q.Missing) { + q.Utterance = foldAnswerIntoUtterance(q.Utterance, text) + } // 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. + // What he had already said about the time, read BEFORE this answer joins it. + // A re-ask that cannot tell the two apart is the one that repeats itself + // byte for byte (V-593). + var taken string if asksAboutTime(q.Missing) { + before := whenKnownOf(whenTextOf(q), q.Slots.HasTime) q.WhenText = strings.TrimSpace(q.WhenText + " " + text) if t, ok := h.readWhen(ctx, intent, q, text); ok { merged.Time, merged.HasTime = t, true } + if before.movedForward(whenKnownOf(whenTextOf(q), merged.HasTime)) { + taken = whenTakenLine(text) + } } if stillOpen(q.Missing, whenTextOf(q), merged) { - return h.reaskOrGiveUp(ctx, q, merged, text), true + return h.reaskOrGiveUp(ctx, q, merged, text, taken), true } h.clarifyStore.Delete(dialogueIDOf(ctx)) @@ -496,7 +516,7 @@ func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.Pendi } // 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 := h.questionFor(remaining[0], q.Attempts+1, whenTextOf(q), merged) + question, ok := h.questionFor(remaining[0], q.Attempts+1, whenTextOf(q), merged, "") if !ok || !q.CanAsk() { return "", false } @@ -518,10 +538,12 @@ func (h *reactiveHandler) askRemainingGap(ctx context.Context, q *dialogue.Pendi // reaskOrGiveUp handles an answer that left the gap open: ask the same question // again while she has attempts left, otherwise say she did not understand and // let the request go. Never returns "" — a mute give-up reads as "done". -func (h *reactiveHandler) reaskOrGiveUp(ctx context.Context, q *dialogue.PendingQuestion, merged dialogue.Slots, text string) string { +// taken is the acknowledgement of what this answer DID give, empty when it gave +// nothing (V-593). The give-up line never carries it: it is not another ask. +func (h *reactiveHandler) reaskOrGiveUp(ctx context.Context, q *dialogue.PendingQuestion, merged dialogue.Slots, text, taken string) string { question := "" if len(q.Missing) > 0 { - question, _ = h.questionFor(q.Missing[0], q.Attempts+1, whenTextOf(q), merged) + question, _ = h.questionFor(q.Missing[0], q.Attempts+1, whenTextOf(q), merged, taken) } if question == "" || !q.CanAsk() { h.clarifyStore.Delete(dialogueIDOf(ctx)) diff --git a/cmd/mavend/clarify_test.go b/cmd/mavend/clarify_test.go index d59741f..732c306 100644 --- a/cmd/mavend/clarify_test.go +++ b/cmd/mavend/clarify_test.go @@ -169,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, _ := whenQuestion(whenNoHour, i, h.now()) + 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, _ := whenQuestion(whenNoHour, 1, h.now()); 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 { @@ -368,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, _ := whenQuestion(whenNoHour, 2, h.now()) + 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) } @@ -685,7 +685,7 @@ func TestIncompleteReminderAsksInsteadOfFailing(t *testing.T) { h, st := newRoutingClarifyHandler(t) reply := h.handleText(ctx, "web", "напомни позвонить маме") - want, _ := whenQuestion(whenNoHour, 1, h.now()) + want, _ := whenQuestion(whenNoHour, 1, h.now(), "") if reply != want { t.Fatalf("reply = %q, want the time question %q", reply, want) } diff --git a/cmd/mavend/dialogue_contract_test.go b/cmd/mavend/dialogue_contract_test.go index 23187ba..7db4ced 100644 --- a/cmd/mavend/dialogue_contract_test.go +++ b/cmd/mavend/dialogue_contract_test.go @@ -118,7 +118,14 @@ type turn struct { 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 + gap whenGap + // took — the words of the PREVIOUS turn that this ask must acknowledge + // before asking again (V-593). Empty ⇒ the ask carries no acknowledgement, + // which is right for a first ask and for an answer that moved nothing. + took string + // differs — this reply must not be byte-identical to the one before it. Set + // on a re-ask whose turn moved the request forward (V-593). + differs bool contains []string notContain []string // noQuestion — the reply must not be any clarify question. Used where the @@ -178,7 +185,7 @@ func wantedQuestion(tn turn, now time.Time) (string, bool) { if gap == whenComplete { gap = whenNoHour } - return whenQuestion(gap, tn.attempt, now) + return whenQuestion(gap, tn.attempt, now, whenTakenLine(tn.took)) } return clarifyQuestionFor(tn.question, tn.attempt) } @@ -201,6 +208,7 @@ func runTrace(t *testing.T, tr trace) { id := dialogueIDFor(sourceText, conversation) var claims []claim + var previous string fail := func(turnIdx int, format string, args ...any) { t.Helper() lines := make([]string, 0, len(claims)) @@ -259,6 +267,10 @@ func runTrace(t *testing.T, tr trace) { fail(i, "reply %q carries %q and must not", body, unwanted) } } + if tn.differs && reply == previous { + fail(i, "reply %q is byte-identical to the one before it, and his turn between them answered part of the gap", reply) + } + previous = reply checkParked(t, fail, i, h.clarifyStore.Get(id, h.now()), tn.parked) } checkEnd(t, ctx, st, h, tr.end, claims) @@ -400,7 +412,7 @@ func dialogueTraces() []trace { turns: []turn{ {say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1, parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "маме"}}, - {say: "в 11:00", question: dialogue.SlotTime, attempt: 2, gap: whenNoDay, + {say: "в 11:00", question: dialogue.SlotTime, attempt: 2, gap: whenNoDay, took: "в 11:00", parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2, carries: "маме"}}, {say: "сегодня", contains: []string{"11:00"}, notContain: []string{"?"}}, }, @@ -413,7 +425,9 @@ func dialogueTraces() []trace { turns: []turn{ {say: "запиши", question: dialogue.SlotKey, attempt: 1, parked: &parkedWant{slot: dialogue.SlotKey, attempt: 1}}, - {say: "пил воду", contains: []string{"water"}}, + // His words back, not the key the parser filed them under + // (V-592). "water" is machine vocabulary and he never said it. + {say: "пил воду", contains: []string{"пил воду"}}, }, end: endState{factKeys: []string{"water"}}, }, @@ -522,8 +536,42 @@ func dialogueTraces() []trace { turns: []turn{ {say: "напомни позвонить врачу", question: dialogue.SlotTime, attempt: 1, parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "врачу"}}, + // Nothing she says about it may be a word he did not say + // (V-592). On the box this sentence came back as "Проверила, что + // ты выпел стакан воды": a non-word for the verb, a glass copied + // out of the example in ReplySystemPrompt, and a claim to have + // checked something. The store held key=water value="drank" + // throughout, so all of it was generated from two tokens. + // + // The positive half of the contract — the confirmation IS his + // sentence — is asserted by "fact completed over two turns" + // above. It cannot be asserted here: the hash embedder marks + // this route Clarify, and an unsure fact is answered with the + // canned line rather than a confirmation of anything. {say: "я выпил воды", contains: []string{"напоминание?"}, - parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "врачу"}}, + notContain: []string{"стакан", "выпел", "Проверила", "water"}, + parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "врачу"}}, + }, + end: endState{}, + }, + // V-593: two asks about the same half of the day, with a turn between + // them that answered the DAY. Asking again is right and asking in the + // same bytes is not — from his side it is indistinguishable from not + // having been heard, which is what the whole V-558 family is about. + // + // The clock still opens every ask (the owner's rule, V-579); the + // acknowledgement goes after it and before the question. + { + name: "a re-ask names what the answer before it gave her", + turns: []turn{ + {say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1, + parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}}, + {say: "на 9", question: dialogue.SlotTime, attempt: 2, gap: whenAmbiguousHour, took: "на 9", + contains: []string{"Сейчас "}, + parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2}}, + {say: "на завтра", question: dialogue.SlotTime, attempt: 3, gap: whenAmbiguousHour, took: "на завтра", + contains: []string{"Сейчас ", "завтра"}, differs: true, + parked: &parkedWant{slot: dialogue.SlotTime, attempt: 3}}, }, end: endState{}, }, @@ -540,9 +588,9 @@ func dialogueTraces() []trace { turns: []turn{ {say: "напомни позвонить маме", question: dialogue.SlotTime, attempt: 1, parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}}, - {say: "на 9", question: dialogue.SlotTime, attempt: 2, gap: whenAmbiguousHour, + {say: "на 9", question: dialogue.SlotTime, attempt: 2, gap: whenAmbiguousHour, took: "на 9", parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2}}, - {say: "утра", question: dialogue.SlotTime, attempt: 3, gap: whenNoDay, + {say: "утра", question: dialogue.SlotTime, attempt: 3, gap: whenNoDay, took: "утра", parked: &parkedWant{slot: dialogue.SlotTime, attempt: 3}}, {say: "завтра", contains: []string{"09:00"}}, }, @@ -657,9 +705,13 @@ func dialogueTraces() []trace { // 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, + // Each ask names what the turn before it gave her (V-593). The + // two asks about the half of the day are the same question and + // must not be the same sentence: he answered between them, and a + // reply with no trace of that reads as not having been heard. + {say: "а, да, прости - на 9.", question: dialogue.SlotTime, attempt: 2, gap: whenAmbiguousHour, took: "а, да, прости - на 9.", parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2}}, - {say: "на завтра.", question: dialogue.SlotTime, attempt: 3, gap: whenAmbiguousHour, + {say: "на завтра.", question: dialogue.SlotTime, attempt: 3, gap: whenAmbiguousHour, took: "на завтра.", parked: &parkedWant{slot: dialogue.SlotTime, attempt: 3}}, }, end: endState{}, @@ -679,7 +731,7 @@ func dialogueTraces() []trace { parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1}}, {say: "какая сейчас погода в Риме?", parked: &parkedWant{slot: dialogue.SlotTime, attempt: 1, carries: "маме"}}, - {say: "в 11:00", question: dialogue.SlotTime, attempt: 2, gap: whenNoDay, + {say: "в 11:00", question: dialogue.SlotTime, attempt: 2, gap: whenNoDay, took: "в 11:00", parked: &parkedWant{slot: dialogue.SlotTime, attempt: 2, carries: "маме"}}, {say: "сегодня", contains: []string{"11:00"}}, }, diff --git a/cmd/mavend/reminderwhen.go b/cmd/mavend/reminderwhen.go index 2c28ee7..f0046fc 100644 --- a/cmd/mavend/reminderwhen.go +++ b/cmd/mavend/reminderwhen.go @@ -59,8 +59,15 @@ func whenGapOf(text string, hasTime bool) whenGap { // 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) { +// +// taken is what his last turn added, in his own words, and it goes between the +// clock and the question (V-593). It is empty whenever his turn moved nothing +// forward, which is the case where repeating the question verbatim is honest. +func whenQuestion(gap whenGap, attempt int, now time.Time, taken string) (string, bool) { clock := fmt.Sprintf("Сейчас %s.", now.Format("15:04")) + if taken != "" { + clock += " " + taken + } switch gap { case whenNoHour: q, ok := clarifyQuestionFor(dialogue.SlotTime, attempt) @@ -76,6 +83,50 @@ func whenQuestion(gap whenGap, attempt int, now time.Time) (string, bool) { return "", false } +// whenKnown — the three things he has to say about the time, and whether the +// words so far say them. Read off the same predicates whenGapOf reads, so the +// two cannot disagree about what is still open. +type whenKnown struct{ hour, part, day bool } + +func whenKnownOf(text string, hasTime bool) whenKnown { + if !router.NamesAnHour(text) { + return whenKnown{} + } + if router.NamesAnInterval(text) { + return whenKnown{hour: true, part: true, day: true} + } + if !hasTime { + return whenKnown{} + } + return whenKnown{ + hour: true, + part: !router.HourIsAmbiguous(text), + day: router.NamesADay(text), + } +} + +// movedForward reports whether b says something a did not. +func (a whenKnown) movedForward(b whenKnown) bool { + return (!a.hour && b.hour) || (!a.part && b.part) || (!a.day && b.day) +} + +// whenTakenLine — the acknowledgement in front of a re-ask, in the words he +// just used (V-593). +// +// It is an echo and never a restatement, for the same reason the fact +// confirmation is (V-592): a 1.7B asked to say a Russian sentence back invents. +// Its only job is evidence that the turn between two asks was heard, so after +// "на 9" and then "на завтра" she does not ask "утра или вечера?" twice +// byte-identically while he wonders whether the microphone is on. +func whenTakenLine(text string) string { + text = strings.TrimSpace(text) + text = strings.TrimRight(text, " \t.,!?;:") + if text == "" { + return "" + } + return "Поняла: " + text + "." +} + // whenTextOf is everything he has said about when, the original request plus // every answer he has given to a question about it. //