173531be8c
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>
159 lines
5.6 KiB
Go
159 lines
5.6 KiB
Go
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
|
||
}
|