package phraser import ( "context" "encoding/json" "net/http" "net/http/httptest" "strings" "testing" "github.com/kami/maven/internal/loop" ) // grammarSpy stands in for llama-server: it records the grammar field of every // request and always answers with a contract-shaped reply. type grammarSpy struct { srv *httptest.Server grammars []string } func newGrammarSpy(t *testing.T) *grammarSpy { t.Helper() s := &grammarSpy{} s.srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { var req chatReq if err := json.NewDecoder(r.Body).Decode(&req); err != nil { t.Errorf("spy: decode request: %v", err) } s.grammars = append(s.grammars, req.Grammar) w.Header().Set("Content-Type", "application/json") w.Write([]byte(`{"choices":[{"message":{"content":"{\"response\": \"ага\", \"mood\": \"neutral\"}"}}]}`)) })) t.Cleanup(s.srv.Close) return s } // callAllPhrasingPaths hits every path that expects the JSON contract. // LLMNudges must be set on the phraser under test: nudges come from templates // by default and never reach the model at all. func callAllPhrasingPaths(t *testing.T, p *LLMPhraser) { t.Helper() ctx := context.Background() if _, err := p.PhraseNudge(ctx, loop.Candidate{Rule: loop.WaterRule(), Severity: loop.Sev1}); err != nil { t.Fatalf("PhraseNudge: %v", err) } if _, err := p.PhraseChat(ctx, "привет", nil); err != nil { t.Fatalf("PhraseChat: %v", err) } // Both branches: no notes (general knowledge) and with notes (grounded). if _, err := p.PhraseQuery(ctx, "сколько воды я выпил", nil); err != nil { t.Fatalf("PhraseQuery (no notes): %v", err) } if _, err := p.PhraseQuery(ctx, "сколько воды я выпил", []string{"два литра"}); err != nil { t.Fatalf("PhraseQuery (notes): %v", err) } } func TestGrammarIsAttachedToEveryPhrasingRequest(t *testing.T) { if strings.TrimSpace(responseGrammar) == "" { t.Fatal("responseGrammar is empty") } spy := newGrammarSpy(t) p := NewLLMPhraserAt(spy.srv.URL, Config{LLMNudges: true}) callAllPhrasingPaths(t, p) if len(spy.grammars) != 4 { t.Fatalf("expected 4 requests, got %d", len(spy.grammars)) } for i, g := range spy.grammars { if g != responseGrammar { t.Errorf("request %d carries grammar %q, want responseGrammar", i, g) } } } func TestNoGrammarConfigDisablesIt(t *testing.T) { spy := newGrammarSpy(t) p := NewLLMPhraserAt(spy.srv.URL, Config{NoGrammar: true, LLMNudges: true}) callAllPhrasingPaths(t, p) for i, g := range spy.grammars { if g != "" { t.Errorf("request %d still carries a grammar with NoGrammar set: %q", i, g) } } } // The grammar's string rule must accept any codepoint, not just ASCII. Replies // are Russian: an ASCII-only class would constrain the model into empty replies. func TestGrammarStringRuleIsNotASCIIOnly(t *testing.T) { if !strings.Contains(responseGrammar, `[^"\\\x00-\x1F]`) { t.Error("string rule is not the any-codepoint-except-quote-backslash-and-controls class; Cyrillic replies would be impossible") } // The control range must be out (Vikunja #537): a raw newline inside a JSON // string is not JSON, and the model wrote one whenever it wanted two lines. if strings.Contains(responseGrammar, `([^"\\] |`) { t.Error("string rule still admits raw control characters; a multi-line reply will fail to parse") } } // What the grammar describes must survive the parser that reads it back — a // Russian body with an escaped quote inside, hand-built to test the contract. func TestGrammarShapedJSONParses(t *testing.T) { raw := `{"response": "он сказал \"привет\" и ушёл.\nвот так.", "mood": "confused"}` text, mood, err := parseResponseMood(raw) if err != nil { t.Fatalf("grammar-shaped JSON did not parse: %v", err) } if want := "он сказал \"привет\" и ушёл.\nвот так."; text != want { t.Errorf("response = %q, want %q", text, want) } if mood != "confused" { t.Errorf("mood = %q, want confused", mood) } } // Every mood the grammar permits is one the contract knows, and all five are there. func TestGrammarMoodEnumMatchesTheContract(t *testing.T) { for _, m := range []string{"neutral", "happy", "thinking", "tired", "confused"} { if !strings.Contains(responseGrammar, `"\"`+m+`\""`) { t.Errorf("mood %q missing from the grammar", m) } } // No sixth mood: the enum line lists exactly five alternatives. for _, line := range strings.Split(responseGrammar, "\n") { if strings.HasPrefix(line, "mood") { if n := strings.Count(line, "|") + 1; n != 5 { t.Errorf("mood rule lists %d alternatives, want 5: %s", n, line) } } } }