diff --git a/internal/router/agenda_test.go b/internal/router/agenda_test.go index 19ad16f..18bbcfb 100644 --- a/internal/router/agenda_test.go +++ b/internal/router/agenda_test.go @@ -28,7 +28,7 @@ func TestAgendaQuestionsRouteToQuery(t *testing.T) { "какие у меня встречи завтра", "покажи расписание на среду", } { - d, err := r.Route(context.Background(), u, refNow()) + d, err := r.Route(context.Background(), NormalizedInput{Text: u}, refNow()) if err != nil { t.Fatalf("route(%q): %v", u, err) } @@ -48,7 +48,7 @@ func TestAgendaGrammarsLeaveTheClockAlone(t *testing.T) { "который час", "сколько сейчас времени", } { - d, err := r.Route(context.Background(), u, refNow()) + d, err := r.Route(context.Background(), NormalizedInput{Text: u}, refNow()) if err != nil { t.Fatalf("route(%q): %v", u, err) } @@ -66,7 +66,7 @@ func TestAgendaGrammarSparesStatements(t *testing.T) { "у меня кончилась вода", "напомни мне завтра позвонить маме", } { - d, err := r.Route(context.Background(), u, refNow()) + d, err := r.Route(context.Background(), NormalizedInput{Text: u}, refNow()) if err != nil { t.Fatalf("route(%q): %v", u, err) } @@ -89,7 +89,7 @@ func TestNarrativeGrammarsRouteToQuery(t *testing.T) { "объясни как работает дизель", "опиши Ватерлоо", } { - d, err := r.Route(context.Background(), u, refNow()) + d, err := r.Route(context.Background(), NormalizedInput{Text: u}, refNow()) if err != nil { t.Fatalf("%q: %v", u, err) } @@ -114,7 +114,7 @@ func TestAgendaCoversOtherDaysAndNamedEvents(t *testing.T) { "во сколько созвон", "когда будет совещание", } { - d, err := r.Route(context.Background(), u, refNow()) + d, err := r.Route(context.Background(), NormalizedInput{Text: u}, refNow()) if err != nil { t.Fatalf("route(%q): %v", u, err) } @@ -129,7 +129,7 @@ func TestAgendaCoversOtherDaysAndNamedEvents(t *testing.T) { func TestNarrativeGrammarLeavesCapturesAlone(t *testing.T) { r := agendaRouter(t) r.grammars = append(r.grammars, NarrativeQueryGrammars()...) - d, err := r.Route(context.Background(), "расскажи и запиши что я пил воду", refNow()) + d, err := r.Route(context.Background(), NormalizedInput{Text: "расскажи и запиши что я пил воду"}, refNow()) if err != nil { t.Fatal(err) } @@ -147,7 +147,7 @@ func TestAgendaGrammarsLeaveTheWorldAlone(t *testing.T) { "когда была битва при ватерлоо", "когда изобрели телефон", } { - d, err := r.Route(context.Background(), u, refNow()) + d, err := r.Route(context.Background(), NormalizedInput{Text: u}, refNow()) if err != nil { t.Fatalf("route(%q): %v", u, err) } diff --git a/internal/router/boundary_test.go b/internal/router/boundary_test.go index c46560f..59dfade 100644 --- a/internal/router/boundary_test.go +++ b/internal/router/boundary_test.go @@ -58,7 +58,7 @@ func TestStage0SetsGrammarProducer(t *testing.T) { r := buildTestRouter(t) now := time.Now() // "напомни позвонить маме завтра" — a reminder grammar match. - d, err := r.Route(context.Background(), "напомни позвонить маме завтра", now) + d, err := r.Route(context.Background(), NormalizedInput{Text: "напомни позвонить маме завтра"}, now) if err != nil { t.Fatalf("Route: %v", err) } @@ -76,7 +76,7 @@ func TestClassifierSetsProducer(t *testing.T) { r := buildTestRouterNoModel(t) now := time.Now() // "как дела" — a free-form chat utterance that no grammar matches. - d, err := r.Route(context.Background(), "как дела", now) + d, err := r.Route(context.Background(), NormalizedInput{Text: "как дела"}, now) if err != nil { t.Fatalf("Route: %v", err) } @@ -93,7 +93,7 @@ func TestClarifyProducerIsClassifier(t *testing.T) { now := time.Now() // "привет как дела что нового" — a long ambiguous utterance that no // grammar matches and the classifier scores below the clarify threshold. - d, err := r.Route(context.Background(), "привет как дела что нового", now) + d, err := r.Route(context.Background(), NormalizedInput{Text: "привет как дела что нового"}, now) if err != nil { t.Fatalf("Route: %v", err) } @@ -119,7 +119,7 @@ func TestStage0ProducerOnEveryGrammar(t *testing.T) { {"который час", "system-time"}, } for _, u := range utterances { - d, err := r.Route(context.Background(), u.text, now) + d, err := r.Route(context.Background(), NormalizedInput{Text: u.text}, now) if err != nil { t.Errorf("%s: Route: %v", u.name, err) continue diff --git a/internal/router/capability_test.go b/internal/router/capability_test.go index 5d30a5c..23c803e 100644 --- a/internal/router/capability_test.go +++ b/internal/router/capability_test.go @@ -321,7 +321,7 @@ func TestRouterRoute_CapabilitySelectionPopulated(t *testing.T) { r := newTestRouter(t, 0.3) // Stage-0 grammar path: wakeword-act. - d, err := r.Route(t.Context(), "maven restart nginx", refNow()) + d, err := r.Route(t.Context(), NormalizedInput{Text: "maven restart nginx"}, refNow()) if err != nil { t.Fatal(err) } @@ -336,7 +336,7 @@ func TestRouterRoute_CapabilitySelectionPopulated(t *testing.T) { } // Classifier path: raw utterance matches allowlist. - d, err = r.Route(t.Context(), "restart nginx", refNow()) + d, err = r.Route(t.Context(), NormalizedInput{Text: "restart nginx"}, refNow()) if err != nil { t.Fatal(err) } @@ -365,7 +365,7 @@ func TestRouterRoute_ClassifierUnresolvedAct(t *testing.T) { Threshold: 0.3, }) - d, err := r.Route(t.Context(), "deploy the thing", refNow()) + d, err := r.Route(t.Context(), NormalizedInput{Text: "deploy the thing"}, refNow()) if err != nil { t.Fatal(err) } diff --git a/internal/router/claim_test.go b/internal/router/claim_test.go index e9dd784..467c46e 100644 --- a/internal/router/claim_test.go +++ b/internal/router/claim_test.go @@ -145,7 +145,7 @@ func TestClaimOfLeavesTheDecisionAlone(t *testing.T) { Extractor: Extractor{}, Threshold: 0.55, }) - before, err := r.Route(context.Background(), "напомни полить цветы", time.Now()) + before, err := r.Route(context.Background(), NormalizedInput{Text: "напомни полить цветы"}, time.Now()) if err != nil { t.Fatalf("Route: %v", err) } diff --git a/internal/router/eval/claims_test.go b/internal/router/eval/claims_test.go index 8f170ae..653e707 100644 --- a/internal/router/eval/claims_test.go +++ b/internal/router/eval/claims_test.go @@ -123,7 +123,7 @@ func reportConfidences(t *testing.T, name string, emb router.Embedder) { byMargin := map[string]*bucket{} var cosines, margins []float64 for _, c := range f.Cases { - d, err := r.Route(context.Background(), c.Utterance, now) + d, err := r.Route(context.Background(), router.NormalizedInput{Text: c.Utterance}, now) if err != nil { t.Fatalf("%s: %v", c.ID, err) } diff --git a/internal/router/eval/resolution_test.go b/internal/router/eval/resolution_test.go index 885ed29..ffcad67 100644 --- a/internal/router/eval/resolution_test.go +++ b/internal/router/eval/resolution_test.go @@ -78,7 +78,7 @@ func TestResolutionMethodMatrix(t *testing.T) { resolved := 0 counts := [5]int{} // fixed, matcher, raw, llm, fallback for _, c := range rf.Cases { - d, err := r.Route(ctx, c.Utterance, rfNow) + d, err := r.Route(ctx, router.NormalizedInput{Text: c.Utterance}, rfNow) if err != nil || d.Intent != router.IntentAct { continue } @@ -132,7 +132,7 @@ func TestResolutionMethodMatrix(t *testing.T) { resolved := 0 counts := [5]int{} for _, c := range ef.Cases { - d, err := r2.Route(ctx, c.Utterance, efNow) + d, err := r2.Route(ctx, router.NormalizedInput{Text: c.Utterance}, efNow) if err != nil || d.Intent != router.IntentAct { continue } @@ -189,7 +189,7 @@ func TestShadowMatcherComparison(t *testing.T) { same, different, routeOnly, matcherOnly, missBoth, total := 0, 0, 0, 0, 0, 0 for _, c := range f.Cases { - d, err := r.Route(ctx, c.Utterance, now) + d, err := r.Route(ctx, router.NormalizedInput{Text: c.Utterance}, now) if err != nil || d.Intent != router.IntentAct { continue } diff --git a/internal/router/fastpath_test.go b/internal/router/fastpath_test.go index 17f3a27..e00c1e4 100644 --- a/internal/router/fastpath_test.go +++ b/internal/router/fastpath_test.go @@ -154,7 +154,7 @@ func TestRouteIdenticalBeforeAfter(t *testing.T) { } for _, u := range utterances { - d, err := r.Route(context.Background(), u.text, now) + d, err := r.Route(context.Background(), NormalizedInput{Text: u.text}, now) if err != nil { t.Errorf("%s: Route: %v", u.text, err) continue @@ -214,3 +214,137 @@ func TestTryFastPathFillMatchedSlots(t *testing.T) { t.Errorf("Time = %s, want %s", got, want) } } + +// TestNormalizedInputReachesRouteIntact pins that the NormalizedInput +// constructed at ingress arrives at Router.Route without reconstruction. +func TestNormalizedInputReachesRouteIntact(t *testing.T) { + r := newTestRouter(t, 0.0) + r.grammars = append(r.grammars, SystemTimeDateGrammars()...) + now := refNow() + + input := NormalizedInput{ + Text: "который час", + MatchText: "который час", + Source: InputSourceText, + } + d, err := r.Route(context.Background(), input, now) + if err != nil { + t.Fatalf("Route: %v", err) + } + if d.Intent != IntentSystem { + t.Errorf("Intent = %q, want %q", d.Intent, IntentSystem) + } + if d.Utterance != input.Text { + t.Errorf("Utterance = %q, want %q (Decision.Utterance must equal input.Text)", d.Utterance, input.Text) + } +} + +// TestTryFastPathReceivesMatchText pins that TryFastPath receives the +// same NormalizedInput that Route was given (including MatchText). +// Today no grammar reads MatchText, so the result must be identical +// whether MatchText is set or empty — this freezes the dark-data contract. +func TestTryFastPathReceivesMatchText(t *testing.T) { + r := newTestRouter(t, 0.0) + r.grammars = append(r.grammars, SystemTimeDateGrammars()...) + now := refNow() + + without := NormalizedInput{Text: "который час"} + with := NormalizedInput{Text: "который час", MatchText: "который час"} + + fastWithout, err := r.TryFastPath(context.Background(), without, now) + if err != nil { + t.Fatalf("TryFastPath (without): %v", err) + } + fastWith, err := r.TryFastPath(context.Background(), with, now) + if err != nil { + t.Fatalf("TryFastPath (with): %v", err) + } + if fastWithout.Matched != fastWith.Matched { + t.Errorf("Matched: without=%v, with=%v", fastWithout.Matched, fastWith.Matched) + } + if fastWithout.Matched && fastWith.Matched { + if fastWithout.Decision.Intent != fastWith.Decision.Intent { + t.Errorf("Intent: without=%q, with=%q", fastWithout.Decision.Intent, fastWith.Decision.Intent) + } + if fastWithout.Decision.Confidence != fastWith.Decision.Confidence { + t.Errorf("Confidence: without=%f, with=%f", fastWithout.Decision.Confidence, fastWith.Decision.Confidence) + } + } +} + +// TestMatchTextDoesNotChangeRouting pins the dark-data invariant: +// same Text, different MatchText → same Decision. This must hold until +// an explicit later slice opts a consumer into MatchText. +func TestMatchTextDoesNotChangeRouting(t *testing.T) { + r := newTestRouter(t, 0.0) + r.grammars = append(r.grammars, SystemTimeDateGrammars()...) + r.grammars = append(r.grammars, ReminderGrammar()) + now := refNow() + + utterances := []struct { + text string + want Intent + }{ + {"который час", IntentSystem}, + {"напомни позвонить маме завтра", IntentReminder}, + } + + for _, u := range utterances { + without := NormalizedInput{Text: u.text} + with := NormalizedInput{Text: u.text, MatchText: NormalizeMatchText(u.text)} + + dWithout, err := r.Route(context.Background(), without, now) + if err != nil { + t.Errorf("%s (without MatchText): Route: %v", u.text, err) + continue + } + dWith, err := r.Route(context.Background(), with, now) + if err != nil { + t.Errorf("%s (with MatchText): Route: %v", u.text, err) + continue + } + if dWithout.Intent != dWith.Intent { + t.Errorf("%s: Intent changed: without=%q, with=%q", u.text, dWithout.Intent, dWith.Intent) + } + if dWithout.Confidence != dWith.Confidence { + t.Errorf("%s: Confidence changed: without=%f, with=%f", u.text, dWithout.Confidence, dWith.Confidence) + } + if dWithout.Stage != dWith.Stage { + t.Errorf("%s: Stage changed: without=%d, with=%d", u.text, dWithout.Stage, dWith.Stage) + } + } +} + +// TestDecisionUtteranceEqualsInputText pins that Decision.Utterance is +// always input.Text, regardless of which cascade path was taken. +func TestDecisionUtteranceEqualsInputText(t *testing.T) { + r := newTestRouter(t, 0.0) + r.grammars = append(r.grammars, SystemTimeDateGrammars()...) + r.grammars = append(r.grammars, ReminderGrammar()) + now := refNow() + + utterances := []struct { + text string + want Intent + }{ + {"который час", IntentSystem}, + {"напомни позвонить маме завтра", IntentReminder}, + {"как дела", IntentChat}, + } + + for _, u := range utterances { + input := NormalizedInput{Text: u.text} + d, err := r.Route(context.Background(), input, now) + if err != nil { + t.Errorf("%s: Route: %v", u.text, err) + continue + } + if d.Intent != u.want { + t.Errorf("%s: Intent = %q, want %q", u.text, d.Intent, u.want) + continue + } + if d.Utterance != u.text { + t.Errorf("%s: Decision.Utterance = %q, want %q", u.text, d.Utterance, u.text) + } + } +} diff --git a/internal/router/feedgrammar_test.go b/internal/router/feedgrammar_test.go index 025b855..9cc9efb 100644 --- a/internal/router/feedgrammar_test.go +++ b/internal/router/feedgrammar_test.go @@ -25,7 +25,7 @@ func TestFeedQuestionsRouteToQuery(t *testing.T) { "расскажи что в новостных лентах", "покажи ленту", } { - d, err := r.Route(context.Background(), u, refNow()) + d, err := r.Route(context.Background(), NormalizedInput{Text: u}, refNow()) if err != nil { t.Fatalf("route(%q): %v", u, err) } @@ -44,7 +44,7 @@ func TestFeedGrammarLeavesTheGreetingAlone(t *testing.T) { "что нового?", "у меня новая лента в инстаграме", } { - d, err := r.Route(context.Background(), u, refNow()) + d, err := r.Route(context.Background(), NormalizedInput{Text: u}, refNow()) if err != nil { t.Fatalf("route(%q): %v", u, err) } diff --git a/internal/router/llmrouter_test.go b/internal/router/llmrouter_test.go index bd58d27..97e88f8 100644 --- a/internal/router/llmrouter_test.go +++ b/internal/router/llmrouter_test.go @@ -168,7 +168,7 @@ func TestRouterFallsBackWhenLLMRefuses(t *testing.T) { Threshold: 0.4, LLM: NewLLMRouter(mockLLM{out: `{"intent":"unknown"}`}), }) - d, err := r.Route(context.Background(), "напомни позвонить маме", refNow()) + d, err := r.Route(context.Background(), NormalizedInput{Text: "напомни позвонить маме"}, refNow()) if err != nil { t.Fatalf("route: %v", err) } @@ -206,7 +206,7 @@ func newLLMTestRouter(t *testing.T, out string) *Router { // reminder was dropped as "no time". func TestLLMDecisionGetsReminderTime(t *testing.T) { r := newLLMTestRouter(t, `{"intent":"reminder","text":"позвонить маме"}`) - d, err := r.Route(context.Background(), "напомни позвонить маме через 2 часа", refNow()) + d, err := r.Route(context.Background(), NormalizedInput{Text: "напомни позвонить маме через 2 часа"}, refNow()) if err != nil { t.Fatalf("route: %v", err) } @@ -225,7 +225,7 @@ func TestLLMDecisionGetsReminderTime(t *testing.T) { // daemon says it could not read the time. func TestLLMReminderWithoutTimeStaysEmpty(t *testing.T) { r := newLLMTestRouter(t, `{"intent":"reminder","text":"позвонить маме"}`) - d, err := r.Route(context.Background(), "напомни позвонить маме", refNow()) + d, err := r.Route(context.Background(), NormalizedInput{Text: "напомни позвонить маме"}, refNow()) if err != nil { t.Fatalf("route: %v", err) } @@ -237,7 +237,7 @@ func TestLLMReminderWithoutTimeStaysEmpty(t *testing.T) { // An act decision arrived with no Fn, so the tool never ran. func TestLLMDecisionGetsActFn(t *testing.T) { r := newLLMTestRouter(t, `{"intent":"act","verb":"restart nginx"}`) - d, err := r.Route(context.Background(), "слушай, restart nginx пожалуйста", refNow()) + d, err := r.Route(context.Background(), NormalizedInput{Text: "слушай, restart nginx пожалуйста"}, refNow()) if err != nil { t.Fatalf("route: %v", err) } @@ -249,7 +249,7 @@ func TestLLMDecisionGetsActFn(t *testing.T) { // The model's own slots win; extraction only fills gaps. func TestLLMSlotsWinOverExtraction(t *testing.T) { r := newLLMTestRouter(t, `{"intent":"fact","key":"hydration","value":"выпил"}`) - d, err := r.Route(context.Background(), "я выпил воду", refNow()) + d, err := r.Route(context.Background(), NormalizedInput{Text: "я выпил воду"}, refNow()) if err != nil { t.Fatalf("route: %v", err) } @@ -261,7 +261,7 @@ func TestLLMSlotsWinOverExtraction(t *testing.T) { // A fact the model left keyless still gets one from the parser. func TestLLMFactGetsKeyFromParser(t *testing.T) { r := newLLMTestRouter(t, `{"intent":"fact","text":"я выпил воду"}`) - d, err := r.Route(context.Background(), "я выпил воду", refNow()) + d, err := r.Route(context.Background(), NormalizedInput{Text: "я выпил воду"}, refNow()) if err != nil { t.Fatalf("route: %v", err) } @@ -279,7 +279,7 @@ func TestLLMFactGetsKeyFromParser(t *testing.T) { // fact/query coin flip. The gate must ask rather than guess confidently. func TestLLMRouterSingleTokenTripsClarify(t *testing.T) { r := newLLMTestRouter(t, `{"intent":"query","text":"вода"}`) - d, err := r.Route(context.Background(), "вода", refNow()) + d, err := r.Route(context.Background(), NormalizedInput{Text: "вода"}, refNow()) if err != nil { t.Fatalf("route: %v", err) } @@ -292,7 +292,7 @@ func TestLLMRouterSingleTokenTripsClarify(t *testing.T) { // whole point is not trading the confident cases away for clarify coverage. func TestLLMRouterMultiWordStaysConfident(t *testing.T) { r := newLLMTestRouter(t, `{"intent":"reminder","text":"позвонить маме"}`) - d, err := r.Route(context.Background(), "напомни позвонить маме", refNow()) + d, err := r.Route(context.Background(), NormalizedInput{Text: "напомни позвонить маме"}, refNow()) if err != nil { t.Fatalf("route: %v", err) } @@ -308,7 +308,7 @@ func TestLLMRouterMultiWordStaysConfident(t *testing.T) { // "бэкап" as a verb — that must not fire a tool blind. func TestLLMRouterActWithoutFnTripsClarify(t *testing.T) { r := newLLMTestRouter(t, `{"intent":"act","verb":"бэкап"}`) - d, err := r.Route(context.Background(), "бэкап сделай пожалуйста расписание", refNow()) + d, err := r.Route(context.Background(), NormalizedInput{Text: "бэкап сделай пожалуйста расписание"}, refNow()) if err != nil { t.Fatalf("route: %v", err) } @@ -325,7 +325,7 @@ func TestLLMRouterActWithoutFnTripsClarify(t *testing.T) { // check firing on the happy path. func TestLLMRouterActWithFnStaysConfident(t *testing.T) { r := newLLMTestRouter(t, `{"intent":"act","verb":"restart nginx"}`) - d, err := r.Route(context.Background(), "слушай, restart nginx пожалуйста", refNow()) + d, err := r.Route(context.Background(), NormalizedInput{Text: "слушай, restart nginx пожалуйста"}, refNow()) if err != nil { t.Fatalf("route: %v", err) } @@ -341,7 +341,7 @@ func TestLLMRouterActWithFnStaysConfident(t *testing.T) { // must clarify instead of silently writing under an empty/guessed key. func TestLLMRouterFactWithoutKeyTripsClarify(t *testing.T) { r := newLLMTestRouter(t, `{"intent":"fact","value":"что-то"}`) - d, err := r.Route(context.Background(), "у меня какая-то фигня случилась вот прямо только что", refNow()) + d, err := r.Route(context.Background(), NormalizedInput{Text: "у меня какая-то фигня случилась вот прямо только что"}, refNow()) if err != nil { t.Fatalf("route: %v", err) } @@ -358,7 +358,7 @@ func TestLLMRouterFactWithoutKeyTripsClarify(t *testing.T) { // through the full Router.Route path, not just the raw LLMRouter. func TestRouterLLMFactWithResolvedKeyStaysConfident(t *testing.T) { r := newLLMTestRouter(t, `{"intent":"fact","text":"я выпил воду"}`) - d, err := r.Route(context.Background(), "я выпил воду", refNow()) + d, err := r.Route(context.Background(), NormalizedInput{Text: "я выпил воду"}, refNow()) if err != nil { t.Fatalf("route: %v", err) } @@ -374,7 +374,7 @@ func TestRouterLLMFactWithResolvedKeyStaysConfident(t *testing.T) { // (Vikunja #383). func TestLLMReminderWithoutSubjectAsksInsteadOfGuessing(t *testing.T) { r := newLLMTestRouter(t, `{"intent":"reminder"}`) - d, err := r.Route(context.Background(), "напомни в 11", refNow()) + d, err := r.Route(context.Background(), NormalizedInput{Text: "напомни в 11"}, refNow()) if err != nil { t.Fatalf("route: %v", err) } @@ -390,7 +390,7 @@ func TestLLMReminderWithoutSubjectAsksInsteadOfGuessing(t *testing.T) { // both halves still runs without a question. func TestLLMReminderWithSubjectIsNotGated(t *testing.T) { r := newLLMTestRouter(t, `{"intent":"reminder","text":"позвонить маме"}`) - d, err := r.Route(context.Background(), "напомни в 11 позвонить маме", refNow()) + d, err := r.Route(context.Background(), NormalizedInput{Text: "напомни в 11 позвонить маме"}, refNow()) if err != nil { t.Fatalf("route: %v", err) } @@ -431,7 +431,7 @@ func TestRouteGrammarCoversSources(t *testing.T) { // would take real query sources off the turn for a name nothing answers. func TestLLMUnknownSourceFallsToTheFloor(t *testing.T) { r := newLLMTestRouter(t, `{"intent":"query","text":"что там с бэкапами","source":"praxis"}`) - d, err := r.Route(context.Background(), "что там с бэкапами", refNow()) + d, err := r.Route(context.Background(), NormalizedInput{Text: "что там с бэкапами"}, refNow()) if err != nil { t.Fatalf("route: %v", err) } @@ -443,7 +443,7 @@ func TestLLMUnknownSourceFallsToTheFloor(t *testing.T) { // And a known one survives, or the read-back is just a filter. func TestLLMNamedSourceSurvives(t *testing.T) { r := newLLMTestRouter(t, `{"intent":"query","text":"кто такой Линус Торвальдс","source":"world"}`) - d, err := r.Route(context.Background(), "кто такой Линус Торвальдс?", refNow()) + d, err := r.Route(context.Background(), NormalizedInput{Text: "кто такой Линус Торвальдс?"}, refNow()) if err != nil { t.Fatalf("route: %v", err) } @@ -457,7 +457,7 @@ func TestLLMNamedSourceSurvives(t *testing.T) { // checks. func TestLLMSourceIsQueryOnly(t *testing.T) { r := newLLMTestRouter(t, `{"intent":"note","text":"кофе кончился","source":"recall"}`) - d, err := r.Route(context.Background(), "запиши что кофе кончился", refNow()) + d, err := r.Route(context.Background(), NormalizedInput{Text: "запиши что кофе кончился"}, refNow()) if err != nil { t.Fatalf("route: %v", err) } diff --git a/internal/router/narrative_test.go b/internal/router/narrative_test.go index b357a9f..2833246 100644 --- a/internal/router/narrative_test.go +++ b/internal/router/narrative_test.go @@ -37,7 +37,7 @@ func TestNarrativeAndRestOfDayRouteToQueryAtStageZero(t *testing.T) { "что дальше", "what's next?", } { - d, err := r.Route(context.Background(), u, refNow()) + d, err := r.Route(context.Background(), NormalizedInput{Text: u}, refNow()) if err != nil { t.Fatalf("route(%q): %v", u, err) } @@ -61,7 +61,7 @@ func TestNarrativeGrammarLeavesOtherTurnsAlone(t *testing.T) { "расскажи шутку", "расскажи", } { - d, err := r.Route(context.Background(), u, refNow()) + d, err := r.Route(context.Background(), NormalizedInput{Text: u}, refNow()) if err != nil { t.Fatalf("route(%q): %v", u, err) } @@ -75,7 +75,7 @@ func TestNarrativeGrammarLeavesOtherTurnsAlone(t *testing.T) { // search leg wants "битву при Ватерлоо", not "расскажи про битву при Ватерлоо". func TestNarrativeGrammarKeepsTheTopic(t *testing.T) { r := narrativeRouter(t) - d, err := r.Route(context.Background(), "расскажи про битву при Ватерлоо", refNow()) + d, err := r.Route(context.Background(), NormalizedInput{Text: "расскажи про битву при Ватерлоо"}, refNow()) if err != nil { t.Fatal(err) } diff --git a/internal/router/possession_test.go b/internal/router/possession_test.go index a5b1f71..0dac23a 100644 --- a/internal/router/possession_test.go +++ b/internal/router/possession_test.go @@ -56,7 +56,7 @@ func TestPossessionStatementBeatsStatisticalQueryGuess(t *testing.T) { Classifier: classifier, Threshold: 0, }) - d, err := r.Route(context.Background(), "у меня новый ноутбук", refNow()) + d, err := r.Route(context.Background(), NormalizedInput{Text: "у меня новый ноутбук"}, refNow()) if err != nil { t.Fatal(err) } diff --git a/internal/router/router_test.go b/internal/router/router_test.go index 9c5a285..718898b 100644 --- a/internal/router/router_test.go +++ b/internal/router/router_test.go @@ -78,7 +78,7 @@ func newTestRouter(t *testing.T, threshold float64) *Router { func TestStage0WakeWordAct(t *testing.T) { r := newTestRouter(t, 0.0) - d, err := r.Route(context.Background(), "maven, restart nginx", refNow()) + d, err := r.Route(context.Background(), NormalizedInput{Text: "maven, restart nginx"}, refNow()) if err != nil { t.Fatalf("route: %v", err) } @@ -94,7 +94,7 @@ func TestStage0WakeWordFallsThroughOnUnknownAct(t *testing.T) { // wakeword prefix alone doesn't guarantee a known command. "maven, i'm tired" // is a fact-ish utterance → falls through to the classifier. r := newTestRouter(t, 0.0) - d, err := r.Route(context.Background(), "maven, i drank water", refNow()) + d, err := r.Route(context.Background(), NormalizedInput{Text: "maven, i drank water"}, refNow()) if err != nil { t.Fatalf("route: %v", err) } @@ -112,7 +112,7 @@ func TestStage0GrammarFiresThroughCyrillicWakeWord(t *testing.T) { r := newTestRouter(t, 0.0) r.grammars = append(r.grammars, SystemTimeDateGrammars()...) - d, err := r.Route(context.Background(), "Мэйвен который час", refNow()) + d, err := r.Route(context.Background(), NormalizedInput{Text: "Мэйвен который час"}, refNow()) if err != nil { t.Fatalf("route: %v", err) } @@ -130,7 +130,7 @@ func TestStage0ReminderCarriesTheHourHeSaid(t *testing.T) { r := newTestRouter(t, 0.0) r.grammars = append(r.grammars, ReminderGrammar()) - d, err := r.Route(context.Background(), "напомни в 11:00 позвонить маме", refNow()) + d, err := r.Route(context.Background(), NormalizedInput{Text: "напомни в 11:00 позвонить маме"}, refNow()) if err != nil { t.Fatalf("route: %v", err) } @@ -156,7 +156,7 @@ func TestStage0ReminderCarriesTheHourHeSaid(t *testing.T) { // be able to replace it. func TestStage0MatchedSlotBeatsTheExtractor(t *testing.T) { r := newTestRouter(t, 0.0) - d, err := r.Route(context.Background(), "maven, restart nginx", refNow()) + d, err := r.Route(context.Background(), NormalizedInput{Text: "maven, restart nginx"}, refNow()) if err != nil { t.Fatalf("route: %v", err) } @@ -176,7 +176,7 @@ func TestStage0QueryKeepsAnEmptyText(t *testing.T) { r := newTestRouter(t, 0.0) r.grammars = append(r.grammars, AgendaQueryGrammars()...) - d, err := r.Route(context.Background(), "что у меня сегодня", refNow()) + d, err := r.Route(context.Background(), NormalizedInput{Text: "что у меня сегодня"}, refNow()) if err != nil { t.Fatalf("route: %v", err) } @@ -192,7 +192,7 @@ func TestStage0QueryKeepsAnEmptyText(t *testing.T) { func TestStage1ClassifiesAct(t *testing.T) { r := newTestRouter(t, 0.0) - d, err := r.Route(context.Background(), "restart the backup now", refNow()) + d, err := r.Route(context.Background(), NormalizedInput{Text: "restart the backup now"}, refNow()) if err != nil { t.Fatalf("route: %v", err) } @@ -206,7 +206,7 @@ func TestStage1ClassifiesAct(t *testing.T) { func TestStage1ClassifiesFact(t *testing.T) { r := newTestRouter(t, 0.0) - d, err := r.Route(context.Background(), "i drank water", refNow()) + d, err := r.Route(context.Background(), NormalizedInput{Text: "i drank water"}, refNow()) if err != nil { t.Fatalf("route: %v", err) } @@ -230,7 +230,7 @@ func TestStage1ClassifiesNoteAndQuery(t *testing.T) { {"when did i last eat", IntentQuery}, } for _, c := range cases { - d, err := r.Route(context.Background(), c.in, refNow()) + d, err := r.Route(context.Background(), NormalizedInput{Text: c.in}, refNow()) if err != nil { t.Fatalf("route %q: %v", c.in, err) } @@ -245,7 +245,7 @@ func TestStage1ClassifiesNoteAndQuery(t *testing.T) { func TestStage2ReminderSlotExtraction(t *testing.T) { r := newTestRouter(t, 0.0) now := refNow() - d, err := r.Route(context.Background(), "remind me in four hours", now) + d, err := r.Route(context.Background(), NormalizedInput{Text: "remind me in four hours"}, now) if err != nil { t.Fatalf("route: %v", err) } @@ -265,7 +265,7 @@ func TestStage2ReminderAtClockRollsToTomorrow(t *testing.T) { // "wake me at 7" said at 12:00 → fires tomorrow 07:00 (already past today). r := newTestRouter(t, 0.0) now := refNow() - d, err := r.Route(context.Background(), "wake me at 7", now) + d, err := r.Route(context.Background(), NormalizedInput{Text: "wake me at 7"}, now) if err != nil { t.Fatalf("route: %v", err) } @@ -280,7 +280,7 @@ func TestStage2ReminderAtClockRollsToTomorrow(t *testing.T) { func TestStage2FactSleptDuration(t *testing.T) { r := newTestRouter(t, 0.0) - d, err := r.Route(context.Background(), "slept 6h", refNow()) + d, err := r.Route(context.Background(), NormalizedInput{Text: "slept 6h"}, refNow()) if err != nil { t.Fatalf("route: %v", err) } @@ -298,7 +298,7 @@ func TestStage3ClarifyBelowThreshold(t *testing.T) { // high threshold ⇒ even a well-classified utterance is gated to clarify. // "shuts up when uncertain": a misrouted fact is a confident wrong write. r := newTestRouter(t, 0.99) - d, err := r.Route(context.Background(), "i drank water", refNow()) + d, err := r.Route(context.Background(), NormalizedInput{Text: "i drank water"}, refNow()) if err != nil { t.Fatalf("route: %v", err) } @@ -314,7 +314,7 @@ func TestStage3ClarifyBelowThreshold(t *testing.T) { func TestStage3PassesAboveThreshold(t *testing.T) { r := newTestRouter(t, 0.0) // threshold 0 ⇒ nothing gated - d, err := r.Route(context.Background(), "i drank water", refNow()) + d, err := r.Route(context.Background(), NormalizedInput{Text: "i drank water"}, refNow()) if err != nil { t.Fatalf("route: %v", err) } @@ -331,7 +331,7 @@ func TestColdBootNoIntents(t *testing.T) { emb := NewHashEmbedder(128) c := NewClassifier(emb) r := New(Config{Classifier: c, Threshold: 0}) - if _, err := r.Route(context.Background(), "something freeform", refNow()); err != ErrNoIntents { + if _, err := r.Route(context.Background(), NormalizedInput{Text: "something freeform"}, refNow()); err != ErrNoIntents { t.Fatalf("cold boot: want ErrNoIntents, got %v", err) } } @@ -342,7 +342,7 @@ func TestCorrectMisrouteGrowsClassifier(t *testing.T) { r := newTestRouter(t, 0.4) // "note the backup is broken" looks note-ish but the user meant a fact // (loop should know the backup is down). Without correction it routes note. - before, err := r.Route(context.Background(), "backup is broken", refNow()) + before, err := r.Route(context.Background(), NormalizedInput{Text: "backup is broken"}, refNow()) if err != nil { t.Fatalf("route: %v", err) } @@ -359,7 +359,7 @@ func TestCorrectMisrouteGrowsClassifier(t *testing.T) { t.Fatalf("correct: %v", err) } } - after, err := r.Route(context.Background(), "backup is broken", refNow()) + after, err := r.Route(context.Background(), NormalizedInput{Text: "backup is broken"}, refNow()) if err != nil { t.Fatalf("route: %v", err) } @@ -403,7 +403,7 @@ func TestStage1ClassifiesChat(t *testing.T) { {"расскажи что-нибудь", IntentChat}, } for _, c := range cases { - d, err := r.Route(context.Background(), c.in, refNow()) + d, err := r.Route(context.Background(), NormalizedInput{Text: c.in}, refNow()) if err != nil { t.Fatalf("route %q: %v", c.in, err) }