From bac8673f05daf5f5e4240203d8ce5998c8c0949a Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 6 Aug 2026 02:20:03 +0400 Subject: [PATCH] a routed intent beats a frame match mid-flow (V-577) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Every token of "что у меня сегодня?" is frame, so ownContent left nothing, needsRoute returned false and no route was computed at all. The parked reminder then read "сегодня" as its time and the question was answered nowhere. A question shape now gets routed even when it leaves no content of its own, and a role that fills nothing she asked about is decided by the route. A statement he makes mid-flow gets a role of its own, roleAside, so a note or a fact is stored and the question comes back instead of being dropped in silence. Co-Authored-By: Claude Opus 5 --- cmd/mavend/actions_reminder.go | 4 +- cmd/mavend/turnrole.go | 71 ++++++++++++++++++++++++++++++++++ cmd/mavend/turnrole_test.go | 31 +++++++++++++++ cmd/mavend/turnroute.go | 12 +++++- 4 files changed, 116 insertions(+), 2 deletions(-) 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) }