Files
Maven/cmd/mavend/turnrole.go
T
claude 1a64c30427 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.
2026-08-06 00:55:53 +04:00

252 lines
9.2 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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
}