diff --git a/internal/phraser/world_test.go b/internal/phraser/world_test.go new file mode 100644 index 0000000..1cdb6ba --- /dev/null +++ b/internal/phraser/world_test.go @@ -0,0 +1,164 @@ +package phraser + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/kami/maven/internal/llm" + "github.com/kami/maven/internal/loop" +) + +// fakeRemote — a workstation model that is up or down on command, and records +// what it was asked. +type fakeRemote struct { + up bool + reply string + err error + got []llm.Req +} + +func (f *fakeRemote) Available() bool { return f.up } + +func (f *fakeRemote) CompleteRemote(_ context.Context, r llm.Req) (string, error) { + f.got = append(f.got, r) + if f.err != nil { + return "", f.err + } + return f.reply, nil +} + +// The three outcomes of the naming half, in one place. The middle one is the +// whole task: a gap he is told about, not an answer from the smaller model. +func TestPhraseWorldNamesTheGapOnlyWhenThereIsOne(t *testing.T) { + answer := `{"response": "Небо голубое из-за рэлеевского рассеяния.", "mood": "neutral"}` + + t.Run("no workstation configured: the resident model answers as today", func(t *testing.T) { + spy := newPromptSpy(t) + p := NewLLMPhraserAt(spy.srv.URL, Config{}) + got, err := p.PhraseWorld(context.Background(), "почему небо голубое", nil) + if err != nil { + t.Fatalf("PhraseWorld: %v", err) + } + if got == "" { + t.Fatal("no reply from the resident model") + } + if len(spy.user) != 1 { + t.Fatalf("resident model saw %d requests, want 1", len(spy.user)) + } + }) + + t.Run("workstation up: it answers and the resident model is not asked", func(t *testing.T) { + spy := newPromptSpy(t) + p := NewLLMPhraserAt(spy.srv.URL, Config{}) + remote := &fakeRemote{up: true, reply: answer} + p.UseRemote(remote) + got, err := p.PhraseWorld(context.Background(), "почему небо голубое", nil) + if err != nil { + t.Fatalf("PhraseWorld: %v", err) + } + if !strings.Contains(got, "рассеяния") { + t.Errorf("reply is not the workstation's: %q", got) + } + if len(spy.user) != 0 { + t.Errorf("the resident model was asked %d times, want 0", len(spy.user)) + } + }) + + t.Run("workstation down: the gap, and nothing invented", func(t *testing.T) { + spy := newPromptSpy(t) + p := NewLLMPhraserAt(spy.srv.URL, Config{}) + p.UseRemote(&fakeRemote{up: false}) + got, err := p.PhraseWorld(context.Background(), "почему небо голубое", nil) + if !errors.Is(err, ErrNoWorldModel) { + t.Fatalf("err = %v, want ErrNoWorldModel", err) + } + if got != "" { + t.Errorf("got a reply %q with no world model", got) + } + if len(spy.user) != 0 { + t.Errorf("the resident model answered a world question %d times, want 0", len(spy.user)) + } + }) + + t.Run("workstation errors mid-request: still the gap", func(t *testing.T) { + spy := newPromptSpy(t) + p := NewLLMPhraserAt(spy.srv.URL, Config{}) + p.UseRemote(&fakeRemote{up: true, err: errors.New("connection refused")}) + if _, err := p.PhraseWorld(context.Background(), "почему небо голубое", nil); !errors.Is(err, ErrNoWorldModel) { + t.Fatalf("err = %v, want ErrNoWorldModel", err) + } + if len(spy.user) != 0 { + t.Errorf("the resident model answered a world question %d times, want 0", len(spy.user)) + } + }) +} + +// Prompt parity: the workstation model is asked the same question in the same +// words, or the fixtures measure one thing and the daemon ships another. +func TestPhraseWorldSendsTheSamePromptsAsPhraseQuery(t *testing.T) { + spy := newPromptSpy(t) + resident := NewLLMPhraserAt(spy.srv.URL, Config{}) + if _, err := resident.PhraseQuery(context.Background(), "кто написал войну и мир", []string{"Лев Толстой"}); err != nil { + t.Fatal(err) + } + + remote := &fakeRemote{up: true, reply: `{"response": "Толстой.", "mood": "neutral"}`} + offloaded := NewLLMPhraserAt(spy.srv.URL, Config{}) + offloaded.UseRemote(remote) + if _, err := offloaded.PhraseWorld(context.Background(), "кто написал войну и мир", []string{"Лев Толстой"}); err != nil { + t.Fatal(err) + } + + if len(remote.got) != 1 { + t.Fatalf("the workstation saw %d requests, want 1", len(remote.got)) + } + if remote.got[0].System != spy.system[0] { + t.Errorf("system prompts differ:\nremote: %q\nresident: %q", remote.got[0].System, spy.system[0]) + } + if remote.got[0].User != spy.user[0] { + t.Errorf("user prompts differ:\nremote: %q\nresident: %q", remote.got[0].User, spy.user[0]) + } +} + +// The silent half. A nudge phrased on the workstation is not news, and one +// phrased here because the card is busy is not news either — but it must be +// sampled the same way, or the workstation quietly changes how she sounds. +func TestNudgePhrasingPrefersTheWorkstationSilently(t *testing.T) { + spy := newPromptSpy(t) + p := NewLLMPhraserAt(spy.srv.URL, Config{LLMNudges: true}) + remote := &fakeRemote{up: true, reply: `{"response": "Выпей воды.", "mood": "neutral"}`} + p.UseRemote(remote) + + pn, err := p.PhraseNudge(context.Background(), loop.Candidate{Rule: loop.WaterRule(), Severity: loop.Sev1}) + if err != nil { + t.Fatalf("PhraseNudge: %v", err) + } + if pn.Body != "Выпей воды." { + t.Errorf("body = %q, want the workstation's wording", pn.Body) + } + if len(remote.got) != 1 { + t.Fatalf("the workstation saw %d requests, want 1", len(remote.got)) + } + if remote.got[0].Temperature != chatTemperature { + t.Errorf("temperature = %v, want %v (what the resident transport samples at)", + remote.got[0].Temperature, chatTemperature) + } + if len(spy.user) != 0 { + t.Errorf("the resident model phrased %d nudges, want 0", len(spy.user)) + } +} + +func TestNudgePhrasingFallsBackWhenTheCardIsBusy(t *testing.T) { + spy := newPromptSpy(t) + p := NewLLMPhraserAt(spy.srv.URL, Config{LLMNudges: true}) + p.UseRemote(&fakeRemote{up: false}) + + if _, err := p.PhraseNudge(context.Background(), loop.Candidate{Rule: loop.WaterRule(), Severity: loop.Sev1}); err != nil { + t.Fatalf("PhraseNudge: %v", err) + } + if len(spy.user) != 1 { + t.Fatalf("the resident model phrased %d nudges, want 1", len(spy.user)) + } +}