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.
441 lines
17 KiB
Go
441 lines
17 KiB
Go
package main
|
||
|
||
import (
|
||
"context"
|
||
"strings"
|
||
"testing"
|
||
"time"
|
||
|
||
"github.com/kami/maven/internal/dialogue"
|
||
"github.com/kami/maven/internal/router"
|
||
)
|
||
|
||
// TestOwnContentSeparatesAValueFromAQuestion pins the test the whole role
|
||
// classifier rests on: after the frame, the numbers and the closed time sets
|
||
// come out, does anything of his own remain? A hedged time leaves nothing. A
|
||
// question about the weather leaves the weather.
|
||
func TestOwnContentSeparatesAValueFromAQuestion(t *testing.T) {
|
||
cases := []struct {
|
||
text string
|
||
own bool
|
||
}{
|
||
{"в 11:00", false},
|
||
{"в семь вечера", false},
|
||
{"нет, в 15:00", false},
|
||
{"а что если в 11:00", false},
|
||
{"на 9", false},
|
||
{"а, да, прости — на 9", false},
|
||
{"завтра", false},
|
||
{"в половине восьмого", false},
|
||
{"какая сейчас погода в Риме?", true},
|
||
{"кто изобрёл телефон", true},
|
||
{"напомни в 11:00", true},
|
||
{"позвонить маме", true},
|
||
{"запиши что я пил воду", true},
|
||
}
|
||
for _, tc := range cases {
|
||
if got := len(ownContent(tc.text)) > 0; got != tc.own {
|
||
t.Errorf("ownContent(%q) = %v, want own content = %v", tc.text, ownContent(tc.text), tc.own)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestCancelIsTheWholeUtterance — a call-off calls the request off, and a
|
||
// sentence that merely contains the word does not.
|
||
func TestCancelIsTheWholeUtterance(t *testing.T) {
|
||
for _, yes := range []string{"отмена", "забудь", "неважно", "проехали", "cancel", "ой, отмена"} {
|
||
if !isCancel(yes) {
|
||
t.Errorf("isCancel(%q) = false, want true", yes)
|
||
}
|
||
}
|
||
for _, no := range []string{"забудь купить молоко", "в 11:00", "позвонить маме", ""} {
|
||
if isCancel(no) {
|
||
t.Errorf("isCancel(%q) = true, want false", no)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestTurnRoleReadsTheRoutedDecision — the inversion itself. The same utterance
|
||
// gets a different role depending on what the router made of it, which is the
|
||
// evidence the old guard never had.
|
||
func TestTurnRoleReadsTheRoutedDecision(t *testing.T) {
|
||
q := &dialogue.PendingQuestion{
|
||
Intent: dialogue.Intent(router.IntentReminder),
|
||
Missing: []dialogue.Slot{dialogue.SlotTime},
|
||
}
|
||
dec := func(in router.Intent, s router.Slots) router.Decision {
|
||
return router.Decision{Intent: in, Slots: s}
|
||
}
|
||
cases := []struct {
|
||
name string
|
||
text string
|
||
routed router.Decision
|
||
ok bool
|
||
answer dialogue.Slots
|
||
want turnRole
|
||
}{
|
||
{
|
||
// The measured defect. The extractor finds "сейчас" and would have
|
||
// closed the gap with it; the route says this is a question of its
|
||
// own, and the question wins.
|
||
name: "a world question mid-flow is a side query",
|
||
text: "какая сейчас погода в Риме?",
|
||
routed: dec(router.IntentQuery, router.Slots{Text: "какая сейчас погода в Риме?"}),
|
||
ok: true,
|
||
answer: dialogue.Slots{HasTime: true, Time: time.Now()},
|
||
want: roleSideQuery,
|
||
},
|
||
{
|
||
name: "a hedged time is an answer even routed as a query",
|
||
text: "а что если в 11:00",
|
||
routed: dec(router.IntentQuery, router.Slots{Text: "а что если в 11:00"}),
|
||
ok: true,
|
||
answer: dialogue.Slots{HasTime: true, Time: time.Now()},
|
||
want: roleAnswer,
|
||
},
|
||
{
|
||
name: "a fresh reminder is a new request",
|
||
text: "напомни завтра позвонить маме",
|
||
routed: dec(router.IntentReminder, router.Slots{Text: "позвонить маме", HasTime: true}),
|
||
ok: true,
|
||
want: roleNewRequest,
|
||
},
|
||
{
|
||
name: "a capture is a new request",
|
||
text: "запиши что я пил воду",
|
||
routed: dec(router.IntentFact, router.Slots{Key: "water", HasKey: true}),
|
||
ok: true,
|
||
want: roleNewRequest,
|
||
},
|
||
{
|
||
name: "an act that resolved to a capability is a new request",
|
||
text: "выключи свет в спальне",
|
||
routed: dec(router.IntentAct, router.Slots{Fn: "light_off", HasFn: true}),
|
||
ok: true,
|
||
want: roleNewRequest,
|
||
},
|
||
{
|
||
// She could not route it. An utterance she did not understand does
|
||
// not outrank the question in front of it.
|
||
name: "a clarify decision is not a request of its own",
|
||
text: "выключи свет",
|
||
routed: router.Decision{Intent: router.IntentAct, Slots: router.Slots{Fn: "light_off", HasFn: true}, Clarify: true},
|
||
ok: true,
|
||
want: roleAnswer,
|
||
},
|
||
{
|
||
// V-577 shape 1. Every token is frame, so the content gate called
|
||
// this an answer and the reminder took "сегодня" for its time. It
|
||
// fills nothing she asked about, so the route decides, and the route
|
||
// says the calendar answers it.
|
||
name: "an agenda question of pure frame words is a side query",
|
||
text: "что у меня сегодня?",
|
||
routed: dec(router.IntentQuery, router.Slots{}),
|
||
ok: true,
|
||
want: roleSideQuery,
|
||
},
|
||
{
|
||
// V-577 shape 2. Neither a slot value nor a request nor a cancel.
|
||
// It was dropped in silence; it is an aside, and an aside is stored
|
||
// and re-asked.
|
||
name: "a fact stated mid-flow is an aside",
|
||
text: "у меня новый ноутбук",
|
||
routed: dec(router.IntentNote, router.Slots{Text: "у меня новый ноутбук"}),
|
||
ok: true,
|
||
want: roleAside,
|
||
},
|
||
{
|
||
// A route she is not sure of is not evidence that he stated
|
||
// anything, and "позвонить маме" is the answer to the other half of
|
||
// a reminder.
|
||
name: "an unsure note is not an aside",
|
||
text: "позвонить маме",
|
||
routed: router.Decision{Intent: router.IntentNote, Clarify: true},
|
||
ok: true,
|
||
want: roleAnswer,
|
||
},
|
||
{
|
||
name: "a bare noun that answers nothing is still an answer",
|
||
text: "ага",
|
||
ok: false,
|
||
want: roleAnswer,
|
||
},
|
||
{
|
||
name: "no route to read falls back to the shape",
|
||
text: "кто изобрёл телефон",
|
||
ok: false,
|
||
want: roleSideQuery,
|
||
},
|
||
{
|
||
name: "a call-off needs no route at all",
|
||
text: "отмена",
|
||
ok: false,
|
||
want: roleCancel,
|
||
},
|
||
}
|
||
for _, tc := range cases {
|
||
if got := classifyTurnRole(q, tc.text, tc.answer, tc.routed, tc.ok); got != tc.want {
|
||
t.Errorf("%s: classifyTurnRole(%q) = %s, want %s", tc.name, tc.text, got, tc.want)
|
||
}
|
||
}
|
||
}
|
||
|
||
// TestTurnRoleNamesACorrection — the answer overwrites a slot she was not
|
||
// asking about. Handled like an answer, named as what it is.
|
||
func TestTurnRoleNamesACorrection(t *testing.T) {
|
||
nine := time.Date(2026, 8, 6, 9, 0, 0, 0, time.UTC)
|
||
q := &dialogue.PendingQuestion{
|
||
Intent: dialogue.Intent(router.IntentReminder),
|
||
Missing: []dialogue.Slot{dialogue.SlotText},
|
||
Slots: dialogue.Slots{HasTime: true, Time: nine.Add(2 * time.Hour)},
|
||
}
|
||
got := classifyTurnRole(q, "нет, на 9", dialogue.Slots{HasTime: true, Time: nine}, router.Decision{}, false)
|
||
if got != roleCorrection {
|
||
t.Fatalf("role = %s, want %s", got, roleCorrection)
|
||
}
|
||
}
|
||
|
||
// TestRomeIsAnsweredAndTheReminderIsNotInvented — the measured failure of
|
||
// 2026-08-05, end to end through the real cascade. "напомни позвонить маме"
|
||
// parks the time question; the weather question that follows must not become
|
||
// its answer and must not create a reminder for a time nobody asked for.
|
||
//
|
||
// V-560 got that far by DROPPING the parked request and saying so, and the
|
||
// owner rejected the notice on sight: he did not ask to lose the reminder. So
|
||
// the contract here is V-561's — the flow is suspended, this turn's reply ends
|
||
// with the question coming back, and nothing says anything was let go.
|
||
func TestRomeIsAnsweredAndTheReminderIsNotInvented(t *testing.T) {
|
||
ctx := context.Background()
|
||
h, st := newRoutingClarifyHandler(t)
|
||
|
||
if reply := h.handleText(ctx, "web", "напомни позвонить маме"); !strings.Contains(reply, "?") {
|
||
t.Fatalf("expected the time question, got %q", reply)
|
||
}
|
||
reply := h.handleText(ctx, "web", "какая сейчас погода в Риме?")
|
||
if strings.Contains(reply, "напомню") {
|
||
t.Fatalf("the question was eaten as the reminder's time again: %q", reply)
|
||
}
|
||
if strings.Contains(reply, clarifyDropped) {
|
||
t.Fatalf("a side query suspends the flow; nothing was dropped, so nothing may say so: %q", reply)
|
||
}
|
||
resumed, _ := clarifyResumedFor(dialogue.SlotTime)
|
||
if !strings.HasSuffix(reply, resumed) {
|
||
t.Fatalf("the reply must end with the resumed question %q, got %q", resumed, reply)
|
||
}
|
||
if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 0 {
|
||
t.Fatalf("a reminder was invented for a time nobody asked for: %v err=%v", reminders, err)
|
||
}
|
||
// Still parked, and still on its first attempt: he answered the side query,
|
||
// not this question, so no retry may have been spent on it.
|
||
q := h.clarifyStore.Get(dialogueIDFor(sourceText, "web"), h.now())
|
||
if q == nil {
|
||
t.Fatal("the parked question was dropped instead of suspended")
|
||
}
|
||
if q.Attempts != 1 {
|
||
t.Fatalf("the side query spent a clarify attempt: attempts = %d, want 1", q.Attempts)
|
||
}
|
||
if !strings.Contains(q.Utterance, "маме") {
|
||
t.Fatalf("the suspended request lost what it was about: %q", q.Utterance)
|
||
}
|
||
}
|
||
|
||
// TestClarifyCancelEndsTheExchange — "отмена" while she is waiting calls the
|
||
// half-built request off, out loud, and creates nothing.
|
||
func TestClarifyCancelEndsTheExchange(t *testing.T) {
|
||
ctx := context.Background()
|
||
h, st := newRoutingClarifyHandler(t)
|
||
|
||
if reply := h.handleText(ctx, "web", "напомни позвонить маме"); !strings.Contains(reply, "?") {
|
||
t.Fatalf("expected the time question, got %q", reply)
|
||
}
|
||
if reply := h.handleText(ctx, "web", "отмена"); reply != clarifyCancelled {
|
||
t.Fatalf("reply = %q, want %q", reply, clarifyCancelled)
|
||
}
|
||
if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 0 {
|
||
t.Fatalf("a cancelled request still landed: %v err=%v", reminders, err)
|
||
}
|
||
if h.clarifyStore.Get(dialogueIDFor(sourceText, "web"), h.now()) != nil {
|
||
t.Fatal("a cancelled exchange must leave nothing parked")
|
||
}
|
||
}
|
||
|
||
// TestTheTurnIsRoutedOnce — the cost bound. A turn with a question parked pays
|
||
// for one extra route and not two: the clarify resolver and the pipeline read
|
||
// the same memo.
|
||
func TestTheTurnIsRoutedOnce(t *testing.T) {
|
||
h, _ := newRoutingClarifyHandler(t)
|
||
rt := h.newTurnRoute(router.NormalizedInput{Text: "какая сейчас погода в Риме?", Source: sourceText}, h.now())
|
||
ctx := withTurnRoute(withDialogueID(context.Background(), voiceDialogueID), rt)
|
||
|
||
first, ok := h.routeForRole(ctx, rt.input.Text)
|
||
if !ok {
|
||
t.Fatal("the cascade must produce a decision to classify against")
|
||
}
|
||
second, _, _, err := rt.resolve(ctx)
|
||
if err != nil {
|
||
t.Fatalf("resolve: %v", err)
|
||
}
|
||
if second.Intent != first.Intent || second.Utterance != first.Utterance {
|
||
t.Fatalf("the pipeline routed again and got something else: %+v vs %+v", second, first)
|
||
}
|
||
}
|
||
|
||
// TestASuspendedQuestionDoesNotRideForever — V-654, the measured failure of
|
||
// 2026-08-07 (docs/evals/2026-08-07-week-of-usage-transcript.md, t=51 to t=58).
|
||
//
|
||
// A side query suspends the parked question, spends no attempt and restarts the
|
||
// TTL. Nothing else bounded it, so one unfilled time slot came back on the end
|
||
// of six consecutive unrelated replies and stopped only when a seventh turn
|
||
// happened to read as a failed answer. Three step-asides, then she lets it go
|
||
// and says so.
|
||
func TestASuspendedQuestionDoesNotRideForever(t *testing.T) {
|
||
ctx := context.Background()
|
||
h, st := newRoutingClarifyHandler(t)
|
||
id := dialogueIDFor(sourceText, "web")
|
||
resumed, _ := clarifyResumedFor(dialogue.SlotTime)
|
||
|
||
if reply := h.handleText(ctx, "web", "напомни позвонить маме"); !strings.Contains(reply, "?") {
|
||
t.Fatalf("expected the time question, got %q", reply)
|
||
}
|
||
|
||
// Three questions of his own. Each one is answered as itself and each one
|
||
// brings the open question back, exactly as V-561 asks.
|
||
asides := []string{
|
||
"о чём мы вчера говорили?",
|
||
"какие у меня напоминания?",
|
||
"сколько времени?",
|
||
}
|
||
for i, text := range asides {
|
||
reply := h.handleText(ctx, "web", text)
|
||
if !strings.HasSuffix(reply, resumed) {
|
||
t.Fatalf("side query %d: the question must come back, got %q", i+1, reply)
|
||
}
|
||
if strings.Contains(reply, clarifyDropped) {
|
||
t.Fatalf("side query %d: nothing was let go yet, so nothing may say so: %q", i+1, reply)
|
||
}
|
||
q := h.clarifyStore.Get(id, h.now())
|
||
if q == nil {
|
||
t.Fatalf("side query %d: the question was dropped early", i+1)
|
||
}
|
||
if q.Attempts != 1 {
|
||
t.Fatalf("side query %d: a step-aside spent an attempt: %d", i+1, q.Attempts)
|
||
}
|
||
if q.Suspends != i+1 {
|
||
t.Fatalf("side query %d: suspends = %d, want %d", i+1, q.Suspends, i+1)
|
||
}
|
||
}
|
||
|
||
// The fourth. She has stepped aside as often as she is willing to, so the
|
||
// request goes — out loud, and without the question on the tail.
|
||
reply := h.handleText(ctx, "web", "что у меня сегодня?")
|
||
if !strings.Contains(reply, clarifyDropped) {
|
||
t.Fatalf("the request was let go in silence: %q", reply)
|
||
}
|
||
if strings.HasSuffix(reply, resumed) {
|
||
t.Fatalf("a question she has let go must not be asked again: %q", reply)
|
||
}
|
||
if h.clarifyStore.Get(id, h.now()) != nil {
|
||
t.Fatal("the question must be gone once she has said she let it go")
|
||
}
|
||
if reminders, err := st.DueReminders(ctx, h.now().Add(48*time.Hour)); err != nil || len(reminders) != 0 {
|
||
t.Fatalf("a reminder was invented for a time nobody gave: %v err=%v", reminders, err)
|
||
}
|
||
}
|
||
|
||
// TestAnAnsweredGapResetsTheSuspendBudget — the counter measures CONSECUTIVE
|
||
// step-asides. He filled a gap, so the run is broken and the next question
|
||
// starts with its full allowance: a long exchange he is engaged with must not
|
||
// run out of patience on his behalf.
|
||
func TestAnAnsweredGapResetsTheSuspendBudget(t *testing.T) {
|
||
ctx := context.Background()
|
||
h, _ := newRoutingClarifyHandler(t)
|
||
id := dialogueIDFor(sourceText, "web")
|
||
|
||
// A bare "напомни" is missing both halves, so answering the subject re-parks
|
||
// the request with a question about the time.
|
||
if reply := h.handleText(ctx, "web", "напомни"); !strings.Contains(reply, "?") {
|
||
t.Fatalf("expected a question, got %q", reply)
|
||
}
|
||
if reply := h.handleText(ctx, "web", "какие у меня напоминания?"); reply == "" {
|
||
t.Fatal("the side query must be answered as itself")
|
||
}
|
||
if q := h.clarifyStore.Get(id, h.now()); q == nil || q.Suspends != 1 {
|
||
t.Fatalf("the side query was not counted: %+v", q)
|
||
}
|
||
if reply := h.handleText(ctx, "web", "позвонить маме"); reply == "" {
|
||
t.Fatal("the answer must be consumed")
|
||
}
|
||
q := h.clarifyStore.Get(id, h.now())
|
||
if q == nil {
|
||
t.Fatal("a reminder still needs its time, so a question must be parked")
|
||
}
|
||
if q.Suspends != 0 {
|
||
t.Fatalf("answering a gap must reset the suspend budget: suspends = %d", q.Suspends)
|
||
}
|
||
// The ride it already took is carried across the re-park (V-663). Resetting
|
||
// both counters here is what let one question ride twenty-six replies.
|
||
if q.Rides != 1 {
|
||
t.Fatalf("the aside it already took was forgotten: rides = %d", q.Rides)
|
||
}
|
||
}
|
||
|
||
// TestTwoBoundsCannotRearmEachOther — V-663.
|
||
//
|
||
// MaxSuspends landed and the measurement did not move: twenty-six of 140 turns
|
||
// carried a tail before it and twenty-six after. This is the shape it misses,
|
||
// taken from the 2026-08-08 run, where one question rode turns 7 to 13.
|
||
//
|
||
// An aside spends no attempt, so MaxAttempts never reaches it. A turn that
|
||
// reads as a failed answer zeroes Suspends, so MaxSuspends never reaches the
|
||
// asides either. Alternating the two rearms each bound with the other's
|
||
// traffic. Rides counts both kinds and is never reset, so it is what ends this.
|
||
func TestTwoBoundsCannotRearmEachOther(t *testing.T) {
|
||
ctx := context.Background()
|
||
h, _ := newRoutingClarifyHandler(t)
|
||
id := dialogueIDFor(sourceText, "web")
|
||
resumed, _ := clarifyResumedFor(dialogue.SlotTime)
|
||
|
||
if reply := h.handleText(ctx, "web", "напомни позвонить маме"); !strings.Contains(reply, "?") {
|
||
t.Fatalf("expected the time question, got %q", reply)
|
||
}
|
||
|
||
// Two asides. Each one rides and neither spends an attempt.
|
||
for i := 0; i < 2; i++ {
|
||
reply := h.handleText(ctx, "web", "какие у меня напоминания?")
|
||
if !strings.HasSuffix(reply, resumed) {
|
||
t.Fatalf("aside %d: the question must come back, got %q", i+1, reply)
|
||
}
|
||
}
|
||
q := h.clarifyStore.Get(id, h.now())
|
||
if q == nil || q.Rides != 2 || q.Suspends != 2 {
|
||
t.Fatalf("after two asides: %+v", q)
|
||
}
|
||
|
||
// A pleasantry. It used to read as a failed answer, so she re-asked the
|
||
// question at a man saying thank you and spent an attempt doing it. Now it
|
||
// is an aside: answered as itself, question on the tail, one more ride.
|
||
reply := h.handleText(ctx, "web", "спасибо")
|
||
if !strings.HasSuffix(reply, resumed) {
|
||
t.Fatalf("a pleasantry lost the parked question: %q", reply)
|
||
}
|
||
q = h.clarifyStore.Get(id, h.now())
|
||
if q == nil || q.Attempts != 1 {
|
||
t.Fatalf("a pleasantry spent an attempt: %+v", q)
|
||
}
|
||
if q.Rides != 3 {
|
||
t.Fatalf("a pleasantry rode free: %+v", q)
|
||
}
|
||
|
||
// One more ride of any kind and the request goes, out loud.
|
||
reply = h.handleText(ctx, "web", "какие у меня напоминания?")
|
||
if !strings.Contains(reply, clarifyDropped) {
|
||
t.Fatalf("the question rode four asides and was let go in silence: %q", reply)
|
||
}
|
||
if strings.HasSuffix(reply, resumed) {
|
||
t.Fatalf("a question she has let go must not be asked again: %q", reply)
|
||
}
|
||
if h.clarifyStore.Get(id, h.now()) != nil {
|
||
t.Fatal("the question must be gone once she has said she let it go")
|
||
}
|
||
}
|