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.
This commit is contained in:
addrkit
2026-10-09 20:40:20 +07:00
parent 5cc3556ac5
commit 78691f3a22
17 changed files with 590 additions and 48 deletions
+88
View File
@@ -0,0 +1,88 @@
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)
}
})
}
}