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 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 ) // 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 } // isPleasantry matches the WHOLE utterance against lexicon.Pleasantries, after // lowercasing and dropping the punctuation a greeting carries. // // Whole utterance and not tokens. Every token rule tried here was wrong on // something: "вечер" answers "это утра или вечера?", "нет" answers a confirm, // and "спокойной" alone is not an utterance at all. A greeting is a fixed // phrase, so matching it as one costs nothing and claims nothing else. func isPleasantry(text string) bool { t := strings.ToLower(strings.TrimSpace(text)) t = strings.Trim(t, " .,!?…") t = strings.Join(strings.Fields(t), " ") if t == "" { return false } for _, p := range lexicon.Pleasantries() { if t == p { 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. // // 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. // A pleasantry is neither (V-663). "спасибо" and "привет" fell through to // roleAnswer, so a question about a reminder's DAY was re-asked at a man // saying thank you, and the retry it spent was one of the three bounds // meant to end the ride. It is an aside: answered as itself, the question // resumed on the tail, no attempt spent, one ride counted. Placed above the // content gate because "доброе утро" has content and states nothing, so // neither half of the evidence below can reach it. if q != nil && isPleasantry(text) { return roleAside } 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 } return roleAnswer } if router.IsQuestionShaped(text) || (ok && (routed.Intent == router.IntentQuery || routed.Intent == router.IntentSystem)) { return roleSideQuery } 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 // 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 }