1a64c30427
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.
252 lines
9.2 KiB
Go
252 lines
9.2 KiB
Go
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
|
||
}
|