87a3b163e7
First behavior-preserving slice of the Maven redesign. Establishes explicit ingress/routing boundaries and enough observability to refactor later without changing current routing, action, clarification, or execution semantics. Types introduced: - NormalizedInput (internal/router/source.go): Text + InputSource, the typed ingress boundary replacing raw string at the turn entry. - InputSource (internal/router/source.go): channel provenance enum (tap:voice, tap:text). Reuses the existing turnSource distinction. - RouteProducer (internal/router/intent.go): which cascade stage produced the decision (grammar, heads, llm, classifier). Changes: - Decision carries a Producer RouteProducer field, set at each cascade stage (grammar, heads, LLM, classifier). - turnRoute carries NormalizedInput instead of bare text string. - runTurn takes NormalizedInput instead of (text, src). - decision.Record carries InputSource and RouteProducer for observability; RoutingTrace persists route_producer (migration #27). - turnSource is now a type alias for router.InputSource. Behavior preserved: - Stage-0 grammars unchanged: same order, same matching, same confidence. - Cascade fallthrough order unchanged (grammar → heads → llm → classifier). - Clarification behavior unchanged. - Action dispatch unchanged. - No new linguistic normalization.
129 lines
4.9 KiB
Go
129 lines
4.9 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
|
|
input router.NormalizedInput
|
|
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(input router.NormalizedInput, now time.Time) *turnRoute {
|
|
return &turnRoute{h: h, input: input, 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.input.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.input.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(router.NormalizedInput{Text: text, Source: sourceText}, 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)
|
|
}
|