diff --git a/cmd/mavend/actions_reminder.go b/cmd/mavend/actions_reminder.go index 7ff97ed..4f58862 100644 --- a/cmd/mavend/actions_reminder.go +++ b/cmd/mavend/actions_reminder.go @@ -19,7 +19,9 @@ func (h *reactiveHandler) actionReminder(ctx context.Context, dec router.Decisio // time wasn't parsed. Run the parser as a fallback. if dec.Stage == 0 && h.timeParser != nil { t, ok, err := h.timeParser.Parse(ctx, dec.Utterance, h.now()) - if err == nil && ok { + // Same gate as the extractor (V-577, V-579): a request that named + // no hour gets asked about, never completed from the clock. + if err == nil && ok && router.NamesAnHour(dec.Utterance) { dec.Slots.Time = t dec.Slots.HasTime = true } diff --git a/cmd/mavend/turnrole.go b/cmd/mavend/turnrole.go index 7ca5a19..677b1d8 100644 --- a/cmd/mavend/turnrole.go +++ b/cmd/mavend/turnrole.go @@ -31,6 +31,7 @@ const ( roleCorrection turnRole = "correction" // it replaces a value she already had roleSideQuery turnRole = "side_query" // a question of its own, asked mid-flow roleNewRequest turnRole = "new_request" // a different request entirely + roleAside turnRole = "aside" // something he stated, not an answer roleCancel turnRole = "cancel" // call the pending action off roleNotApplicable turnRole = "not_applicable" // nothing is pending; not our turn ) @@ -212,11 +213,28 @@ func classifyTurnRole(q *dialogue.PendingQuestion, text string, answer dialogue. // tests are the floor and answer for free; the route is what sees a request // with no shape to it — "погода в риме" asks a question and carries neither // a question mark nor an interrogative, and only the router knows that. + // + // An utterance of pure frame gets one more chance, and V-577 is why. Every + // token of "что у меня сегодня?" is frame, so the content gate called it an + // answer, the parked reminder took "сегодня" for its time, and the question + // he asked was answered nowhere. A routed intent beats a frame match, + // because the frame is a hint and the route is a decision. + // + // The condition is that it fills nothing she asked about. That keeps the + // hedged "а что если в 11:00" an answer, which is what it is: it carries the + // hour, and no route saying "question" changes that. It works because the + // extractor no longer reads a day word as the current clock, so a sentence + // that names no hour now fills nothing to weigh. own := false if len(ownContent(text)) > 0 { own = offlineOwnRequest(text) || (ok && carriesOwnRequest(routed, text)) + } else if ok && fillsNothingAsked(q, answer) { + own = carriesOwnRequest(routed, text) } if !own { + if isAside(q, text, answer, routed, ok) { + return roleAside + } if replacesFilledSlot(q, answer) { return roleCorrection } @@ -228,6 +246,59 @@ func classifyTurnRole(q *dialogue.PendingQuestion, text string, answer dialogue. return roleNewRequest } +// isAside reports whether the utterance is something he STATED while she was +// waiting on a question (V-577 shape 2). +// +// "у меня новый ноутбук" said into a parked reminder was dropped in silence: it +// carries no capture verb, so it is not a request of its own, and it fills no +// slot, so it is not an answer either. Neither storing it nor saying it was +// ignored is the one behaviour that is wrong, and it was the behaviour. +// +// Three conditions, and all three are needed. The route has to call it a +// statement AND stand behind that, so a bare time is never an aside. It has to +// fill none of what she asked about, so an answer she can use stays an answer. +// And it has to say something, so a shrug is still a failed answer and still +// spends a retry. +func isAside(q *dialogue.PendingQuestion, text string, answer dialogue.Slots, routed router.Decision, ok bool) bool { + if !ok || q == nil { + return false + } + if !statesSomething(routed) { + return false + } + if len(ownContent(text)) == 0 { + return false + } + return fillsNothingAsked(q, answer) +} + +// statesSomething reports whether the route is evidence that these words state +// a thing, rather than a guess she has to interrupt a flow over. +// +// Two kinds of evidence, and the second one exists because the classifier floor +// marks nearly everything Clarify. A parsed fact key comes from the +// deterministic fact parser and not from a similarity score, so "я выпил воды" +// is a statement on any engine. A confident note or fact is the other kind, and +// that is the one the resident model gives for "у меня новый ноутбук". +func statesSomething(routed router.Decision) bool { + switch routed.Intent { + case router.IntentFact: + return routed.Slots.HasKey || !routed.Clarify + case router.IntentNote: + return !routed.Clarify + } + return false +} + +// fillsNothingAsked reports whether the utterance gave her none of what she +// asked for. Nothing is pending counts as nothing filled. +func fillsNothingAsked(q *dialogue.PendingQuestion, answer dialogue.Slots) bool { + if q == nil { + return true + } + return len(dialogue.StillMissing(q.Missing, answer)) == len(q.Missing) +} + // replacesFilledSlot reports whether the utterance overwrites something the // pending action already had, rather than filling the gap she asked about — // "нет, на девять" while she is waiting for the subject. Both are handled the diff --git a/cmd/mavend/turnrole_test.go b/cmd/mavend/turnrole_test.go index 4d85b68..c230519 100644 --- a/cmd/mavend/turnrole_test.go +++ b/cmd/mavend/turnrole_test.go @@ -123,6 +123,37 @@ func TestTurnRoleReadsTheRoutedDecision(t *testing.T) { ok: true, want: roleAnswer, }, + { + // V-577 shape 1. Every token is frame, so the content gate called + // this an answer and the reminder took "сегодня" for its time. It + // fills nothing she asked about, so the route decides, and the route + // says the calendar answers it. + name: "an agenda question of pure frame words is a side query", + text: "что у меня сегодня?", + routed: dec(router.IntentQuery, router.Slots{}), + ok: true, + want: roleSideQuery, + }, + { + // V-577 shape 2. Neither a slot value nor a request nor a cancel. + // It was dropped in silence; it is an aside, and an aside is stored + // and re-asked. + name: "a fact stated mid-flow is an aside", + text: "у меня новый ноутбук", + routed: dec(router.IntentNote, router.Slots{Text: "у меня новый ноутбук"}), + ok: true, + want: roleAside, + }, + { + // A route she is not sure of is not evidence that he stated + // anything, and "позвонить маме" is the answer to the other half of + // a reminder. + name: "an unsure note is not an aside", + text: "позвонить маме", + routed: router.Decision{Intent: router.IntentNote, Clarify: true}, + ok: true, + want: roleAnswer, + }, { name: "a bare noun that answers nothing is still an answer", text: "ага", diff --git a/cmd/mavend/turnroute.go b/cmd/mavend/turnroute.go index d852ad7..a2018b7 100644 --- a/cmd/mavend/turnroute.go +++ b/cmd/mavend/turnroute.go @@ -110,6 +110,16 @@ func (h *reactiveHandler) routeForRole(ctx context.Context, text string) (router // This is a fast path to the SAME answer and must stay one. If it ever needs a // rule the classifier does not have, it has become a second decision procedure // and it is the thing V-560 deleted. +// +// A question shape is the exception and V-577 is why (measured 2026-08-06). +// "что у меня сегодня?" is an interrogative, a preposition, a particle and a day +// word, so every token of it is frame and it left no content of its own. The +// fast path called it an answer, the parked reminder read "сегодня" as its time, +// and the question he asked was never answered. Asked alone the same sentence +// routes to query at stage 0, so the route knew and was never consulted. func needsRoute(text string) bool { - return !isCancel(text) && len(ownContent(text)) > 0 + if isCancel(text) { + return false + } + return len(ownContent(text)) > 0 || router.IsQuestionShaped(text) }