# 📋 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 2–3 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 ``` 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"; ---