Files
addrkit/internal/adapters/llm/rewriter_test.go
T
Maksim Totmin f1537f1d6b addrkit: strict ordinal matching, LLM-variant guard, externalized prompt
- match: require candidate ordinal to equal the query ordinal in fuzzy search
  (deterministic typo recall 73% -> 80%, no wrong houses)
- resolver: compatibleVariant guard — LLM may only fix street spelling, keeping
  the same words, ordinal and house unit; damerau distance for transpositions
- llm: prompt moved out of code (go:embed default_prompt.txt) with
  llm.system_prompt / llm.system_prompt_file overrides; guided JSON parsing
  tolerant of thinking blocks
- address: recognize ordinals appearing after the street name
- tests: first coverage for the LLM adapter and config prompt loading; README

Measured (sample 200): deterministic 94.7-95.8% exact / 0 wrong across 4 seeds;
LLM+guard typo 90.7-89.8% / 0 wrong. LLM fallback latency ~1.2-1.9s.
2026-10-09 21:40:46 +07:00

89 lines
2.7 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package llm
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"git.totmin.ru/en2zmax/addrkit/internal/config"
)
// chatServer returns a test server that echoes a fixed content and captures the
// request body it received.
func chatServer(t *testing.T, content string) (*httptest.Server, *chatRequest) {
t.Helper()
var got chatRequest
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewDecoder(r.Body).Decode(&got)
w.Header().Set("Content-Type", "application/json")
resp := map[string]any{"choices": []any{map[string]any{"message": map[string]any{"content": content}}}}
_ = json.NewEncoder(w).Encode(resp)
}))
t.Cleanup(ts.Close)
return ts, &got
}
func TestRewriteUsesConfiguredPrompt(t *testing.T) {
ts, got := chatServer(t, `{"variants":["А"]}`)
r := New(config.LLMConfig{URL: ts.URL, Model: "m", SystemPrompt: "CUSTOM PROMPT"})
out, err := r.Rewrite(context.Background(), "вход")
if err != nil {
t.Fatalf("Rewrite: %v", err)
}
if len(got.Messages) == 0 || got.Messages[0].Content != "CUSTOM PROMPT" {
t.Errorf("system prompt not applied: %+v", got.Messages)
}
if len(out) != 1 || out[0] != "А" {
t.Errorf("variants = %v", out)
}
}
func TestRewriteFallsBackToEmbeddedPrompt(t *testing.T) {
ts, got := chatServer(t, `{"variants":[]}`)
r := New(config.LLMConfig{URL: ts.URL, Model: "m"})
if _, err := r.Rewrite(context.Background(), "x"); err != nil {
t.Fatalf("Rewrite: %v", err)
}
if len(got.Messages) == 0 || !strings.Contains(got.Messages[0].Content, "нормализатор") {
t.Errorf("embedded default prompt not used: %q", got.Messages[0].Content)
}
}
func TestRewriteDisablesThinking(t *testing.T) {
ts, got := chatServer(t, `{"variants":[]}`)
r := New(config.LLMConfig{URL: ts.URL, Model: "m", DisableThinking: true})
if _, err := r.Rewrite(context.Background(), "x"); err != nil {
t.Fatalf("Rewrite: %v", err)
}
if v, ok := got.ChatTemplateKwargs["enable_thinking"]; !ok || v != false {
t.Errorf("enable_thinking not disabled: %+v", got.ChatTemplateKwargs)
}
}
func TestParseVariants(t *testing.T) {
cases := []struct {
name string
content string
want int
}{
{"clean", `{"variants":["a","b"]}`, 2},
{"with thinking", "<think>hmm</think>\n{\"variants\":[\"a\"]}", 1},
{"unterminated thinking", "<think>hmm {\"variants\":[\"a\"]}", 0},
{"prose around", "Here you go: {\"variants\":[\"a\",\"b\",\"c\"]} done", 3},
{"garbage", "no json here", 0},
}
for _, tt := range cases {
t.Run(tt.name, func(t *testing.T) {
if got := parseVariants(tt.content); len(got) != tt.want {
t.Errorf("parseVariants(%q) = %v, want %d items", tt.content, got, tt.want)
}
})
}
}