Initial implementation of the bot: - cmd/chat-bot: CLI entrypoint (serve, reindex, ask, version) - internal/agent: LLM provider client + agent runner with RAG injection - internal/config: YAML config loader (providers, RAG, persona, server) - internal/i18n: response-language detection (EN/ES) - internal/persona: persona system prompt assembly from YAML - internal/portfolio: heading-based chunker + SQLite FTS5 indexer - internal/server: chi router with /api/chat (SSE), /api/health, /api/info, /api/reindex, middleware (RequestID, Logging, CORS, RateLimit) - internal/streaming: SSE protocol helpers (start, chunk, sources, done, error) - web/: drop-in vanilla-JS chat widget (no build, no deps) + demo + README - bench/: reproducible driver benchmark (modernc vs mattn SQLite) - configs/portfolio-bot.yaml: llama.cpp default provider, SQLite RAG, canine persona - docs/architecture.md / .es.md: aligned with SQLite FTS5 + llama.cpp decisions - data/projects/README*.md: project data documentation - README.md / .es.md: updated for current implementation All tests pass (go test ./...). Bot is functional end-to-end with the configured LLM provider.
420 lines
No EOL
12 KiB
Go
420 lines
No EOL
12 KiB
Go
package server
|
|
|
|
import (
|
|
"bufio"
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
|
|
"github.com/VictorVargas/rony-llm-agent/pkg/llm"
|
|
"github.com/VictorVargas/rony-llm-agent/pkg/llm/mock"
|
|
llmpersona "github.com/VictorVargas/rony-llm-agent/pkg/persona"
|
|
|
|
"github.com/VictorVargas/rony-chat-bot/internal/agent"
|
|
"github.com/VictorVargas/rony-chat-bot/internal/config"
|
|
"github.com/VictorVargas/rony-chat-bot/internal/portfolio"
|
|
)
|
|
|
|
// newTestServer wires a Server backed by a mock LLM, listens on a random
|
|
// port, and returns the base URL plus a teardown. The persona is fixed and
|
|
// the RAG store is nil (these tests don't exercise retrieval).
|
|
//
|
|
// To make /api/health pass, the LLM endpoint is pointed at a tiny stub
|
|
// HTTP server that returns 200 on /health. Callers can override this with
|
|
// newTestServerWithLLM(t, llmStatus).
|
|
func newTestServer(t *testing.T) (string, *config.Config) {
|
|
t.Helper()
|
|
return newTestServerWithLLM(t, http.StatusOK)
|
|
}
|
|
|
|
func newTestServerWithLLM(t *testing.T, llmStatus int) (string, *config.Config) {
|
|
t.Helper()
|
|
cli := mock.NewWithStream(mockChunks("hello", "world"))
|
|
p := llmpersona.Persona{Name: "TestBot", Tone: "concise", Language: "English"}
|
|
|
|
// Stub HTTP server that mimics llama-server's /health. If llmStatus != 200
|
|
// the health probe will report down.
|
|
llmStub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
if r.URL.Path == "/health" {
|
|
w.WriteHeader(llmStatus)
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusNotFound)
|
|
}))
|
|
t.Cleanup(llmStub.Close)
|
|
|
|
// Real (in-memory) FTS5 store so the store probe reports up. Without
|
|
// it the bot is "degraded" (200, but store down) — a confusing default.
|
|
dbPath := filepath.Join(t.TempDir(), "t.db")
|
|
store, err := portfolio.OpenStore(dbPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Cleanup(func() { _ = store.Close() })
|
|
|
|
runner := agent.New(cli, p, "test system prompt", store, 5)
|
|
|
|
cfg := &config.Config{
|
|
Server: config.Server{
|
|
Host: "127.0.0.1",
|
|
Port: 0,
|
|
CORSOrigins: []string{"http://localhost:4321"},
|
|
RateLimit: config.RateLimit{RequestsPerMinute: 0, Burst: 0}, // disabled
|
|
},
|
|
Providers: []config.Provider{{
|
|
Name: "mock", Type: "llamacpp", Model: "test", Default: true,
|
|
Endpoint: llmStub.URL + "/v1",
|
|
}},
|
|
RAG: config.RAG{Enabled: false, TopK: 5, DataPath: ".", DBPath: dbPath},
|
|
Persona: config.Persona{Name: "TestBot", Language: "English"},
|
|
}
|
|
h := NewHandlers(cfg, runner, store, "test")
|
|
srv := New(cfg, h)
|
|
|
|
ts := httptest.NewUnstartedServer(srv.httpSrv.Handler)
|
|
ts.Start()
|
|
t.Cleanup(ts.Close)
|
|
|
|
return ts.URL, cfg
|
|
}
|
|
|
|
// mockChunks converts plain strings into a stream of single-word deltas
|
|
// followed by a final usage chunk, matching what a real provider would emit.
|
|
func mockChunks(words ...string) []llm.StreamChunk {
|
|
out := make([]llm.StreamChunk, 0, len(words)+1)
|
|
for _, w := range words {
|
|
out = append(out, llm.StreamChunk{Delta: w + " "})
|
|
}
|
|
out = append(out, llm.StreamChunk{
|
|
Delta: "",
|
|
FinishReason: "stop",
|
|
Usage: llm.TokenUsage{InputTokens: 5, OutputTokens: 3},
|
|
})
|
|
return out
|
|
}
|
|
|
|
func TestHealth(t *testing.T) {
|
|
url, _ := newTestServer(t)
|
|
resp, err := http.Get(url + "/api/health")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 200 {
|
|
t.Errorf("status = %d, want 200", resp.StatusCode)
|
|
}
|
|
var body HealthResponse
|
|
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if body.Status != "healthy" {
|
|
t.Errorf("status = %q, want healthy", body.Status)
|
|
}
|
|
if body.Components["llm"].Status != "up" {
|
|
t.Errorf("llm component = %q, want up", body.Components["llm"].Status)
|
|
}
|
|
if body.Version == "" {
|
|
t.Error("version should be set")
|
|
}
|
|
}
|
|
|
|
func TestHealthLLMDown(t *testing.T) {
|
|
// Stub LLM that returns 500 on /health → bot should be "unhealthy" (503).
|
|
url, _ := newTestServerWithLLM(t, http.StatusInternalServerError)
|
|
resp, err := http.Get(url + "/api/health")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusServiceUnavailable {
|
|
t.Errorf("status = %d, want 503", resp.StatusCode)
|
|
}
|
|
var body HealthResponse
|
|
_ = json.NewDecoder(resp.Body).Decode(&body)
|
|
if body.Status != "unhealthy" {
|
|
t.Errorf("status = %q, want unhealthy", body.Status)
|
|
}
|
|
if body.Components["llm"].Status != "down" {
|
|
t.Errorf("llm component = %q, want down", body.Components["llm"].Status)
|
|
}
|
|
}
|
|
|
|
func TestHealthDeep(t *testing.T) {
|
|
url, _ := newTestServer(t)
|
|
resp, err := http.Get(url + "/api/health?deep=true")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 200 {
|
|
t.Errorf("status = %d, want 200", resp.StatusCode)
|
|
}
|
|
var body HealthResponse
|
|
_ = json.NewDecoder(resp.Body).Decode(&body)
|
|
if body.Components["store"].Status != "up" {
|
|
t.Errorf("store component = %q, want up", body.Components["store"].Status)
|
|
}
|
|
// Deep mode adds a chunks count to the store details.
|
|
if _, ok := body.Components["store"].Details["chunks"]; !ok {
|
|
t.Errorf("deep mode should include chunks count in details, got: %v", body.Components["store"].Details)
|
|
}
|
|
}
|
|
|
|
func TestInfo(t *testing.T) {
|
|
url, _ := newTestServer(t)
|
|
resp, err := http.Get(url + "/api/info")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 200 {
|
|
t.Errorf("status = %d, want 200", resp.StatusCode)
|
|
}
|
|
var body map[string]any
|
|
_ = json.NewDecoder(resp.Body).Decode(&body)
|
|
for _, k := range []string{"name", "version", "provider", "model", "rag", "top_k"} {
|
|
if _, ok := body[k]; !ok {
|
|
t.Errorf("missing field %q in /api/info: %v", k, body)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestChatSSE(t *testing.T) {
|
|
url, _ := newTestServer(t)
|
|
body := strings.NewReader(`{"messages":[{"role":"user","content":"hi"}],"stream":true}`)
|
|
resp, err := http.Post(url+"/api/chat", "application/json", body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
if got := resp.Header.Get("Content-Type"); !strings.HasPrefix(got, "text/event-stream") {
|
|
t.Errorf("Content-Type = %q, want text/event-stream", got)
|
|
}
|
|
if resp.StatusCode != 200 {
|
|
t.Errorf("status = %d, want 200", resp.StatusCode)
|
|
}
|
|
|
|
events := parseSSE(t, resp.Body)
|
|
// No RAG store in this test → no "sources" event.
|
|
wantTypes := []string{"start", "chunk", "chunk", "done"}
|
|
if len(events) < len(wantTypes) {
|
|
t.Fatalf("got %d events, want >= %d (%v)", len(events), len(wantTypes), events)
|
|
}
|
|
for i, want := range wantTypes {
|
|
if events[i].event != want {
|
|
t.Errorf("event[%d] = %q, want %q", i, events[i].event, want)
|
|
}
|
|
}
|
|
if events[1].data == "" {
|
|
t.Errorf("first chunk data is empty")
|
|
}
|
|
}
|
|
|
|
func TestChatSSEWithRAG(t *testing.T) {
|
|
// Build a temp store with one project so the sources event fires.
|
|
dir := t.TempDir()
|
|
if err := writeFileR(dir+"/src/proj.md", "# Demo\n\n## Tech stack\n- Go\n- SQLite database\n"); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
store, err := openStoreForTest(dir + "/t.db", dir+"/src")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer store.Close()
|
|
|
|
cli := mock.NewWithStream(mockChunks("answer"))
|
|
p := llmpersona.Persona{Name: "TestBot", Tone: "concise", Language: "English"}
|
|
runner := agent.New(cli, p, "test system prompt", store, 5)
|
|
cfg := &config.Config{
|
|
Server: config.Server{Host: "127.0.0.1", Port: 0, CORSOrigins: []string{"*"}},
|
|
Providers: []config.Provider{{Name: "mock", Type: "llamacpp", Model: "test", Default: true}},
|
|
RAG: config.RAG{Enabled: true, TopK: 5},
|
|
Persona: config.Persona{Name: "TestBot", Language: "English"},
|
|
}
|
|
h := NewHandlers(cfg, runner, store, "test")
|
|
srv := New(cfg, h)
|
|
ts := httptest.NewUnstartedServer(srv.httpSrv.Handler)
|
|
ts.Start()
|
|
defer ts.Close()
|
|
|
|
body := strings.NewReader(`{"messages":[{"role":"user","content":"what database?"}],"stream":true}`)
|
|
resp, err := http.Post(ts.URL+"/api/chat", "application/json", body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
|
|
events := parseSSE(t, resp.Body)
|
|
var sawSources bool
|
|
for _, e := range events {
|
|
if e.event == "sources" && strings.Contains(e.data, "proj") {
|
|
sawSources = true
|
|
}
|
|
}
|
|
if !sawSources {
|
|
t.Errorf("no sources event with project id, got events: %v", events)
|
|
}
|
|
}
|
|
|
|
func TestChatNoStream(t *testing.T) {
|
|
url, _ := newTestServer(t)
|
|
body := strings.NewReader(`{"messages":[{"role":"user","content":"hi"}],"stream":false}`)
|
|
resp, err := http.Post(url+"/api/chat", "application/json", body)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 200 {
|
|
t.Errorf("status = %d, want 200", resp.StatusCode)
|
|
}
|
|
if got := resp.Header.Get("Content-Type"); !strings.HasPrefix(got, "application/json") {
|
|
t.Errorf("Content-Type = %q, want application/json", got)
|
|
}
|
|
var out struct {
|
|
Content string `json:"content"`
|
|
Sources []string `json:"sources"`
|
|
Usage struct {
|
|
InputTokens int `json:"input_tokens"`
|
|
OutputTokens int `json:"output_tokens"`
|
|
} `json:"usage"`
|
|
}
|
|
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if out.Content == "" {
|
|
t.Error("content is empty")
|
|
}
|
|
if out.Usage.InputTokens == 0 && out.Usage.OutputTokens == 0 {
|
|
t.Error("usage is all zero")
|
|
}
|
|
}
|
|
|
|
func TestChatInvalidBody(t *testing.T) {
|
|
url, _ := newTestServer(t)
|
|
cases := []struct {
|
|
name, body string
|
|
}{
|
|
{"empty messages", `{"messages":[]}`},
|
|
{"empty content", `{"messages":[{"role":"user","content":""}]}`},
|
|
{"invalid role", `{"messages":[{"role":"wizard","content":"x"}]}`},
|
|
{"bad json", `{not json`},
|
|
}
|
|
for _, c := range cases {
|
|
t.Run(c.name, func(t *testing.T) {
|
|
resp, err := http.Post(url+"/api/chat", "application/json", strings.NewReader(c.body))
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 400 {
|
|
t.Errorf("status = %d, want 400", resp.StatusCode)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestChatWrongMethod(t *testing.T) {
|
|
url, _ := newTestServer(t)
|
|
resp, err := http.Get(url + "/api/chat")
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 405 {
|
|
t.Errorf("status = %d, want 405", resp.StatusCode)
|
|
}
|
|
}
|
|
|
|
func TestCORSReject(t *testing.T) {
|
|
url, _ := newTestServer(t)
|
|
body := strings.NewReader(`{"messages":[{"role":"user","content":"hi"}]}`)
|
|
req, _ := http.NewRequest("POST", url+"/api/chat", body)
|
|
req.Header.Set("Origin", "https://evil.example")
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 403 {
|
|
t.Errorf("status = %d, want 403", resp.StatusCode)
|
|
}
|
|
if aco := resp.Header.Get("Access-Control-Allow-Origin"); aco != "" {
|
|
t.Errorf("ACAO = %q, want empty", aco)
|
|
}
|
|
}
|
|
|
|
func TestCORSAllow(t *testing.T) {
|
|
url, _ := newTestServer(t)
|
|
body := strings.NewReader(`{"messages":[{"role":"user","content":"hi"}]}`)
|
|
req, _ := http.NewRequest("POST", url+"/api/chat", body)
|
|
req.Header.Set("Origin", "http://localhost:4321")
|
|
req.Header.Set("Content-Type", "application/json")
|
|
resp, err := http.DefaultClient.Do(req)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != 200 {
|
|
t.Errorf("status = %d, want 200", resp.StatusCode)
|
|
}
|
|
if aco := resp.Header.Get("Access-Control-Allow-Origin"); aco != "http://localhost:4321" {
|
|
t.Errorf("ACAO = %q, want http://localhost:4321", aco)
|
|
}
|
|
}
|
|
|
|
// sseEvent is one parsed "event: x\ndata: y\n\n" record.
|
|
type sseEvent struct {
|
|
event string
|
|
data string
|
|
}
|
|
|
|
func parseSSE(t *testing.T, r io.Reader) []sseEvent {
|
|
t.Helper()
|
|
var out []sseEvent
|
|
scanner := bufio.NewScanner(r)
|
|
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
|
|
var cur sseEvent
|
|
flush := func() {
|
|
if cur.event != "" || cur.data != "" {
|
|
out = append(out, cur)
|
|
}
|
|
cur = sseEvent{}
|
|
}
|
|
for scanner.Scan() {
|
|
line := scanner.Text()
|
|
switch {
|
|
case line == "":
|
|
flush()
|
|
case strings.HasPrefix(line, "event: "):
|
|
cur.event = strings.TrimPrefix(line, "event: ")
|
|
case strings.HasPrefix(line, "data: "):
|
|
if cur.data != "" {
|
|
cur.data += "\n"
|
|
}
|
|
cur.data += strings.TrimPrefix(line, "data: ")
|
|
}
|
|
}
|
|
flush()
|
|
if err := scanner.Err(); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// Verify the unused imports are not actually unused (httptest, context, etc.).
|
|
var _ = httptest.NewRecorder
|
|
|
|
// writeFileR + openStoreForTest are tiny shims so the test file doesn't
|
|
// need to import os/filepath directly.
|
|
func writeFileR(path, content string) error {
|
|
return osWriteFile(path, []byte(content), 0o644)
|
|
}
|
|
func openStoreForTest(dbPath, srcDir string) (ragStore, error) {
|
|
return ragOpenStore(dbPath, srcDir)
|
|
} |