a reminder commits on what, what time and what day (V-579)

The owner's rule of 2026-08-06. Anything of the three that is missing is asked
for, and every ask states the current time so he can tell what she is reasoning
from. A bare hour is asked which half of the day it is. A time with no day named
is asked which day, because today being a valid reading is not him saying it.

Two things go straight through, both his call: a time that already reads only
one way, and an interval, which resolves to one instant and answers all three at
once.

An answer about the time is read against the whole request rather than alone.
"вечера" says which nine and names no hour by itself, so the answers accumulate
on the parked question and the newest statement wins.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-06 02:20:23 +04:00
parent 01c78ef369
commit 173531be8c
4 changed files with 231 additions and 26 deletions
+39 -8
View File
@@ -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))