a re-ask names what the answer before it gave her (V-593)
After "на 9" and then "на завтра" she asked "Сейчас 02:34. Это утра или вечера?" twice, byte for byte. Asking again is right — the half of the day is still unsaid — but a reply with no trace of his turn in it is indistinguishable from not having been heard, which is the failure mode the V-558 family exists to remove. whenKnownOf reads the three things he has to say about the time off the same predicates whenGapOf reads. When his answer moved any of them forward, the ask carries an acknowledgement of what it took, in his own words and never a restatement: a 1.7B asked to say a Russian sentence back is exactly where V-592 came from. When it moved nothing, there is nothing to acknowledge and the question repeats honestly. The clock still opens every time question, per the owner's ruling. The acknowledgement goes between it and the question. Whether she should state the clock on every ask of one flow is his call, not mine. Also folds a fact's raw answer into the parked utterance (V-592): a fact fills no Text slot, so "запиши" + "пил воду" confirmed as "запиши". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+29
-7
@@ -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))
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"}},
|
||||
},
|
||||
|
||||
@@ -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.
|
||||
//
|
||||
|
||||
Reference in New Issue
Block a user