From 1a64c30427b4eb2e7e840c5b813fed308e8f9e09 Mon Sep 17 00:00:00 2001 From: claude Date: Thu, 6 Aug 2026 00:55:53 +0400 Subject: [PATCH] voice: the turn role, read off the routed decision (V-560) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The turn role — answer, correction, side_query, new_request, cancel, plus the not_applicable a resolver may return — decided from what the router made of the utterance instead of from whatever the extractor found inside it. The content gate in front of the evidence is what separates a hedged slot value from a question: 'а что если в 11:00' leaves nothing of its own behind and 'какая сейчас погода в Риме' leaves the weather and Rome. Nothing calls it yet. --- cmd/mavend/turnrole.go | 251 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 251 insertions(+) create mode 100644 cmd/mavend/turnrole.go diff --git a/cmd/mavend/turnrole.go b/cmd/mavend/turnrole.go new file mode 100644 index 0000000..7ca5a19 --- /dev/null +++ b/cmd/mavend/turnrole.go @@ -0,0 +1,251 @@ +package main + +import ( + "strings" + "unicode" + + "github.com/kami/maven/internal/dialogue" + "github.com/kami/maven/internal/lexicon" + "github.com/kami/maven/internal/router" +) + +// turnRole — what this utterance IS, relative to the action Maven is in the +// middle of assembling. The five roles are the owner's vocabulary (Vikunja +// #558), plus the sixth answer a resolver is allowed to give: not_applicable, +// which hands the turn back to generic dispatch. +// +// It exists because the arbitration used to be ordering. The clarify resolver, +// the confirm gate, the follow-up merge and the repair marker all ran BEFORE +// the router, so the claimant holding conversational state decided what an +// utterance was without asking the one component whose job that is — and on +// 2026-08-05 "какая сейчас погода в Риме?" became the time of a reminder, +// because the extractor found "сейчас" in it and nothing looked at the rest. +// +// The rule that fixes that: a routed decision which stands on its own — its own +// intent, its own slots filled from its own words — is not an answer, whatever +// the extractor found inside it. +type turnRole string + +const ( + roleAnswer turnRole = "answer" // it fills the slot she asked about + 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 + roleCancel turnRole = "cancel" // call the pending action off + roleNotApplicable turnRole = "not_applicable" // nothing is pending; not our turn +) + +// frameWords — the words that can stand around a bare slot value without adding +// a request. Every member is a closed class from internal/lexicon: the frame +// itself, the interrogatives, the parts of a spoken clock, the days and the +// months. Assembled once; the sets are copies, so this cannot edit them. +var frameWords = buildFrameWords() + +func buildFrameWords() map[string]bool { + out := make(map[string]bool) + add := func(list []string) { + for _, w := range list { + out[strings.ToLower(w)] = true + } + } + add(lexicon.SlotValueFrame()) + add(lexicon.Interrogatives()) + add(lexicon.PartsOfDay()) + add(lexicon.HalfHourWords()) + add(lexicon.DayOffsetWords()) + for i := 0; i < 7; i++ { + out[lexicon.Weekday(i)] = true + } + for m := 1; m <= 12; m++ { + out[lexicon.MonthGenitive(m)] = true + } + for hh := 0; hh <= 23; hh++ { + add(strings.Fields(lexicon.HourSpoken(hh))) + } + return out +} + +// cancelWords — the same, for the words that call the pending action off. +var cancelWords = buildCancelWords() + +func buildCancelWords() map[string]bool { + out := make(map[string]bool) + for _, w := range lexicon.DialogueCancel() { + out[strings.ToLower(w)] = true + } + return out +} + +// turnTokens splits an utterance the way the router's own predicates do: over +// letters and digits, lowercased, so punctuation and a clock's colon fall out. +func turnTokens(text string) []string { + return strings.FieldsFunc(strings.ToLower(text), func(r rune) bool { + return !unicode.IsLetter(r) && !unicode.IsDigit(r) + }) +} + +// ownContent lists the tokens of an utterance that are neither frame nor value: +// what it is about, over and above the thing she asked for. Numbers go out +// because a number is the commonest slot value there is, and the closed number +// and day lexicons go out with them. +// +// Empty ⇒ the utterance is a slot value and nothing else, however it is dressed +// up. That is the whole test, and it is what separates "а что если в 11:00" — +// which is a time, hedged — from "какая сейчас погода в Риме?", which leaves +// "погода" and "риме" behind and is therefore about something. +func ownContent(text string) []string { + var out []string + for _, tok := range turnTokens(text) { + if frameWords[tok] || lexicon.IsFillerParticle(tok) { + continue + } + if _, ok := lexicon.Cardinal(tok); ok { + continue + } + if _, ok := lexicon.Ordinal(tok); ok { + continue + } + if isNumeric(tok) { + continue + } + out = append(out, tok) + } + return out +} + +func isNumeric(tok string) bool { + for _, r := range tok { + if !unicode.IsDigit(r) { + return false + } + } + return tok != "" +} + +// isCancel reports whether the utterance is nothing but a call-off. Every +// content token has to be a cancel word, so "забудь" ends the exchange and +// "забудь купить молоко" does not. +func isCancel(text string) bool { + content := ownContent(text) + if len(content) == 0 { + return false + } + for _, tok := range content { + if !cancelWords[tok] { + return false + } + } + return true +} + +// carriesOwnRequest reads the ROUTED decision for the thing that decides this: +// does the utterance ask for something in its own right? Each intent is asked +// the question in its own terms, because "its own slots filled from its own +// words" means a different field for each of them. +// +// A query or a system question needs no further evidence — the router already +// read a question in these words. The write intents need the verb or the slot +// that names the request, so a bare value the router guessed a home for does +// not count as one. +func carriesOwnRequest(dec router.Decision, text string) bool { + if dec.Clarify { + // The router itself was unsure. An utterance she could not route is + // not an utterance that outranks the question in front of it. + return false + } + switch dec.Intent { + case router.IntentQuery, router.IntentSystem: + return true + case router.IntentReminder: + return carriesReminderVerb(text) + case router.IntentFact, router.IntentNote: + return router.CarriesCaptureVerb(text) + case router.IntentAct: + // An act that resolved to a capability is a command. One that did not + // is words she cannot execute anyway, so it stays an answer and gets + // re-asked — the same thing that happens to it today. + return dec.Slots.HasFn + default: // chat + return false + } +} + +// carriesReminderVerb — "напомни" and its forms, matched over tokens. The +// reminder verbs are a closed lexicon and are not capture verbs, so +// CarriesCaptureVerb never sees them. +func carriesReminderVerb(text string) bool { + toks := turnTokens(text) + for _, v := range lexicon.ReminderVerbs() { + for _, t := range toks { + if t == strings.ToLower(v) { + return true + } + } + } + return false +} + +// offlineOwnRequest is the shape half of the evidence: the offline token tests, +// which cost nothing and never depend on the model that produced the routing. +// It is also the whole answer when there is no route to read — the classifier +// is the failure floor and a turn must never break on the model. +func offlineOwnRequest(text string) bool { + return router.IsQuestionShaped(text) || router.CarriesCaptureVerb(text) || carriesReminderVerb(text) +} + +// classifyTurnRole decides what this utterance is against the pending action. +// +// The fast path is the first line and it is a fast path to the SAME answer, not +// a second decision procedure: an utterance with no content of its own can +// never be a request of its own, so it can never be anything but an answer, and +// the route below would spend a second on the resident model to say so. Every +// other utterance is routed first, and the role is read off the decision. +// +// `routed` is the turn's routing, already computed; ok is false when there was +// none to compute (no router wired, or the route failed). A failed route falls +// to the offline shape tests rather than breaking the turn. +func classifyTurnRole(q *dialogue.PendingQuestion, text string, answer dialogue.Slots, routed router.Decision, ok bool) turnRole { + if isCancel(text) { + return roleCancel + } + // Two pieces of evidence, and the content gate in front of both. The shape + // 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. + own := false + if len(ownContent(text)) > 0 { + own = offlineOwnRequest(text) || (ok && carriesOwnRequest(routed, text)) + } + if !own { + if replacesFilledSlot(q, answer) { + return roleCorrection + } + return roleAnswer + } + if router.IsQuestionShaped(text) || (ok && (routed.Intent == router.IntentQuery || routed.Intent == router.IntentSystem)) { + return roleSideQuery + } + return roleNewRequest +} + +// 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 +// same way (dialogue.Answer already prefers the newer value), so this only +// names the turn honestly for the log and for the decision trace V-564 adds. +func replacesFilledSlot(q *dialogue.PendingQuestion, answer dialogue.Slots) bool { + if q == nil { + return false + } + asked := make(map[dialogue.Slot]bool, len(q.Missing)) + for _, s := range q.Missing { + asked[s] = true + } + if answer.HasTime && q.Slots.HasTime && !asked[dialogue.SlotTime] && !answer.Time.Equal(q.Slots.Time) { + return true + } + if answer.HasKey && q.Slots.HasKey && !asked[dialogue.SlotKey] && answer.Key != q.Slots.Key { + return true + } + return false +}