package eval import ( "context" "os" "strings" "testing" "time" "github.com/kami/maven/internal/llm" "github.com/kami/maven/internal/persona" "github.com/kami/maven/internal/phraser" ) // TestLLMPhrasingBaseline — the resident model wording real nudges. Opt-in, // same shape as internal/router/eval's MAVEN_LLM_URL gate, because CI has no // model and a phrasing run costs minutes on the CPU target. // // llama-server -m /mnt/hdd1/llms/qwen3.5/Qwen3.5-0.8B.Q4_K_M.gguf \ // --host 127.0.0.1 --port 18099 -c 2048 -ngl 99 // MAVEN_LLM_URL=http://127.0.0.1:18099 make eval-phrasing // // It reports and does not assert a quality bar. The numbers are the input to // tuning the persona prompt; an assertion here would be the test inventing the // bar rather than measuring against it. The one thing worth failing on is a // harness fault — every case erroring means the run measured infrastructure. func TestLLMPhrasingBaseline(t *testing.T) { base := os.Getenv("MAVEN_LLM_URL") if base == "" { t.Skip("MAVEN_LLM_URL unset — point it at a running llama-server (see doc comment)") } // A local llama-server must not go through an HTTP proxy. This box proxies // loopback through a SOCKS bridge that answers 503, which would score every // case as a phrasing error and read as "the model cannot phrase". noProxyLoopback(t) ctx := context.Background() f, err := Load() if err != nil { t.Fatalf("Load: %v", err) } cfg := phraser.DefaultConfig("") // Generous: an unconstrained 0.8B can spend a minute thinking before it // writes a word, and a timeout would be scored as a model failure. cfg.Timeout = 5 * time.Minute // The same shared context block the daemon prepends (internal/persona), // with an empty config — that is the deployment we actually ship. cfg.ContextBlock = func() string { return persona.Facts{}.Block(time.Now()) } p := phraser.NewLLMPhraserAt(base, cfg) defer p.Close() // Label the run with whatever gguf the server actually has loaded. It used // to say "0.8B" no matter what, so two runs of two different models came // out named the same and were easy to mix up when comparing. model, err := llm.ModelID(ctx, base) if err != nil { // An unlabelled score is still a score, but say so loudly — a made-up // name in a bake-off table is worse than no name. t.Logf("could not read model id from %s: %v — report will say %q", base, err, llm.UnknownModel) model = llm.UnknownModel } t.Logf("scoring model %s at %s", model, base) rep, err := Score(ctx, "llm ("+model+", built-in persona)", p, f) if err != nil { t.Fatalf("Score: %v", err) } t.Log("\n" + rep.String() + "\nmessages:\n" + rep.Messages() + "\nfailures:\n" + rep.Failures()) if rep.Errors == rep.Total { t.Errorf("all %d cases errored — harness fault, not a measurement", rep.Total) } } // noProxyLoopback appends the loopback host to no_proxy before any request, so // http.ProxyFromEnvironment (which caches the environment on first use) sees it. func noProxyLoopback(t *testing.T) { t.Helper() for _, key := range []string{"no_proxy", "NO_PROXY"} { cur := os.Getenv(key) if strings.Contains(cur, "127.0.0.1") { continue } if cur == "" { t.Setenv(key, "127.0.0.1,localhost") continue } t.Setenv(key, cur+",127.0.0.1,localhost") } }