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.
116 lines
No EOL
3 KiB
Go
116 lines
No EOL
3 KiB
Go
package portfolio
|
|
|
|
import (
|
|
"context"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestReindexAndSearch(t *testing.T) {
|
|
// Use a temp dir so we don't pollute data/projects
|
|
dir := t.TempDir()
|
|
srcDir := filepath.Join(dir, "src")
|
|
if err := os.MkdirAll(srcDir, 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Copy the real example-project.md
|
|
data, err := os.ReadFile("../../data/projects/example-project.md")
|
|
if err != nil {
|
|
t.Skip("example-project.md not found, skipping integration test:", err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(srcDir, "example-project.md"), data, 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
dbPath := filepath.Join(dir, "test.db")
|
|
store, err := OpenStore(dbPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer store.Close()
|
|
|
|
files, chunks, err := store.Reindex(context.Background(), srcDir, DefaultChunkerConfig())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if files != 1 {
|
|
t.Errorf("files = %d, want 1", files)
|
|
}
|
|
if chunks < 4 {
|
|
t.Errorf("chunks = %d, want >= 4 (frontmatter + 4 H2 sections)", chunks)
|
|
}
|
|
|
|
// Search for something specific to the file
|
|
hits, err := store.Search(context.Background(), "Go SQLite framework", 5)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(hits) == 0 {
|
|
t.Error("expected at least one hit for 'Go SQLite framework'")
|
|
}
|
|
t.Logf("Search hits: %d", len(hits))
|
|
for i, h := range hits {
|
|
t.Logf(" [%d] project=%s section=%s score=%.3f body=%q", i, h.ProjectID, h.Section, h.Score, truncate(h.Content, 80))
|
|
}
|
|
|
|
// No results for nonsense
|
|
hits, err = store.Search(context.Background(), "asdfqwerty12345", 5)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if len(hits) != 0 {
|
|
t.Errorf("expected 0 hits for nonsense query, got %d", len(hits))
|
|
}
|
|
}
|
|
|
|
func TestReindexFromRealData(t *testing.T) {
|
|
// Index the real data/projects/ to sanity-check on real data
|
|
srcDir := "../../data/projects"
|
|
if _, err := os.Stat(srcDir); err != nil {
|
|
t.Skip("data/projects not found:", err)
|
|
}
|
|
dir := t.TempDir()
|
|
dbPath := filepath.Join(dir, "real.db")
|
|
store, err := OpenStore(dbPath)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
defer store.Close()
|
|
files, chunks, err := store.Reindex(context.Background(), srcDir, DefaultChunkerConfig())
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Logf("Indexed %d files, %d chunks", files, chunks)
|
|
if files == 0 {
|
|
t.Fatal("no files indexed")
|
|
}
|
|
// Print what we got
|
|
sections := map[string]int{}
|
|
_, _ = store.Search(context.Background(), "rony", 100) // noop
|
|
// Walk DB to see sections — easier: just re-read with a fresh load
|
|
_ = sections
|
|
|
|
// Verify queries work
|
|
for _, q := range []string{"rony-llm-agent", "Qwen", "agent harness", "tech stack", "deploy"} {
|
|
hits, err := store.Search(context.Background(), q, 3)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
t.Logf("query %q → %d hits", q, len(hits))
|
|
for _, h := range hits {
|
|
if !strings.Contains(strings.ToLower(h.Content), strings.ToLower(strings.SplitN(q, "-", 2)[0])) {
|
|
// FTS5 might do prefix matches; this is a soft assertion
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func truncate(s string, n int) string {
|
|
s = strings.ReplaceAll(s, "\n", " ")
|
|
if len(s) <= n {
|
|
return s
|
|
}
|
|
return s[:n] + "..."
|
|
} |