Files
Maven/cmd/mavend/turnrole.go
T
claude a886217223 A greeting is not a failed answer (V-663)
classifyTurnRole read "спасибо" and "привет" as answers to whatever was
parked, so she re-asked "В какой день?" at a man saying thank you and
spent one of three attempts doing it. That attempt is a bound meant to end
the ride, so the pleasantry both produced the worst reply in the corpus and
paid for the privilege.

They are asides now: answered as themselves, the question resumed on the
tail, no attempt spent, one ride counted.

The set is a new closed lexicon entry, matched as WHOLE utterances. Every
token rule tried was wrong on something. "вечер" answers "это утра или
вечера?" and "нет" answers a confirm, so anything that could fill a slot
stays out. The control words stay out too, because isCancel owns them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013ptwopxyo3Z2kwFckHkLvN
2026-08-08 21:43:15 +04:00

355 lines
14 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
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
}