# π 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) β β β β β Hybrid RAG β SQLite FTS5 (BM25) β vectors, fused with RRF β β β over data/projects/ + data/docs/ β β βββ embeddings server (:9200, nomic-embed-v2-moe) β β β β β LLM (llama.cpp local default / Ollama or Anthropic optional) β βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ ``` Two local model servers, not one. Both are plain `llama-server` processes the bot talks to over HTTP; neither is linked into the binary. ### 2.2 Main components | Component | Path | Responsibility | |---|---|---| | **HTTP server** | `internal/server/` | chi handlers, SSE streaming | | **Agent runner** | `internal/agent/` | Wrapper over `rony-llm-agent`: prompt assembly, retrieval, language selection, compaction | | **Portfolio loader** | `internal/portfolio/` | Reads `data/projects/` and `data/docs/` (`.md` + `.mdx`), indexes into SQLite FTS5 + vectors, hybrid search | | **Embeddings client** | `internal/embed/` | OpenAI-compatible embeddings, normalisation, float32 codec | | **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) β dense vectors | Zero external deps, single file. No ANN index: a portfolio is hundreds of chunks, so a full scan is microseconds | | **LLM** | llama.cpp (qwen2.5-3b-instruct Q4_K_M) β default; Ollama as alt | Self-hosted by default. 3B, not 1.5B: see the benchmark in `configs/portfolio-bot.yaml` | | **Embeddings** | `nomic-embed-v2-moe` Q5_K_M, 768-dim | Multilingual β the whole point is matching Spanish questions to English documents | | **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, "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":"57f4aa3c7fab466bc4de9c43b296903e"} 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}} ``` 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. **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-3b-instruct", "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) }) } ``` ### 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) > β **The four decisions this section left open have been made and measured:** > > - **FTS5 tokenizer** β `unicode61 remove_diacritics 2`, as specced. Stemming > was not the bottleneck; the language gap was, and embeddings close it. > - **SQLite driver** β `modernc.org/sqlite` (pure Go, no CGO). Benchmark below. > - **Chunking** β by markdown heading, not fixed size, with oversized sections > split at `###` before falling back to byte offsets. Β§4.1. > - **Semantic similarity** β added. BM25 alone returned **nothing at all** for > a Spanish question about an English document. Β§4.2. ### 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,mdx} kind=project β announced in the catalogue data/docs/*.{md,mdx} kind=doc β retrievable, never announced β (skip each directory's README β those are instructions, not content) Raw markdown β (split by heading; sections over the limit split at ###, then by size) β (drop the frontmatter chunk) Chunks βββ SQLite FTS5 virtual table "portfolio_chunks" βββ embeddings endpoint β "portfolio_vectors" (id, content_hash, dim, vec) Indexed corpus ``` **Two kinds of source.** Everything under `data_path` is one of Victor's projects and is listed in the catalogue injected into every prompt. Everything under `docs_path` is searchable evidence that is *not* a project β his CV, an about page. The CV is what someone deciding whether to hire actually reads, and it was unreachable while it lived only in the Astro site; but filing it under projects made the bot list "cv" as one of his works. **Three things are excluded or reshaped, each because of a measured failure:** | Rule | Failure it fixes | |---|---| | Skip `README*` in both directories | `data/projects/README.md` was indexed, so the catalogue announced "README" and "README.es" as projects of Victor's | | Drop the frontmatter chunk | Dense metadata in a very short chunk is a magnet for short queries β a CV's `location:` field answered *"ΒΏDΓ³nde ha trabajado Victor?"* with a city instead of a work history | | Split oversized sections at `###` | A CV's Experience section is a list of jobs; size-splitting cut one mid-word, stranding the employer's name in the previous chunk. Chunks now hold one job each, named `Experience β Metrimex β Frontend Developer` | **When it runs:** - Manually: `./chat-bot reindex` - On startup with `serve --reindex-on-start` - Via HTTP: `POST /api/reindex` Vectors are built at index time, so enabling embeddings requires a reindex. The chunk table is derived data: `OpenStore` rebuilds it when its columns are missing, so upgrading an existing install needs no migration step. Conversation tables are never touched by that rebuild. ### 4.2 Retrieval pipeline ``` User query "ΒΏCon quΓ© se paga en la tienda de ropa?" β βββ FTS5 MATCH, BM25 ranking β top 15 (topK Γ 3) βββ embed(query) β cosine vs vecs β top 15 (topK Γ 3) β (Reciprocal Rank Fusion, k=60) Top 5 chunks β (system prompt + project catalogue + excerpts + language directive) LLM generates answer ``` **Both halves are load-bearing.** Keyword search matches words exactly β no stemming, no translation. The corpus is written in English and visitors ask in Spanish, so the words carrying the meaning score zero: measured on the real corpus, `"paga"` appears 0 times in a document that says *"Payments: Stripe"* and `"trabajado"` 0 times in one that says *"worked"*. The question above retrieved **nothing at all**. Embeddings (`nomic-embed-v2-moe`, multilingual) put all three `tienda-ropa` chunks on top β but they blur exact rare tokens, where BM25 is sharp. **Why RRF rather than a weighted score.** A BM25 score and a cosine share no scale, so blending them numerically means inventing a conversion factor and retuning it whenever the corpus changes. RRF ignores the magnitudes and ranks by agreement between the two orderings: each list contributes `1/(60 + rank)`. A chunk both halves like beats one that either loves alone. **Degradation.** If the embeddings endpoint is down or disabled, the vector half returns nothing and retrieval continues keyword-only rather than failing the request. **Stale vectors.** Chunk ids are derived from position, so they survive edits to the body. Without a guard, editing a document leaves vectors that still describe the text that was removed β reproduced live by changing a project's payment provider and watching the old one keep coming back. Each vector stores a hash of the exact text it was built from, and rows whose hash no longer matches the chunk are skipped with a warning until the next reindex. **The catalogue.** Top-K returns the best-matching *sections*, so a broad question like "what has Victor built?" cannot be answered from retrieval alone, and a small model asked to enumerate from partial hits invents the rest. The full project list is injected every turn at a cost of ~10 tokens per project. This is what stopped the bot naming projects that do not exist. ### 4.3 Schema ```go // internal/portfolio/indexer.go const schema = ` CREATE VIRTUAL TABLE IF NOT EXISTS portfolio_chunks USING fts5( id UNINDEXED, project_id UNINDEXED, kind UNINDEXED, -- 'project' | 'doc' source_file UNINDEXED, section UNINDEXED, -- the heading this chunk came from chunk_index UNINDEXED, content, tokenize = 'unicode61 remove_diacritics 2' ); ` // Vectors live in an ordinary table keyed by chunk id. There is no ANN index: // a portfolio is hundreds of chunks, not millions, so a full scan with a dot // product is microseconds and needs no extension. const vectorSchema = ` CREATE TABLE IF NOT EXISTS portfolio_vectors ( chunk_id TEXT PRIMARY KEY, content_hash TEXT NOT NULL, -- sha256 of the exact text embedded dim INTEGER NOT NULL, vec BLOB NOT NULL -- little-endian float32, unit-normalised ); ` ``` FTS5 has no `ALTER TABLE ADD COLUMN`, so `ensureChunkSchema` drops and recreates `portfolio_chunks` when an older database is missing a column. That is safe precisely because the table is a derived index β every row is regenerated from the markdown on the next reindex. It deliberately touches only the index tables; conversations live in the same file and are real user data. Vectors are unit-normalised at write time, so a dot product **is** the cosine and retrieval needs no division per comparison. `dim` is stored so a change of embedding model is detected rather than silently producing garbage similarity: rows whose dimension does not match the query vector are skipped. **Key API:** | Function | File | Purpose | |---|---|---| | `SourcesFor(dataPath, docsPath)` | `indexer.go` | Builds the `[]Source` pair; an empty path is skipped, so `docs_path` is optional without branching | | `Store.Reindex(ctx, sources, cfg)` | `indexer.go` | Rebuilds both tables from disk | | `Store.Search(ctx, query, topK)` | `indexer.go` | BM25 only, excludes `section = 'frontmatter'` | | `Store.HybridSearch(ctx, emb, q, topK)` | `hybrid.go` | BM25 + vectors fused with RRF | | `Store.Catalog(ctx)` | `indexer.go` | Project list for the prompt; filters `kind = 'project'` | | `embed.Client.Embed(ctx, texts)` | `internal/embed/` | OpenAI-compatible embeddings, reordered by the response `index` field | ### 4.4 Assembling the prompt `agent.Runner.BuildMessages` runs once per request and produces exactly one system message followed by the conversational turns. The order of operations is load-bearing: ```go // internal/agent/runner.go func (r *Runner) BuildMessages(ctx context.Context, history []Message) ([]Message, string, error) { // 1. Pull system-role notes (the compactor's summary) out of the turn list. notes, history := foldSystemNotes(history) // 2. The visitor's language selects which rendition of the prompt we // build on, so it is resolved before anything else. lang := detectLanguage(history) systemPrompt := r.promptFor(lang) // 3. Resolved before retrieval so the excerpt budget accounts for it. catalog := r.catalogBlock(ctx) // 4. Hybrid retrieval on the last user turn. hits, err := r.store.HybridSearch(ctx, r.embedder, last.Content, r.topK) ragContext = r.limitRAGContext(systemPrompt, catalog, formatHits(hits), history) // 5. prompt β catalogue β excerpts β summary β language directive. system := botpersona.BuildSystemPrompt(systemPrompt, catalog, ragContext) system += "\n\n" + strings.Join(notes, "\n\n") system += "\n\n" + r.languageDirective(lang) history, err = r.fitHistory(system, history) return append([]Message{{Role: RoleSystem, Content: system}}, history...), ragContext, nil } ``` **Why exactly one system message.** Gemma 3's chat template raises *"Conversation roles must alternate user/assistant/..."* on any system message after the first, which llama-server surfaces as HTTP 400 β enabling compaction used to kill the conversation outright the first time it fired. Anthropic's API rejects mid-conversation system turns too, so folding is the portable behaviour rather than a Gemma workaround. `foldSystemNotes` never aliases the caller's slice; the handler reuses the history it passes in. **Why the language directive goes last.** It is the instruction a small model is most likely to still be holding when it starts generating. Position was not enough on its own, though β see below. **Matching the visitor's language** took three attempts, measured on gemma-3-1b over the same five Spanish questions: | Approach | Result | |---|---| | English prompt + "reply in the user's language" | 1/5 answered in Spanish | | English prompt + Spanish few-shot examples | 5/5 Spanish, but ~2/5 were the example reply copied verbatim instead of an answer | | A full Spanish rendition of the prompt (`system_prompt_es`) | 4/5 Spanish, 4/5 real answers | So `internal/i18n` detects the language and `promptFor` selects the rendition; the directive reinforces a prompt already written in the right language rather than trying to override one written in the wrong one. The two renditions in the YAML have to be kept in sync by hand. **Why keyword search alone was not enough.** The original plan cited three reasons to skip embeddings β no model to run, one file and one driver, and BM25 being strong on structured project docs. The first two still hold and cost what was predicted (~0.91 GB resident, a second process). The third was right about the corpus and wrong about the questions: BM25 is excellent at retrieving English documents given English keywords, and this bot is asked in Spanish. That is not a morphology problem a `trigram` tokenizer fixes β it is a translation problem. Hence Β§4.2, and hence both halves are kept. --- ## ποΈ 4.5 Auto-compaction Long conversations eventually run out of context. Auto-compaction folds the older portion of the conversation into a single summary system message when the previous turn's input tokens cross a configurable threshold. **How much headroom there actually is.** Measured over 20 real requests at `context_size: 4096`, the largest prompt this bot ever built was **1255 tokens** β system prompt, project catalogue, five retrieved chunks and the question β with a median of 1069. That leaves room for roughly a dozen short turns before the 75% threshold (~3070 tokens) is reached, not the 2β3 an earlier draft of this document estimated. Compaction is therefore a safety net for genuinely long threads rather than something that fires in a typical visit; across the whole benchmark it never triggered and nothing was truncated. ### When it fires `agent.Runner.Compact` runs once per `/api/chat` request, before the RAG search. It compares the runner's most recent `Usage.InputTokens` (reported by the provider in the previous streamed chunk) against `client.Capabilities().MaxContextWindow Γ threshold_ratio`. | Setting | Default | What it controls | |---|---|---| | `compaction.enabled` | `false` | Master switch. | | `compaction.threshold_ratio` | `0.75` | Trigger when used tokens β₯ window Γ ratio. | | `compaction.keep_recent_turns` | `4` | How many of the latest user turns are kept verbatim after compaction. | | `compaction.summary_system_prompt` | *(built-in bilingual)* | Override the instruction sent to the LLM when summarizing. | Short-circuits silently when compaction is disabled, the provider doesn't report a window (`Capabilities().MaxContextWindow == 0`), the history is shorter than `keep_recent_turns`, or usage is still unknown (first turn). ### How the summary is made 1. `splitByTurns(history, keep_recent_turns)` divides messages into `(older, recent)` on user-role boundaries so a kept turn's user/assistant pair always stays together. 2. `renderTranscript(older)` flattens older messages into a `User:` / `Assistant:` transcript (skipping tool messages and empty assistant placeholders). 3. The runner calls `client.Generate(...)` with the summary prompt + transcript and a 512-token cap so the compaction step itself stays cheap. 4. The returned text is prepended as a system message (`"Earlier conversation summary:\nβ¦"`), followed by the recent tail. 5. `LastCompaction()` returns `CompactionStats` so the SSE handler can emit a `compaction` event right before the streamed chunks. ### Failure mode If `Generate` errors or returns an empty summary, compaction falls back to `truncateToBudget`: drop oldest user-turns one at a time until the remaining slice fits `threshold` tokens (heuristic: `len(s) / 4 + 1`). The current user turn is always preserved. The fallback is logged at WARN and the request still proceeds β a flaky summarize call never fails the user's request. ### Wire protocol Streaming responses gain an optional `compaction` event: ``` data: {"type":"compaction","older_turns":6,"kept_turns":2,"summary_tokens":120,"window_tokens":4096,"used_tokens":3500} ``` Emitted after `start` (when applicable) and before `sources` / `chunk`. The widget can render this as a subtle "Context compacted" hint or ignore it β both are valid. ### Persistence Compaction is **per-request**. The full transcript is still saved to `messages` in `data/portfolio.db` verbatim, so `GET /api/conversations/{id}` always returns the original history. Only what we send to the LLM is reduced β the next session can re-read the full thread from the DB. --- ## π 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"; ---