bac8673f05
Every token of "что у меня сегодня?" is frame, so ownContent left nothing, needsRoute returned false and no route was computed at all. The parked reminder then read "сегодня" as its time and the question was answered nowhere. A question shape now gets routed even when it leaves no content of its own, and a role that fills nothing she asked about is decided by the route. A statement he makes mid-flow gets a role of its own, roleAside, so a note or a fact is stored and the question comes back instead of being dropped in silence. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
323 lines
12 KiB
Go
323 lines
12 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
|
||
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
|
||
}
|
||
|
||
// 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.
|
||
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
|
||
}
|