Files
Maven/cmd/mavend/turnrole_test.go
T
claude 87a3b163e7 router: introduce typed ingress boundary and route producer observability
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.
2026-09-05 20:16:40 +04:00

441 lines
17 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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")
}
}