package main import ( "context" "net/http" "strings" "sync" "testing" "github.com/kami/maven/internal/router" ) // TestEntityReferences pins when his own words win over the model's, and that // every name he said goes over rather than one of them being picked. func TestEntityReferences(t *testing.T) { for _, tc := range []struct { name string utterance string text string want []string }{ { name: "the model transliterated the name", utterance: "перезапусти muzick indexer", text: "перезагрузить музик индексер", want: []string{"muzick indexer"}, }, { name: "it kept the name, so nothing to repair", utterance: "перезапусти muzick indexer", text: "перезагрузить muzick indexer", want: []string{"перезагрузить muzick indexer"}, }, { name: "an all-Russian entity name is not a rewrite", utterance: "перезапусти домашний сервер", text: "перезагрузить домашний сервер", want: []string{"перезагрузить домашний сервер"}, }, { name: "an English turn never enters the recovery", utterance: "restart muzick indexer", text: "restart muzick indexer", want: []string{"restart muzick indexer"}, }, { name: "both names go over, in the order he said them", utterance: "а перезапусти-ка nginx на muzick-indexer, пожалуйста", text: "перезагрузить нгинкс", want: []string{"nginx", "muzick-indexer"}, }, { name: "one stray letter is not a name", utterance: "перезапусти сервер a", text: "перезагрузить сервер", want: []string{"перезагрузить сервер"}, }, { name: "the same name twice is one question", utterance: "перезапусти nginx, ну правда, nginx", text: "перезагрузить нгинкс", want: []string{"nginx"}, }, } { t.Run(tc.name, func(t *testing.T) { dec := router.Decision{Utterance: tc.utterance, Slots: router.Slots{Text: tc.text}} got := entityReferences(dec) if len(got) != len(tc.want) { t.Fatalf("entityReferences = %q, want %q", got, tc.want) } for i := range got { if got[i] != tc.want[i] { t.Fatalf("entityReferences = %q, want %q", got, tc.want) } } }) } } // TestNexusIsAskedForTheNameHeSaid — the defect end to end (Vikunja #476): the // router hands over a transliterated Text, and Nexus must still be asked about // the service that exists. func TestNexusIsAskedForTheNameHeSaid(t *testing.T) { ctx := context.Background() nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service")) hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded")) h := ecoHandler(t, nexus, nil, hexis) dec := router.Decision{ Utterance: "перезапусти muzick indexer", Intent: router.IntentAct, Slots: router.Slots{Text: "перезагрузить музик индексер", Fn: "restart", HasFn: true}, } h.handleHexisAct(ctx, dec) reqs := nexus.Requests() if len(reqs) == 0 { t.Fatal("nexus was never asked") } body := string(reqs[0].Body) if !strings.Contains(body, "muzick indexer") { t.Fatalf("nexus resolve body = %s, want the name he said", body) } } // TestAnEntityActReachesHexisInsteadOfAsking — the second half of #476. The // stage-3 gate thins an act with no allowlisted fn, and that question used to // be the whole turn, so the Hexis path was unreachable from voice or chat. func TestAnEntityActReachesHexisInsteadOfAsking(t *testing.T) { ctx := context.Background() nexus := newFakeNexus(t, fixtureNexusResolved("ent_muzick", "Muzick indexer", "service")) hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded")) h := ecoHandler(t, nexus, nil, hexis) dec := router.Decision{ Utterance: "перезапусти muzick indexer", Intent: router.IntentAct, Stage: 3, Clarify: true, Slots: router.Slots{Text: "restart status muzick indexer"}, } reply := h.hexisBeforeClarify(ctx, dec) if reply == "" { t.Fatal("a resolvable entity act must reach hexis rather than fall through to the question") } if hexis.Count("", "/api/v1") == 0 { t.Fatal("hexis was never contacted") } } // TestClarifyStillAsksWithoutHexis — the narrowing. No ecosystem, no change: // she asks exactly what she asked before. func TestClarifyStillAsksWithoutHexis(t *testing.T) { h, _, _ := newClarifyHandler(t) dec := router.Decision{ Utterance: "перезапусти muzick indexer", Intent: router.IntentAct, Stage: 3, Clarify: true, Slots: router.Slots{Text: "перезагрузить музик индексер"}, } if reply := h.hexisBeforeClarify(context.Background(), dec); reply != "" { t.Fatalf("no hexis must mean no reply, got %q", reply) } if _, asked := h.askClarify(voiceCtx(), dec); !asked { t.Fatal("she must still ask what to do") } } // A verb is not an entity. Before the reach gate, an exact local matcher hit // with no arguments still sent the raw verb to Nexus and could discover a // similarly named entity through Hexis. The local tool lane may handle or // reject it, but the ecosystem must not be consulted without a target. func TestBareMatchedActNeverReachesNexus(t *testing.T) { nexus := newFakeNexus(t, fixtureNexusResolved("ent_power", "Power", "service")) hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded")) h, _, _ := newClarifyHandler(t) h.ecosystem = ecoHandler(t, nexus, nil, hexis).ecosystem reply := h.actionAct(context.Background(), router.Decision{ Utterance: "выключи", Intent: router.IntentAct, Slots: router.Slots{Fn: "выключи", HasFn: true, Text: "выключи"}, }) if len(nexus.Requests()) != 0 { t.Fatalf("bare verb reached Nexus: %+v", nexus.Requests()) } if reply == "" { t.Fatal("bare act disappeared instead of staying in Maven's local lane") } } func TestUnresolvedActNeverReachesNexusBeforeClarify(t *testing.T) { nexus := newFakeNexus(t, fixtureNexusResolved("ent_it", "It", "service")) hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded")) h := ecoHandler(t, nexus, nil, hexis) dec := router.Decision{ Utterance: "сделай это", Intent: router.IntentAct, Stage: 3, Clarify: true, Slots: router.Slots{Text: "сделай это"}, } if reply := h.hexisBeforeClarify(context.Background(), dec); reply != "" { t.Fatalf("unresolved act was answered by Hexis: %q", reply) } if len(nexus.Requests()) != 0 { t.Fatalf("unresolved act reached Nexus: %+v", nexus.Requests()) } } // nexusInOrder serves one resolve answer per call, in order, so a test can say // what Nexus knows about the first name and what it knows about the second. The // last body repeats once the list runs out. func nexusInOrder(t *testing.T, bodies ...string) *fakeServer { t.Helper() var mu sync.Mutex n := 0 return newFakeServer(t, map[string]http.HandlerFunc{ "POST /api/v1/resolve": func(w http.ResponseWriter, r *http.Request) { mu.Lock() body := bodies[min(n, len(bodies)-1)] n++ mu.Unlock() w.Header().Set("Content-Type", "application/json") _, _ = w.Write([]byte(body)) }, }) } // TestTwoResolvedNamesAsk — «перезапусти nginx на muzick-indexer» names a // service and the host it runs on. Both are real, and which one he meant is not // in the utterance, so she asks. Picking one by length was the old behaviour and // length is not evidence (Vikunja #524). func TestTwoResolvedNamesAsk(t *testing.T) { ctx := context.Background() nexus := nexusInOrder(t, fixtureNexusResolved("ent_nginx", "nginx", "service"), fixtureNexusResolved("ent_host", "Muzick indexer", "device"), ) hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded")) h := ecoHandler(t, nexus, nil, hexis) dec := router.Decision{ Utterance: "перезапусти nginx на muzick-indexer", Intent: router.IntentAct, Slots: router.Slots{Text: "перезагрузить нгинкс", Fn: "restart", HasFn: true}, } reply := h.handleHexisAct(ctx, dec) if !strings.Contains(reply, "nginx") || !strings.Contains(reply, "Muzick indexer") { t.Fatalf("reply = %q, want both names she found", reply) } if hexis.Count("POST", "/api/v1/execute") != 0 { t.Fatal("she must not execute against a target she is still asking about") } } // TestTheNameNexusKnowsWins — the other half. Two names go over and only one is // an entity, so there is nothing to ask about and the act runs. func TestTheNameNexusKnowsWins(t *testing.T) { ctx := context.Background() nexus := nexusInOrder(t, fixtureNexusNotFound(), fixtureNexusResolved("ent_muzick", "Muzick indexer", "service"), ) hexis := newFakeHexis(t, restartCaps(), fixtureHexisExecuted("exec_1", "succeeded")) h := ecoHandler(t, nexus, nil, hexis) dec := router.Decision{ Utterance: "перезапусти nginx на muzick-indexer", Intent: router.IntentAct, Slots: router.Slots{Text: "перезагрузить нгинкс", Fn: "restart", HasFn: true}, } reply := h.handleHexisAct(ctx, dec) if reply == "" { t.Fatal("the resolvable name must carry the act") } if len(nexus.Requests()) != 2 { t.Fatalf("nexus asked %d times, want both names", len(nexus.Requests())) } if hexis.Count("POST", "/api/v1/execute") == 0 { t.Fatal("hexis was never asked to run it") } }