Files
claude 85a3397bf4 Cancel a reminder by voice, and honour a refusal (V-719)
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>
2026-08-15 17:19:25 +04:00

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)
}