router: update all Route callers for NormalizedInput + add invariant tests (slice 11)
Migrate 54 test call sites to construct NormalizedInput{Text: ...}.
Add invariant tests:
- TestNormalizedInputReachesRouteIntact: ingress NormalizedInput reaches Route
- TestTryFastPathReceivesMatchText: TryFastPath gets the same input
- TestMatchTextDoesNotChangeRouting: same Text + different MatchText → same Decision
- TestDecisionUtteranceEqualsInputText: Decision.Utterance == input.Text
This commit is contained in:
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user