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. // // 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) if !ok { return "", false } return clock + " " + q, true case whenAmbiguousHour: return clock + " Это утра или вечера?", true case whenNoDay: return clock + " В какой день?", true } 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. // // 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 }