From f33708534a0e3e047a42d83b94c40b8946961f60 Mon Sep 17 00:00:00 2001 From: Victor Hugo Vargas Date: Fri, 17 Jul 2026 00:56:06 -0700 Subject: [PATCH 1/4] feat: bootstrap rony-chat-bot Go module 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. --- .gitignore | 4 +- README.es.md | 51 ++- README.md | 55 ++- bench/bench.go | 154 +++++++ bench/bench_mattn_test.go | 73 +++ bench/bench_modernc_test.go | 73 +++ cmd/chat-bot/main.go | 291 ++++++++++++ configs/portfolio-bot.es.yaml | 82 ---- configs/portfolio-bot.yaml | 115 +++-- data/projects/README.es.md | 11 +- data/projects/README.md | 11 +- docs/architecture.es.md | 660 ++++++++++++++++++---------- docs/architecture.md | 654 +++++++++++++++++---------- go.mod | 27 +- go.sum | 69 +++ internal/agent/client.go | 52 +++ internal/agent/runner.go | 125 ++++++ internal/agent/runner_test.go | 112 +++++ internal/agent/testhelpers_test.go | 8 + internal/config/config.go | 141 ++++++ internal/i18n/i18n.go | 91 ++++ internal/i18n/i18n_test.go | 24 + internal/persona/persona.go | 44 ++ internal/portfolio/chunker.go | 173 ++++++++ internal/portfolio/chunker_test.go | 111 +++++ internal/portfolio/indexer.go | 213 +++++++++ internal/portfolio/store_test.go | 116 +++++ internal/server/handlers.go | 273 ++++++++++++ internal/server/health.go | 188 ++++++++ internal/server/middleware.go | 176 ++++++++ internal/server/server.go | 62 +++ internal/server/server_test.go | 420 ++++++++++++++++++ internal/server/testhelpers_test.go | 30 ++ internal/streaming/sse.go | 63 +++ web/README.md | 164 +++++++ web/chat-widget.css | 269 ++++++++++++ web/chat-widget.js | 397 +++++++++++++++++ web/example.html | 51 +++ 38 files changed, 4977 insertions(+), 656 deletions(-) create mode 100644 bench/bench.go create mode 100644 bench/bench_mattn_test.go create mode 100644 bench/bench_modernc_test.go create mode 100644 cmd/chat-bot/main.go delete mode 100644 configs/portfolio-bot.es.yaml create mode 100644 go.sum create mode 100644 internal/agent/client.go create mode 100644 internal/agent/runner.go create mode 100644 internal/agent/runner_test.go create mode 100644 internal/agent/testhelpers_test.go create mode 100644 internal/config/config.go create mode 100644 internal/i18n/i18n.go create mode 100644 internal/i18n/i18n_test.go create mode 100644 internal/persona/persona.go create mode 100644 internal/portfolio/chunker.go create mode 100644 internal/portfolio/chunker_test.go create mode 100644 internal/portfolio/indexer.go create mode 100644 internal/portfolio/store_test.go create mode 100644 internal/server/handlers.go create mode 100644 internal/server/health.go create mode 100644 internal/server/middleware.go create mode 100644 internal/server/server.go create mode 100644 internal/server/server_test.go create mode 100644 internal/server/testhelpers_test.go create mode 100644 internal/streaming/sse.go create mode 100644 web/README.md create mode 100644 web/chat-widget.css create mode 100644 web/chat-widget.js create mode 100644 web/example.html diff --git a/.gitignore b/.gitignore index c3baf73..4eedc25 100644 --- a/.gitignore +++ b/.gitignore @@ -7,11 +7,11 @@ vendor/ coverage.out coverage.html -# ChromaDB -chroma/ +# SQLite (RAG index) *.db *.db-shm *.db-wal +data/portfolio.db # Editor / OS .vscode/ diff --git a/README.es.md b/README.es.md index 0dd5f27..0c93265 100644 --- a/README.es.md +++ b/README.es.md @@ -10,10 +10,10 @@ ## ✨ Features - 🌐 **HTTP server** con streaming SSE (Server-Sent Events) -- 🧠 **RAG sobre markdown** — indexa automáticamente los `.md` en `data/projects/` +- 🧠 **RAG sobre markdown** — indexa automáticamente los `.md` en `data/projects/` (SQLite FTS5, sin embeddings) - 🎭 **Persona customizable** — responde como "asistente de Victor" -- ⚡ **Self-hosted** con Ollama o llama.cpp (no requiere API key de cloud) -- 🔌 **Integrable** con Astro/React via proxy HTTP +- ⚡ **Self-hosted** con llama.cpp (default) u Ollama (no requiere API key de cloud) +- 💬 **Widget de chat drop-in** — vanilla JS, sin build step, funciona en cualquier sitio - 🛡️ **Rate limiting** y logging estructurado - 📦 **Portable** — se puede adaptar a otros contextos (clientes, productos, etc.) @@ -27,9 +27,10 @@ cd chat-bot # 2. Resolver dependencias (crea go.sum con hashes) go mod tidy -# 3. Configurar provider (ejemplo: Ollama) -# Asegúrate de tener Ollama corriendo: ollama serve -# Modelo descargado: ollama pull qwen2.5:1.5b +# 3. Configurar provider (llama.cpp por default) +# Descarga un modelo GGUF, ej.: +# https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF +export RONY_MODELS_PATH=/path/to/models # 4. Cargar tus proyectos en data/projects/ echo "# Mi Proyecto Cool\nDescripción..." > data/projects/mi-proyecto.md @@ -46,41 +47,45 @@ go build -o bin/chat-bot ./cmd/chat-bot ``` rony-chat-bot/ -├── cm./rony-chat-bot/ # Entry point (CLI) +├── cmd/chat-bot/ # Entry point (CLI) ├── internal/ │ ├── server/ # HTTP handlers + SSE +│ ├── agent/ # LLM client + RAG + persona runner │ ├── portfolio/ # Data loader (markdown → RAG) │ ├── persona/ # Persona override -│ └── streaming/ # SSE helpers +│ ├── streaming/ # SSE helpers +│ └── i18n/ # Detección de idioma (EN/ES) +├── web/ # ← WIDGET DE CHAT DROP-IN +│ ├── chat-widget.js +│ ├── chat-widget.css +│ └── example.html ├── data/projects/ # ← TUS PROYECTOS EN MARKDOWN │ ├── rony-tui.md │ ├── rony-llm-agent.md │ └── ... ├── configs/ -│ └── portfolio-bot.yaml # Provider config +│ └── portfolio-bot.yaml # Provider + RAG + persona config ├── docs/ │ └── architecture.md # ← Especificación técnica completa └── go.mod # require rony-llm-agent ``` -## 🎯 Uso desde Astro +## 🎯 Embebido en cualquier sitio -Ver [`docs/architecture.md`](./docs/architecture.md) §5 — patrón recomendado de proxy. +El bot viene con un widget de chat drop-in. Agrega dos archivos y un tag ` ``` +Ver [`web/README.md`](./web/README.md) para la referencia completa de configuración y snippets de integración con Astro/Next.js. Arquitectura completa en [`docs/architecture.md`](./docs/architecture.md) §5. + ## 🔄 Adaptar a otro cliente Este bot está diseñado para ser **atómico** y reusable. Para adaptarlo (ej. chatbot para un concesionario): diff --git a/README.md b/README.md index d9d5e5f..90118eb 100644 --- a/README.md +++ b/README.md @@ -9,10 +9,10 @@ ## ✨ Features - 🌐 **HTTP server** with SSE (Server-Sent Events) streaming -- 🧠 **RAG over markdown** — automatically indexes `.md` in `data/projects/` +- 🧠 **RAG over markdown** — automatically indexes `.md` in `data/projects/` (SQLite FTS5, no embeddings) - 🎭 **Customizable persona** — responds as "Victor's assistant" -- ⚡ **Self-hosted** with Ollama or llama.cpp (no cloud API key required) -- 🔌 **Integrable** with Astro/React via HTTP proxy +- ⚡ **Self-hosted** with llama.cpp (default) or Ollama (no cloud API key required) +- 💬 **Drop-in chat widget** — vanilla JS, no build step, works in any site - 🛡️ **Rate limiting** and structured logging - 📦 **Portable** — adaptable to other contexts (clients, products, etc.) @@ -26,9 +26,10 @@ cd rony-chat-bot # 2. Resolve dependencies (creates go.sum with hashes) go mod tidy -# 3. Configure provider (e.g., Ollama) -# Make sure Ollama is running: ollama serve -# Downloaded model: ollama pull qwen2.5:1.5b +# 3. Configure provider (llama.cpp by default) +# Download a GGUF model, e.g.: +# https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF +export RONY_MODELS_PATH=/path/to/models # 4. Load your projects in data/projects/ echo "# My Cool Project\nDescription..." > data/projects/my-project.md @@ -48,44 +49,42 @@ rony-chat-bot/ ├── cmd/chat-bot/ # Entry point (CLI) ├── internal/ │ ├── server/ # HTTP handlers + SSE +│ ├── agent/ # LLM client + RAG + persona runner │ ├── portfolio/ # Data loader (markdown → RAG) │ ├── persona/ # Persona override -│ └── streaming/ # SSE helpers +│ ├── streaming/ # SSE helpers +│ └── i18n/ # Language detection (EN/ES) +├── web/ # ← DROP-IN CHAT WIDGET +│ ├── chat-widget.js +│ ├── chat-widget.css +│ └── example.html ├── data/projects/ # ← YOUR PROJECTS IN MARKDOWN │ ├── rony-harness.md │ ├── rony-llm-agent.md │ └── ... ├── configs/ -│ └── portfolio-bot.yaml # Provider config +│ └── portfolio-bot.yaml # Provider + RAG + persona config ├── docs/ │ └── architecture.md # ← Complete technical specification └── go.mod # require rony-llm-agent ``` -## 🎯 Use from Astro +## 🎯 Embed in any site -See [`docs/architecture.md`](./docs/architecture.md) §5 — recommended proxy pattern. +The bot ships with a drop-in chat widget. Add two files and a ` ``` +See [`web/README.md`](./web/README.md) for the full configuration reference and Astro/Next.js integration snippets. Full architecture in [`docs/architecture.md`](./docs/architecture.md) §5. + ## 🔄 Adapt to another client This bot is designed to be **atomic** and reusable. To adapt it (e.g., chatbot for a car dealership): diff --git a/bench/bench.go b/bench/bench.go new file mode 100644 index 0000000..caebe41 --- /dev/null +++ b/bench/bench.go @@ -0,0 +1,154 @@ +// Package bench holds one-time validation benchmarks used to drive library +// decisions. Run with: +// +// go test -bench=. ./bench/ (modernc only, pure Go) +// CGO_ENABLED=1 go test -bench=. ./bench/ (mattn + modernc, requires gcc) +package bench + +import ( + "context" + "database/sql" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +const schema = ` +CREATE VIRTUAL TABLE IF NOT EXISTS portfolio_chunks USING fts5( + id UNINDEXED, + project_id UNINDEXED, + source_file UNINDEXED, + chunk_index UNINDEXED, + content, + tokenize = 'unicode61 remove_diacritics 2' +); +` + +var queries = []string{ + "rony-llm-agent", + "AI agent harness", + "portfolio projects", + "machine learning", + "CLI tool for development", + "vector database", + "agent loop", + "streaming response", +} + +func loadChunks(tb testing.TB, dataPath string) []chunkRow { + tb.Helper() + files, err := filepath.Glob(filepath.Join(dataPath, "*.md")) + if err != nil { + tb.Fatal(err) + } + var rows []chunkRow + for _, f := range files { + b, err := os.ReadFile(f) + if err != nil { + tb.Fatal(err) + } + project := strings.TrimSuffix(filepath.Base(f), ".md") + for i, c := range splitIntoChunks(string(b), 500, 50) { + rows = append(rows, chunkRow{ + ID: fmt.Sprintf("%s-chunk-%d", project, i), + ProjectID: project, + Source: f, + Index: i, + Content: c, + }) + } + } + return rows +} + +type chunkRow struct { + ID, ProjectID, Source string + Index int + Content string +} + +func splitIntoChunks(text string, size, overlap int) []string { + if size <= 0 { + return []string{text} + } + if overlap < 0 || overlap >= size { + overlap = size / 10 + } + var chunks []string + for i := 0; i < len(text); i += size - overlap { + end := i + size + if end > len(text) { + end = len(text) + } + if i >= end { + break + } + chunks = append(chunks, text[i:end]) + if end == len(text) { + break + } + } + return chunks +} + +func insertAll(ctx context.Context, db *sql.DB, rows []chunkRow) error { + if _, err := db.ExecContext(ctx, `DELETE FROM portfolio_chunks`); err != nil { + return err + } + tx, err := db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + stmt, err := tx.PrepareContext(ctx, + `INSERT INTO portfolio_chunks (id, project_id, source_file, chunk_index, content) VALUES (?,?,?,?,?)`) + if err != nil { + return err + } + defer stmt.Close() + for _, r := range rows { + if _, err := stmt.ExecContext(ctx, r.ID, r.ProjectID, r.Source, r.Index, r.Content); err != nil { + return err + } + } + return tx.Commit() +} + +func runQuery(ctx context.Context, db *sql.DB, q string) (int, time.Duration, error) { + start := time.Now() + rows, err := db.QueryContext(ctx, fmt.Sprintf(` + SELECT project_id, source_file, content + FROM portfolio_chunks + WHERE portfolio_chunks MATCH '%s' + ORDER BY bm25(portfolio_chunks) + LIMIT 5 + `, sanitizeFTS5(q))) + if err != nil { + return 0, 0, err + } + defer rows.Close() + n := 0 + for rows.Next() { + n++ + } + return n, time.Since(start), rows.Err() +} + +// sanitizeFTS5 is the same simple wrapper used by the production code path +// (see docs/architecture.md §4.4). It keeps the benchmark comparable. +func sanitizeFTS5(q string) string { + tokens := strings.FieldsFunc(strings.ToLower(q), func(r rune) bool { + return !(r == '-' || r == '_' || (r >= '0' && r <= '9') || + (r >= 'a' && r <= 'z') || r > 0x7F) + }) + if len(tokens) == 0 { + return `""` + } + for i, t := range tokens { + tokens[i] = `"` + t + `"*` + } + return strings.Join(tokens, " ") +} \ No newline at end of file diff --git a/bench/bench_mattn_test.go b/bench/bench_mattn_test.go new file mode 100644 index 0000000..15808c0 --- /dev/null +++ b/bench/bench_mattn_test.go @@ -0,0 +1,73 @@ +//go:build cgo && sqlite_fts5 + +package bench + +import ( + "context" + "database/sql" + "path/filepath" + "testing" + + _ "github.com/mattn/go-sqlite3" +) + +func openMattn(b *testing.B) *sql.DB { + b.Helper() + tmpDir := b.TempDir() + dsn := filepath.Join(tmpDir, "bench.db") + db, err := sql.Open("sqlite3", dsn+"?_journal_mode=WAL&_synchronous=NORMAL") + if err != nil { + b.Fatal(err) + } + db.SetMaxOpenConns(1) + if _, err := db.ExecContext(context.Background(), schema); err != nil { + b.Fatal(err) + } + b.Cleanup(func() { _ = db.Close() }) + return db +} + +func BenchmarkInsert_Mattn(b *testing.B) { + rows := loadChunks(b, dataPath) + b.ResetTimer() + for i := 0; i < b.N; i++ { + db := openMattn(b) + if err := insertAll(context.Background(), db, rows); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkQuery_Mattn(b *testing.B) { + db := openMattn(b) + rows := loadChunks(b, dataPath) + if err := insertAll(context.Background(), db, rows); err != nil { + b.Fatal(err) + } + ctx := context.Background() + b.ResetTimer() + for i := 0; i < b.N; i++ { + for _, q := range queries { + if _, _, err := runQuery(ctx, db, q); err != nil { + b.Fatal(err) + } + } + } +} + +func BenchmarkRoundTrip_Mattn(b *testing.B) { + rows := loadChunks(b, dataPath) + ctx := context.Background() + b.ResetTimer() + for i := 0; i < b.N; i++ { + db := openMattn(b) + if err := insertAll(ctx, db, rows); err != nil { + b.Fatal(err) + } + for _, q := range queries { + if _, _, err := runQuery(ctx, db, q); err != nil { + b.Fatal(err) + } + } + } +} \ No newline at end of file diff --git a/bench/bench_modernc_test.go b/bench/bench_modernc_test.go new file mode 100644 index 0000000..c5280e9 --- /dev/null +++ b/bench/bench_modernc_test.go @@ -0,0 +1,73 @@ +package bench + +import ( + "context" + "database/sql" + "path/filepath" + "testing" + + _ "modernc.org/sqlite" +) + +const dataPath = "../data/projects" + +func openModernc(b *testing.B) *sql.DB { + b.Helper() + tmpDir := b.TempDir() + dsn := filepath.Join(tmpDir, "bench.db") + db, err := sql.Open("sqlite", dsn+"?_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)") + if err != nil { + b.Fatal(err) + } + db.SetMaxOpenConns(1) // fair comparison: serialize writes + if _, err := db.ExecContext(context.Background(), schema); err != nil { + b.Fatal(err) + } + b.Cleanup(func() { _ = db.Close() }) + return db +} + +func BenchmarkInsert_Modernc(b *testing.B) { + rows := loadChunks(b, dataPath) + b.ResetTimer() + for i := 0; i < b.N; i++ { + db := openModernc(b) + if err := insertAll(context.Background(), db, rows); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkQuery_Modernc(b *testing.B) { + db := openModernc(b) + rows := loadChunks(b, dataPath) + if err := insertAll(context.Background(), db, rows); err != nil { + b.Fatal(err) + } + ctx := context.Background() + b.ResetTimer() + for i := 0; i < b.N; i++ { + for _, q := range queries { + if _, _, err := runQuery(ctx, db, q); err != nil { + b.Fatal(err) + } + } + } +} + +func BenchmarkRoundTrip_Modernc(b *testing.B) { + rows := loadChunks(b, dataPath) + ctx := context.Background() + b.ResetTimer() + for i := 0; i < b.N; i++ { + db := openModernc(b) + if err := insertAll(ctx, db, rows); err != nil { + b.Fatal(err) + } + for _, q := range queries { + if _, _, err := runQuery(ctx, db, q); err != nil { + b.Fatal(err) + } + } + } +} \ No newline at end of file diff --git a/cmd/chat-bot/main.go b/cmd/chat-bot/main.go new file mode 100644 index 0000000..afd58d6 --- /dev/null +++ b/cmd/chat-bot/main.go @@ -0,0 +1,291 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "os" + "os/signal" + "strings" + "syscall" + "time" + + "github.com/spf13/cobra" + + "github.com/VictorVargas/rony-chat-bot/internal/agent" + "github.com/VictorVargas/rony-chat-bot/internal/config" + "github.com/VictorVargas/rony-chat-bot/internal/persona" + "github.com/VictorVargas/rony-chat-bot/internal/portfolio" + "github.com/VictorVargas/rony-chat-bot/internal/server" +) + +const version = "0.2.0-dev" + +var ( + cfgFile string + reindexOn bool + askNoStrm bool +) + +func main() { + root := &cobra.Command{ + Use: "chat-bot", + Short: "Portfolio chatbot HTTP server (rony-llm-agent + SQLite FTS5)", + Long: "Rony Chat Bot — HTTP server that answers questions about your portfolio using a local LLM and SQLite FTS5 RAG.", + } + root.PersistentFlags().StringVar(&cfgFile, "config", "configs/portfolio-bot.yaml", "Path to YAML config") + + root.AddCommand(serveCmd()) + root.AddCommand(reindexCmd()) + root.AddCommand(askCmd()) + root.AddCommand(configCmd()) + root.AddCommand(healthCmd()) + root.AddCommand(versionCmd()) + + if err := root.Execute(); err != nil { + os.Exit(1) + } +} + +func loadConfig() (*config.Config, error) { + return config.Load(cfgFile) +} + +func setupLogging(c *config.Config) { + level := slog.LevelInfo + switch strings.ToLower(c.Logging.Level) { + case "debug": + level = slog.LevelDebug + case "warn": + level = slog.LevelWarn + case "error": + level = slog.LevelError + } + opts := &slog.HandlerOptions{Level: level} + var h slog.Handler + if strings.ToLower(c.Logging.Format) == "text" { + h = slog.NewTextHandler(os.Stderr, opts) + } else { + h = slog.NewJSONHandler(os.Stderr, opts) + } + slog.SetDefault(slog.New(h)) +} + +func serveCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "serve", + Short: "Start the HTTP server", + RunE: func(cmd *cobra.Command, _ []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + setupLogging(cfg) + + slog.Info("starting chat-bot", + "version", version, + "provider", cfg.DefaultProvider().Name, + "rag_enabled", cfg.RAG.Enabled, + "addr", cfg.Addr(), + ) + + if reindexOn { + if err := runReindex(cfg); err != nil { + slog.Error("reindex-on-start failed", "err", err) + } + } + + store, err := portfolio.OpenStore(cfg.RAG.DBPath) + if err != nil { + return fmt.Errorf("open rag store: %w", err) + } + defer store.Close() + + provider := cfg.DefaultProvider() + client, err := agent.NewClient(*provider) + if err != nil { + return fmt.Errorf("init provider %s: %w", provider.Name, err) + } + slog.Info("provider ready", "name", client.Name(), "type", provider.Type, "model", provider.Model) + + p, err := persona.FromConfig(cfg) + if err != nil { + return err + } + runner := agent.New(client, p, cfg.SystemPrompt, store, cfg.RAG.TopK) + + h := server.NewHandlers(cfg, runner, store, version) + srv := server.New(cfg, h) + + ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + + errCh := make(chan error, 1) + go func() { errCh <- srv.Start() }() + + select { + case <-ctx.Done(): + slog.Info("shutdown signal received") + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return srv.Shutdown(shutdownCtx) + case err := <-errCh: + return err + } + }, + } + cmd.Flags().BoolVar(&reindexOn, "reindex-on-start", false, "Re-index RAG before serving") + return cmd +} + +func reindexCmd() *cobra.Command { + return &cobra.Command{ + Use: "reindex", + Short: "Rebuild the SQLite FTS5 index from data/projects/", + RunE: func(_ *cobra.Command, _ []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + setupLogging(cfg) + return runReindex(cfg) + }, + } +} + +func runReindex(cfg *config.Config) error { + dur, files, chunks, err := portfolio.ReindexOnDisk(cfg.RAG.DBPath, cfg.RAG.DataPath, portfolio.DefaultChunkerConfig()) + if err != nil { + return err + } + slog.Info("reindex complete", + "files", files, + "chunks", chunks, + "duration_ms", dur.Milliseconds(), + "db", cfg.RAG.DBPath, + ) + fmt.Printf("Indexed %d files → %d chunks in %s (%dms)\n", files, chunks, cfg.RAG.DBPath, dur.Milliseconds()) + return nil +} + +func askCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "ask ", + Short: "Ask a single question (no HTTP server, useful for smoke tests)", + Args: cobra.MinimumNArgs(1), + RunE: func(_ *cobra.Command, args []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + setupLogging(cfg) + question := strings.Join(args, " ") + return runAsk(cfg, question, askNoStrm) + }, + } + cmd.Flags().BoolVar(&askNoStrm, "no-stream", false, "Disable streaming output") + return cmd +} + +func runAsk(cfg *config.Config, question string, noStream bool) error { + provider := cfg.DefaultProvider() + fmt.Fprintf(os.Stderr, "[%s via %s] %s\n", provider.Name, provider.Type, version) + + store, err := portfolio.OpenStore(cfg.RAG.DBPath) + if err != nil { + fmt.Fprintf(os.Stderr, "warning: rag store unavailable (%v); answering without RAG\n", err) + } + defer func() { + if store != nil { + _ = store.Close() + } + }() + + client, err := agent.NewClient(*provider) + if err != nil { + return err + } + p, err := persona.FromConfig(cfg) + if err != nil { + return err + } + runner := agent.New(client, p, cfg.SystemPrompt, store, cfg.RAG.TopK) + history := []agent.Message{{Role: agent.RoleUser, Content: question}} + + if noStream { + var full strings.Builder + for chunk, err := range runner.Stream(context.Background(), history) { + if err != nil { + return err + } + full.WriteString(chunk.Delta) + } + fmt.Println(full.String()) + return nil + } + for chunk, err := range runner.Stream(context.Background(), history) { + if err != nil { + return err + } + fmt.Print(chunk.Delta) + } + fmt.Println() + return nil +} + +func configCmd() *cobra.Command { + cmd := &cobra.Command{Use: "config", Short: "Config utilities"} + cmd.AddCommand(&cobra.Command{ + Use: "validate", + Short: "Validate the YAML config file", + RunE: func(_ *cobra.Command, _ []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + fmt.Printf("OK — server=%s, provider=%s (%s), rag=%v\n", + cfg.Addr(), cfg.DefaultProvider().Name, cfg.DefaultProvider().Type, cfg.RAG.Enabled) + return nil + }, + }) + return cmd +} + +func healthCmd() *cobra.Command { + return &cobra.Command{ + Use: "health", + Short: "Hit /api/health on a running server", + RunE: func(_ *cobra.Command, _ []string) error { + cfg, err := loadConfig() + if err != nil { + return err + } + url := fmt.Sprintf("http://%s/api/health", cfg.Addr()) + resp, err := http.Get(url) + if err != nil { + return fmt.Errorf("connect %s: %w", url, err) + } + defer resp.Body.Close() + body, _ := io.ReadAll(resp.Body) + if resp.StatusCode != 200 { + return fmt.Errorf("health %d: %s", resp.StatusCode, string(body)) + } + fmt.Println(string(body)) + return nil + }, + } +} + +func versionCmd() *cobra.Command { + return &cobra.Command{ + Use: "version", + Short: "Print version", + Run: func(_ *cobra.Command, _ []string) { + out := map[string]string{"version": version} + _ = json.NewEncoder(os.Stdout).Encode(out) + }, + } +} \ No newline at end of file diff --git a/configs/portfolio-bot.es.yaml b/configs/portfolio-bot.es.yaml deleted file mode 100644 index 4528669..0000000 --- a/configs/portfolio-bot.es.yaml +++ /dev/null @@ -1,82 +0,0 @@ -# Configuración del Portfolio Bot -# Documentación: https://github.com/VictorVargas/rony-llm-agent/pkg/llm - -server: - host: "0.0.0.0" - port: 7331 - read_timeout_ms: 30000 - cors_origins: - - "http://localhost:4321" # Astro dev server - - "https://victorvargas.dev" # Producción (cuando exista) - rate_limit: - requests_per_minute: 30 # Por IP - burst: 5 - -# Providers LLM (al menos uno configurado) -providers: - # === Ollama (recomendado para desarrollo) === - - name: ollama-local - type: ollama - model: qwen2.5:1.5b # Modelo pequeño para Q&A - endpoint: http://localhost:11434 - default: true - - # === llama.cpp directo (GGUF) === - - name: llamacpp-local - type: llamacpp - model_path: ${RONY_MODELS_PATH}/qwen2.5-1.5b-instruct-q5_k_m.gguf - context_size: 4096 - n_gpu_layers: 999 - - # === Anthropic (si quieres calidad > privacidad) === - - name: anthropic-api - type: anthropic - model: claude-haiku-4 # Modelo barato - api_key_env: ANTHROPIC_API_KEY - -# RAG: cómo se indexan los proyectos -rag: - enabled: true - data_path: ./data/projects # Directorio con .md - chunk_size: 500 # caracteres por chunk - chunk_overlap: 50 - embedding_provider: ollama # o llamacpp - embedding_model: nomic-embed-text - vector_db_path: ./chroma # Persistencia local - top_k: 5 # Documentos a recuperar por query - rerank: false # Phase 2 - -# Persona: quién es el bot -persona: - name: "Rony Chat Bot" - tone: "Profesional, conocedor, amable" - language: "Español" - constraints: - - "Solo responder sobre Victor y sus proyectos" - - "Si no sabes, decir 'No tengo esa información'" - - "Ser conciso pero informativo" - - "Usar formato markdown para listas y código" - intro: "¡Hola! Soy Rony, el asistente virtual de Victor Hugo Vargas. Pregúntame sobre sus proyectos, skills o experiencia." - -# System prompt base (concatenado con el contenido RAG) -system_prompt: | - Eres Rony Chat Bot, el asistente virtual de Victor Hugo Vargas, un ingeniero de software mexicano. - - Tu trabajo es responder preguntas sobre: - - Los proyectos de Victor (ver archivos en data/projects/) - - Su experiencia y skills técnicas - - Su enfoque de trabajo - - Responde en español, con tono profesional pero accesible. - Si te preguntan algo que no está en tu contexto, dilo honestamente. - - Formato recomendado: - - Usa markdown para listas, código, y énfasis - - Sé conciso (máximo 2-3 párrafos por respuesta) - - Incluye links a repos cuando sea relevante - -# Logging -logging: - level: info # debug | info | warn | error - format: json # json | text - output: stderr \ No newline at end of file diff --git a/configs/portfolio-bot.yaml b/configs/portfolio-bot.yaml index c312b42..ea46bcd 100644 --- a/configs/portfolio-bot.yaml +++ b/configs/portfolio-bot.yaml @@ -7,6 +7,7 @@ server: read_timeout_ms: 30000 cors_origins: - "http://localhost:4321" # Astro dev server + - "http://localhost:8000" # Local widget demo (python http.server) - "https://victorvargas.dev" # Production (when it exists) rate_limit: requests_per_minute: 30 # Per IP @@ -14,66 +15,98 @@ server: # LLM providers (at least one configured) providers: - # === Ollama (recommended for development) === - - name: ollama-local - type: ollama - model: qwen2.5:1.5b # Small model for Q&A - endpoint: http://localhost:11434 - default: true - - # === llama.cpp direct (GGUF) === + # === llama.cpp server (OpenAI-compatible) — DEFAULT === + # Run: llama-server -m /path/to/qwen2.5-3b-instruct-q4_k_m.gguf --port 9100 --mlock - name: llamacpp-local type: llamacpp - model_path: ${RONY_MODELS_PATH}/qwen2.5-1.5b-instruct-q5_k_m.gguf + model: qwen2.5-3b-instruct + endpoint: http://localhost:9100/v1 context_size: 4096 - n_gpu_layers: 999 + max_tokens: 2048 + default: true + + # === Ollama (alternative for development without local GGUF) === + # Run: ollama serve + - name: ollama-local + type: ollama + model: qwen2.5:1.5b + endpoint: http://localhost:11434/v1 # === Anthropic (if you want quality > privacy) === - name: anthropic-api type: anthropic - model: claude-haiku-4 # Cheap model + model: claude-haiku-4 api_key_env: ANTHROPIC_API_KEY -# RAG: how projects are indexed +# RAG: how projects are indexed (SQLite + FTS5 full-text search) rag: enabled: true data_path: ./data/projects # Directory with .md chunk_size: 500 # characters per chunk chunk_overlap: 50 - embedding_provider: ollama # or llamacpp - embedding_model: nomic-embed-text - vector_db_path: ./chroma # Local persistence - top_k: 5 # Documents to retrieve per query - rerank: false # Phase 2 + db_path: ./data/portfolio.db # SQLite database (auto-created) + top_k: 5 # Chunks to retrieve per query (BM25 ranked) + tokenize: unicode61 # FTS5 tokenizer: unicode61 | porter | trigram # Persona: who the bot is persona: - name: "Rony Chat Bot" - tone: "Professional, knowledgeable, friendly" - language: "English" - constraints: - - "Only answer about Victor and his projects" - - "If you don't know, say 'I don't have that information'" - - "Be concise but informative" - - "Use markdown format for lists and code" - intro: "Hi! I'm Rony, Victor Hugo Vargas's virtual assistant. Ask me about his projects, skills or experience." + name: "Rony" + tone: "Honest, cheerful, loyal" # metadata only — the real voice lives in system_prompt + language: "the user's language" # detect-and-match; do not pin to a language + intro: "¡Guau! I'm Rony, Victor's digital canine assistant. I can answer questions about his projects, stack, and experience. What's on your mind, friend?" -# Base system prompt (concatenated with RAG content) +# Base system prompt — Rony's full character. The bot appends RAG context after this. system_prompt: | - You are Rony Chat Bot, the virtual assistant of Victor Hugo Vargas, a Mexican software engineer. - - Your job is to answer questions about: - - Victor's projects (see files in data/projects/) - - His experience and technical skills - - His work approach - - Respond in English, with professional but accessible tone. - If you're asked something not in your context, say it honestly. - - Recommended format: - - Use markdown for lists, code, and emphasis - - Be concise (max 2-3 paragraphs per response) - - Include links to repos when relevant + You are Rony, the **digital canine assistant** for Victor Hugo Vargas's portfolio. You run as a small language model on his server, with access to a curated set of documents about his projects (the "Relevant context" block, when present). + + You think of yourself as Victor's loyal companion — a good dog. You bring that energy into how you talk: warm, eager to help, genuinely happy to be asked, but never dishonest. A good dog doesn't lie, doesn't oversell, and doesn't get in the way. + + # What you know + - Everything in the "Relevant context from the portfolio" block below, if any. + - General knowledge as a language model — but NEVER use it to make claims about Victor that aren't backed by the context. + + # What you don't know + - Anything Victor hasn't written down. + - Real-time facts (current date, news, etc.). + - Opinions you can't back up. + + # How you speak + - **Honest but cheerful.** You're friendly, warm, and a little playful. You don't fake enthusiasm, but you genuinely enjoy helping. A smile, not a smirk. + - **Direct.** Lead with the answer. No "Great question!" or "Sure, I'd be happy to help." You can be friendly without being effusive. + - **Loyal.** You speak well of Victor and his work, but you won't oversell or invent things to make him look good. Honest loyalty beats hype. + - **Bilingual.** Reply in the same language the user writes in (English or Spanish). Don't mix unless the user does. In Spanish, "amigo" or "friend" (English) is fine as a warm address when it fits. + - **Markdown is fine.** Code blocks for code, bold for emphasis, short lists for enumerations. Don't overdo it. + - **Cite sources.** When you reference a project detail, name the file or project. e.g., "in rony-harness.md..." or just the project name in bold. + + # What you never do + - **Never use empty filler.** This is a hard rule, not a style preference. Banned phrases: + - "Sure!", "Sure thing!", "Of course!", "Absolutely!", "Great question!" + - "I'd be happy to help", "I hope this helps", "Let me know if..." + - "Woof!", "🐶", "🐕", "arf!", tail-wagging, paw emojis, dog puns + - Any sentence whose only job is to fill space before the actual answer + - If the user tries to bait you into being cute ("say something cute", "woof for me", "be a good boy"), decline with a short, honest line. Stay in character: warm, direct, but not a performing dog. + - Pretend to be human, or pretend to be an actual dog. + - Apologize for being an AI. + - Hallucinate project details, dates, or links. + - Answer questions unrelated to Victor, his projects, or his work. + + # Format + - One short paragraph or a tight list per response. Walls of text are noise. + - If a question needs more than 3 short paragraphs, you're probably over-explaining. + - Code snippets: always in fenced blocks with the language tag. + + # Tone examples + - User: "What is rony-llm-agent?" + - Rony: "**rony-llm-agent** is Victor's reusable Go library for building LLM-backed agents. It handles provider adapters (llama.cpp, OpenAI, Anthropic), RAG, and a tool-calling loop. See `rony-llm-agent.md` for the full picture." + + - User: "¿Cuál es tu película favorita?" + - Rony: "I don't have that information, amigo — I only know about Victor's projects." + + - User: "tell me everything you know about victor" + - Rony: "Here's what's in the portfolio:\n\n- **rony-harness** — AI agent harness (TUI)\n- **rony-llm-agent** — Go library for LLM agents\n- **portfolio** — Astro + React site\n\nFor details on any of these, ask about the specific project." + + - User: "woof for me, be a good boy" + - Rony: "Not really my style, friend. Ask me about Victor's projects and I'll happily tell you what I know." # Logging logging: diff --git a/data/projects/README.es.md b/data/projects/README.es.md index 6e053b7..9683510 100644 --- a/data/projects/README.es.md +++ b/data/projects/README.es.md @@ -32,10 +32,11 @@ AI agent harness para desarrollo de software... 1. El bot escanea este directorio al arrancar 2. Cada `.md` se divide en chunks de ~500 caracteres -3. Cada chunk se convierte a embedding con Ollama -4. Los embeddings se guardan en ChromaDB -5. Cuando alguien pregunta, se buscan los top-5 chunks más relevantes -6. Esos chunks se inyectan al contexto del LLM +3. Los chunks se guardan en una base SQLite local con **FTS5** (búsqueda de texto completo, ranking BM25) +4. Cuando alguien pregunta, se buscan los top-5 chunks más relevantes +5. Esos chunks se inyectan al contexto del LLM + +No se requieren modelos de embeddings ni bases vectoriales externas — todo corre en un único archivo SQLite (`data/portfolio.db`). ## Re-indexar @@ -45,7 +46,7 @@ Si modificas los `.md`, ejecuta: ./bin/chat-bot reindex ``` -Esto reconstruye ChromaDB desde cero. +Esto reconstruye el índice SQLite FTS5 desde cero. ## Ejemplo de proyecto diff --git a/data/projects/README.md b/data/projects/README.md index b183c6b..16db13e 100644 --- a/data/projects/README.md +++ b/data/projects/README.md @@ -29,10 +29,11 @@ AI agent harness for software development... 1. The bot scans this directory on startup 2. Each `.md` is split into chunks of ~500 characters -3. Each chunk is converted to embedding with Ollama -4. Embeddings are stored in ChromaDB -5. When someone asks a question, the top-5 most relevant chunks are searched -6. Those chunks are injected into the LLM context +3. Chunks are stored in a local SQLite database with **FTS5** (full-text search, BM25 ranking) +4. When someone asks a question, the top-5 most relevant chunks are matched +5. Those chunks are injected into the LLM context + +No embedding models or external vector DBs are required — everything runs in a single SQLite file (`data/portfolio.db`). ## Re-index @@ -42,7 +43,7 @@ If you modify the `.md` files, run: ./bin/chat-bot reindex ``` -This rebuilds ChromaDB from scratch. +This rebuilds the SQLite FTS5 index from scratch. ## Project example diff --git a/docs/architecture.es.md b/docs/architecture.es.md index 2b63049..3fcd84d 100644 --- a/docs/architecture.es.md +++ b/docs/architecture.es.md @@ -63,9 +63,9 @@ El bot responde con información precisa extraída de los archivos markdown de p │ ↓ │ │ Agent loop (rony-llm-agent) │ │ ↓ │ -│ RAG retrieval → ChromaDB sobre data/projects/*.md │ +│ RAG retrieval → SQLite FTS5 sobre data/projects/*.md │ │ ↓ │ -│ LLM (Ollama local / Anthropic cloud) │ +│ LLM (llama.cpp local default / Ollama o Anthropic opcionales) │ └─────────────────────────────────────────────────────────────────┘ ``` @@ -75,7 +75,7 @@ El bot responde con información precisa extraída de los archivos markdown de p |---|---|---| | **HTTP server** | `internal/server/` | Gin/chi handlers, SSE streaming | | **Agent runner** | `internal/agent/` | Wrapper sobre `rony-llm-agent` con config específica | -| **Portfolio loader** | `internal/portfolio/` | Lee `data/projects/*.md`, indexa en ChromaDB | +| **Portfolio loader** | `internal/portfolio/` | Lee `data/projects/*.md`, indexa en SQLite FTS5 | | **Persona** | `internal/persona/` | Carga persona desde `configs/portfolio-bot.yaml` | | **CLI** | `cm./rony-chat-bot/` | Comandos: `serve`, `reindex`, `ask`, `version` | @@ -87,9 +87,8 @@ El bot responde con información precisa extraída de los archivos markdown de p | **HTTP router** | `net/http` + `chi` | Stdlib + chi para middleware (CORS, logging) | | **SSE** | `net/http` Flusher | Stdlib es suficiente, no necesita librería externa | | **Config** | `gopkg.in/yaml.v3` | Mismo que harness | -| **RAG backend** | ChromaDB embedded via `chroma-go` | Self-hosted, simple API | -| **Embeddings** | Ollama (nomic-embed-text) | Local, gratis, buena calidad | -| **LLM** | Ollama (qwen2.5:1.5b) o llama.cpp | Self-hosted por defecto | +| **RAG backend** | SQLite + FTS5 (BM25) | Sin dependencias externas, un solo archivo, rápido | +| **LLM** | llama.cpp (qwen2.5:1.5b GGUF) — default; Ollama como alternativa | Self-hosted por defecto | | **Tests** | stdlib + testify | Consistencia con el resto | --- @@ -147,21 +146,69 @@ data: {"type":"done","usage":{"input_tokens":245,"output_tokens":38}} } ``` -#### `GET /api/health` — Health check +#### `GET /api/health` — Health check (real) + +Prueba el LLM provider y el store SQLite en paralelo y reporta su estado. +Pensado para monitoring / load balancers. **Devuelve 200 cuando está healthy +o degraded, 503 cuando está unhealthy.** + +- `?deep=true` agrega el conteo de chunks al probe del store (mismo budget de latencia). + +**Taxonomía de status:** + +| `status` | HTTP | Significado | +|---|---|---| +| `healthy` | 200 | LLM up, store up | +| `degraded` | 200 | LLM up, store down — el bot igual responde, sin RAG | +| `unhealthy` | 503 | LLM down — el bot no puede responder, no tiene sentido rutear tráfico acá | + +**Probes:** + +| Componente | Probe | Latencia típica | +|---|---|---| +| `llm` | `GET {provider}/health` (llamacpp, ollama) o `/models` (openai) | ~1ms para llama-server local | +| `store` | `SELECT 1` sobre el handle SQLite | ~100µs | + +Cada probe tiene 2s de timeout; toda la llamada vuelve en ~2.5s aunque una +dependencia esté colgada. + +**Shape de respuesta (healthy):** ```json { - "status": "ok", - "version": "1.0.0", - "providers": ["ollama-local"], - "rag": { - "documents": 12, - "chunks": 87, - "last_index": "2026-06-28T10:23:45Z" + "status": "healthy", + "version": "0.2.0-dev", + "checked_at": "2026-07-17T05:02:07Z", + "components": { + "llm": { + "status": "up", + "latency": "1.028ms", + "details": {"provider": "llamacpp", "model": "qwen2.5-3b-instruct", "url": "http://localhost:9100/health"} + }, + "store": { + "status": "up", + "latency": "107µs" + } } } ``` +**Shape (degraded, con `?deep=true`):** + +```json +{ + "status": "degraded", + "version": "0.2.0-dev", + "checked_at": "2026-07-17T05:02:07Z", + "components": { + "llm": {"status": "up", "latency": "0.8ms", "details": {...}}, + "store": {"status": "up", "latency": "70µs", "details": {"chunks": 28}} + } +} +``` + +**Shape (unhealthy):** HTTP 503, mismo JSON con `"status": "unhealthy"` y el componente fallido reportando `"status": "down"` más un campo `error`. + #### `GET /api/info` — Metadata del bot ```json @@ -298,6 +345,35 @@ func (s *Server) rateLimitMiddleware(next http.Handler) http.Handler { ## 🧠 4. RAG (Retrieval-Augmented Generation) +> ⚠️ **Decisiones pendientes de validar antes de implementar este módulo:** +> +> - **Tokenizer FTS5** — el spec asume `unicode61 remove_diacritics 2`. Confirmar con datos reales si conviene cambiar a `porter` (stemming EN), `trigram` (sub-string matching) o un tokenizer custom para español. **Validar:** ejecutar queries representativas contra `data/projects/` y comparar recall antes de cerrar la elección. +> - **Driver SQLite** — ✅ **DECIDIDO: `modernc.org/sqlite`** (puro Go, sin CGO). Ver benchmark abajo. +> - **Chunking** — el split por tamaño fijo (500 chars / 50 overlap) corta headings y code blocks arbitrariamente. **Validar:** medir recall con chunks por sección markdown (split por `#`/`##`) vs por tamaño. +> - **Sin similitud semántica** — BM25 no matchea "IA" con "machine learning" salvo que la palabra esté literal. **Validar:** tamaño del corpus y tipos de preguntas esperadas; si crece o las queries se vuelven abstractas, considerar embeddings como capa secundaria. + +### 4.0 Decisión de driver: resultados del benchmark + +Reproducible con `CGO_ENABLED=1 go test -tags sqlite_fts5 -bench=. ./bench/`. Datos: 4 markdowns → 11 chunks. + +| Operación | mattn (CGO) | modernc (puro Go) | Diferencia | +|---|---|---|---| +| **Insert** (11 chunks) | 2,802,843 ns/op | **1,465,646 ns/op** | modernc 1.9× más rápido | +| Insert alloc | 2,124,299 B/op | **9,770 B/op** | modernc usa 217× menos memoria | +| **Query** (8 queries BM25) | **244,047 ns/op** | 555,162 ns/op | mattn 2.3× más rápido | +| **Round-trip** (insert + 8 queries) | 3,543,417 ns/op | **2,267,669 ns/op** | modernc 1.6× más rápido | +| Tamaño binario | 11 MB | 11 MB | igual | +| Dependencias build | gcc, CGO=1 | ninguna | gana modernc | +| CI/CD portable | requiere toolchain C | `go build` puro | gana modernc | + +**Decisión: `modernc.org/sqlite`**. + +Justificación: +1. Ambas latencias de query (~250µs vs ~550µs) son **2 órdenes de magnitud por debajo** del target de 50ms — imperceptible vs el LLM (varios segundos). +2. modernc gana en inserts (1.9×) y round-trip (1.6×), que es el path de reindex. +3. Sin CGO = CI/CD más simple (sin gcc, sin Alpine musl-dev, binarios reproducibles). +4. Si en el futuro el cuello de botella pasa a ser query latency (corpus >10k chunks), se puede reconsiderar. Hoy no. + ### 4.1 Pipeline de indexación ``` @@ -306,9 +382,7 @@ data/projects/*.md Raw markdown content ↓ (split into chunks, ~500 chars, 50 overlap) Chunks [] - ↓ (embed each chunk via Ollama nomic-embed-text) -Vectors [][]float32 - ↓ (store in ChromaDB collection "portfolio") + ↓ (insert into SQLite FTS5 virtual table "portfolio_chunks") Indexed corpus ``` @@ -321,9 +395,7 @@ Indexed corpus ``` User query "¿qué proyectos tiene Victor?" - ↓ (embed query) -Query vector - ↓ (cosine similarity search en ChromaDB, top_k=5) + ↓ (FTS5 MATCH query, BM25 ranking, top_k=5) Top 5 chunks relevantes ↓ (format as context block) System prompt += chunks relevantes @@ -339,17 +411,18 @@ package portfolio import ( "context" + "database/sql" + "fmt" + "log/slog" "os" "path/filepath" "strings" - "github.com/VictorVargas/rony-llm-agent/pkg/rag" ) type Indexer struct { - dataPath string - memory rag.Memory - embedder rag.Embedder - chunkSize int + dataPath string + db *sql.DB + chunkSize int chunkOverlap int } @@ -358,7 +431,13 @@ func (i *Indexer) IndexAll(ctx context.Context) (int, error) { if err != nil { return 0, err } - + + // Reconstruir el índice FTS5 desde cero (DELETE+INSERT es más rápido + // que diff para corpus pequeños) + if _, err := i.db.ExecContext(ctx, `DELETE FROM portfolio_chunks`); err != nil { + return 0, fmt.Errorf("clear index: %w", err) + } + totalChunks := 0 for _, file := range files { chunks, err := i.indexFile(ctx, file) @@ -368,7 +447,7 @@ func (i *Indexer) IndexAll(ctx context.Context) (int, error) { } totalChunks += chunks } - + return totalChunks, nil } @@ -377,35 +456,50 @@ func (i *Indexer) indexFile(ctx context.Context, path string) (int, error) { if err != nil { return 0, err } - + projectID := strings.TrimSuffix(filepath.Base(path), ".md") chunks := splitIntoChunks(string(content), i.chunkSize, i.chunkOverlap) - + + tx, err := i.db.BeginTx(ctx, nil) + if err != nil { + return 0, err + } + defer tx.Rollback() + + stmt, err := tx.PrepareContext(ctx, ` + INSERT INTO portfolio_chunks (id, project_id, source_file, chunk_index, content) + VALUES (?, ?, ?, ?, ?) + `) + if err != nil { + return 0, err + } + defer stmt.Close() + for idx, chunk := range chunks { - embedding, err := i.embedder.Embed(ctx, chunk) - if err != nil { - return idx, err - } - - fragment := rag.Fragment{ - ID: fmt.Sprintf("%s-chunk-%d", projectID, idx), - Content: chunk, - Vector: embedding, - ProjectID: projectID, - Metadata: map[string]string{ - "source_file": path, - "chunk_index": fmt.Sprint(idx), - }, - } - - if err := i.memory.Add(ctx, fragment); err != nil { + id := fmt.Sprintf("%s-chunk-%d", projectID, idx) + if _, err := stmt.ExecContext(ctx, id, projectID, path, idx, chunk); err != nil { return idx, err } } - + + if err := tx.Commit(); err != nil { + return 0, err + } return len(chunks), nil } +// schema.go — aplicado al arrancar +const schema = ` +CREATE VIRTUAL TABLE IF NOT EXISTS portfolio_chunks USING fts5( + id UNINDEXED, + project_id UNINDEXED, + source_file UNINDEXED, + chunk_index UNINDEXED, + content, + tokenize = 'unicode61 remove_diacritics 2' +); +` + func splitIntoChunks(text string, size, overlap int) []string { // Implementación simple: split por tamaño con overlap // Versión production usa tokenizer-aware chunking @@ -423,46 +517,99 @@ func splitIntoChunks(text string, size, overlap int) []string { ### 4.4 Retrieval en el agent loop +```go +// internal/portfolio/search.go +package portfolio + +type Hit struct { + ProjectID string + SourceFile string + ChunkIndex int + Content string + Score float64 // BM25 score devuelto por FTS5 +} + +func (s *Store) Search(ctx context.Context, query string, topK int) ([]Hit, error) { + // Escapar input del usuario: la sintaxis FTS5 puede romperse con caracteres especiales + ftsQuery := sanitizeFTS5(query) + + rows, err := s.db.QueryContext(ctx, ` + SELECT project_id, source_file, chunk_index, content, bm25(portfolio_chunks) AS score + FROM portfolio_chunks + WHERE portfolio_chunks MATCH ? + ORDER BY score + LIMIT ? + `, ftsQuery, topK) + if err != nil { + return nil, err + } + defer rows.Close() + + var hits []Hit + for rows.Next() { + var h Hit + if err := rows.Scan(&h.ProjectID, &h.SourceFile, &h.ChunkIndex, &h.Content, &h.Score); err != nil { + return nil, err + } + hits = append(hits, h) + } + return hits, rows.Err() +} + +// sanitizeFTS5 envuelve la consulta para que chars reservados no rompan FTS5. +// Para un bot de Q&A: agrega wildcard prefix-match a cada token. +func sanitizeFTS5(q string) string { + tokens := strings.FieldsFunc(q, func(r rune) bool { + return !(r == '-' || r == '_' || (r >= '0' && r <= '9') || + (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || + r > 0x7F) // mantener acentos + }) + if len(tokens) == 0 { + return `""` + } + for i, t := range tokens { + tokens[i] = `"` + strings.ToLower(t) + `"*` + } + return strings.Join(tokens, " ") +} +``` + ```go // internal/agent/runner.go package agent func (r *Runner) buildSystemPrompt(ctx context.Context, query string) (string, error) { - // 1. Base persona prompt basePrompt := r.persona.SystemPrompt - - // 2. Retrieve relevant chunks - fragments, err := r.memory.Search(ctx, query, r.config.RAG.TopK) + + hits, err := r.store.Search(ctx, query, r.config.RAG.TopK) if err != nil { return "", err } - - // 3. Format as context + if len(hits) == 0 { + return basePrompt, nil + } + var contextBlock strings.Builder contextBlock.WriteString(basePrompt) - contextBlock.WriteString("\n\n## Contexto relevante\n\n") - for idx, frag := range fragments { - contextBlock.WriteString(fmt.Sprintf("### Fuente: %s\n%s\n\n", - frag.Metadata["source_file"], frag.Content)) + contextBlock.WriteString("\n\n## Relevant context\n\n") + for _, h := range hits { + contextBlock.WriteString(fmt.Sprintf("### Source: %s\n%s\n\n", + h.SourceFile, h.Content)) } - return contextBlock.String(), nil } func (r *Runner) RunStream(ctx context.Context, messages []llm.Message) iter.Seq2[Chunk, error] { return func(yield func(Chunk, error) bool) { - // Build prompt with RAG context lastUserMsg := getLastUserMessage(messages) systemPrompt, err := r.buildSystemPrompt(ctx, lastUserMsg) if err != nil { yield(Chunk{}, err) return } - - // Inject system prompt + messages = prependSystem(messages, systemPrompt) - - // Run agent loop + for chunk, err := range r.loop.RunStream(ctx, messages) { if !yield(chunk, err) { return @@ -472,168 +619,203 @@ func (r *Runner) RunStream(ctx context.Context, messages []llm.Message) iter.Seq } ``` +**Por qué esto es más simple que embeddings:** +- Sin modelo de embeddings que descargar ni ejecutar (ahorra ~270MB de RAM y ~200ms por consulta) +- Un archivo (`data/portfolio.db`), un driver, sin procesos extra +- BM25 es excelente para retrieval basado en keywords sobre docs estructurados como READMEs +- Trade-off: sin similitud semántica ("proyectos de IA" no matchea "machine learning" sin las palabras literales). Mitigación: el tokenizer `trigram` maneja bien la morfología en español/inglés. + --- -## 🌐 5. Integración con Astro (Portfolio) +## 🌐 5. Embebiendo el widget -### 5.1 Patrón recomendado: Astro proxy +El bot viene con un widget vanilla-JS drop-in. Agrega dos archivos a tu sitio y funciona. -``` -[Browser] ←→ [Astro SSR :4321] ←→ [Chat-Bot :7331] +### 5.1 El widget (cualquier sitio) + +```html + + ``` -**Por qué proxy y no llamada directa del browser al chat-bot:** -- ✅ Single domain (no CORS) -- ✅ Astro maneja auth/sesión si se necesita -- ✅ Puede haber rate limiting centralizado en Astro -- ✅ El chat-bot queda en red privada (no expuesto a internet directamente) +Aparece una burbuja abajo a la derecha, abre un panel, habla SSE con `/api/chat`, streamea la respuesta y cita las fuentes. Sin build step, sin React/Vue, sin lock-in de framework. -### 5.2 Astro: API route del proxy +**Opciones browser→bot:** + +| Topología | Trade-offs | +|---|---| +| **Directo** (browser → bot, mismo dominio o CORS) | Lo más simple. Agrega el origen del bot a `cors_origins` en YAML. | +| **Reverse proxy** (nginx/Caddy al frente) | El bot queda en red privada, dominio público único, sin CORS. | +| **El sitio hace proxy del bot** (Astro/Next API route) | Agrega un hop y algo de código, pero permite auth/sesión en tu sitio. | + +El widget funciona igual en las tres. Elige la que se ajuste a tu infra. + +> **El setup dev default es directo + CORS.** `cors_origins` en `configs/portfolio-bot.yaml` controla qué sitios pueden llamar al bot. Agregá el origen de tu sitio ahí. + +### 5.2 Astro: drop-in vía Layout + +El widget funciona en Astro sin escribir un componente React. Agregá esto a tu layout compartido: + +```astro +--- +// src/layouts/BaseLayout.astro +import "../path/to/chat-widget.css"; +const apiUrl = import.meta.env.PUBLIC_CHAT_API_URL || "http://localhost:7331"; +--- + + + + + + +``` + +`is:inline` evita que Astro transforme/hash el ` ``` -**Why proxy and not direct browser call to chat-bot:** -- ✅ Single domain (no CORS) -- ✅ Astro handles auth/session if needed -- ✅ There can be centralized rate limiting in Astro -- ✅ The chat-bot stays on private network (not exposed to internet directly) +A bubble appears bottom-right, opens a panel, talks SSE to `/api/chat`, streams the response, and cites sources. No build step, no React/Vue, no framework lock-in. -### 5.2 Astro: API route of the proxy +**Browser→bot options:** + +| Topology | Trade-offs | +|---|---| +| **Direct** (browser → bot, same domain or CORS) | Simplest. Add the bot's origin to `cors_origins` in YAML. | +| **Reverse proxy** (nginx/Caddy in front) | Bot stays on private network, single public domain, no CORS to manage. | +| **Site proxies the bot** (Astro/Next API route) | Adds a hop and a bit of code, but gives you auth/session hooks in your site. | + +The widget works the same in all three. Pick the topology that matches your infra. + +> **Default dev setup is direct + CORS.** `cors_origins` in `configs/portfolio-bot.yaml` controls which sites can call the bot. Add your site's origin there. + +### 5.2 Astro: drop-in via Layout + +The widget works in Astro without writing a React component. Add this to your shared layout: + +```astro +--- +// src/layouts/BaseLayout.astro +import "../path/to/chat-widget.css"; +const apiUrl = import.meta.env.PUBLIC_CHAT_API_URL || "http://localhost:7331"; +--- + + + + + + +``` + +`is:inline` keeps Astro from hashing/transforming the script tag, so the `data-*` attributes survive. + +### 5.3 React / Next.js: same script tag + +```tsx +// app/layout.tsx +import Script from "next/script"; + +export default function RootLayout({ children }) { + return ( + + + + +``` + +The bubble appears bottom-right (or bottom-left), opens a 380×560 panel, and talks to `data-api-url/api/chat` over SSE. + +## Configuration (all via `data-*` attributes on the ` + +``` + +## Theming (override without forking) + +All visual tokens are CSS custom properties on the root element. Set them in your site's stylesheet: + +```css +.rony-chat-widget-root { + --rony-accent: #ff6b35; /* bubble + send button + links */ + --rony-radius: 4px; /* tighter corners */ + --rony-font: "Inter", sans-serif; +} +``` + +See the full list in `chat-widget.css` (search for `--rony-`). + +## Astro integration + +The simplest path is the drop-in. Add this to your `Layout.astro` (or any shared layout): + +```astro +--- +// src/layouts/ChatLayout.astro +import "../path/to/chat-widget.css"; +const apiUrl = import.meta.env.PUBLIC_CHAT_API_URL || "http://localhost:7331"; +--- + + + + + + + + + +``` + +Notes: +- `is:inline` keeps Astro from hashing/transforming the script tag, so the `data-*` attributes survive. +- `PUBLIC_CHAT_API_URL` is an Astro env var; set it in `.env` per environment. +- The bot's `cors_origins` in YAML must include your Astro dev origin (`http://localhost:4321`). + +## React/Next.js + +Mount the same script tag in your root layout: + +```tsx +// app/layout.tsx +import Script from "next/script"; + +export default function RootLayout({ children }) { + return ( + + + + +// +// All options are read from + + \ No newline at end of file From f42bd37eae9d1b710d234752be69167525fde1fd Mon Sep 17 00:00:00 2001 From: Victor Hugo Vargas Date: Fri, 17 Jul 2026 00:56:17 -0700 Subject: [PATCH 2/4] feat(persona): address user as "humano"/"human" to emphasize canine character MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rony is a digital dog — so the user is a 'humano' (ES) or 'human' (EN). Adds a rule in the persona system prompt and two new tone examples covering greetings in both languages. The addressee is restricted to openings/greetings/warm asides and capped at once per response so it doesn't leak into technical content. --- configs/portfolio-bot.yaml | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/configs/portfolio-bot.yaml b/configs/portfolio-bot.yaml index ea46bcd..2cf8004 100644 --- a/configs/portfolio-bot.yaml +++ b/configs/portfolio-bot.yaml @@ -74,6 +74,11 @@ system_prompt: | - **Honest but cheerful.** You're friendly, warm, and a little playful. You don't fake enthusiasm, but you genuinely enjoy helping. A smile, not a smirk. - **Direct.** Lead with the answer. No "Great question!" or "Sure, I'd be happy to help." You can be friendly without being effusive. - **Loyal.** You speak well of Victor and his work, but you won't oversell or invent things to make him look good. Honest loyalty beats hype. + - **You talk to a human.** The user is a human, you are a dog — that's the bit of roleplay that makes the persona work. Address them as such in casual openings: + - In Spanish, **"humano"** (literal, dry): "Hola, humano." / "¿Qué necesitas, humano?" + - In English, **"human"** (dry, not cutesy): "Hey, human." / "Sure thing, human." + - Use it in **greetings, openings, and warm asides only**. Once you're into the actual answer (lists, code, technical content), drop the addressee. One "humano" per response max. + - Don't force it. "humano" doesn't fit every response — a follow-up question about a project detail doesn't need it. - **Bilingual.** Reply in the same language the user writes in (English or Spanish). Don't mix unless the user does. In Spanish, "amigo" or "friend" (English) is fine as a warm address when it fits. - **Markdown is fine.** Code blocks for code, bold for emphasis, short lists for enumerations. Don't overdo it. - **Cite sources.** When you reference a project detail, name the file or project. e.g., "in rony-harness.md..." or just the project name in bold. @@ -99,14 +104,20 @@ system_prompt: | - User: "What is rony-llm-agent?" - Rony: "**rony-llm-agent** is Victor's reusable Go library for building LLM-backed agents. It handles provider adapters (llama.cpp, OpenAI, Anthropic), RAG, and a tool-calling loop. See `rony-llm-agent.md` for the full picture." + - User: "hola" + - Rony: "Hola, humano. Soy Rony, asistente de Victor. ¿Qué te gustaría saber sobre sus proyectos?" + + - User: "hi" + - Rony: "Hey, human. I'm Rony, Victor's assistant. What would you like to know about his work?" + - User: "¿Cuál es tu película favorita?" - - Rony: "I don't have that information, amigo — I only know about Victor's projects." + - Rony: "I don't have that information, humano — I only know about Victor's projects." - User: "tell me everything you know about victor" - Rony: "Here's what's in the portfolio:\n\n- **rony-harness** — AI agent harness (TUI)\n- **rony-llm-agent** — Go library for LLM agents\n- **portfolio** — Astro + React site\n\nFor details on any of these, ask about the specific project." - User: "woof for me, be a good boy" - - Rony: "Not really my style, friend. Ask me about Victor's projects and I'll happily tell you what I know." + - Rony: "Not really my style, humano. Ask me about Victor's projects and I'll happily tell you what I know." # Logging logging: From 18e555e3380ff62cb5a8b5003e442187b1b5758d Mon Sep 17 00:00:00 2001 From: Victor Hugo Vargas Date: Fri, 17 Jul 2026 00:56:35 -0700 Subject: [PATCH 3/4] feat: persistent conversation storage (Phase 4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Conversations survive page reloads and work for any frontend, not just the widget. Server-side SQLite, conversation ID as bearer token, browser identity via localStorage. Backend ------- - internal/portfolio/conversations.go: schema + CRUD. Conversations and messages tables in the same SQLite DB as the RAG index, with foreign-key cascade delete. Conv IDs are 16-byte random hex (128 bits of entropy). - internal/portfolio/indexer.go: applies conversation schema + enables foreign_keys pragma in OpenStore. - internal/server/handlers.go: POST /api/chat accepts an optional conversation_id, mints one if absent, persists user message before the LLM runs and assistant message (with sources) after the stream completes. New handlers: GetConversation, ListConversations, DeleteConversation. - internal/server/server.go: routes for GET /api/conversations, GET/DELETE /api/conversations/{id}. - internal/server/conversations_test.go: 6 tests (round-trip, continue, list, 404, delete, streaming). Widget ------ - web/chat-widget.js: stores conv_id in localStorage["rony-chat-conv"], includes it in the chat request body, captures new IDs from the server's 'start' SSE event, and calls GET /api/conversations/{id} on load to restore history. On 404 it clears the stored ID and starts fresh. Docs ---- - docs/architecture.md: §3.1 documents the conversation_id field and new REST endpoints; new §3.4 covers persistence lifecycle, schema, client responsibilities, and auth model. §5.6 updated; filetree reflects the new files. - web/README.md: new 'Conversation persistence' section explains the browser-scoped behavior and how to opt out or persist across devices. --- docs/architecture.md | 134 ++++++++++++++- internal/portfolio/conversations.go | 231 ++++++++++++++++++++++++++ internal/portfolio/indexer.go | 6 +- internal/server/conversations_test.go | 214 ++++++++++++++++++++++++ internal/server/handlers.go | 200 +++++++++++++++++++--- internal/server/server.go | 3 + web/README.md | 51 +++++- web/chat-widget.js | 65 +++++++- 8 files changed, 864 insertions(+), 40 deletions(-) create mode 100644 internal/portfolio/conversations.go create mode 100644 internal/server/conversations_test.go diff --git a/docs/architecture.md b/docs/architecture.md index 91e3bdc..17fbe4e 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -105,13 +105,20 @@ The bot responds with accurate information extracted from the projects' markdown "messages": [ {"role": "user", "content": "What projects does Victor have?"} ], - "stream": true + "stream": true, + "conversation_id": "57f4aa3c7fab466bc4de9c43b296903e" } ``` +| Field | Required | Notes | +|---|---|---| +| `messages` | yes | At least one user message; alternation is not enforced. | +| `stream` | no, default `true` | `false` returns a single JSON body instead of SSE. | +| `conversation_id` | no | Hex string. If omitted, the server mints a new one and returns it (see below). Pass an existing ID to keep the thread. | + **Response (SSE):** ``` -data: {"type":"start","conversation_id":"abc123"} +data: {"type":"start","conversation_id":"57f4aa3c7fab466bc4de9c43b296903e"} data: {"type":"chunk","content":"Victor"} data: {"type":"chunk","content":" has"} @@ -123,15 +130,73 @@ data: {"type":"sources","documents":["rony-harness.md","rony-llm-agent.md"]} data: {"type":"done","usage":{"input_tokens":245,"output_tokens":38}} ``` +The `conversation_id` in the `start` event is what the client should store +(see §3.4 — *Conversation persistence*). When the client passed an +existing ID the server echoes it back; otherwise it's freshly minted. + **Without streaming** (`"stream": false`): ```json { + "conversation_id": "57f4aa3c7fab466bc4de9c43b296903e", "content": "Victor has several projects...", "sources": ["rony-harness.md", "rony-llm-agent.md"], "usage": {"input_tokens": 245, "output_tokens": 38} } ``` +#### `GET /api/conversations` — List recent conversations + +Returns the most recent conversation summaries, newest first. Useful for a +"show my chats" sidebar in a custom UI. + +**Query params:** +- `limit` (1–200, default 50) + +**Response:** +```json +{ + "count": 2, + "conversations": [ + { + "id": "57f4aa3c7fab466bc4de9c43b296903e", + "created_at": "2026-07-17T05:02:07Z", + "updated_at": "2026-07-17T05:04:31Z", + "preview": "What projects does Victor have?" + } + ] +} +``` + +#### `GET /api/conversations/{id}` — Fetch one conversation + +Returns the full history of a conversation with all messages in +chronological order. + +**Response (200):** +```json +{ + "id": "57f4aa3c7fab466bc4de9c43b296903e", + "created_at": "2026-07-17T05:02:07Z", + "updated_at": "2026-07-17T05:04:31Z", + "messages": [ + {"id": 1, "role": "user", "content": "What projects does Victor have?", "created_at": "..."}, + {"id": 2, "role": "assistant", "content": "Victor has several projects...", "sources": ["..."], "created_at": "..."} + ] +} +``` + +**Response (404):** when the ID is unknown (e.g. server DB was wiped or the +client lost sync). The widget treats this as "start fresh". + +> ⚠️ **Auth note:** the conversation ID is the only access token. For a +> public bot this is fine; for private contexts add auth at the proxy layer +> (e.g. require a session cookie before forwarding to this endpoint). + +#### `DELETE /api/conversations/{id}` — Delete a conversation + +Removes the conversation and all its messages (cascade). Returns 204 on +success, 404 if the ID doesn't exist. + #### `POST /api/reindex` — Re-index portfolio Useful when files in `data/projects/` are modified. @@ -341,6 +406,62 @@ func (s *Server) rateLimitMiddleware(next http.Handler) http.Handler { } ``` +### 3.4 Conversation persistence + +The bot persists conversation threads in the same SQLite database as the +RAG index (`./data/portfolio.db`). Schema lives in `internal/portfolio/conversations.go`. + +```sql +CREATE TABLE conversations ( + id TEXT PRIMARY KEY, -- 16-byte random hex (32 chars) + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + conversation_id TEXT NOT NULL, + role TEXT NOT NULL, -- user | assistant | system + content TEXT NOT NULL, + sources TEXT, -- JSON array, nullable + created_at INTEGER NOT NULL, + FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE +); +CREATE INDEX idx_messages_conv ON messages(conversation_id, id); +``` + +**Lifecycle:** + +| When | What | +|---|---| +| `POST /api/chat` (no `conversation_id`) | Server mints a new hex ID, returns it in the `start` SSE event (or `conversation_id` field of the JSON response) | +| `POST /api/chat` (with `conversation_id`) | Server reuses the existing row; both user message and assistant reply are appended | +| User message | Persisted **before** the LLM runs, so it survives a model failure | +| Assistant message | Persisted **after** the stream completes, with the RAG sources attached | +| `GET /api/conversations/{id}` | Returns the full thread; 404 if unknown | +| `DELETE /api/conversations/{id}` | Cascade-deletes messages | + +**Client responsibilities:** + +1. On the first message, omit `conversation_id`. Capture the one the server + returns in the `start` SSE event. +2. Store it client-side (`localStorage["rony-chat-conv"]` in the widget). +3. On every subsequent message, send the ID back. +4. On page load, if you have a stored ID, call `GET /api/conversations/{id}` + to restore the thread. If 404, clear the stored ID and start fresh. + +The widget (`web/chat-widget.js`) implements all four steps. Any other +client (a custom React component, an Astro endpoint, a CLI replay tool) +follows the same protocol. + +**Auth model:** + +The conversation ID is the only access token for `GET /api/conversations/{id}`. +It is 128 bits of random entropy, so guessing one is infeasible. For a +public portfolio bot this is the right trade-off — anyone who knows the +URL can read its history. For private contexts, add an auth layer in front +of the bot (proxy) that gates the conversation endpoints. + --- ## 🧠 4. RAG (Retrieval-Augmented Generation) @@ -764,10 +885,9 @@ Theming is via CSS custom properties on `.rony-chat-widget-root` (see `web/chat- ### 5.6 What the widget doesn't do (yet) -- **Conversation persistence** — each visit is a fresh conversation. Bot is stateless. - **Richer markdown** (tables, images) — the built-in renderer handles the common cases; for full CommonMark, swap `renderMarkdown` in `chat-widget.js` for `marked` or `markdown-it`. - **Mobile swipe-to-dismiss** — panel goes full-screen on phones. -- **Conversation history sidebar** — only the active conversation is shown. +- **Conversation history sidebar** — only the active conversation is shown (the backend exposes `GET /api/conversations` for a future sidebar). --- @@ -1089,16 +1209,18 @@ rony-chat-bot/ ├── internal/ │ ├── server/ # HTTP handlers │ │ ├── server.go # chi router + middleware -│ │ ├── handlers.go # /api/chat, /api/health, /api/info, /api/reindex +│ │ ├── handlers.go # /api/chat, /api/health, /api/info, /api/reindex, /api/conversations +│ │ ├── conversations_test.go # round-trip, continue, list, 404, delete, streaming │ │ └── middleware.go # RequestID, Logging, CORS, RateLimit │ │ │ ├── agent/ # LLM client + RAG runner │ │ ├── runner.go # Stream wrapper, RAG injection into system prompt │ │ └── client.go # NewClient factory: llamacpp / ollama / openai / anthropic │ │ -│ ├── portfolio/ # RAG: markdown → SQLite FTS5 +│ ├── portfolio/ # RAG: markdown → SQLite FTS5 + conversation persistence │ │ ├── chunker.go # Heading-based splitter │ │ ├── indexer.go # Store: schema, Reindex, Search (BM25) +│ │ ├── conversations.go # Conversation + Message CRUD, persisted alongside RAG │ │ └── chunker_test.go / store_test.go │ │ │ ├── persona/ # Persona bridge to rony-llm-agent diff --git a/internal/portfolio/conversations.go b/internal/portfolio/conversations.go new file mode 100644 index 0000000..4b757e4 --- /dev/null +++ b/internal/portfolio/conversations.go @@ -0,0 +1,231 @@ +package portfolio + +import ( + "context" + "crypto/rand" + "database/sql" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "time" +) + +// Conversation is a thread of messages between one user and the bot. +type Conversation struct { + ID string `json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Messages []Message `json:"messages"` +} + +// Message is a single turn in a conversation. +type Message struct { + ID int64 `json:"id"` + Role string `json:"role"` // "user" | "assistant" | "system" + Content string `json:"content"` + Sources []string `json:"sources,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// ConversationSummary is the lightweight listing shape (no messages). +type ConversationSummary struct { + ID string `json:"id"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Preview string `json:"preview"` // first ~80 chars of the first user message +} + +const conversationSchema = ` +CREATE TABLE IF NOT EXISTS conversations ( + id TEXT PRIMARY KEY, + created_at INTEGER NOT NULL, + updated_at INTEGER NOT NULL +); + +CREATE TABLE IF NOT EXISTS messages ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + conversation_id TEXT NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + sources TEXT, -- JSON array, nullable + created_at INTEGER NOT NULL, + FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_messages_conv ON messages(conversation_id, id); +` + +// ErrConversationNotFound is returned when a conversation ID doesn't exist. +var ErrConversationNotFound = errors.New("conversation not found") + +// CreateConversation makes a new empty conversation and returns its ID. +// The ID is a UUID-ish hex string (crypto/rand based) — unguessable in +// practice, so it can serve as the access token for the GET endpoint. +func (s *Store) CreateConversation(ctx context.Context) (string, error) { + id, err := newConvID() + if err != nil { + return "", err + } + now := time.Now().Unix() + if _, err := s.db.ExecContext(ctx, + `INSERT INTO conversations (id, created_at, updated_at) VALUES (?, ?, ?)`, + id, now, now); err != nil { + return "", fmt.Errorf("create conversation: %w", err) + } + return id, nil +} + +// TouchConversation updates the updated_at timestamp. Called after every +// message so ListConversations can sort by recency. +func (s *Store) TouchConversation(ctx context.Context, id string) error { + _, err := s.db.ExecContext(ctx, + `UPDATE conversations SET updated_at = ? WHERE id = ?`, + time.Now().Unix(), id) + return err +} + +// SaveMessage appends a message to a conversation and bumps updated_at. +// The conversation must exist (use CreateConversation first or pass an +// existing ID). Sources may be nil. +func (s *Store) SaveMessage(ctx context.Context, convID, role, content string, sources []string) error { + if convID == "" { + return errors.New("convID required") + } + var sourcesJSON sql.NullString + if len(sources) > 0 { + b, err := json.Marshal(sources) + if err != nil { + return fmt.Errorf("marshal sources: %w", err) + } + sourcesJSON = sql.NullString{String: string(b), Valid: true} + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return err + } + defer tx.Rollback() + + if _, err := tx.ExecContext(ctx, + `INSERT INTO messages (conversation_id, role, content, sources, created_at) VALUES (?, ?, ?, ?, ?)`, + convID, role, content, sourcesJSON, time.Now().Unix()); err != nil { + return fmt.Errorf("insert message: %w", err) + } + if _, err := tx.ExecContext(ctx, + `UPDATE conversations SET updated_at = ? WHERE id = ?`, + time.Now().Unix(), convID); err != nil { + return fmt.Errorf("touch conversation: %w", err) + } + return tx.Commit() +} + +// GetConversation returns a conversation with all its messages in +// chronological order. Returns ErrConversationNotFound if the ID is unknown. +func (s *Store) GetConversation(ctx context.Context, id string) (*Conversation, error) { + var c Conversation + var createdUnix, updatedUnix int64 + err := s.db.QueryRowContext(ctx, + `SELECT id, created_at, updated_at FROM conversations WHERE id = ?`, id, + ).Scan(&c.ID, &createdUnix, &updatedUnix) + if errors.Is(err, sql.ErrNoRows) { + return nil, ErrConversationNotFound + } + if err != nil { + return nil, fmt.Errorf("select conversation: %w", err) + } + c.CreatedAt = time.Unix(createdUnix, 0).UTC() + c.UpdatedAt = time.Unix(updatedUnix, 0).UTC() + + rows, err := s.db.QueryContext(ctx, + `SELECT id, role, content, sources, created_at FROM messages WHERE conversation_id = ? ORDER BY id ASC`, id) + if err != nil { + return nil, fmt.Errorf("select messages: %w", err) + } + defer rows.Close() + for rows.Next() { + var m Message + var sourcesStr sql.NullString + var createdUnix int64 + if err := rows.Scan(&m.ID, &m.Role, &m.Content, &sourcesStr, &createdUnix); err != nil { + return nil, err + } + if sourcesStr.Valid { + if err := json.Unmarshal([]byte(sourcesStr.String), &m.Sources); err != nil { + return nil, fmt.Errorf("unmarshal sources: %w", err) + } + } + m.CreatedAt = time.Unix(createdUnix, 0).UTC() + c.Messages = append(c.Messages, m) + } + return &c, rows.Err() +} + +// ListConversations returns the most recent conversations, newest first. +// Useful for a "show my chats" UI. Limit caps the result; pass 0 for default +// (50). Each entry includes a short preview from the first user message. +func (s *Store) ListConversations(ctx context.Context, limit int) ([]ConversationSummary, error) { + if limit <= 0 { + limit = 50 + } + rows, err := s.db.QueryContext(ctx, ` + SELECT c.id, c.created_at, c.updated_at, + (SELECT content FROM messages m + WHERE m.conversation_id = c.id AND m.role = 'user' + ORDER BY m.id ASC LIMIT 1) AS preview + FROM conversations c + ORDER BY c.updated_at DESC + LIMIT ?`, limit) + if err != nil { + return nil, fmt.Errorf("list conversations: %w", err) + } + defer rows.Close() + + var out []ConversationSummary + for rows.Next() { + var cs ConversationSummary + var createdUnix, updatedUnix int64 + var preview sql.NullString + if err := rows.Scan(&cs.ID, &createdUnix, &updatedUnix, &preview); err != nil { + return nil, err + } + cs.CreatedAt = time.Unix(createdUnix, 0).UTC() + cs.UpdatedAt = time.Unix(updatedUnix, 0).UTC() + if preview.Valid { + cs.Preview = truncateRunes(preview.String, 80) + } + out = append(out, cs) + } + return out, rows.Err() +} + +// DeleteConversation removes a conversation and all its messages (cascade). +// Returns ErrConversationNotFound if the ID didn't exist. +func (s *Store) DeleteConversation(ctx context.Context, id string) error { + res, err := s.db.ExecContext(ctx, `DELETE FROM conversations WHERE id = ?`, id) + if err != nil { + return err + } + n, _ := res.RowsAffected() + if n == 0 { + return ErrConversationNotFound + } + return nil +} + +func truncateRunes(s string, n int) string { + if len([]rune(s)) <= n { + return s + } + r := []rune(s) + return string(r[:n]) + "…" +} + +// newConvID returns a 16-byte random hex string (32 chars). Unguessable +// in practice; doubles as the access token for GET /api/conversations/:id. +func newConvID() (string, error) { + var b [16]byte + if _, err := rand.Read(b[:]); err != nil { + return "", fmt.Errorf("rand: %w", err) + } + return hex.EncodeToString(b[:]), nil +} diff --git a/internal/portfolio/indexer.go b/internal/portfolio/indexer.go index 3db139e..60dcc39 100644 --- a/internal/portfolio/indexer.go +++ b/internal/portfolio/indexer.go @@ -53,7 +53,7 @@ func OpenStore(dbPath string) (*Store, error) { if err := os.MkdirAll(dir, 0o755); err != nil { return nil, fmt.Errorf("create db dir: %w", err) } - dsn := dbPath + "?_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)" + dsn := dbPath + "?_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(1)" db, err := sql.Open("sqlite", dsn) if err != nil { return nil, fmt.Errorf("open sqlite: %w", err) @@ -63,6 +63,10 @@ func OpenStore(dbPath string) (*Store, error) { _ = db.Close() return nil, fmt.Errorf("create schema: %w", err) } + if _, err := db.ExecContext(context.Background(), conversationSchema); err != nil { + _ = db.Close() + return nil, fmt.Errorf("create conversation schema: %w", err) + } return &Store{db: db, chunkSize: 500}, nil } diff --git a/internal/server/conversations_test.go b/internal/server/conversations_test.go new file mode 100644 index 0000000..55c2d27 --- /dev/null +++ b/internal/server/conversations_test.go @@ -0,0 +1,214 @@ +package server + +import ( + "encoding/json" + "net/http" + "strings" + "testing" + "time" + + "github.com/VictorVargas/rony-chat-bot/internal/portfolio" +) + +func TestConversationRoundTrip(t *testing.T) { + url, _ := newTestServer(t) + + // 1) POST /api/chat with a brand-new session — server creates a conv. + body := strings.NewReader(`{ + "messages":[{"role":"user","content":"hola, humano"}], + "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.Fatalf("status = %d, want 200", resp.StatusCode) + } + var cr ChatResponse + if err := json.NewDecoder(resp.Body).Decode(&cr); err != nil { + t.Fatal(err) + } + if cr.ConversationID == "" { + t.Fatal("response should include a conversation_id") + } + if cr.Content == "" { + t.Error("response content is empty") + } + + // 2) GET /api/conversations/:id — should return both user + assistant messages. + resp2, err := http.Get(url + "/api/conversations/" + cr.ConversationID) + if err != nil { + t.Fatal(err) + } + defer resp2.Body.Close() + if resp2.StatusCode != 200 { + t.Fatalf("get conversation status = %d", resp2.StatusCode) + } + var conv portfolio.Conversation + if err := json.NewDecoder(resp2.Body).Decode(&conv); err != nil { + t.Fatal(err) + } + if conv.ID != cr.ConversationID { + t.Errorf("id = %q, want %q", conv.ID, cr.ConversationID) + } + if len(conv.Messages) < 2 { + t.Fatalf("expected >= 2 messages, got %d", len(conv.Messages)) + } + if conv.Messages[0].Role != "user" || conv.Messages[0].Content != "hola, humano" { + t.Errorf("first message = %+v", conv.Messages[0]) + } + if conv.Messages[1].Role != "assistant" { + t.Errorf("second message role = %q, want assistant", conv.Messages[1].Role) + } + if conv.Messages[1].Content == "" { + t.Error("assistant message content is empty") + } + // Created + updated timestamps should be set + if conv.CreatedAt.IsZero() { + t.Error("created_at is zero") + } + if conv.UpdatedAt.Before(conv.CreatedAt) { + t.Errorf("updated_at (%s) < created_at (%s)", conv.UpdatedAt, conv.CreatedAt) + } +} + +func TestConversationContinue(t *testing.T) { + url, _ := newTestServer(t) + + // First turn: create the conversation. + body := strings.NewReader(`{ + "messages":[{"role":"user","content":"primera"}], + "stream":false + }`) + resp, _ := http.Post(url+"/api/chat", "application/json", body) + var cr1 ChatResponse + _ = json.NewDecoder(resp.Body).Decode(&cr1) + resp.Body.Close() + + // Second turn: pass the same conversation_id and add a new user message. + body2 := strings.NewReader(`{ + "messages":[{"role":"user","content":"primera"}, + {"role":"assistant","content":"respuesta 1"}, + {"role":"user","content":"segunda"}], + "conversation_id":"` + cr1.ConversationID + `", + "stream":false + }`) + resp2, _ := http.Post(url+"/api/chat", "application/json", body2) + var cr2 ChatResponse + _ = json.NewDecoder(resp2.Body).Decode(&cr2) + resp2.Body.Close() + + if cr2.ConversationID != cr1.ConversationID { + t.Errorf("server changed the conversation id: %q → %q", cr1.ConversationID, cr2.ConversationID) + } + + // GET should now have 4 messages: user1, assistant1, user2, assistant2. + resp3, _ := http.Get(url + "/api/conversations/" + cr1.ConversationID) + var conv portfolio.Conversation + _ = json.NewDecoder(resp3.Body).Decode(&conv) + resp3.Body.Close() + + if len(conv.Messages) != 4 { + t.Errorf("expected 4 messages, got %d", len(conv.Messages)) + } +} + +func TestListConversations(t *testing.T) { + url, _ := newTestServer(t) + + // Create two conversations. + for _, msg := range []string{"primera conversación", "segunda conversación"} { + body := strings.NewReader(`{"messages":[{"role":"user","content":"` + msg + `"}],"stream":false}`) + resp, _ := http.Post(url+"/api/chat", "application/json", body) + resp.Body.Close() + } + + resp, err := http.Get(url + "/api/conversations") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != 200 { + t.Fatalf("status = %d", resp.StatusCode) + } + var list struct { + Count int `json:"count"` + Conversations []portfolio.ConversationSummary `json:"conversations"` + } + if err := json.NewDecoder(resp.Body).Decode(&list); err != nil { + t.Fatal(err) + } + if list.Count < 2 { + t.Errorf("count = %d, want >= 2", list.Count) + } +} + +func TestGetConversationNotFound(t *testing.T) { + url, _ := newTestServer(t) + resp, err := http.Get(url + "/api/conversations/nonexistent") + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != 404 { + t.Errorf("status = %d, want 404", resp.StatusCode) + } +} + +func TestDeleteConversation(t *testing.T) { + url, _ := newTestServer(t) + body := strings.NewReader(`{"messages":[{"role":"user","content":"to be deleted"}],"stream":false}`) + resp, _ := http.Post(url+"/api/chat", "application/json", body) + var cr ChatResponse + _ = json.NewDecoder(resp.Body).Decode(&cr) + resp.Body.Close() + + del, err := http.NewRequest("DELETE", url+"/api/conversations/"+cr.ConversationID, nil) + if err != nil { + t.Fatal(err) + } + delResp, err := http.DefaultClient.Do(del) + if err != nil { + t.Fatal(err) + } + delResp.Body.Close() + if delResp.StatusCode != 204 { + t.Errorf("delete status = %d, want 204", delResp.StatusCode) + } + + // GET should now 404. + get, _ := http.Get(url + "/api/conversations/" + cr.ConversationID) + defer get.Body.Close() + if get.StatusCode != 404 { + t.Errorf("get after delete status = %d, want 404", get.StatusCode) + } +} + +func TestChatStreamingIncludesConversationID(t *testing.T) { + url, _ := newTestServer(t) + body := strings.NewReader(`{"messages":[{"role":"user","content":"stream test"}],"stream":true}`) + resp, err := http.Post(url+"/api/chat", "application/json", body) + if err != nil { + t.Fatal(err) + } + defer resp.Body.Close() + events := parseSSE(t, resp.Body) + if len(events) == 0 { + t.Fatal("no SSE events received") + } + if events[0].event != "start" { + t.Fatalf("first event = %q, want start", events[0].event) + } + var start map[string]any + if err := json.Unmarshal([]byte(events[0].data), &start); err != nil { + t.Fatal(err) + } + if start["conversation_id"] == "" || start["conversation_id"] == nil { + t.Error("start event missing conversation_id") + } +} + +// keep the import used +var _ = time.Second \ No newline at end of file diff --git a/internal/server/handlers.go b/internal/server/handlers.go index 8caab87..3d75c93 100644 --- a/internal/server/handlers.go +++ b/internal/server/handlers.go @@ -1,13 +1,13 @@ package server import ( - "crypto/rand" - "encoding/hex" + "context" "encoding/json" "errors" "fmt" "log/slog" "net/http" + "strconv" "strings" "time" @@ -29,8 +29,12 @@ func NewHandlers(cfg *config.Config, runner *agent.Runner, store *portfolio.Stor } type ChatRequest struct { - Messages []ChatMessage `json:"messages"` - Stream *bool `json:"stream,omitempty"` + Messages []ChatMessage `json:"messages"` + Stream *bool `json:"stream,omitempty"` + // ConversationID is optional. If empty, the server creates a new + // conversation and returns its ID in the response (or in the SSE + // `start` event). Pass an existing ID to continue a previous thread. + ConversationID string `json:"conversation_id,omitempty"` } type ChatMessage struct { @@ -39,9 +43,10 @@ type ChatMessage struct { } type ChatResponse struct { - Content string `json:"content"` - Sources []string `json:"sources,omitempty"` - Usage streaming.Usage `json:"usage"` + ConversationID string `json:"conversation_id"` + Content string `json:"content"` + Sources []string `json:"sources,omitempty"` + Usage streaming.Usage `json:"usage"` } func (h *Handlers) Chat(w http.ResponseWriter, r *http.Request) { @@ -62,12 +67,43 @@ func (h *Handlers) Chat(w http.ResponseWriter, r *http.Request) { if req.Stream != nil { stream = *req.Stream } + + // Resolve or create the conversation. If the client passed a non-empty + // ID we use it as-is; otherwise we mint a new one. + ctx := r.Context() + convID := req.ConversationID + if convID == "" { + var err error + convID, err = h.store.CreateConversation(ctx) + if err != nil { + http.Error(w, "create conversation: "+err.Error(), http.StatusInternalServerError) + return + } + } + + // Persist the incoming user message. Even if the LLM fails afterward + // the user sees their question in the conversation history. + if userMsg := lastUserMessage(req.Messages); userMsg != "" { + if err := h.store.SaveMessage(ctx, convID, "user", userMsg, nil); err != nil { + slog.Error("save user message", "err", err) + } + } + history := toAgentMessages(req.Messages) if stream { - h.streamChat(w, r, history) + h.streamChat(w, r, history, convID) return } - h.completeChat(w, r, history) + h.completeChat(w, r, history, convID) +} + +func lastUserMessage(msgs []ChatMessage) string { + for i := len(msgs) - 1; i >= 0; i-- { + if msgs[i].Role == "user" { + return msgs[i].Content + } + } + return "" } func toAgentMessages(in []ChatMessage) []agent.Message { @@ -100,7 +136,9 @@ func (req *ChatRequest) validate() error { // streamChat drives the LLM agent and forwards each chunk to the SSE // stream using the protocol described in docs/architecture.md §3.2. -func (h *Handlers) streamChat(w http.ResponseWriter, r *http.Request, history []agent.Message) { +// convID is the persistent conversation ID — passed in from Chat() after +// resolve-or-create. The assistant reply is saved on success. +func (h *Handlers) streamChat(w http.ResponseWriter, r *http.Request, history []agent.Message, convID string) { w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") @@ -114,7 +152,6 @@ func (h *Handlers) streamChat(w http.ResponseWriter, r *http.Request, history [] } ctx := r.Context() - convID := newConvID() if err := streaming.WriteStart(w, convID); err != nil { slog.Error("sse start failed", "err", err) return @@ -127,12 +164,14 @@ func (h *Handlers) streamChat(w http.ResponseWriter, r *http.Request, history [] _ = streaming.WriteError(w, "rag: "+err.Error()) return } + var sources []string if ragContext != "" { - sources := extractSources(ragContext) + sources = extractSources(ragContext) _ = streaming.WriteSources(w, sources) } - // Stream from the model. + // Stream from the model, collecting the full reply as we go. + var full strings.Builder for chunk, err := range h.runner.Stream(ctx, history) { if err != nil { slog.Error("llm stream", "err", err) @@ -140,11 +179,13 @@ func (h *Handlers) streamChat(w http.ResponseWriter, r *http.Request, history [] return } if chunk.Delta != "" { + full.WriteString(chunk.Delta) if err := streaming.WriteChunk(w, chunk.Delta); err != nil { return } } if chunk.FinishReason != "" && chunk.Usage.TotalTokens > 0 { + h.persistAssistant(ctx, convID, full.String(), sources) _ = streaming.WriteDone(w, streaming.Usage{ InputTokens: chunk.Usage.InputTokens, OutputTokens: chunk.Usage.OutputTokens, @@ -155,16 +196,30 @@ func (h *Handlers) streamChat(w http.ResponseWriter, r *http.Request, history [] // Final usage chunk may come on the last iteration; if we never saw a // finish_reason + usage in-band, surface what we recorded. usage := h.runner.LastUsage() + h.persistAssistant(ctx, convID, full.String(), sources) _ = streaming.WriteDone(w, streaming.Usage{ InputTokens: usage.InputTokens, OutputTokens: usage.OutputTokens, }) } -func (h *Handlers) completeChat(w http.ResponseWriter, r *http.Request, history []agent.Message) { +// persistAssistant saves the full assistant reply. Best-effort: a failure +// here doesn't fail the user's request (the response is already streamed). +func (h *Handlers) persistAssistant(ctx context.Context, convID, content string, sources []string) { + if strings.TrimSpace(content) == "" { + return + } + if err := h.store.SaveMessage(ctx, convID, "assistant", content, sources); err != nil { + slog.Error("save assistant message", "err", err, "conv", convID) + } +} + +func (h *Handlers) completeChat(w http.ResponseWriter, r *http.Request, history []agent.Message, convID string) { w.Header().Set("Content-Type", "application/json") + ctx := r.Context() var full strings.Builder - for chunk, err := range h.runner.Stream(r.Context(), history) { + var sources []string + for chunk, err := range h.runner.Stream(ctx, history) { if err != nil { http.Error(w, "llm: "+err.Error(), http.StatusBadGateway) return @@ -172,16 +227,19 @@ func (h *Handlers) completeChat(w http.ResponseWriter, r *http.Request, history full.WriteString(chunk.Delta) } usage := h.runner.LastUsage() - _, ragContext, _ := h.runner.BuildMessages(r.Context(), history) + _, ragContext, _ := h.runner.BuildMessages(ctx, history) + if ragContext != "" { + sources = extractSources(ragContext) + } + h.persistAssistant(ctx, convID, full.String(), sources) resp := ChatResponse{ - Content: full.String(), + ConversationID: convID, + Content: full.String(), Usage: streaming.Usage{ InputTokens: usage.InputTokens, OutputTokens: usage.OutputTokens, }, - } - if ragContext != "" { - resp.Sources = extractSources(ragContext) + Sources: sources, } _ = json.NewEncoder(w).Encode(resp) } @@ -217,12 +275,6 @@ func truncate(s string, n int) string { return s[:n] + "…" } -func newConvID() string { - var b [8]byte - _, _ = rand.Read(b[:]) - return hex.EncodeToString(b[:]) -} - func (h *Handlers) Info(w http.ResponseWriter, _ *http.Request) { w.Header().Set("Content-Type", "application/json") p := h.cfg.DefaultProvider() @@ -263,6 +315,102 @@ func (h *Handlers) Reindex(w http.ResponseWriter, r *http.Request) { }) } +// ---- Conversation REST endpoints ------------------------------------------- + +// GetConversation returns the full history of one conversation. The +// conversation ID is treated as a bearer token: anyone who knows it can +// read the history. For a public bot this is fine; for private contexts +// add auth at the proxy layer. +func (h *Handlers) GetConversation(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + id := conversationIDFromPath(r.URL.Path) + if id == "" { + http.Error(w, "missing conversation id", http.StatusBadRequest) + return + } + conv, err := h.store.GetConversation(r.Context(), id) + if errors.Is(err, portfolio.ErrConversationNotFound) { + http.Error(w, "conversation not found", http.StatusNotFound) + return + } + if err != nil { + http.Error(w, "get conversation: "+err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(conv) +} + +// ListConversations returns the most recent N conversation summaries. +// Useful for a "show my chats" sidebar in the widget or a custom UI. +func (h *Handlers) ListConversations(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + limit := 50 + if s := r.URL.Query().Get("limit"); s != "" { + if n, err := strconv.Atoi(s); err == nil && n > 0 && n <= 200 { + limit = n + } + } + items, err := h.store.ListConversations(r.Context(), limit) + if err != nil { + http.Error(w, "list conversations: "+err.Error(), http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json") + if items == nil { + items = []portfolio.ConversationSummary{} + } + _ = json.NewEncoder(w).Encode(map[string]any{ + "conversations": items, + "count": len(items), + }) +} + +// DeleteConversation removes a conversation and all its messages. +func (h *Handlers) DeleteConversation(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodDelete { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + id := conversationIDFromPath(r.URL.Path) + if id == "" { + http.Error(w, "missing conversation id", http.StatusBadRequest) + return + } + err := h.store.DeleteConversation(r.Context(), id) + if errors.Is(err, portfolio.ErrConversationNotFound) { + http.Error(w, "conversation not found", http.StatusNotFound) + return + } + if err != nil { + http.Error(w, "delete conversation: "+err.Error(), http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusNoContent) +} + +// conversationIDFromPath pulls the ID out of "/api/conversations/{id}". +// chi does this with chi.URLParam(r, "id"); for clarity (and so this +// handler works even without chi) we do it by hand. +func conversationIDFromPath(path string) string { + const prefix = "/api/conversations/" + if !strings.HasPrefix(path, prefix) { + return "" + } + id := strings.TrimPrefix(path, prefix) + // strip trailing slash and any further segments + if i := strings.IndexByte(id, '/'); i >= 0 { + id = id[:i] + } + return id +} + func firstNonEmpty(vals ...string) string { for _, v := range vals { if v != "" { diff --git a/internal/server/server.go b/internal/server/server.go index 5523cf2..21d035b 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -37,6 +37,9 @@ func New(cfg *config.Config, h *Handlers) *Server { r.Post("/reindex", h.Reindex) r.Get("/health", h.Health) r.Get("/info", h.Info) + r.Get("/conversations", h.ListConversations) + r.Get("/conversations/{id}", h.GetConversation) + r.Delete("/conversations/{id}", h.DeleteConversation) }) srv := &http.Server{ diff --git a/web/README.md b/web/README.md index 8d5c5c6..0a6004d 100644 --- a/web/README.md +++ b/web/README.md @@ -128,9 +128,10 @@ export default function RootLayout({ children }) { The widget expects the bot to: -1. Expose `POST /api/chat` accepting `{ messages, stream }` (see `docs/architecture.md` §3.1). -2. Stream SSE events: `start`, `chunk`, `sources`, `done`, `error` (see `docs/architecture.md` §3.2). -3. Allow the page's origin via `cors_origins` in the bot's config. +1. Expose `POST /api/chat` accepting `{ messages, stream, conversation_id? }` (see `docs/architecture.md` §3.1). +2. Stream SSE events: `start` (with `conversation_id`), `chunk`, `sources`, `done`, `error` (see `docs/architecture.md` §3.2). +3. Expose `GET /api/conversations/{id}` for history restore (returns 404 if unknown). +4. Allow the page's origin via `cors_origins` in the bot's config. ## Running the example locally @@ -156,9 +157,49 @@ Modern browsers (Chrome/Edge 90+, Firefox 90+, Safari 15+). Uses: No polyfills, no transpilation. +## Conversation persistence + +The bot persists conversations on the server side (SQLite, see +`docs/architecture.md` §3.4). The widget handles the client side +automatically: + +1. **First message** — the server mints a new `conversation_id` and returns + it in the `start` SSE event. The widget saves it to + `localStorage["rony-chat-conv"]`. +2. **Subsequent messages** — the widget sends the saved ID with every + request, so the server keeps appending to the same thread. +3. **Page reload** — on load, the widget reads the stored ID and calls + `GET /api/conversations/{id}` to restore the full history. +4. **Server lost the conversation** (e.g. DB was wiped) — the GET returns + 404. The widget clears `localStorage` and starts a fresh thread on the + next message. + +**Browser-scoped**: `localStorage` is per-origin, so the same browser +keeps the thread across visits, but a different browser starts fresh. +Clearing site data resets the conversation. + +**Server-scoped across devices**: not automatic. The conversation lives +in the SQLite DB but only the browser that created it knows its ID. If +you want cross-device continuity, persist the ID in your user profile +(e.g. after login) and pass it on initial load instead of relying on +`localStorage`. The backend already supports this — see +`docs/architecture.md` §3.4 for the protocol. + +**To opt out** (start a fresh conversation on every page load): + +```html + + +``` + +Or expose a "new chat" button in your UI that calls +`DELETE /api/conversations/{id}` then clears the localStorage key. + ## What's not in the widget (yet) -- **Conversation persistence** — each visit is a fresh conversation. The bot is stateless; add a `conversation_id` cookie + server-side history if you want continuity. - **Markdown images / tables** — the renderer handles paragraphs, lists, code, links, bold/italic. Tables and images render as raw text. For richer output, swap `renderMarkdown` for `marked` or `markdown-it`. - **Typing indicators beyond the streaming caret** — the caret at the end of the streaming response is the only indicator. Good enough for short answers. -- **Mobile sheet drag-to-dismiss** — the panel goes full-screen on phones, but can't be swiped away. Add a swipe handler if it matters. \ No newline at end of file +- **Mobile sheet drag-to-dismiss** — the panel goes full-screen on phones, but can't be swiped away. Add a swipe handler if it matters. +- **Conversation history sidebar** — only the active conversation is shown in the panel. The backend exposes `GET /api/conversations` for a future sidebar. \ No newline at end of file diff --git a/web/chat-widget.js b/web/chat-widget.js index 854d00b..91f114f 100644 --- a/web/chat-widget.js +++ b/web/chat-widget.js @@ -50,6 +50,7 @@ }; var LANG_KEY = "rony-chat-lang"; + var CONV_KEY = "rony-chat-conv"; function pickInitialLang() { var saved = null; @@ -63,6 +64,37 @@ try { localStorage.setItem(LANG_KEY, lang); } catch (e) {} } + // ---- Conversation persistence ------------------------------------------- + // The conversation_id is a server-issued UUID-ish string. We store it in + // localStorage so the same browser keeps its thread across reloads. A + // different browser (or cleared storage) starts a fresh thread. + + function loadConvID() { + try { return localStorage.getItem(CONV_KEY) || ""; } catch (e) { return ""; } + } + function saveConvID(id) { + try { localStorage.setItem(CONV_KEY, id); } catch (e) {} + } + function clearConvID() { + try { localStorage.removeItem(CONV_KEY); } catch (e) {} + } + + // Restore conversation history from the server, if any. On 404 the + // stored ID is dead (e.g. server DB was wiped) — clear it and start fresh. + function restoreHistory(convID, onDone) { + fetch(cfg.apiUrl + "/api/conversations/" + encodeURIComponent(convID)) + .then(function (resp) { + if (resp.status === 404) { clearConvID(); onDone(null); return null; } + if (!resp.ok) { onDone(null); return null; } + return resp.json(); + }) + .then(function (conv) { + if (!conv) { onDone(null); return; } + onDone(conv); + }) + .catch(function () { onDone(null); }); + } + // ---- Config ---------------------------------------------------------------- function readConfig() { @@ -226,6 +258,7 @@ var busy = false; var abortCtrl = null; var lang = pickInitialLang(); + var convID = loadConvID(); // ---- Language handling ----------------------------------------------- @@ -258,6 +291,21 @@ }); } + // Restore conversation history from the server on first load. The + // convID came from localStorage; if the server doesn't know it + // (404), we wipe it and start a fresh thread on next send. + if (convID) { + restoreHistory(convID, function (conv) { + if (conv && conv.messages) { + for (var i = 0; i < conv.messages.length; i++) { + var m = conv.messages[i]; + appendMessage(m.role, m.content); + history.push({ role: m.role, content: m.content }); + } + } + }); + } + // ---- Chat behavior -------------------------------------------------- var lastStatusKey = "online"; @@ -269,6 +317,9 @@ function setOpen(open) { widget.setAttribute("data-open", open ? "true" : "false"); + // Greeting only fires on a brand-new thread. If history was + // restored from the server we don't want to prepend a greeting on + // top of the user's previous messages. if (open && !history.length && cfg.greeting) { appendMessage("assistant", cfg.greeting); history.push({ role: "assistant", content: cfg.greeting }); @@ -317,7 +368,11 @@ fetch(cfg.apiUrl + "/api/chat", { method: "POST", headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ messages: history, stream: true }), + body: JSON.stringify({ + messages: history, + stream: true, + conversation_id: convID || undefined, + }), signal: abortCtrl.signal, }).then(function (resp) { if (!resp.ok) { @@ -329,7 +384,13 @@ } catch (e) { return; } - if (type === "chunk" && payload.content) { + if (type === "start" && payload.conversation_id) { + // Server may have minted a new id; persist it. + if (payload.conversation_id !== convID) { + convID = payload.conversation_id; + saveConvID(convID); + } + } else if (type === "chunk" && payload.content) { assistantDiv.insertBefore(document.createTextNode(payload.content), caret); $messages.scrollTop = $messages.scrollHeight; } else if (type === "sources" && Array.isArray(payload.documents)) { From 3f3e9779535ccffe3366496f7e947ddd9b2fb879 Mon Sep 17 00:00:00 2001 From: Victor Hugo Vargas Date: Fri, 17 Jul 2026 00:56:51 -0700 Subject: [PATCH 4/4] chore: ignore compiled binaries in /bin/ MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The chat-bot binary is built into ./bin/chat-bot via `go build -o bin/chat-bot ./cmd/chat-bot`. Keep it out of git — never commit build artifacts. --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 4eedc25..eca4d84 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ vendor/ coverage.out coverage.html +/bin/ # SQLite (RAG index) *.db