rony-chat-bot/docs/architecture.es.md
Victor Hugo Vargas 9ee722947a docs(architecture): bring the design doc up to what actually ships
The RAG section still described the pipeline as originally specced, not the
one that runs: fixed-size chunking, keyword-only retrieval, a schema without
kind or content_hash, and an `Indexer` type that does not exist. Someone
reading it to understand the retrieval path would have been wrong about every
part of it.

Section 4, rewritten:
- 4.1 documents both source kinds, the README skip, the frontmatter exclusion
  and the ### sub-split, each with the failure that motivated it.
- 4.2 replaces the BM25-only pipeline with hybrid retrieval, and explains why
  RRF rather than a weighted blend, what happens when the embedder is down,
  and why vectors carry a content hash.
- 4.3 swaps the fictional code sketch for the real schema plus an API table.
- 4.4 documents prompt assembly in the order the code does it, why there is
  exactly one system message, and the three attempts it took to get the
  language right.
- The banner at the top listed four decisions as pending. All four are now
  made and measured, including the one it got wrong: BM25 was strong on the
  corpus but the questions arrive in Spanish, which is a translation problem
  a trigram tokenizer does not solve.
- 4.5 claimed a ~3k-token system prompt leaving room for 2–3 turns. Measured,
  the largest prompt is 1255 tokens and compaction never fired in the whole
  benchmark.

Elsewhere:
- §2 adds the embeddings server and internal/embed; the stack table said
  qwen2.5:1.5b while the config ships 3b.
- §6.1 and §8.1 did not deploy the architecture being described: no embedder,
  no --device none, no --parallel 1, no sampling flags, and a systemd unit
  that started only the bot. §8.1 now has all three units and the memory
  budget.
- §9.4 lists the retrieval tests, since a regression there is silent.
- §11 marks the phases that are done, records where the widget deliberately
  diverged from the plan (vanilla JS, not React + Tailwind), and keeps the
  three known unfixed answer-quality issues.
- §12.1 replaces aspirational targets with measured numbers, and says plainly
  that TTFT <500ms and end-to-end <3s are not met on 2 CPU cores and why that
  is the accepted trade.

Both language editions updated in step.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 15:45:36 -07:00

1411 lines
No EOL
56 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 📋 Rony Chat Bot — Technical Design Document
> 🌐 **Idioma:** [English](architecture.md) | [Español](architecture.es.md)
**Versión:** 1.0
**Autor:** Victor Hugo Vargas
**Fecha:** 2026-06-28
**Estado:** Especificación completa para implementación
**Path:** `rony-chat-bot/docs/architecture.md`
> 📚 **Workspace:** Este proyecto es parte del workspace `Rony/`. Ver [`../README.md`](../../README.md).
>
> 🔑 **Depende de:** [`rony-llm-agent`](https://github.com/VictorVargas/rony-llm-agent) — librería core que provee agent loop, LLM clients, RAG, persona system.
>
> 📐 **Metodología:** Este proyecto sigue el enfoque **SDD + DDD + Hexagonal Architecture**. Los Requisitos Funcionales se numeran como `CRF-XXX`. Ver [`../../METHODOLOGY.md`](../../METHODOLOGY.md).
---
## 🎯 1. Visión del Proyecto
### 1.1 ¿Qué es Chat-Bot?
Un **chatbot HTTP** que responde preguntas sobre Victor Hugo Vargas y sus proyectos. Usa **RAG (Retrieval-Augmented Generation)** sobre archivos markdown que describen cada proyecto, y un LLM local (o cloud) para generar respuestas.
### 1.2 Caso de uso primario
Victor tiene un portfolio web (Astro + React). En el sitio hay un widget de chat donde visitantes pueden preguntar:
- "¿Qué proyectos ha hecho Victor?"
- "¿Cuál es su experiencia con Go?"
- "¿Cómo funciona Rony TUI?"
- "¿Victor ha trabajado con PostgreSQL?"
El bot responde con información precisa extraída de los archivos markdown de proyectos + bio + skills.
### 1.3 Casos de uso secundarios (futuro)
- **Adaptación a clientes:** El mismo bot, con otra data y otra persona, sirve para concesionarios, restaurantes, etc.
- **Standalone CLI:** `./chat-bot ask "¿qué sabes de X?"` para uso desde terminal.
- **Slack/Discord bot:** Wrapper que consume el HTTP API.
### 1.4 Filosofía
- **Self-hosted por defecto** — funciona 100% local con Ollama + modelos 1-3B
- **Cloud opcional** — si se necesita más calidad, swap a Anthropic API
- **Portable** — fácil de fork/customizar para otros contextos
- **Streaming** — respuestas token-por-token con SSE (no espera a respuesta completa)
- **Reutiliza `rony-llm-agent`** — no reinventar el agent loop
---
## 🏗️ 2. Arquitectura
### 2.1 Vista general
```
┌─────────────────────────────────────────────────────────────────┐
│ Browser (Astro site) │
│ ↓ HTTP POST /api/chat │
│ Astro SSR (proxy) ←────────── Sirve portfolio + proxy chat │
│ ↓ HTTP POST /api/chat │
│ Chat-Bot HTTP server (:7331) │
│ ↓ │
│ Agent loop (rony-llm-agent) │
│ ↓ │
│ RAG híbrido → SQLite FTS5 (BM25) ⊕ vectores, fusionados con RRF │
│ ↓ sobre data/projects/ + data/docs/ │
│ ├─→ servidor de embeddings (:9200, nomic-embed-v2-moe) │
│ ↓ │
│ LLM (llama.cpp local default / Ollama o Anthropic opcionales) │
└─────────────────────────────────────────────────────────────────┘
```
Dos servidores de modelos locales, no uno. Ambos son procesos `llama-server`
comunes con los que el bot habla por HTTP; ninguno se enlaza dentro del binario.
### 2.2 Componentes principales
| Componente | Path | Responsabilidad |
|---|---|---|
| **HTTP server** | `internal/server/` | chi handlers, SSE streaming |
| **Agent runner** | `internal/agent/` | Wrapper sobre `rony-llm-agent`: armado del prompt, recuperación, selección de idioma, compactación |
| **Portfolio loader** | `internal/portfolio/` | Lee `data/projects/` y `data/docs/` (`.md` + `.mdx`), indexa en SQLite FTS5 + vectores, búsqueda híbrida |
| **Cliente de embeddings** | `internal/embed/` | Embeddings compatibles con OpenAI, normalización, códec float32 |
| **Persona** | `internal/persona/` | Carga persona desde `configs/portfolio-bot.yaml` |
| **CLI** | `cmd/chat-bot/` | Comandos: `serve`, `reindex`, `ask`, `version` |
### 2.3 Stack tecnológico
| Capa | Tecnología | Razón |
|---|---|---|
| **Lenguaje** | Go 1.26+ | Mismo que `harness`, aprovechar `os.Root`, `iter.Seq` |
| **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** | SQLite + FTS5 (BM25) ⊕ vectores densos | Sin dependencias externas, un solo archivo. Sin índice ANN: un portafolio son cientos de chunks, así que un scan completo son microsegundos |
| **LLM** | llama.cpp (qwen2.5-3b-instruct Q4_K_M) — default; Ollama como alternativa | Self-hosted por defecto. 3B y no 1.5B: ver el benchmark en `configs/portfolio-bot.yaml` |
| **Embeddings** | `nomic-embed-v2-moe` Q5_K_M, 768 dims | Multilingüe — el punto entero es casar preguntas en español con documentos en inglés |
| **Tests** | stdlib + testify | Consistencia con el resto |
---
## 🔌 3. HTTP API
### 3.1 Endpoints
#### `POST /api/chat` — Chat con streaming SSE
**Request:**
```json
{
"messages": [
{"role": "user", "content": "¿Qué proyectos tiene Victor?"}
],
"stream": true
}
```
**Response (SSE):**
```
data: {"type":"start","conversation_id":"abc123"}
data: {"type":"chunk","content":"Victor"}
data: {"type":"chunk","content":" tiene"}
data: {"type":"chunk","content":" varios"}
data: {"type":"chunk","content":" proyectos"}
data: {"type":"sources","documents":["rony-tui.md","rony-llm-agent.md"]}
data: {"type":"done","usage":{"input_tokens":245,"output_tokens":38}}
```
**Sin streaming** (`"stream": false`):
```json
{
"content": "Victor tiene varios proyectos...",
"sources": ["rony-tui.md", "rony-llm-agent.md"],
"usage": {"input_tokens": 245, "output_tokens": 38}
}
```
#### `POST /api/reindex` — Re-indexar portfolio
Útil cuando se modifican archivos en `data/projects/`.
**Request:** vacío
**Response:**
```json
{
"indexed_files": 12,
"total_chunks": 87,
"duration_ms": 4321
}
```
#### `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": "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
{
"name": "Asistente de Victor Hugo Vargas",
"model": "qwen2.5-3b-instruct",
"persona": "...",
"topics": ["proyectos", "experiencia", "skills técnicas"]
}
```
### 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) {
// Headers SSE
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 no soportado", 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 con 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 para capturar 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)
> ✅ **Las cuatro decisiones que esta sección dejaba abiertas ya se tomaron y se midieron:**
>
> - **Tokenizer FTS5** — `unicode61 remove_diacritics 2`, como decía el spec. El
> stemming no era el cuello de botella; el salto de idioma sí, y eso lo
> cierran los embeddings.
> - **Driver SQLite** — `modernc.org/sqlite` (puro Go, sin CGO). Benchmark abajo.
> - **Chunking** — por heading de markdown, no por tamaño fijo, y las secciones
> demasiado grandes se parten en `###` antes de caer al corte por bytes. §4.1.
> - **Similitud semántica** — agregada. BM25 solo devolvía **absolutamente nada**
> ante una pregunta en español sobre un documento en inglés. §4.2.
### 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
```
data/projects/*.{md,mdx} kind=project → se anuncia en el catálogo
data/docs/*.{md,mdx} kind=doc → se busca, nunca se anuncia
↓ (se salta el README de cada carpeta — son instrucciones, no contenido)
Markdown crudo
↓ (split por heading; las secciones grandes se parten en ###, luego por tamaño)
↓ (se descarta el chunk de frontmatter)
Chunks
├─→ tabla virtual FTS5 "portfolio_chunks"
└─→ endpoint de embeddings → "portfolio_vectors" (id, content_hash, dim, vec)
Corpus indexado
```
**Dos tipos de fuente.** Todo lo que está bajo `data_path` es un proyecto de
Victor y se lista en el catálogo que se inyecta en cada prompt. Todo lo que
está bajo `docs_path` es evidencia buscable que *no* es un proyecto: su CV, una
página "sobre mí". El CV es el documento que de verdad lee quien está decidiendo
si contratarlo, y era irrecuperable mientras vivía solo en el sitio de Astro;
pero meterlo en projects hacía que el bot listara "cv" como uno de sus trabajos.
**Tres cosas se excluyen o se reorganizan, cada una por una falla medida:**
| Regla | Falla que arregla |
|---|---|
| Saltar `README*` en ambas carpetas | `data/projects/README.md` se indexaba, así que el catálogo anunciaba "README" y "README.es" como proyectos de Victor |
| Descartar el chunk de frontmatter | Metadata densa en un chunk muy corto es un imán para consultas breves — el campo `location:` de un CV respondía *"¿Dónde ha trabajado Victor?"* con una ciudad en vez del historial laboral |
| Partir las secciones grandes en `###` | La sección Experience de un CV es una lista de empleos; el corte por tamaño partía una entrada a mitad de palabra y dejaba el nombre del empleador huérfano en el chunk anterior. Ahora cada chunk es un empleo, llamado `Experience — Metrimex — Frontend Developer` |
**Cuándo se ejecuta:**
- Manualmente: `./chat-bot reindex`
- Al arrancar, con `serve --reindex-on-start`
- Vía HTTP: `POST /api/reindex`
Los vectores se construyen al indexar, así que activar embeddings exige un
reindex. La tabla de chunks es dato derivado: `OpenStore` la reconstruye cuando
le faltan columnas, de modo que actualizar una instalación existente no
requiere migración. Esa reconstrucción nunca toca las tablas de conversaciones.
### 4.2 Pipeline de retrieval
```
Consulta "¿Con qué se paga en la tienda de ropa?"
├─→ FTS5 MATCH, ranking BM25 → top 15 (topK × 3)
└─→ embed(query) → coseno vs vecs → top 15 (topK × 3)
↓ (Reciprocal Rank Fusion, k=60)
Top 5 chunks
↓ (system prompt + catálogo + extractos + directiva de idioma)
El LLM genera la respuesta
```
**Las dos mitades hacen falta.** La búsqueda por palabras hace coincidencia
exacta: sin stemming, sin traducción. El corpus está escrito en inglés y los
visitantes preguntan en español, así que las palabras con carga semántica
puntúan cero: medido sobre el corpus real, `"paga"` aparece 0 veces en un
documento que dice *"Payments: Stripe"* y `"trabajado"` 0 veces en uno que dice
*"worked"*. La pregunta de arriba no recuperaba **nada**. Los embeddings
(`nomic-embed-v2-moe`, multilingüe) ponen los tres chunks de `tienda-ropa`
arriba — pero difuminan los términos raros exactos, donde BM25 es preciso.
**Por qué RRF y no una puntuación ponderada.** Un score BM25 y un coseno no
comparten escala, así que mezclarlos numéricamente significa inventar un factor
de conversión y reajustarlo cada vez que cambia el corpus. RRF ignora las
magnitudes y ordena por acuerdo entre los dos rankings: cada lista aporta
`1/(60 + rank)`. Un chunk que le gusta a ambas mitades le gana a uno que solo
una adora.
**Degradación.** Si el endpoint de embeddings se cae o se desactiva, la mitad
vectorial devuelve vacío y la recuperación sigue en modo keyword-only en vez de
fallar la petición.
**Vectores obsoletos.** Los ids de chunk se derivan de la posición, así que
sobreviven a las ediciones del cuerpo. Sin una guarda, editar un documento deja
vectores que siguen describiendo el texto que se eliminó — reproducido en vivo
cambiando el medio de pago de un proyecto y viendo cómo el anterior seguía
apareciendo. Cada vector guarda un hash del texto exacto con el que se
construyó, y las filas cuyo hash ya no coincide se ignoran con un warning hasta
el siguiente reindex.
**El catálogo.** El top-K devuelve las *secciones* que mejor coinciden, así que
una pregunta amplia como "¿qué ha construido Victor?" no se puede responder solo
con recuperación, y un modelo pequeño al que se le pide enumerar a partir de
resultados parciales inventa el resto. La lista completa de proyectos se
inyecta en cada turno a un costo de ~10 tokens por proyecto. Esto es lo que
detuvo que el bot nombrara proyectos inexistentes.
### 4.3 Esquema
```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, -- el heading del que salió el chunk
chunk_index UNINDEXED,
content,
tokenize = 'unicode61 remove_diacritics 2'
);
`
// Los vectores viven en una tabla común indexada por chunk id. No hay índice
// ANN: un portafolio son cientos de chunks, no millones, así que un scan
// completo con producto punto son microsegundos y no necesita extensiones.
const vectorSchema = `
CREATE TABLE IF NOT EXISTS portfolio_vectors (
chunk_id TEXT PRIMARY KEY,
content_hash TEXT NOT NULL, -- sha256 del texto exacto embebido
dim INTEGER NOT NULL,
vec BLOB NOT NULL -- float32 little-endian, normalizado
);
`
```
FTS5 no tiene `ALTER TABLE ADD COLUMN`, así que `ensureChunkSchema` dropea y
recrea `portfolio_chunks` cuando una base vieja no tiene alguna columna. Es
seguro precisamente porque la tabla es un índice derivado: cada fila se
regenera desde el markdown en el siguiente reindex. Toca deliberadamente solo
las tablas del índice; las conversaciones viven en el mismo archivo y son datos
reales del usuario.
Los vectores se normalizan al escribirlos, así que el producto punto **es** el
coseno y la búsqueda no necesita una división por comparación. `dim` se guarda
para que un cambio de modelo de embeddings se detecte en vez de producir
similitudes basura en silencio: las filas cuya dimensión no coincide con el
vector de consulta se ignoran.
**API principal:**
| Función | Archivo | Para qué |
|---|---|---|
| `SourcesFor(dataPath, docsPath)` | `indexer.go` | Arma el par `[]Source`; un path vacío se salta, así que `docs_path` es opcional sin ramificar |
| `Store.Reindex(ctx, sources, cfg)` | `indexer.go` | Reconstruye ambas tablas desde disco |
| `Store.Search(ctx, query, topK)` | `indexer.go` | Solo BM25, excluye `section = 'frontmatter'` |
| `Store.HybridSearch(ctx, emb, q, topK)` | `hybrid.go` | BM25 + vectores fusionados con RRF |
| `Store.Catalog(ctx)` | `indexer.go` | Lista de proyectos para el prompt; filtra `kind = 'project'` |
| `embed.Client.Embed(ctx, texts)` | `internal/embed/` | Embeddings compatibles con OpenAI, reordenados por el campo `index` de la respuesta |
### 4.4 Armado del prompt
`agent.Runner.BuildMessages` corre una vez por petición y produce exactamente
un mensaje de sistema seguido de los turnos de conversación. El orden de las
operaciones importa:
```go
// internal/agent/runner.go
func (r *Runner) BuildMessages(ctx context.Context, history []Message) ([]Message, string, error) {
// 1. Saca los mensajes de sistema (el resumen del compactador) de los turnos.
notes, history := foldSystemNotes(history)
// 2. El idioma del visitante decide sobre qué versión del prompt
// construimos, así que se resuelve antes que nada.
lang := detectLanguage(history)
systemPrompt := r.promptFor(lang)
// 3. Se resuelve antes del retrieval para que el presupuesto de
// extractos lo tenga en cuenta.
catalog := r.catalogBlock(ctx)
// 4. Recuperación híbrida sobre el último turno del usuario.
hits, err := r.store.HybridSearch(ctx, r.embedder, last.Content, r.topK)
ragContext = r.limitRAGContext(systemPrompt, catalog, formatHits(hits), history)
// 5. prompt → catálogo → extractos → resumen → directiva de idioma.
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
}
```
**Por qué exactamente un mensaje de sistema.** La plantilla de chat de Gemma 3
lanza *"Conversation roles must alternate user/assistant/..."* ante cualquier
mensaje de sistema que no sea el primero, y llama-server lo devuelve como HTTP
400 — activar la compactación mataba la conversación la primera vez que se
disparaba. La API de Anthropic también rechaza mensajes de sistema a mitad de
conversación, así que plegarlos es el comportamiento portable y no un parche
para Gemma. `foldSystemNotes` nunca aliasea el slice del llamador; el handler
reutiliza el historial que le pasa.
**Por qué la directiva de idioma va al final.** Es la instrucción que un modelo
pequeño tiene más probabilidad de seguir reteniendo cuando empieza a generar.
Aunque la posición sola no alcanzó — ver abajo.
**Responder en el idioma del visitante** costó tres intentos, medidos sobre
gemma-3-1b con las mismas cinco preguntas en español:
| Enfoque | Resultado |
|---|---|
| Prompt en inglés + "respondé en el idioma del usuario" | 1/5 respuestas en español |
| Prompt en inglés + ejemplos few-shot en español | 5/5 en español, pero ~2/5 eran el ejemplo copiado literal en vez de una respuesta |
| Una versión completa del prompt en español (`system_prompt_es`) | 4/5 en español, 4/5 respuestas reales |
Así que `internal/i18n` detecta el idioma y `promptFor` elige la versión; la
directiva refuerza un prompt que ya está escrito en el idioma correcto en vez
de intentar sobreescribir uno escrito en el equivocado. Las dos versiones del
YAML hay que mantenerlas sincronizadas a mano.
**Por qué la búsqueda por palabras no alcanzó.** El plan original daba tres
razones para no usar embeddings: no hay modelo que ejecutar, un solo archivo y
un solo driver, y BM25 es fuerte sobre documentos estructurados. Las dos
primeras siguen siendo ciertas y costaron lo previsto (~0.91 GB residentes, un
proceso más). La tercera acertó sobre el corpus y erró sobre las preguntas:
BM25 es excelente recuperando documentos en inglés dadas palabras en inglés, y
a este bot le preguntan en español. Eso no es un problema de morfología que
arregle un tokenizer `trigram` — es un problema de traducción. De ahí §4.2, y
de ahí que se conserven las dos mitades.
---
## 🗜️ 4.5 Auto-compactación
Las conversaciones largas eventualmente agotan el contexto. La auto-compactación pliega la parte más antigua de la conversación en un único mensaje-resumen del sistema cuando los tokens de entrada del turno anterior cruzan un umbral configurable.
**Cuánto margen hay en realidad.** Medido sobre 20 peticiones reales con `context_size: 4096`, el prompt más grande que este bot llegó a construir fue de **1255 tokens** — system prompt, catálogo de proyectos, cinco chunks recuperados y la pregunta — con una mediana de 1069. Eso deja espacio para una docena larga de turnos cortos antes de llegar al umbral del 75% (~3070 tokens), no los 23 que estimaba un borrador anterior de este documento. La compactación es entonces una red de seguridad para hilos genuinamente largos, no algo que se dispare en una visita típica: en todo el benchmark no se activó nunca y no se truncó nada.
### Cuándo se dispara
`agent.Runner.Compact` corre una vez por request a `/api/chat`, antes de la búsqueda RAG. Compara los `Usage.InputTokens` más recientes del runner (reportados por el provider en el chunk streameado previo) contra `client.Capabilities().MaxContextWindow × threshold_ratio`.
| Config | Default | Qué controla |
|---|---|---|
| `compaction.enabled` | `false` | Switch maestro. |
| `compaction.threshold_ratio` | `0.75` | Dispara cuando tokens usados ≥ ventana × ratio. |
| `compaction.keep_recent_turns` | `4` | Cuántos turnos recientes del usuario se preservan literales tras la compactación. |
| `compaction.summary_system_prompt` | *(bilingüe built-in)* | Override de la instrucción enviada al LLM al resumir. |
Sale silenciosamente cuando la compactación está deshabilitada, el provider no reporta ventana (`Capabilities().MaxContextWindow == 0`), la historia es más corta que `keep_recent_turns`, o el usage aún es desconocido (primer turno).
### Cómo se hace el resumen
1. `splitByTurns(history, keep_recent_turns)` divide los mensajes en `(older, recent)` cortando en límites de rol `user`, así el par user/assistant de un turno preservado queda siempre junto.
2. `renderTranscript(older)` aplana los mensajes antiguos en una transcripción `User:` / `Assistant:` (saltando mensajes tool y placeholders vacíos de assistant).
3. El runner llama a `client.Generate(...)` con el prompt de resumen + la transcripción y un cap de 512 tokens para que la compactación en sí misma sea barata.
4. El texto devuelto se antepone como mensaje de sistema (`"Earlier conversation summary:\n…"`), seguido por la cola reciente.
5. `LastCompaction()` devuelve `CompactionStats` para que el handler SSE emita el evento `compaction` justo antes de los chunks streameados.
### Modo de falla
Si `Generate` falla o devuelve un resumen vacío, la compactación cae a `truncateToBudget`: descarta turnos antiguos del usuario uno por uno hasta que el slice restante entre en `threshold` tokens (heurística: `len(s) / 4 + 1`). El turno actual del usuario siempre se preserva. El fallback se loggea a nivel WARN y el request sigue — un fallo del resumidor nunca rompe la request del usuario.
### Protocolo de cable
Las respuestas streameadas ganan un evento opcional `compaction`:
```
data: {"type":"compaction","older_turns":6,"kept_turns":2,"summary_tokens":120,"window_tokens":4096,"used_tokens":3500}
```
Se emite después del `start` (cuando aplica) y antes de `sources` / `chunk`. El widget puede renderizar esto como un hint sutil "Contexto compactado" o ignorarlo — ambas son válidas.
### Persistencia
La compactación es **por-request**. La transcripción completa igual se guarda en `messages` en `data/portfolio.db` literal, así que `GET /api/conversations/{id}` siempre devuelve la historia original. Sólo se reduce lo que se le manda al LLM — la próxima sesión puede releer el thread completo desde la DB.
---
## 🌐 5. Embebiendo el widget
El bot viene con un widget vanilla-JS drop-in. Agrega dos archivos a tu sitio y funciona.
### 5.1 El widget (cualquier sitio)
```html
<link rel="stylesheet" href="/path/to/chat-widget.css">
<script src="/path/to/chat-widget.js"
data-api-url="https://chat.example.com"
data-title="Pregúntame lo que sea"
data-greeting="¡Hola! Pregúntame sobre los proyectos."
data-position="bottom-right"
data-theme="auto"
defer></script>
```
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.
**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";
---
<html>
<body>
<slot />
<script src="/path/to/chat-widget.js"
data-api-url={apiUrl}
data-title="Pregúntame lo que sea"
data-position="bottom-right"
data-theme="auto"
defer is:inline></script>
</body>
</html>
```
`is:inline` evita que Astro transforme/hash el `<script>`, así los atributos `data-*` sobreviven.
### 5.3 React / Next.js: el mismo `<script>`
```tsx
// app/layout.tsx
import Script from "next/script";
export default function RootLayout({ children }) {
return (
<html>
<head>
<link rel="stylesheet" href="/chat-widget.css" />
<Script src="/chat-widget.js"
data-api-url={process.env.NEXT_PUBLIC_CHAT_API_URL}
data-title="Pregúntame lo que sea"
data-position="bottom-right"
data-theme="auto"
strategy="afterInteractive" />
</head>
<body>{children}</body>
</html>
);
}
```
### 5.4 Si querés un proxy server-side (Astro/Next API route)
El widget también puede llamar a un endpoint same-origin que reenvía al bot. Esto tiene sentido cuando necesitás:
- Auth en `/api/chat` (solo usuarios logueados)
- Rate limiting centralizado a nivel sitio
- Ocultar el origen del bot al browser
```typescript
// src/pages/api/chat.ts (Astro) o app/api/chat/route.ts (Next)
const CHAT_BOT_URL = process.env.CHAT_BOT_URL || "http://localhost:7331";
export const POST = async ({ request }) => {
const body = await request.json();
// (opcional) auth check, rate limit, session lookup acá
const resp = await fetch(`${CHAT_BOT_URL}/api/chat`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
return new Response(resp.body, {
status: resp.status,
headers: {
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
});
};
```
Entonces apuntás el widget a `/api/chat` (mismo origen) en vez de la URL del bot.
### 5.5 Referencia de configuración del widget
Todas las opciones son atributos `data-*` en el `<script>`:
| Atributo | Default | Notas |
|---|---|---|
| `data-api-url` | *(requerido)* | URL base del bot. Sin slash final. |
| `data-title` | `"Chat"` | Texto del header. |
| `data-greeting` | `""` | Primer mensaje del asistente al abrir el panel. |
| `data-position` | `"bottom-right"` | `"bottom-right"` o `"bottom-left"`. |
| `data-theme` | `"auto"` | `"auto"` (sigue el OS), `"light"`, `"dark"`. |
El theming se hace vía CSS custom properties en `.rony-chat-widget-root` (ver `web/chat-widget.css`):
```css
.rony-chat-widget-root {
--rony-accent: #ff6b35;
--rony-radius: 4px;
--rony-font: "Inter", sans-serif;
}
```
### 5.6 Lo que el widget NO hace (aún)
- **Persistencia de conversación** — cada visita es nueva. El bot es stateless.
- **Markdown enriquecido** (tablas, imágenes) — el renderer built-in cubre los casos comunes; para CommonMark completo, cambiá `renderMarkdown` por `marked` o `markdown-it`.
- **Swipe-to-dismiss en mobile** — el panel pasa a full-screen en mobile, sin gesto.
- **Historial de conversaciones** — solo se ve la conversación activa.
---
## 🤖 6. Self-hosting con llama.cpp (default)
### 6.1 Setup
llama-server es un proceso separado al que el bot se conecta por HTTP. **Ambos puertos (el del bot y el de llama-server) son configurables** — elegí lo que se ajuste a tu entorno.
```bash
# 1. Asegúrate de tener un modelo GGUF disponible
# Descárgalo de Hugging Face, ej.:
# https://huggingface.co/Qwen/Qwen2.5-3B-Instruct-GGUF
export RONY_MODELS_PATH=/path/to/models
ls $RONY_MODELS_PATH/qwen2.5-3b-instruct-q4_k_m.gguf
# 2. Arrancar llama-server (puerto configurable; default de llama.cpp es 8080)
llama-server \
-m $RONY_MODELS_PATH/Qwen2.5/qwen2.5-3b-instruct-q4_k_m.gguf \
--port 9100 --host 127.0.0.1 \
--ctx-size 4096 --parallel 1 \
--device none --threads 2 --mlock \
--temp 0.7 --top-k 20 --top-p 0.8 --repeat-penalty 1.05
# 3. Arrancar el servidor de embeddings (segundo proceso, segunda terminal)
llama-server \
-m $RONY_MODELS_PATH/embeddings/nomic-embed-v2-moe.Q5_K_M.gguf \
--port 9200 --embedding --pooling mean \
--ctx-size 2048 --parallel 1 --device none --threads 2
# 4. Verifica que configs/portfolio-bot.yaml apunte a los mismos puertos
# providers[0].endpoint: http://localhost:9100/v1
# embeddings.endpoint: http://localhost:9200/v1
# 5. Construir el índice (necesita el embebedor arriba — los vectores se crean acá)
./bin/chat-bot reindex
# 6. Arrancar el bot (puerto default 7331, también configurable)
./bin/chat-bot serve
# → Sirve en http://localhost:7331
# → Override: ./bin/chat-bot serve --port 9101 --host 127.0.0.1
```
**Flags que no son opcionales, cada una por una razón medida:**
| Flag | Por qué |
|---|---|
| `--device none` | llama.cpp levanta el backend de GPU compilado aunque pases `-ngl 0`, y en un host sin GPU esos buffers salen de la RAM del sistema. Medido en qwen2.5-3b: 2,54 GB con una GPU absorbiéndolos, **3,66 GB sin ella**. Presupuestá con el segundo número |
| `--parallel 1` | `--ctx-size` se reparte entre slots y el default son 4, así que `--ctx-size 4096` sin esto le deja 1024 tokens a cada petición |
| `--mlock` | Previene swap — crítico en un VPS compartido |
| `--temp` / `--top-k` / `--top-p` | Usá los valores que publican los autores del modelo, no los defaults de llama.cpp. gemma-3-1b con `temperature 0.7` y el resto sin setear devolvía respuestas de 16 tokens |
| `--pooling mean` (embebedor) | Sin ella el endpoint no devuelve un vector por entrada y el cliente rechaza la respuesta |
**Referencia de puertos:**
| Qué | Default | Cómo cambiarlo |
|---|---|---|
| Puerto HTTP de `llama-server` | 8080 (convención de llama.cpp) | flag `--port N` al arrancar `llama-server` |
| Puerto del `llama-server` de embeddings | 8080 (misma convención) | `--port N`; este repo usa 9200 |
| Puerto HTTP del chat-bot | 7331 | flag `--port N` en `serve`, o `server.port` en YAML |
| URL bot → llama-server | `http://localhost:8080/v1` | campo `endpoint` del provider en YAML |
Mantené `context_size` en el YAML igual a `--ctx-size`: el bot calcula sus
presupuestos de RAG y compactación a partir de ese número y nunca le pregunta
al servidor qué tiene en realidad, así que un desacople significa prompts que
el servidor rechaza.
El provider `llamacpp` se importa desde `rony-llm-agent/pkg/llm/providers/llamacpp` y habla HTTP con el servidor de arriba — sin CGO, sin enlazar contra llama.cpp.
### 6.2 Alternativa: Ollama (más fácil para desarrollo)
Si prefieres no gestionar archivos GGUF manualmente, Ollama ofrece los mismos modelos con un flujo más simple:
```bash
# 1. Instalar Ollama
curl -fsSL https://ollama.com/install.sh | sh
# 2. Descargar modelo de chat
ollama pull qwen2.5:1.5b
# 3. Verificar
ollama list
# 4. Editar configs/portfolio-bot.yaml para marcar ollama-local como default:
# providers[0].default: true (y quitar default de llamacpp-local)
# Ollama expone una API OpenAI-compatible en :11434/v1
# 5. Arrancar el bot
ollama serve &
./bin/chat-bot serve
```
### 6.3 Alternativa: llama.cpp directo (avanzado)
Para más control o si Ollama no funciona en tu setup:
```yaml
providers:
- name: llamacpp-local
type: llamacpp
model: qwen2.5-3b-instruct
endpoint: http://localhost:9100/v1 # configurable, ver §6.1
context_size: 4096 # debe coincidir con --ctx-size
max_tokens: 640
temperature: 0.7 # defaults publicados de Qwen instruct
top_k: 20
top_p: 0.8
repeat_penalty: 1.05
default: true
```
El adapter `llamacpp` se importa desde `rony-llm-agent/pkg/llm/providers/llamacpp` y habla HTTP con `llama-server` — sin CGO, sin enlazar contra llama.cpp.
---
## 📦 7. CLI del bot
### 7.1 Comandos
```bash
# Arrancar servidor HTTP
chat-bot serve [--port 7331] [--host 0.0.0.0] [--reindex-on-start]
# Re-indexar (lee data/projects/ + data/docs/ *.md y *.mdx → FTS5 + vectores)
# Necesita el servidor de embeddings arriba si los embeddings están activos.
chat-bot reindex
# Pregunta única (sin servidor, útil para tests)
chat-bot ask "¿Qué proyectos tiene Victor?" [--no-rag]
# Validar config
chat-bot config validate
# Health check (útil para monitoring)
chat-bot health
# Versión
chat-bot version
```
### 7.2 Implementación con Cobra
```go
// cm./rony-chat-bot/main.go
package main
import (
"github.com/spf13/cobra"
)
func main() {
root := &cobra.Command{
Use: "chat-bot",
Short: "Portfolio chatbot HTTP server",
}
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 serveCmd() *cobra.Command {
var port int
var host string
var reindexOnStart bool
cmd := &cobra.Command{
Use: "serve",
Short: "Start HTTP server",
RunE: func(cmd *cobra.Command, args []string) error {
return server.Serve(server.Config{
Port: port,
Host: host,
ReindexOnStart: reindexOnStart,
})
},
}
cmd.Flags().IntVar(&port, "port", 7331, "HTTP port")
cmd.Flags().StringVar(&host, "host", "0.0.0.0", "HTTP host")
cmd.Flags().BoolVar(&reindexOnStart, "reindex-on-start", false, "Re-index RAG before serving")
return cmd
}
```
---
## 🚀 8. Deployment
### 8.1 Recomendación: Self-hosted en VPS
Target: **2 cores de CPU, 8 GB de RAM, sin GPU.** Tres procesos — el bot y dos
instancias de `llama-server` — así que tres units. El bot depende de los dos.
```bash
# 1. Build
go build -o /usr/local/bin/chat-bot ./cmd/chat-bot
# 2. El LLM
cat > /etc/systemd/system/llama-chat.service <<EOF
[Unit]
Description=llama-server (modelo de chat)
After=network.target
[Service]
Type=simple
User=chatbot
ExecStart=/usr/local/bin/llama-server \\
-m /opt/models/Qwen2.5/qwen2.5-3b-instruct-q4_k_m.gguf \\
--port 9100 --host 127.0.0.1 \\
--ctx-size 4096 --parallel 1 \\
--device none --threads 2 --mlock \\
--temp 0.7 --top-k 20 --top-p 0.8 --repeat-penalty 1.05
Restart=on-failure
# --mlock necesita que la memoria se pueda bloquear
LimitMEMLOCK=infinity
[Install]
WantedBy=multi-user.target
EOF
# 3. El embebedor
cat > /etc/systemd/system/llama-embed.service <<EOF
[Unit]
Description=llama-server (embeddings)
After=network.target
[Service]
Type=simple
User=chatbot
ExecStart=/usr/local/bin/llama-server \\
-m /opt/models/embeddings/nomic-embed-v2-moe.Q5_K_M.gguf \\
--port 9200 --host 127.0.0.1 \\
--embedding --pooling mean \\
--ctx-size 2048 --parallel 1 --device none --threads 2
Restart=on-failure
[Install]
WantedBy=multi-user.target
EOF
# 4. El bot
cat > /etc/systemd/system/chat-bot.service <<EOF
[Unit]
Description=Portfolio Chat Bot
After=network.target llama-chat.service llama-embed.service
Wants=llama-chat.service llama-embed.service
[Service]
Type=simple
User=chatbot
WorkingDirectory=/opt/chat-bot
ExecStart=/usr/local/bin/chat-bot serve
Restart=on-failure
[Install]
WantedBy=multi-user.target
EOF
sudo systemctl enable --now llama-chat llama-embed chat-bot
# 5. Construir el índice una vez que los dos servidores estén arriba
sudo -u chatbot /usr/local/bin/chat-bot reindex
```
**Presupuesto de memoria.** 3,64 GB (LLM) + 0,91 GB (embebedor) + 0,02 GB (bot)
**4,6 GB residentes**, dejando ~3,4 GB para lo demás que comparta el VPS.
Presupuestá con `--device none` puesto: sin esa flag los números se ven ~1,1 GB
más chicos en una máquina con GPU y después no se reproducen en producción. Ver
[`vps-context-sizing.md`](./vps-context-sizing.md).
`reindex` hay que volver a correrlo después de editar el markdown **y** después
de activar o cambiar el modelo de embeddings — los vectores se construyen al
indexar, y un cambio de modelo altera la dimensión.
### 8.2 Reverse proxy (Caddy)
```
# /etc/caddy/Caddyfile
chat.victorvargas.dev {
reverse_proxy localhost:7331
}
```
### 8.3 Monitoring
```bash
# Health check periódico
curl -s http://localhost:7331/api/health | jq
# Logs
journalctl -u chat-bot -f
```
---
## 🧪 9. Testing
### 9.1 Unit tests
```go
// internal/server/chat_test.go
package server
func TestHandleChat_ValidRequest(t *testing.T) {
s := newTestServer(t)
req := httptest.NewRequest("POST", "/api/chat", strings.NewReader(`{
"messages": [{"role": "user", "content": "hola"}]
}`))
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
s.handleChat(w, req)
assert.Equal(t, 200, w.Code)
assert.Equal(t, "text/event-stream", w.Header().Get("Content-Type"))
}
func TestHandleChat_RateLimit(t *testing.T) {
s := newTestServerWithConfig(t, server.Config{
RateLimit: 1, // 1 request per minute
})
// First request OK
req1 := newChatRequest("hola")
w1 := httptest.NewRecorder()
s.handleChat(w1, req1)
assert.Equal(t, 200, w1.Code)
// Second request denied
req2 := newChatRequest("hola de nuevo")
w2 := httptest.NewRecorder()
s.handleChat(w2, req2)
assert.Equal(t, 429, w2.Code)
}
```
### 9.2 Integration tests con mock LLM
```go
// internal/agent/runner_test.go
func TestRunner_RAGContextIsInjected(t *testing.T) {
mockLLM := mock.New(mock.Responses{
{Match: "proyectos", Response: "Victor tiene varios proyectos..."},
})
memory := newMockMemoryWithDocs(t, []rag.Fragment{
{Content: "Rony TUI: AI agent harness...", ProjectID: "rony-tui"},
{Content: "rony-llm-agent: librería Go...", ProjectID: "rony-llm-agent"},
})
runner := agent.NewRunner(agent.Config{
LLM: mockLLM,
Memory: memory,
Persona: testPersona,
})
resp, _ := runner.Run(context.Background(), []llm.Message{
{Role: llm.RoleUser, Content: "¿qué proyectos tiene Victor?"},
})
// Verify LLM received context chunks in system prompt
lastReq := mockLLM.LastRequest()
assert.Contains(t, lastReq.Messages[0].Content, "Rony TUI")
assert.Contains(t, lastReq.Messages[0].Content, "rony-llm-agent")
}
```
### 9.3 E2E test con Astro
```bash
# 1. Arrancar chat-bot en :7331
./bin/chat-bot serve &
# 2. Arrancar Astro en :4321
cd ../portfolio && npm run dev &
# 3. Hacer request al proxy de Astro
curl -X POST http://localhost:4321/api/chat \
-H "Content-Type: application/json" \
-d '{"messages":[{"role":"user","content":"hola"}]}'
# 4. Verificar SSE stream
```
### 9.4 Tests de recuperación
La recuperación es la parte de este bot donde una regresión es silenciosa —
nada falla, las respuestas simplemente empeoran de a poco — así que cada falla
encontrada en las pruebas tiene un test que la fija. Los ejemplos de código de
arriba son ilustrativos; estos son reales.
| Test | Qué fija |
|---|---|
| `TestHybridSearchFindsChunkKeywordSearchCannot` | El hueco pregunta-en-español / documento-en-inglés, la razón de ser de los embeddings |
| `TestHybridSearchFallsBackWhenEmbedderFails` | Un endpoint de embeddings caído degrada a keyword-only, nunca falla la petición |
| `TestVectorSearchIgnoresVectorsWhoseChunkChanged` | Los vectores obsoletos tras editar un cuerpo se ignoran |
| `TestVectorSearchIgnoresMismatchedDimensions` | Cambiar el modelo de embeddings no produce similitudes basura |
| `TestReindexSkipsTheDirectoryReadmes` | El catálogo nunca anuncia `README` como proyecto |
| `TestReindexIndexesMdxAndSeparatesDocsFromProjects` | El `.mdx` se indexa; el CV se busca pero nunca se lista |
| `TestOpenStoreMigratesIndexWithoutKindColumn` | Actualizar una instalación existente no requiere migración |
| `TestSubSplitPrefersH3BoundariesOverByteOffsets` | Los empleos del CV quedan enteros en vez de cortados a mitad de palabra |
| `TestBuildMessagesFoldsSystemNotesIntoOneSystemMessage` | La compactación no puede reintroducir el HTTP 400 |
| `TestLanguageDirectiveIsLastInSystemPrompt` | La directiva conserva la posición que necesita para funcionar |
Corren contra SQLite real y un embebedor de prueba, así que no hace falta
ningún servidor de modelos: `go test ./...` alcanza.
---
## 📂 10. Estructura del Proyecto
```
rony-chat-bot/
├── cmd/
│ └── chat-bot/
│ └── main.go # Entrypoint CLI
├── internal/
│ ├── server/ # HTTP handlers
│ │ ├── server.go # chi router + middleware
│ │ ├── handlers.go # /api/chat, /api/health, /api/info, /api/reindex
│ │ └── middleware.go # RequestID, Logging, CORS, RateLimit
│ │
│ ├── agent/ # LLM client + RAG runner
│ │ ├── runner.go # Wrapper Stream, inyección de RAG en system prompt
│ │ └── client.go # Factory NewClient: llamacpp / ollama / openai / anthropic
│ │
│ ├── portfolio/ # RAG: markdown → SQLite FTS5 + vectores
│ │ ├── chunker.go # Heading-based splitter, sub-split en ###
│ │ ├── indexer.go # Store: schema, Reindex, Search (BM25), Catalog
│ │ ├── hybrid.go # HybridSearch: BM25 ⊕ vectores vía RRF, EmbedChunks
│ │ └── chunker_test.go / store_test.go / hybrid_test.go
│ │
│ ├── embed/ # Cliente de embeddings
│ │ ├── embed.go # Embed, Normalize, Similarity, Encode/Decode
│ │ └── embed_test.go
│ │
│ ├── persona/ # Bridge persona → rony-llm-agent
│ │ └── persona.go # FromConfig, BuildSystemPrompt (con contexto RAG)
│ │
│ ├── streaming/ # Helpers protocolo SSE
│ │ └── sse.go # WriteStart/Chunk/Sources/Done/Error
│ │
│ ├── i18n/ # Detección de idioma (ES/EN) para la respuesta
│ │
│ └── config/ # Loader YAML + validación
├── web/ # ← WIDGET DE CHAT DROP-IN
│ ├── chat-widget.js # Vanilla JS, ~12 KB
│ ├── chat-widget.css # Estilos scoped, themable vía CSS custom props
│ ├── example.html # Demo local (python -m http.server)
│ └── README.md # Guía de integración (HTML, Astro, Next.js)
├── data/
│ ├── projects/ # ← Un .md/.mdx por proyecto — se lista en el catálogo
│ │ ├── rony-tui.md
│ │ ├── rony-llm-agent.md
│ │ ├── example-project.md
│ │ └── README.md # Instrucciones; el indexer lo salta
│ │
│ └── docs/ # ← Material de referencia que NO es proyecto
│ ├── cv.mdx # Normalmente un símlink; gitignoreado
│ └── README.md # Instrucciones; el indexer lo salta
├── configs/
│ └── portfolio-bot.yaml # Provider + RAG + persona config
├── docs/
│ ├── architecture.md # ← THIS FILE
│ └── architecture.es.md
├── bench/ # Benchmark reproducible de drivers SQLite
├── go.mod # require rony-llm-agent, modernc.org/sqlite
└── README.md
```
---
## 📅 11. Roadmap
### Fase 1: MVP — hecho
- [x] Setup proyecto (`go mod init`, estructura)
- [x] HTTP server con un endpoint `/api/chat`
- [x] SSE streaming funcional
- [x] RAG indexer (`data/projects/` + `data/docs/`, `.md` + `.mdx` → FTS5)
- [x] RAG retriever (query → top-k chunks)
- [x] Persona loader desde YAML
- [x] Integración con llama.cpp — **qwen2.5-3b**, no el 1.5b planeado
- [x] CLI: `serve`, `reindex`, `ask`
- [x] Tests básicos
### Fase 2: Integración con Astro — hecho, de otra forma
- [x] Widget drop-in en vanilla JS — **reemplazó** al componente React y a la
ruta proxy de Astro que estaban planeados. Sin build step, sin atarse a
un framework, y funciona en las tres topologías de §5.1 en vez de solo
detrás de un proxy
- [x] Styling del widget — CSS scoped con custom properties, **no**
TailwindCSS; un widget drop-in no puede asumir el toolchain del sitio
- [ ] E2E test automatizado: Astro → chat-bot → respuesta (sigue siendo manual, §9.3)
### Fase 3: Polish — hecho
- [x] Rate limiting por IP
- [x] Logging estructurado (JSON)
- [x] Health checks para monitoring
- [x] systemd service files (§8.1)
- [x] README + docs de deployment
### Fase 4: Opcionales — casi todo hecho
- [x] Múltiples conversaciones (session ID)
- [x] Historial de chats persistido
- [x] Multi-idioma (EN/ES) — detección más un prompt completo en español, §4.4
- [x] Auto-compactación para hilos largos, §4.5
- [ ] Análisis de preguntas frecuentes
- [ ] Versión standalone CLI más pulida (`chat-bot ask`)
### Fase 5: Calidad de respuesta — hecho
Todo lo de acá salió de medir respuestas reales, no del plan original; cada
punto existe porque algo estaba observablemente mal.
- [x] Recuperación híbrida (BM25 ⊕ embeddings, RRF) — §4.2
- [x] Guarda de content-hash contra vectores obsoletos — §4.2
- [x] Catálogo de proyectos inyectado en cada turno, para frenar los nombres inventados
- [x] Documentos de referencia separados de los proyectos, para que el CV se
pueda buscar sin quedar listado como proyecto — §4.1
- [x] Parámetros de sampling del fabricante cableados desde la config a llama.cpp
- [x] `context_size` bajado de 8192 a 4096 según el uso medido — §4.5
### Conocido y sin arreglar
Anotado para que no se vuelva a reportar como bug nuevo:
- El modelo lee bien las fechas del CV pero hace mal la aritmética sobre ellas
— "Jul 2024 Jun 2026" reportado como tres años.
- A veces atribuye un dato al archivo fuente equivocado.
- *"¿Dónde ha trabajado Victor?"* responde con proyectos en vez de empleadores.
Depende del fraseo: *"¿En qué empresas ha trabajado?"* y *"¿Cuánto tiempo
estuvo en Metrimex?"* responden bien.
---
## 📐 12. Especificaciones de Calidad
### 12.1 Métricas de rendimiento
| Métrica | Target | Medido en el target de 2 cores solo-CPU |
|---|---|---|
| Latencia de recuperación (top-5, híbrida) | <50ms | **~40ms** 37ms son el round-trip de embeber la consulta; BM25 y el scan de vectores son sub-milisegundo |
| Memoria del proceso del bot | <150MB | **~20MB** |
| Huella total (bot + LLM + embebedor) | entra en 8GB con margen | **~4,6GB** (3,64 + 0,91 + 0,02), dejando ~3,4GB para el resto del host |
| Throughput de generación | | **21,0 tok/s** en estado estable, ~11 tok/s en la primera petición en frío |
| TTFT (Time-to-first-token) | <500ms | **No se cumple, y no es alcanzable acá.** Dos threads tienen que hacer el prefill de un prompt de ~1100 tokens antes del primer token. El target original asumía una máquina con GPU |
| End-to-end (pregunta respuesta completa) | <3s | **No se cumple: ~21s** para una respuesta típica. El bot no es el cuello de botella; un modelo 3B sobre 2 cores |
Las dos últimas filas son el costo honesto de la restricción de hardware, y el
widget está construido alrededor de eso: las respuestas se transmiten token a
token, así que el visitante ve texto moviéndose en un par de segundos en vez de
esperar 21s por un bloque. Recuperar cualquiera de los dos targets significa un
modelo más chico, y el benchmark de 20 preguntas en `configs/portfolio-bot.yaml`
mide lo que eso cuesta en precisión gemma-3-1b promedia 13,8s contra los 21,0s
de qwen, y responde 6 preguntas menos de cada 10 correctamente.
### 12.2 Pruebas requeridas
- Unit tests: cobertura 70%
- Integration tests: con mock LLM + SQLite FTS5 en memoria
- E2E: al menos un flujo completo Astro chat-bot
---
## 🔒 13. Seguridad
### 13.1 Implementado
- **Rate limiting** por IP (default 30 req/min)
- **CORS restrictivo** solo origins configurados
- **Input validation** JSON schema validation en requests
- **No PII storage** no guardamos conversaciones por default
- **Local-only por default** sin llamadas a APIs cloud
### 13.2 Diferido / Opcional
- Auth con API key (para uso privado)
- Logging de queries para analytics
- Anonymization de IPs en logs
- HTTPS via reverse proxy (Caddy/nginx)
---
## 📚 14. Referencias
- **SSE Spec:** https://html.spec.whatwg.org/multipage/server-sent-events.html
- **Ollama API:** https://github.com/ollama/ollama/blob/main/docs/api.md
- **SQLite FTS5:** https://www.sqlite.org/fts5.html
- **Go SQLite driver:** https://github.com/mattn/go-sqlite3 (CGO) o https://modernc.org/sqlite (Go puro)
- **qwen2.5-3b-instruct:** https://huggingface.co/Qwen/Qwen2.5-3B-Instruct
- **nomic-embed-text-v2-moe:** https://huggingface.co/nomic-ai/nomic-embed-text-v2-moe-GGUF
- **Reciprocal Rank Fusion:** Cormack, Clarke & Büttcher (2009), *Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods*
- **Astro API routes:** https://docs.astro.build/en/guides/endpoints/
- **rony-llm-agent:** https://github.com/VictorVargas/rony-llm-agent
---
**Documento listo para implementación. 🚀**