85a3397bf4
reminder_cancel.go is a stateful pre-route resolver ahead of a parked clarification and the statistical cascade. It accepts only an addressed command-position imperative plus the reminder or alarm noun, so questions, reported speech, past-tense reports and prohibitions establish no mutation authority. Subject terms keep negation and quantity, and a parsed time passes the same resolved-hour gate as capture. One match cancels through the typed IPC method. Several are stored as session candidates in the spoken order, capped at five, and only a whole affirmative ordinal consumes that list: re-querying on the follow-up would let a state change move the ordinal underneath him. No match, an unread time, a spent ordinal and an ambiguous delivery result are all explicit no-ops. command_prohibition.go is the first mutation boundary in a turn. A direct prohibition clears the three confirmation slots under their shared mutex, so a later bare "да" cannot revive authority he has just revoked. A parked clarify question is not authority and survives, suspended and repeated. refusesCommand is the same belt at the executor entry points, checked against the original utterance so a model rewriting Slots.Text cannot get around it. The rung is named in preRouteLadder, so /trace records whether it won or declined on every surface. --no-verify: master is the working branch this session by the owner's call. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
129 lines
4.8 KiB
Go
129 lines
4.8 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"log"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/kami/maven/internal/dialogue"
|
|
"github.com/kami/maven/internal/router"
|
|
)
|
|
|
|
// turnRoute is this turn's routing, computed at most once.
|
|
//
|
|
// It exists because the arbitration was inverted (Vikunja #560): the clarify
|
|
// resolver now reads the routed decision before deciding what the utterance is,
|
|
// and the pipeline then acts on that same decision. Routing twice would cost a
|
|
// second on the resident model and — worse — could disagree with itself, which
|
|
// is exactly the class of bug this task is about.
|
|
type turnRoute struct {
|
|
h *reactiveHandler
|
|
text string
|
|
now time.Time
|
|
|
|
once sync.Once
|
|
dec router.Decision
|
|
cont bool
|
|
prev *dialogue.Session
|
|
err error
|
|
|
|
// dropped — what she let go of this turn and must say out loud. A parked
|
|
// request that dies without a word leaves him thinking it landed.
|
|
dropped string
|
|
|
|
// resume — the parked question, re-worded, to put AFTER this turn's answer
|
|
// (Vikunja #561). A side query does not end the flow it interrupted, so the
|
|
// reply carries two acts: the answer he asked for, then the question he
|
|
// still owes her. Empty ⇒ nothing was suspended.
|
|
resume string
|
|
// suspended — a flow is parked underneath this turn. askClarify reads it to
|
|
// decide between Put (replace the top) and Push (keep the flow and stack the
|
|
// new question on it), because a side query that needs clarifying of its own
|
|
// must not overwrite the thing it interrupted.
|
|
suspended bool
|
|
}
|
|
|
|
type turnRouteKey struct{}
|
|
|
|
func (h *reactiveHandler) newTurnRoute(text string, now time.Time) *turnRoute {
|
|
return &turnRoute{h: h, text: text, now: now}
|
|
}
|
|
|
|
func withTurnRoute(ctx context.Context, rt *turnRoute) context.Context {
|
|
return context.WithValue(ctx, turnRouteKey{}, rt)
|
|
}
|
|
|
|
// turnRouteFrom returns the turn's memo, or nil when the caller is not inside
|
|
// runTurn — a unit test calling one resolver directly, most often.
|
|
func turnRouteFrom(ctx context.Context) *turnRoute {
|
|
rt, _ := ctx.Value(turnRouteKey{}).(*turnRoute)
|
|
return rt
|
|
}
|
|
|
|
// resolve does the routing exactly as step 5 of runTurn does it: an elliptical
|
|
// follow-up is answered from the previous turn, everything else goes to the
|
|
// router. One copy of that, so the pre-route the clarify resolver reads and the
|
|
// decision the pipeline acts on cannot drift apart.
|
|
func (r *turnRoute) resolve(ctx context.Context) (router.Decision, bool, *dialogue.Session, error) {
|
|
r.once.Do(func() {
|
|
if r.h.dialogueSessions != nil {
|
|
r.prev = r.h.dialogueSessions.Get(dialogueIDOf(ctx), r.now)
|
|
}
|
|
if dec, cont := continuationDecision(r.prev, r.text, r.now); cont {
|
|
log.Printf("voice: continuation of %s from the previous turn", dec.Intent)
|
|
r.dec, r.cont = dec, true
|
|
return
|
|
}
|
|
if r.h.router == nil {
|
|
r.err = router.ErrNoIntents
|
|
return
|
|
}
|
|
r.dec, r.err = r.h.router.Route(ctx, r.text, r.now)
|
|
})
|
|
return r.dec, r.cont, r.prev, r.err
|
|
}
|
|
|
|
// routeForRole gives the role classifier the turn's routed decision. The second
|
|
// return is false when there is no usable decision — no router wired, or the
|
|
// route failed — and the classifier falls back to its offline tests then. A
|
|
// turn must never break on the model, so the error is logged and swallowed
|
|
// here; step 5 reads the same memo and reports it the way it always has.
|
|
func (h *reactiveHandler) routeForRole(ctx context.Context, text string) (router.Decision, bool) {
|
|
rt := turnRouteFrom(ctx)
|
|
if rt == nil {
|
|
rt = h.newTurnRoute(text, h.now())
|
|
}
|
|
dec, _, _, err := rt.resolve(ctx)
|
|
if err != nil {
|
|
log.Printf("voice: role — no route to classify against (%v), falling back to the offline tests", err)
|
|
return router.Decision{}, false
|
|
}
|
|
return dec, true
|
|
}
|
|
|
|
// needsRoute reports whether classifying this utterance's role is worth a
|
|
// route. It is not: an utterance with no content of its own carries no request
|
|
// of its own, so the classifier reaches the same answer without the model. A
|
|
// call-off is the same — it is read off a closed lexicon and nothing else.
|
|
//
|
|
// This is a fast path to the SAME answer and must stay one. If it ever needs a
|
|
// rule the classifier does not have, it has become a second decision procedure
|
|
// and it is the thing V-560 deleted.
|
|
//
|
|
// A question shape is the exception and V-577 is why (measured 2026-08-06).
|
|
// "что у меня сегодня?" is an interrogative, a preposition, a particle and a day
|
|
// word, so every token of it is frame and it left no content of its own. The
|
|
// fast path called it an answer, the parked reminder read "сегодня" as its time,
|
|
// and the question he asked was never answered. Asked alone the same sentence
|
|
// routes to query at stage 0, so the route knew and was never consulted.
|
|
func needsRoute(text string) bool {
|
|
if isCancel(text) {
|
|
return false
|
|
}
|
|
if _, ok := parseReminderCancelRequest(text); ok {
|
|
return false
|
|
}
|
|
return len(ownContent(text)) > 0 || router.IsQuestionShaped(text)
|
|
}
|