# π Rony Chat Bot β Technical Design Document **Version:** 1.0 **Author:** Victor Hugo Vargas **Date:** 2026-06-28 **Status:** Complete specification for implementation **Path:** `rony-chat-bot/docs/architecture.md` > π **Language:** [English](./architecture.md) | [EspaΓ±ol](./architecture.es.md) > > π **Workspace:** This project is part of the `Rony/` workspace. See [`../README.md`](../../README.md). > > π **Depends on:** [`rony-llm-agent`](https://github.com/VictorVargas/rony-llm-agent) β core library that provides agent loop, LLM clients, RAG, persona system. > > π **Methodology:** This project follows the **SDD + DDD + Hexagonal Architecture** approach. Functional Requirements are numbered as `CRF-XXX`. See [`../../METHODOLOGY.md`](../../METHODOLOGY.md). --- ## π― 1. Project Vision ### 1.1 What is Chat-Bot? An **HTTP chatbot** that answers questions about Victor Hugo Vargas and his projects. Uses **RAG (Retrieval-Augmented Generation)** over markdown files describing each project, and a local LLM (or cloud) to generate responses. ### 1.2 Primary use case Victor has a portfolio website (Astro + React). On the site there's a chat widget where visitors can ask: - "What projects has Victor done?" - "What's his experience with Go?" - "How does Rony Harness work?" - "Has Victor worked with PostgreSQL?" The bot responds with accurate information extracted from the projects' markdown files + bio + skills. ### 1.3 Secondary use cases (future) - **Client adaptation:** The same bot, with other data and another persona, serves car dealerships, restaurants, etc. - **Standalone CLI:** `./chat-bot ask "what do you know about X?"` for terminal use. - **Slack/Discord bot:** Wrapper that consumes the HTTP API. ### 1.4 Philosophy - **Self-hosted by default** β works 100% local with Ollama + 1-3B models - **Cloud optional** β if you need more quality, swap to Anthropic API - **Portable** β easy to fork/customize for other contexts - **Streaming** β token-by-token responses with SSE (no waiting for complete response) - **Reuses `rony-llm-agent`** β doesn't reinvent the agent loop --- ## ποΈ 2. Architecture ### 2.1 Overview ``` βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ β Browser (Astro site) β β β HTTP POST /api/chat β β Astro SSR (proxy) βββββββββββ Serves portfolio + proxy chat β β β HTTP POST /api/chat β β Chat-Bot HTTP server (:7331) β β β β β Agent loop (rony-llm-agent) β β β β β RAG retrieval β SQLite FTS5 over data/projects/*.md β β β β β LLM (llama.cpp local default / Ollama or Anthropic optional) β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ ``` ### 2.2 Main components | Component | Path | Responsibility | |---|---|---| | **HTTP server** | `internal/server/` | Gin/chi handlers, SSE streaming | | **Agent runner** | `internal/agent/` | Wrapper over `rony-llm-agent` with specific config | | **Portfolio loader** | `internal/portfolio/` | Reads `data/projects/*.md`, indexes in SQLite FTS5 | | **Persona** | `internal/persona/` | Loads persona from `configs/portfolio-bot.yaml` | | **CLI** | `cmd/chat-bot/` | Commands: `serve`, `reindex`, `ask`, `version` | ### 2.3 Tech stack | Layer | Technology | Reason | |---|---|---| | **Language** | Go 1.26+ | Same as rony-harness, leverage `os.Root`, `iter.Seq` | | **HTTP router** | `net/http` + `chi` | Stdlib + chi for middleware (CORS, logging) | | **SSE** | `net/http` Flusher | Stdlib is enough, no external library needed | | **Config** | `gopkg.in/yaml.v3` | Same as harness | | **RAG backend** | SQLite + FTS5 (BM25) | Zero external deps, single file, fast | | **LLM** | llama.cpp (qwen2.5:1.5b GGUF) β default; Ollama as alt | Self-hosted by default | | **Tests** | stdlib + testify | Consistency with the rest | --- ## π 3. HTTP API ### 3.1 Endpoints #### `POST /api/chat` β Chat with SSE streaming **Request:** ```json { "messages": [ {"role": "user", "content": "What projects does Victor have?"} ], "stream": true } ``` **Response (SSE):** ``` data: {"type":"start","conversation_id":"abc123"} data: {"type":"chunk","content":"Victor"} data: {"type":"chunk","content":" has"} data: {"type":"chunk","content":" several"} data: {"type":"chunk","content":" projects"} data: {"type":"sources","documents":["rony-harness.md","rony-llm-agent.md"]} data: {"type":"done","usage":{"input_tokens":245,"output_tokens":38}} ``` **Without streaming** (`"stream": false`): ```json { "content": "Victor has several projects...", "sources": ["rony-harness.md", "rony-llm-agent.md"], "usage": {"input_tokens": 245, "output_tokens": 38} } ``` #### `POST /api/reindex` β Re-index portfolio Useful when files in `data/projects/` are modified. **Request:** empty **Response:** ```json { "indexed_files": 12, "total_chunks": 87, "duration_ms": 4321 } ``` #### `GET /api/health` β Health check (real) Probes the LLM provider and the SQLite store in parallel and returns their states. Designed for monitoring/load balancers. **Returns 200 when healthy or degraded, 503 when unhealthy.** - `?deep=true` adds a chunk count to the store probe (same latency budget). **Status taxonomy:** | `status` | HTTP | Meaning | |---|---|---| | `healthy` | 200 | LLM up, store up | | `degraded` | 200 | LLM up, store down β bot still answers, just without RAG | | `unhealthy` | 503 | LLM down β bot cannot answer, no point routing traffic here | **Probe details:** | Component | Probe | Latency | |---|---|---| | `llm` | `GET {provider}/health` (llamacpp, ollama) or `/models` (openai) | ~1ms for local llama-server | | `store` | `SELECT 1` on the SQLite handle | ~100Β΅s | Each probe has a 2s timeout; the whole call returns within ~2.5s even if a dependency hangs. **Response shape (healthy):** ```json { "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" } } } ``` **Response shape (degraded, with `?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}} } } ``` **Response shape (unhealthy):** HTTP 503, same JSON with `"status": "unhealthy"` and the failed component reporting `"status": "down"` plus an `error` field. #### `GET /api/info` β Bot metadata ```json { "name": "Rony Chat Bot", "model": "qwen2.5:1.5b", "persona": "...", "topics": ["projects", "experience", "technical skills"] } ``` ### 3.2 SSE Implementation ```go // internal/server/chat.go package server import ( "encoding/json" "fmt" "net/http" "github.com/VictorVargas/rony-llm-agent/pkg/agent" ) func (s *Server) handleChat(w http.ResponseWriter, r *http.Request) { // SSE headers w.Header().Set("Content-Type", "text/event-stream") w.Header().Set("Cache-Control", "no-cache") w.Header().Set("Connection", "keep-alive") w.Header().Set("X-Accel-Buffering", "no") flusher, ok := w.(http.Flusher) if !ok { http.Error(w, "SSE not supported", http.StatusInternalServerError) return } // Parse request var req ChatRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { writeError(w, flusher, "invalid request", err) return } // Start event writeSSE(w, flusher, "start", map[string]string{ "conversation_id": generateConvID(), }) // Run agent with streaming sources := []string{} for chunk, err := range s.agent.RunStream(r.Context(), req.Messages) { if err != nil { writeSSE(w, flusher, "error", map[string]string{"message": err.Error()}) return } if chunk.Type == "source" { sources = append(sources, chunk.Source) } writeSSE(w, flusher, chunk.Type, chunk.Data) } // Done event writeSSE(w, flusher, "done", map[string]any{ "usage": map[string]int{ "input_tokens": 245, "output_tokens": 38, }, }) } func writeSSE(w http.ResponseWriter, flusher http.Flusher, eventType string, data any) { payload, _ := json.Marshal(data) fmt.Fprintf(w, "data: {\"type\":%q,\"data\":%s}\n\n", eventType, payload) flusher.Flush() } ``` ### 3.3 Middleware ```go // internal/server/middleware.go package server func (s *Server) loggingMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { start := time.Now() // Wrap response writer to capture status rw := &statusRecorder{ResponseWriter: w, status: 200} next.ServeHTTP(rw, r) slog.Info("http.request", "method", r.Method, "path", r.URL.Path, "status", rw.status, "duration_ms", time.Since(start).Milliseconds(), "ip", r.RemoteAddr, ) }) } func (s *Server) corsMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { origin := r.Header.Get("Origin") for _, allowed := range s.config.Server.CORSOrigins { if origin == allowed { w.Header().Set("Access-Control-Allow-Origin", origin) w.Header().Set("Access-Control-Allow-Methods", "POST, GET, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type") break } } if r.Method == "OPTIONS" { w.WriteHeader(204) return } next.ServeHTTP(w, r) }) } func (s *Server) rateLimitMiddleware(next http.Handler) http.Handler { limiter := rate.NewLimiter(rate.Every(time.Minute/time.Duration(s.config.Server.RateLimit.RequestsPerMinute)), s.config.Server.RateLimit.Burst) return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if !limiter.Allow() { http.Error(w, "rate limit exceeded", http.StatusTooManyRequests) return } next.ServeHTTP(w, r) }) } ``` --- ## π§ 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 esta 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 types of questions esperadas; si el corpus crece o las queries se vuelven abstractas, considerar agregar embeddings como capa secundaria. ### 4.0 Driver decision: benchmark results 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 | | Binary size | 11 MB | 11 MB | igual | | Build deps | gcc, CGO=1 | nada | modernc gana | | CI/CD portable | requiere toolchain C | `go build` puro | modernc gana | **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 Indexing pipeline ``` data/projects/*.md β (read all files) Raw markdown content β (split into chunks, ~500 chars, 50 overlap) Chunks [] β (insert into SQLite FTS5 virtual table "portfolio_chunks") Indexed corpus ``` **When it runs:** - On bot startup (if `--reindex-on-start` flag) - Manually: `./chat-bot reindex` - Via HTTP: `POST /api/reindex` ### 4.2 Retrieval pipeline ``` User query "what projects does Victor have?" β (FTS5 MATCH query, BM25 ranking, top_k=5) Top 5 relevant chunks β (format as context block) System prompt += relevant chunks β (send to LLM) LLM generates answer ``` ### 4.3 Implementation ```go // internal/portfolio/indexer.go package portfolio import ( "context" "database/sql" "fmt" "log/slog" "os" "path/filepath" "strings" ) type Indexer struct { dataPath string db *sql.DB chunkSize int chunkOverlap int } func (i *Indexer) IndexAll(ctx context.Context) (int, error) { files, err := filepath.Glob(filepath.Join(i.dataPath, "*.md")) if err != nil { return 0, err } // Rebuild FTS5 index from scratch (delete + insert is faster than diff for small corpora) 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) if err != nil { slog.Warn("failed to index file", "file", file, "err", err) continue } totalChunks += chunks } return totalChunks, nil } func (i *Indexer) indexFile(ctx context.Context, path string) (int, error) { content, err := os.ReadFile(path) 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 { 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 β applied at startup 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 { // Simple implementation: split by size with overlap // Production version uses tokenizer-aware chunking var chunks []string for i := 0; i < len(text); i += size - overlap { end := i + size if end > len(text) { end = len(text) } chunks = append(chunks, text[i:end]) } return chunks } ``` ### 4.4 Retrieval in the agent loop ```go // internal/portfolio/search.go package portfolio type Hit struct { ProjectID string SourceFile string ChunkIndex int Content string Score float64 // BM25 score from FTS5 } func (s *Store) Search(ctx context.Context, query string, topK int) ([]Hit, error) { // Escape user input: FTS5 syntax can break with special chars 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 wraps the user query so reserved chars and unquoted strings don't crash FTS5. // A pragmatic choice for a Q&A bot: append prefix-match wildcard to each 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) // keep accented chars }) 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) { basePrompt := r.persona.SystemPrompt hits, err := r.store.Search(ctx, query, r.config.RAG.TopK) if err != nil { return "", err } if len(hits) == 0 { return basePrompt, nil } var contextBlock strings.Builder contextBlock.WriteString(basePrompt) 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) { lastUserMsg := getLastUserMessage(messages) systemPrompt, err := r.buildSystemPrompt(ctx, lastUserMsg) if err != nil { yield(Chunk{}, err) return } messages = prependSystem(messages, systemPrompt) for chunk, err := range r.loop.RunStream(ctx, messages) { if !yield(chunk, err) { return } } } } ``` **Why this is simpler than embeddings:** - No embedding model to download or run (saves ~270MB of RAM and ~200ms per query) - One file (`data/portfolio.db`), one driver, no extra process - BM25 ranking is excellent for keyword-based retrieval over structured docs like project READMEs - Trade-off: no semantic similarity ("projects about AI" won't match "machine learning" without the literal words). Mitigation: `trigram` tokenizer handles morphology well for English/Spanish. --- ## π 5. Embedding the widget The bot ships with a drop-in vanilla-JS widget. Add two files to your site and it works. ### 5.1 The widget (any site) ```html ``` 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. **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"; ---