voice: the turn role, read off the routed decision (V-560)

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.
This commit is contained in:
2026-08-06 00:55:53 +04:00
parent 5753f90752
commit 1a64c30427
+251
View File
@@ -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
}