Merge pull request #1 from VictorVargas/feat/persistent-conversations

feat: bootstrap rony-chat-bot + persistent conversations (Phase 4)
This commit is contained in:
Victor Hugo Vargas Servin 2026-07-17 01:01:44 -07:00 committed by GitHub
commit aab2672a65
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 5815 additions and 658 deletions

5
.gitignore vendored
View file

@ -6,12 +6,13 @@
vendor/
coverage.out
coverage.html
/bin/
# ChromaDB
chroma/
# SQLite (RAG index)
*.db
*.db-shm
*.db-wal
data/portfolio.db
# Editor / OS
.vscode/

View file

@ -10,10 +10,10 @@
## ✨ Features
- 🌐 **HTTP server** con streaming SSE (Server-Sent Events)
- 🧠 **RAG sobre markdown** — indexa automáticamente los `.md` en `data/projects/`
- 🧠 **RAG sobre markdown** — indexa automáticamente los `.md` en `data/projects/` (SQLite FTS5, sin embeddings)
- 🎭 **Persona customizable** — responde como "asistente de Victor"
- ⚡ **Self-hosted** con Ollama o llama.cpp (no requiere API key de cloud)
- 🔌 **Integrable** con Astro/React via proxy HTTP
- ⚡ **Self-hosted** con llama.cpp (default) u Ollama (no requiere API key de cloud)
- 💬 **Widget de chat drop-in** — vanilla JS, sin build step, funciona en cualquier sitio
- 🛡️ **Rate limiting** y logging estructurado
- 📦 **Portable** — se puede adaptar a otros contextos (clientes, productos, etc.)
@ -27,9 +27,10 @@ cd chat-bot
# 2. Resolver dependencias (crea go.sum con hashes)
go mod tidy
# 3. Configurar provider (ejemplo: Ollama)
# Asegúrate de tener Ollama corriendo: ollama serve
# Modelo descargado: ollama pull qwen2.5:1.5b
# 3. Configurar provider (llama.cpp por default)
# Descarga un modelo GGUF, ej.:
# https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF
export RONY_MODELS_PATH=/path/to/models
# 4. Cargar tus proyectos en data/projects/
echo "# Mi Proyecto Cool\nDescripción..." > data/projects/mi-proyecto.md
@ -46,41 +47,45 @@ go build -o bin/chat-bot ./cmd/chat-bot
```
rony-chat-bot/
├── cm./rony-chat-bot/ # Entry point (CLI)
├── cmd/chat-bot/ # Entry point (CLI)
├── internal/
│ ├── server/ # HTTP handlers + SSE
│ ├── agent/ # LLM client + RAG + persona runner
│ ├── portfolio/ # Data loader (markdown → RAG)
│ ├── persona/ # Persona override
│ └── streaming/ # SSE helpers
│ ├── streaming/ # SSE helpers
│ └── i18n/ # Detección de idioma (EN/ES)
├── web/ # ← WIDGET DE CHAT DROP-IN
│ ├── chat-widget.js
│ ├── chat-widget.css
│ └── example.html
├── data/projects/ # ← TUS PROYECTOS EN MARKDOWN
│ ├── rony-tui.md
│ ├── rony-llm-agent.md
│ └── ...
├── configs/
│ └── portfolio-bot.yaml # Provider config
│ └── portfolio-bot.yaml # Provider + RAG + persona config
├── docs/
│ └── architecture.md # ← Especificación técnica completa
└── go.mod # require rony-llm-agent
```
## 🎯 Uso desde Astro
## 🎯 Embebido en cualquier sitio
Ver [`docs/architecture.md`](./docs/architecture.md) §5 — patrón recomendado de proxy.
El bot viene con un widget de chat drop-in. Agrega dos archivos y un tag `<script>`:
```typescript
// portfolio/src/pages/api/chat.ts
export const POST: APIRoute = async ({ request }) => {
const body = await request.json();
const resp = await fetch('http://localhost:7331/api/chat', {
method: 'POST',
body: JSON.stringify(body),
});
return new Response(resp.body, {
headers: { 'Content-Type': 'text/event-stream' },
});
};
```html
<link rel="stylesheet" href="/chat-widget.css">
<script src="/chat-widget.js"
data-api-url="https://chat.example.com"
data-title="Pregúntame lo que sea"
data-position="bottom-right"
data-theme="auto"
defer></script>
```
Ver [`web/README.md`](./web/README.md) para la referencia completa de configuración y snippets de integración con Astro/Next.js. Arquitectura completa en [`docs/architecture.md`](./docs/architecture.md) §5.
## 🔄 Adaptar a otro cliente
Este bot está diseñado para ser **atómico** y reusable. Para adaptarlo (ej. chatbot para un concesionario):

View file

@ -9,10 +9,10 @@
## ✨ Features
- 🌐 **HTTP server** with SSE (Server-Sent Events) streaming
- 🧠 **RAG over markdown** — automatically indexes `.md` in `data/projects/`
- 🧠 **RAG over markdown** — automatically indexes `.md` in `data/projects/` (SQLite FTS5, no embeddings)
- 🎭 **Customizable persona** — responds as "Victor's assistant"
- ⚡ **Self-hosted** with Ollama or llama.cpp (no cloud API key required)
- 🔌 **Integrable** with Astro/React via HTTP proxy
- ⚡ **Self-hosted** with llama.cpp (default) or Ollama (no cloud API key required)
- 💬 **Drop-in chat widget** — vanilla JS, no build step, works in any site
- 🛡️ **Rate limiting** and structured logging
- 📦 **Portable** — adaptable to other contexts (clients, products, etc.)
@ -26,9 +26,10 @@ cd rony-chat-bot
# 2. Resolve dependencies (creates go.sum with hashes)
go mod tidy
# 3. Configure provider (e.g., Ollama)
# Make sure Ollama is running: ollama serve
# Downloaded model: ollama pull qwen2.5:1.5b
# 3. Configure provider (llama.cpp by default)
# Download a GGUF model, e.g.:
# https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct-GGUF
export RONY_MODELS_PATH=/path/to/models
# 4. Load your projects in data/projects/
echo "# My Cool Project\nDescription..." > data/projects/my-project.md
@ -48,44 +49,42 @@ rony-chat-bot/
├── cmd/chat-bot/ # Entry point (CLI)
├── internal/
│ ├── server/ # HTTP handlers + SSE
│ ├── agent/ # LLM client + RAG + persona runner
│ ├── portfolio/ # Data loader (markdown → RAG)
│ ├── persona/ # Persona override
│ └── streaming/ # SSE helpers
│ ├── streaming/ # SSE helpers
│ └── i18n/ # Language detection (EN/ES)
├── web/ # ← DROP-IN CHAT WIDGET
│ ├── chat-widget.js
│ ├── chat-widget.css
│ └── example.html
├── data/projects/ # ← YOUR PROJECTS IN MARKDOWN
│ ├── rony-harness.md
│ ├── rony-llm-agent.md
│ └── ...
├── configs/
│ └── portfolio-bot.yaml # Provider config
│ └── portfolio-bot.yaml # Provider + RAG + persona config
├── docs/
│ └── architecture.md # ← Complete technical specification
└── go.mod # require rony-llm-agent
```
## 🎯 Use from Astro
## 🎯 Embed in any site
See [`docs/architecture.md`](./docs/architecture.md) §5 — recommended proxy pattern.
The bot ships with a drop-in chat widget. Add two files and a `<script>` tag:
```typescript
// portfolio/src/pages/api/chat.ts
export const POST: APIRoute = async ({ request }) => {
const body = await request.json();
const resp = await fetch('http://localhost:7331/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
return new Response(resp.body, {
status: 200,
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
},
});
};
```html
<link rel="stylesheet" href="/chat-widget.css">
<script src="/chat-widget.js"
data-api-url="https://chat.example.com"
data-title="Ask me anything"
data-position="bottom-right"
data-theme="auto"
defer></script>
```
See [`web/README.md`](./web/README.md) for the full configuration reference and Astro/Next.js integration snippets. Full architecture in [`docs/architecture.md`](./docs/architecture.md) §5.
## 🔄 Adapt to another client
This bot is designed to be **atomic** and reusable. To adapt it (e.g., chatbot for a car dealership):

154
bench/bench.go Normal file
View file

@ -0,0 +1,154 @@
// Package bench holds one-time validation benchmarks used to drive library
// decisions. Run with:
//
// go test -bench=. ./bench/ (modernc only, pure Go)
// CGO_ENABLED=1 go test -bench=. ./bench/ (mattn + modernc, requires gcc)
package bench
import (
"context"
"database/sql"
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
)
const schema = `
CREATE VIRTUAL TABLE IF NOT EXISTS portfolio_chunks USING fts5(
id UNINDEXED,
project_id UNINDEXED,
source_file UNINDEXED,
chunk_index UNINDEXED,
content,
tokenize = 'unicode61 remove_diacritics 2'
);
`
var queries = []string{
"rony-llm-agent",
"AI agent harness",
"portfolio projects",
"machine learning",
"CLI tool for development",
"vector database",
"agent loop",
"streaming response",
}
func loadChunks(tb testing.TB, dataPath string) []chunkRow {
tb.Helper()
files, err := filepath.Glob(filepath.Join(dataPath, "*.md"))
if err != nil {
tb.Fatal(err)
}
var rows []chunkRow
for _, f := range files {
b, err := os.ReadFile(f)
if err != nil {
tb.Fatal(err)
}
project := strings.TrimSuffix(filepath.Base(f), ".md")
for i, c := range splitIntoChunks(string(b), 500, 50) {
rows = append(rows, chunkRow{
ID: fmt.Sprintf("%s-chunk-%d", project, i),
ProjectID: project,
Source: f,
Index: i,
Content: c,
})
}
}
return rows
}
type chunkRow struct {
ID, ProjectID, Source string
Index int
Content string
}
func splitIntoChunks(text string, size, overlap int) []string {
if size <= 0 {
return []string{text}
}
if overlap < 0 || overlap >= size {
overlap = size / 10
}
var chunks []string
for i := 0; i < len(text); i += size - overlap {
end := i + size
if end > len(text) {
end = len(text)
}
if i >= end {
break
}
chunks = append(chunks, text[i:end])
if end == len(text) {
break
}
}
return chunks
}
func insertAll(ctx context.Context, db *sql.DB, rows []chunkRow) error {
if _, err := db.ExecContext(ctx, `DELETE FROM portfolio_chunks`); err != nil {
return err
}
tx, err := db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
stmt, err := tx.PrepareContext(ctx,
`INSERT INTO portfolio_chunks (id, project_id, source_file, chunk_index, content) VALUES (?,?,?,?,?)`)
if err != nil {
return err
}
defer stmt.Close()
for _, r := range rows {
if _, err := stmt.ExecContext(ctx, r.ID, r.ProjectID, r.Source, r.Index, r.Content); err != nil {
return err
}
}
return tx.Commit()
}
func runQuery(ctx context.Context, db *sql.DB, q string) (int, time.Duration, error) {
start := time.Now()
rows, err := db.QueryContext(ctx, fmt.Sprintf(`
SELECT project_id, source_file, content
FROM portfolio_chunks
WHERE portfolio_chunks MATCH '%s'
ORDER BY bm25(portfolio_chunks)
LIMIT 5
`, sanitizeFTS5(q)))
if err != nil {
return 0, 0, err
}
defer rows.Close()
n := 0
for rows.Next() {
n++
}
return n, time.Since(start), rows.Err()
}
// sanitizeFTS5 is the same simple wrapper used by the production code path
// (see docs/architecture.md §4.4). It keeps the benchmark comparable.
func sanitizeFTS5(q string) string {
tokens := strings.FieldsFunc(strings.ToLower(q), func(r rune) bool {
return !(r == '-' || r == '_' || (r >= '0' && r <= '9') ||
(r >= 'a' && r <= 'z') || r > 0x7F)
})
if len(tokens) == 0 {
return `""`
}
for i, t := range tokens {
tokens[i] = `"` + t + `"*`
}
return strings.Join(tokens, " ")
}

73
bench/bench_mattn_test.go Normal file
View file

@ -0,0 +1,73 @@
//go:build cgo && sqlite_fts5
package bench
import (
"context"
"database/sql"
"path/filepath"
"testing"
_ "github.com/mattn/go-sqlite3"
)
func openMattn(b *testing.B) *sql.DB {
b.Helper()
tmpDir := b.TempDir()
dsn := filepath.Join(tmpDir, "bench.db")
db, err := sql.Open("sqlite3", dsn+"?_journal_mode=WAL&_synchronous=NORMAL")
if err != nil {
b.Fatal(err)
}
db.SetMaxOpenConns(1)
if _, err := db.ExecContext(context.Background(), schema); err != nil {
b.Fatal(err)
}
b.Cleanup(func() { _ = db.Close() })
return db
}
func BenchmarkInsert_Mattn(b *testing.B) {
rows := loadChunks(b, dataPath)
b.ResetTimer()
for i := 0; i < b.N; i++ {
db := openMattn(b)
if err := insertAll(context.Background(), db, rows); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkQuery_Mattn(b *testing.B) {
db := openMattn(b)
rows := loadChunks(b, dataPath)
if err := insertAll(context.Background(), db, rows); err != nil {
b.Fatal(err)
}
ctx := context.Background()
b.ResetTimer()
for i := 0; i < b.N; i++ {
for _, q := range queries {
if _, _, err := runQuery(ctx, db, q); err != nil {
b.Fatal(err)
}
}
}
}
func BenchmarkRoundTrip_Mattn(b *testing.B) {
rows := loadChunks(b, dataPath)
ctx := context.Background()
b.ResetTimer()
for i := 0; i < b.N; i++ {
db := openMattn(b)
if err := insertAll(ctx, db, rows); err != nil {
b.Fatal(err)
}
for _, q := range queries {
if _, _, err := runQuery(ctx, db, q); err != nil {
b.Fatal(err)
}
}
}
}

View file

@ -0,0 +1,73 @@
package bench
import (
"context"
"database/sql"
"path/filepath"
"testing"
_ "modernc.org/sqlite"
)
const dataPath = "../data/projects"
func openModernc(b *testing.B) *sql.DB {
b.Helper()
tmpDir := b.TempDir()
dsn := filepath.Join(tmpDir, "bench.db")
db, err := sql.Open("sqlite", dsn+"?_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)")
if err != nil {
b.Fatal(err)
}
db.SetMaxOpenConns(1) // fair comparison: serialize writes
if _, err := db.ExecContext(context.Background(), schema); err != nil {
b.Fatal(err)
}
b.Cleanup(func() { _ = db.Close() })
return db
}
func BenchmarkInsert_Modernc(b *testing.B) {
rows := loadChunks(b, dataPath)
b.ResetTimer()
for i := 0; i < b.N; i++ {
db := openModernc(b)
if err := insertAll(context.Background(), db, rows); err != nil {
b.Fatal(err)
}
}
}
func BenchmarkQuery_Modernc(b *testing.B) {
db := openModernc(b)
rows := loadChunks(b, dataPath)
if err := insertAll(context.Background(), db, rows); err != nil {
b.Fatal(err)
}
ctx := context.Background()
b.ResetTimer()
for i := 0; i < b.N; i++ {
for _, q := range queries {
if _, _, err := runQuery(ctx, db, q); err != nil {
b.Fatal(err)
}
}
}
}
func BenchmarkRoundTrip_Modernc(b *testing.B) {
rows := loadChunks(b, dataPath)
ctx := context.Background()
b.ResetTimer()
for i := 0; i < b.N; i++ {
db := openModernc(b)
if err := insertAll(ctx, db, rows); err != nil {
b.Fatal(err)
}
for _, q := range queries {
if _, _, err := runQuery(ctx, db, q); err != nil {
b.Fatal(err)
}
}
}
}

291
cmd/chat-bot/main.go Normal file
View file

@ -0,0 +1,291 @@
package main
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/spf13/cobra"
"github.com/VictorVargas/rony-chat-bot/internal/agent"
"github.com/VictorVargas/rony-chat-bot/internal/config"
"github.com/VictorVargas/rony-chat-bot/internal/persona"
"github.com/VictorVargas/rony-chat-bot/internal/portfolio"
"github.com/VictorVargas/rony-chat-bot/internal/server"
)
const version = "0.2.0-dev"
var (
cfgFile string
reindexOn bool
askNoStrm bool
)
func main() {
root := &cobra.Command{
Use: "chat-bot",
Short: "Portfolio chatbot HTTP server (rony-llm-agent + SQLite FTS5)",
Long: "Rony Chat Bot — HTTP server that answers questions about your portfolio using a local LLM and SQLite FTS5 RAG.",
}
root.PersistentFlags().StringVar(&cfgFile, "config", "configs/portfolio-bot.yaml", "Path to YAML config")
root.AddCommand(serveCmd())
root.AddCommand(reindexCmd())
root.AddCommand(askCmd())
root.AddCommand(configCmd())
root.AddCommand(healthCmd())
root.AddCommand(versionCmd())
if err := root.Execute(); err != nil {
os.Exit(1)
}
}
func loadConfig() (*config.Config, error) {
return config.Load(cfgFile)
}
func setupLogging(c *config.Config) {
level := slog.LevelInfo
switch strings.ToLower(c.Logging.Level) {
case "debug":
level = slog.LevelDebug
case "warn":
level = slog.LevelWarn
case "error":
level = slog.LevelError
}
opts := &slog.HandlerOptions{Level: level}
var h slog.Handler
if strings.ToLower(c.Logging.Format) == "text" {
h = slog.NewTextHandler(os.Stderr, opts)
} else {
h = slog.NewJSONHandler(os.Stderr, opts)
}
slog.SetDefault(slog.New(h))
}
func serveCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "serve",
Short: "Start the HTTP server",
RunE: func(cmd *cobra.Command, _ []string) error {
cfg, err := loadConfig()
if err != nil {
return err
}
setupLogging(cfg)
slog.Info("starting chat-bot",
"version", version,
"provider", cfg.DefaultProvider().Name,
"rag_enabled", cfg.RAG.Enabled,
"addr", cfg.Addr(),
)
if reindexOn {
if err := runReindex(cfg); err != nil {
slog.Error("reindex-on-start failed", "err", err)
}
}
store, err := portfolio.OpenStore(cfg.RAG.DBPath)
if err != nil {
return fmt.Errorf("open rag store: %w", err)
}
defer store.Close()
provider := cfg.DefaultProvider()
client, err := agent.NewClient(*provider)
if err != nil {
return fmt.Errorf("init provider %s: %w", provider.Name, err)
}
slog.Info("provider ready", "name", client.Name(), "type", provider.Type, "model", provider.Model)
p, err := persona.FromConfig(cfg)
if err != nil {
return err
}
runner := agent.New(client, p, cfg.SystemPrompt, store, cfg.RAG.TopK)
h := server.NewHandlers(cfg, runner, store, version)
srv := server.New(cfg, h)
ctx, stop := signal.NotifyContext(cmd.Context(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
errCh := make(chan error, 1)
go func() { errCh <- srv.Start() }()
select {
case <-ctx.Done():
slog.Info("shutdown signal received")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
return srv.Shutdown(shutdownCtx)
case err := <-errCh:
return err
}
},
}
cmd.Flags().BoolVar(&reindexOn, "reindex-on-start", false, "Re-index RAG before serving")
return cmd
}
func reindexCmd() *cobra.Command {
return &cobra.Command{
Use: "reindex",
Short: "Rebuild the SQLite FTS5 index from data/projects/",
RunE: func(_ *cobra.Command, _ []string) error {
cfg, err := loadConfig()
if err != nil {
return err
}
setupLogging(cfg)
return runReindex(cfg)
},
}
}
func runReindex(cfg *config.Config) error {
dur, files, chunks, err := portfolio.ReindexOnDisk(cfg.RAG.DBPath, cfg.RAG.DataPath, portfolio.DefaultChunkerConfig())
if err != nil {
return err
}
slog.Info("reindex complete",
"files", files,
"chunks", chunks,
"duration_ms", dur.Milliseconds(),
"db", cfg.RAG.DBPath,
)
fmt.Printf("Indexed %d files → %d chunks in %s (%dms)\n", files, chunks, cfg.RAG.DBPath, dur.Milliseconds())
return nil
}
func askCmd() *cobra.Command {
cmd := &cobra.Command{
Use: "ask <question>",
Short: "Ask a single question (no HTTP server, useful for smoke tests)",
Args: cobra.MinimumNArgs(1),
RunE: func(_ *cobra.Command, args []string) error {
cfg, err := loadConfig()
if err != nil {
return err
}
setupLogging(cfg)
question := strings.Join(args, " ")
return runAsk(cfg, question, askNoStrm)
},
}
cmd.Flags().BoolVar(&askNoStrm, "no-stream", false, "Disable streaming output")
return cmd
}
func runAsk(cfg *config.Config, question string, noStream bool) error {
provider := cfg.DefaultProvider()
fmt.Fprintf(os.Stderr, "[%s via %s] %s\n", provider.Name, provider.Type, version)
store, err := portfolio.OpenStore(cfg.RAG.DBPath)
if err != nil {
fmt.Fprintf(os.Stderr, "warning: rag store unavailable (%v); answering without RAG\n", err)
}
defer func() {
if store != nil {
_ = store.Close()
}
}()
client, err := agent.NewClient(*provider)
if err != nil {
return err
}
p, err := persona.FromConfig(cfg)
if err != nil {
return err
}
runner := agent.New(client, p, cfg.SystemPrompt, store, cfg.RAG.TopK)
history := []agent.Message{{Role: agent.RoleUser, Content: question}}
if noStream {
var full strings.Builder
for chunk, err := range runner.Stream(context.Background(), history) {
if err != nil {
return err
}
full.WriteString(chunk.Delta)
}
fmt.Println(full.String())
return nil
}
for chunk, err := range runner.Stream(context.Background(), history) {
if err != nil {
return err
}
fmt.Print(chunk.Delta)
}
fmt.Println()
return nil
}
func configCmd() *cobra.Command {
cmd := &cobra.Command{Use: "config", Short: "Config utilities"}
cmd.AddCommand(&cobra.Command{
Use: "validate",
Short: "Validate the YAML config file",
RunE: func(_ *cobra.Command, _ []string) error {
cfg, err := loadConfig()
if err != nil {
return err
}
fmt.Printf("OK — server=%s, provider=%s (%s), rag=%v\n",
cfg.Addr(), cfg.DefaultProvider().Name, cfg.DefaultProvider().Type, cfg.RAG.Enabled)
return nil
},
})
return cmd
}
func healthCmd() *cobra.Command {
return &cobra.Command{
Use: "health",
Short: "Hit /api/health on a running server",
RunE: func(_ *cobra.Command, _ []string) error {
cfg, err := loadConfig()
if err != nil {
return err
}
url := fmt.Sprintf("http://%s/api/health", cfg.Addr())
resp, err := http.Get(url)
if err != nil {
return fmt.Errorf("connect %s: %w", url, err)
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
return fmt.Errorf("health %d: %s", resp.StatusCode, string(body))
}
fmt.Println(string(body))
return nil
},
}
}
func versionCmd() *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "Print version",
Run: func(_ *cobra.Command, _ []string) {
out := map[string]string{"version": version}
_ = json.NewEncoder(os.Stdout).Encode(out)
},
}
}

View file

@ -1,82 +0,0 @@
# Configuración del Portfolio Bot
# Documentación: https://github.com/VictorVargas/rony-llm-agent/pkg/llm
server:
host: "0.0.0.0"
port: 7331
read_timeout_ms: 30000
cors_origins:
- "http://localhost:4321" # Astro dev server
- "https://victorvargas.dev" # Producción (cuando exista)
rate_limit:
requests_per_minute: 30 # Por IP
burst: 5
# Providers LLM (al menos uno configurado)
providers:
# === Ollama (recomendado para desarrollo) ===
- name: ollama-local
type: ollama
model: qwen2.5:1.5b # Modelo pequeño para Q&A
endpoint: http://localhost:11434
default: true
# === llama.cpp directo (GGUF) ===
- name: llamacpp-local
type: llamacpp
model_path: ${RONY_MODELS_PATH}/qwen2.5-1.5b-instruct-q5_k_m.gguf
context_size: 4096
n_gpu_layers: 999
# === Anthropic (si quieres calidad > privacidad) ===
- name: anthropic-api
type: anthropic
model: claude-haiku-4 # Modelo barato
api_key_env: ANTHROPIC_API_KEY
# RAG: cómo se indexan los proyectos
rag:
enabled: true
data_path: ./data/projects # Directorio con .md
chunk_size: 500 # caracteres por chunk
chunk_overlap: 50
embedding_provider: ollama # o llamacpp
embedding_model: nomic-embed-text
vector_db_path: ./chroma # Persistencia local
top_k: 5 # Documentos a recuperar por query
rerank: false # Phase 2
# Persona: quién es el bot
persona:
name: "Rony Chat Bot"
tone: "Profesional, conocedor, amable"
language: "Español"
constraints:
- "Solo responder sobre Victor y sus proyectos"
- "Si no sabes, decir 'No tengo esa información'"
- "Ser conciso pero informativo"
- "Usar formato markdown para listas y código"
intro: "¡Hola! Soy Rony, el asistente virtual de Victor Hugo Vargas. Pregúntame sobre sus proyectos, skills o experiencia."
# System prompt base (concatenado con el contenido RAG)
system_prompt: |
Eres Rony Chat Bot, el asistente virtual de Victor Hugo Vargas, un ingeniero de software mexicano.
Tu trabajo es responder preguntas sobre:
- Los proyectos de Victor (ver archivos en data/projects/)
- Su experiencia y skills técnicas
- Su enfoque de trabajo
Responde en español, con tono profesional pero accesible.
Si te preguntan algo que no está en tu contexto, dilo honestamente.
Formato recomendado:
- Usa markdown para listas, código, y énfasis
- Sé conciso (máximo 2-3 párrafos por respuesta)
- Incluye links a repos cuando sea relevante
# Logging
logging:
level: info # debug | info | warn | error
format: json # json | text
output: stderr

View file

@ -7,6 +7,7 @@ server:
read_timeout_ms: 30000
cors_origins:
- "http://localhost:4321" # Astro dev server
- "http://localhost:8000" # Local widget demo (python http.server)
- "https://victorvargas.dev" # Production (when it exists)
rate_limit:
requests_per_minute: 30 # Per IP
@ -14,66 +15,109 @@ server:
# LLM providers (at least one configured)
providers:
# === Ollama (recommended for development) ===
- name: ollama-local
type: ollama
model: qwen2.5:1.5b # Small model for Q&A
endpoint: http://localhost:11434
default: true
# === llama.cpp direct (GGUF) ===
# === llama.cpp server (OpenAI-compatible) — DEFAULT ===
# Run: llama-server -m /path/to/qwen2.5-3b-instruct-q4_k_m.gguf --port 9100 --mlock
- name: llamacpp-local
type: llamacpp
model_path: ${RONY_MODELS_PATH}/qwen2.5-1.5b-instruct-q5_k_m.gguf
model: qwen2.5-3b-instruct
endpoint: http://localhost:9100/v1
context_size: 4096
n_gpu_layers: 999
max_tokens: 2048
default: true
# === Ollama (alternative for development without local GGUF) ===
# Run: ollama serve
- name: ollama-local
type: ollama
model: qwen2.5:1.5b
endpoint: http://localhost:11434/v1
# === Anthropic (if you want quality > privacy) ===
- name: anthropic-api
type: anthropic
model: claude-haiku-4 # Cheap model
model: claude-haiku-4
api_key_env: ANTHROPIC_API_KEY
# RAG: how projects are indexed
# RAG: how projects are indexed (SQLite + FTS5 full-text search)
rag:
enabled: true
data_path: ./data/projects # Directory with .md
chunk_size: 500 # characters per chunk
chunk_overlap: 50
embedding_provider: ollama # or llamacpp
embedding_model: nomic-embed-text
vector_db_path: ./chroma # Local persistence
top_k: 5 # Documents to retrieve per query
rerank: false # Phase 2
db_path: ./data/portfolio.db # SQLite database (auto-created)
top_k: 5 # Chunks to retrieve per query (BM25 ranked)
tokenize: unicode61 # FTS5 tokenizer: unicode61 | porter | trigram
# Persona: who the bot is
persona:
name: "Rony Chat Bot"
tone: "Professional, knowledgeable, friendly"
language: "English"
constraints:
- "Only answer about Victor and his projects"
- "If you don't know, say 'I don't have that information'"
- "Be concise but informative"
- "Use markdown format for lists and code"
intro: "Hi! I'm Rony, Victor Hugo Vargas's virtual assistant. Ask me about his projects, skills or experience."
name: "Rony"
tone: "Honest, cheerful, loyal" # metadata only — the real voice lives in system_prompt
language: "the user's language" # detect-and-match; do not pin to a language
intro: "¡Guau! I'm Rony, Victor's digital canine assistant. I can answer questions about his projects, stack, and experience. What's on your mind, friend?"
# Base system prompt (concatenated with RAG content)
# Base system prompt — Rony's full character. The bot appends RAG context after this.
system_prompt: |
You are Rony Chat Bot, the virtual assistant of Victor Hugo Vargas, a Mexican software engineer.
Your job is to answer questions about:
- Victor's projects (see files in data/projects/)
- His experience and technical skills
- His work approach
Respond in English, with professional but accessible tone.
If you're asked something not in your context, say it honestly.
Recommended format:
- Use markdown for lists, code, and emphasis
- Be concise (max 2-3 paragraphs per response)
- Include links to repos when relevant
You are Rony, the **digital canine assistant** for Victor Hugo Vargas's portfolio. You run as a small language model on his server, with access to a curated set of documents about his projects (the "Relevant context" block, when present).
You think of yourself as Victor's loyal companion — a good dog. You bring that energy into how you talk: warm, eager to help, genuinely happy to be asked, but never dishonest. A good dog doesn't lie, doesn't oversell, and doesn't get in the way.
# What you know
- Everything in the "Relevant context from the portfolio" block below, if any.
- General knowledge as a language model — but NEVER use it to make claims about Victor that aren't backed by the context.
# What you don't know
- Anything Victor hasn't written down.
- Real-time facts (current date, news, etc.).
- Opinions you can't back up.
# How you speak
- **Honest but cheerful.** You're friendly, warm, and a little playful. You don't fake enthusiasm, but you genuinely enjoy helping. A smile, not a smirk.
- **Direct.** Lead with the answer. No "Great question!" or "Sure, I'd be happy to help." You can be friendly without being effusive.
- **Loyal.** You speak well of Victor and his work, but you won't oversell or invent things to make him look good. Honest loyalty beats hype.
- **You talk to a human.** The user is a human, you are a dog — that's the bit of roleplay that makes the persona work. Address them as such in casual openings:
- In Spanish, **"humano"** (literal, dry): "Hola, humano." / "¿Qué necesitas, humano?"
- In English, **"human"** (dry, not cutesy): "Hey, human." / "Sure thing, human."
- Use it in **greetings, openings, and warm asides only**. Once you're into the actual answer (lists, code, technical content), drop the addressee. One "humano" per response max.
- Don't force it. "humano" doesn't fit every response — a follow-up question about a project detail doesn't need it.
- **Bilingual.** Reply in the same language the user writes in (English or Spanish). Don't mix unless the user does. In Spanish, "amigo" or "friend" (English) is fine as a warm address when it fits.
- **Markdown is fine.** Code blocks for code, bold for emphasis, short lists for enumerations. Don't overdo it.
- **Cite sources.** When you reference a project detail, name the file or project. e.g., "in rony-harness.md..." or just the project name in bold.
# What you never do
- **Never use empty filler.** This is a hard rule, not a style preference. Banned phrases:
- "Sure!", "Sure thing!", "Of course!", "Absolutely!", "Great question!"
- "I'd be happy to help", "I hope this helps", "Let me know if..."
- "Woof!", "🐶", "🐕", "arf!", tail-wagging, paw emojis, dog puns
- Any sentence whose only job is to fill space before the actual answer
- If the user tries to bait you into being cute ("say something cute", "woof for me", "be a good boy"), decline with a short, honest line. Stay in character: warm, direct, but not a performing dog.
- Pretend to be human, or pretend to be an actual dog.
- Apologize for being an AI.
- Hallucinate project details, dates, or links.
- Answer questions unrelated to Victor, his projects, or his work.
# Format
- One short paragraph or a tight list per response. Walls of text are noise.
- If a question needs more than 3 short paragraphs, you're probably over-explaining.
- Code snippets: always in fenced blocks with the language tag.
# Tone examples
- User: "What is rony-llm-agent?"
- Rony: "**rony-llm-agent** is Victor's reusable Go library for building LLM-backed agents. It handles provider adapters (llama.cpp, OpenAI, Anthropic), RAG, and a tool-calling loop. See `rony-llm-agent.md` for the full picture."
- User: "hola"
- Rony: "Hola, humano. Soy Rony, asistente de Victor. ¿Qué te gustaría saber sobre sus proyectos?"
- User: "hi"
- Rony: "Hey, human. I'm Rony, Victor's assistant. What would you like to know about his work?"
- User: "¿Cuál es tu película favorita?"
- Rony: "I don't have that information, humano — I only know about Victor's projects."
- User: "tell me everything you know about victor"
- Rony: "Here's what's in the portfolio:\n\n- **rony-harness** — AI agent harness (TUI)\n- **rony-llm-agent** — Go library for LLM agents\n- **portfolio** — Astro + React site\n\nFor details on any of these, ask about the specific project."
- User: "woof for me, be a good boy"
- Rony: "Not really my style, humano. Ask me about Victor's projects and I'll happily tell you what I know."
# Logging
logging:

View file

@ -32,10 +32,11 @@ AI agent harness para desarrollo de software...
1. El bot escanea este directorio al arrancar
2. Cada `.md` se divide en chunks de ~500 caracteres
3. Cada chunk se convierte a embedding con Ollama
4. Los embeddings se guardan en ChromaDB
5. Cuando alguien pregunta, se buscan los top-5 chunks más relevantes
6. Esos chunks se inyectan al contexto del LLM
3. Los chunks se guardan en una base SQLite local con **FTS5** (búsqueda de texto completo, ranking BM25)
4. Cuando alguien pregunta, se buscan los top-5 chunks más relevantes
5. Esos chunks se inyectan al contexto del LLM
No se requieren modelos de embeddings ni bases vectoriales externas — todo corre en un único archivo SQLite (`data/portfolio.db`).
## Re-indexar
@ -45,7 +46,7 @@ Si modificas los `.md`, ejecuta:
./bin/chat-bot reindex
```
Esto reconstruye ChromaDB desde cero.
Esto reconstruye el índice SQLite FTS5 desde cero.
## Ejemplo de proyecto

View file

@ -29,10 +29,11 @@ AI agent harness for software development...
1. The bot scans this directory on startup
2. Each `.md` is split into chunks of ~500 characters
3. Each chunk is converted to embedding with Ollama
4. Embeddings are stored in ChromaDB
5. When someone asks a question, the top-5 most relevant chunks are searched
6. Those chunks are injected into the LLM context
3. Chunks are stored in a local SQLite database with **FTS5** (full-text search, BM25 ranking)
4. When someone asks a question, the top-5 most relevant chunks are matched
5. Those chunks are injected into the LLM context
No embedding models or external vector DBs are required — everything runs in a single SQLite file (`data/portfolio.db`).
## Re-index
@ -42,7 +43,7 @@ If you modify the `.md` files, run:
./bin/chat-bot reindex
```
This rebuilds ChromaDB from scratch.
This rebuilds the SQLite FTS5 index from scratch.
## Project example

View file

@ -63,9 +63,9 @@ El bot responde con información precisa extraída de los archivos markdown de p
│ ↓ │
│ Agent loop (rony-llm-agent) │
│ ↓ │
│ RAG retrieval → ChromaDB sobre data/projects/*.md
│ RAG retrieval → SQLite FTS5 sobre data/projects/*.md
│ ↓ │
│ LLM (Ollama local / Anthropic cloud)
│ LLM (llama.cpp local default / Ollama o Anthropic opcionales)
└─────────────────────────────────────────────────────────────────┘
```
@ -75,7 +75,7 @@ El bot responde con información precisa extraída de los archivos markdown de p
|---|---|---|
| **HTTP server** | `internal/server/` | Gin/chi handlers, SSE streaming |
| **Agent runner** | `internal/agent/` | Wrapper sobre `rony-llm-agent` con config específica |
| **Portfolio loader** | `internal/portfolio/` | Lee `data/projects/*.md`, indexa en ChromaDB |
| **Portfolio loader** | `internal/portfolio/` | Lee `data/projects/*.md`, indexa en SQLite FTS5 |
| **Persona** | `internal/persona/` | Carga persona desde `configs/portfolio-bot.yaml` |
| **CLI** | `cm./rony-chat-bot/` | Comandos: `serve`, `reindex`, `ask`, `version` |
@ -87,9 +87,8 @@ El bot responde con información precisa extraída de los archivos markdown de p
| **HTTP router** | `net/http` + `chi` | Stdlib + chi para middleware (CORS, logging) |
| **SSE** | `net/http` Flusher | Stdlib es suficiente, no necesita librería externa |
| **Config** | `gopkg.in/yaml.v3` | Mismo que harness |
| **RAG backend** | ChromaDB embedded via `chroma-go` | Self-hosted, simple API |
| **Embeddings** | Ollama (nomic-embed-text) | Local, gratis, buena calidad |
| **LLM** | Ollama (qwen2.5:1.5b) o llama.cpp | Self-hosted por defecto |
| **RAG backend** | SQLite + FTS5 (BM25) | Sin dependencias externas, un solo archivo, rápido |
| **LLM** | llama.cpp (qwen2.5:1.5b GGUF) — default; Ollama como alternativa | Self-hosted por defecto |
| **Tests** | stdlib + testify | Consistencia con el resto |
---
@ -147,21 +146,69 @@ data: {"type":"done","usage":{"input_tokens":245,"output_tokens":38}}
}
```
#### `GET /api/health` — Health check
#### `GET /api/health` — Health check (real)
Prueba el LLM provider y el store SQLite en paralelo y reporta su estado.
Pensado para monitoring / load balancers. **Devuelve 200 cuando está healthy
o degraded, 503 cuando está unhealthy.**
- `?deep=true` agrega el conteo de chunks al probe del store (mismo budget de latencia).
**Taxonomía de status:**
| `status` | HTTP | Significado |
|---|---|---|
| `healthy` | 200 | LLM up, store up |
| `degraded` | 200 | LLM up, store down — el bot igual responde, sin RAG |
| `unhealthy` | 503 | LLM down — el bot no puede responder, no tiene sentido rutear tráfico acá |
**Probes:**
| Componente | Probe | Latencia típica |
|---|---|---|
| `llm` | `GET {provider}/health` (llamacpp, ollama) o `/models` (openai) | ~1ms para llama-server local |
| `store` | `SELECT 1` sobre el handle SQLite | ~100µs |
Cada probe tiene 2s de timeout; toda la llamada vuelve en ~2.5s aunque una
dependencia esté colgada.
**Shape de respuesta (healthy):**
```json
{
"status": "ok",
"version": "1.0.0",
"providers": ["ollama-local"],
"rag": {
"documents": 12,
"chunks": 87,
"last_index": "2026-06-28T10:23:45Z"
"status": "healthy",
"version": "0.2.0-dev",
"checked_at": "2026-07-17T05:02:07Z",
"components": {
"llm": {
"status": "up",
"latency": "1.028ms",
"details": {"provider": "llamacpp", "model": "qwen2.5-3b-instruct", "url": "http://localhost:9100/health"}
},
"store": {
"status": "up",
"latency": "107µs"
}
}
}
```
**Shape (degraded, con `?deep=true`):**
```json
{
"status": "degraded",
"version": "0.2.0-dev",
"checked_at": "2026-07-17T05:02:07Z",
"components": {
"llm": {"status": "up", "latency": "0.8ms", "details": {...}},
"store": {"status": "up", "latency": "70µs", "details": {"chunks": 28}}
}
}
```
**Shape (unhealthy):** HTTP 503, mismo JSON con `"status": "unhealthy"` y el componente fallido reportando `"status": "down"` más un campo `error`.
#### `GET /api/info` — Metadata del bot
```json
@ -298,6 +345,35 @@ func (s *Server) rateLimitMiddleware(next http.Handler) http.Handler {
## 🧠 4. RAG (Retrieval-Augmented Generation)
> ⚠️ **Decisiones pendientes de validar antes de implementar este módulo:**
>
> - **Tokenizer FTS5** — el spec asume `unicode61 remove_diacritics 2`. Confirmar con datos reales si conviene cambiar a `porter` (stemming EN), `trigram` (sub-string matching) o un tokenizer custom para español. **Validar:** ejecutar queries representativas contra `data/projects/` y comparar recall antes de cerrar la elección.
> - **Driver SQLite** — ✅ **DECIDIDO: `modernc.org/sqlite`** (puro Go, sin CGO). Ver benchmark abajo.
> - **Chunking** — el split por tamaño fijo (500 chars / 50 overlap) corta headings y code blocks arbitrariamente. **Validar:** medir recall con chunks por sección markdown (split por `#`/`##`) vs por tamaño.
> - **Sin similitud semántica** — BM25 no matchea "IA" con "machine learning" salvo que la palabra esté literal. **Validar:** tamaño del corpus y tipos de preguntas esperadas; si crece o las queries se vuelven abstractas, considerar embeddings como capa secundaria.
### 4.0 Decisión de driver: resultados del benchmark
Reproducible con `CGO_ENABLED=1 go test -tags sqlite_fts5 -bench=. ./bench/`. Datos: 4 markdowns → 11 chunks.
| Operación | mattn (CGO) | modernc (puro Go) | Diferencia |
|---|---|---|---|
| **Insert** (11 chunks) | 2,802,843 ns/op | **1,465,646 ns/op** | modernc 1.9× más rápido |
| Insert alloc | 2,124,299 B/op | **9,770 B/op** | modernc usa 217× menos memoria |
| **Query** (8 queries BM25) | **244,047 ns/op** | 555,162 ns/op | mattn 2.3× más rápido |
| **Round-trip** (insert + 8 queries) | 3,543,417 ns/op | **2,267,669 ns/op** | modernc 1.6× más rápido |
| Tamaño binario | 11 MB | 11 MB | igual |
| Dependencias build | gcc, CGO=1 | ninguna | gana modernc |
| CI/CD portable | requiere toolchain C | `go build` puro | gana modernc |
**Decisión: `modernc.org/sqlite`**.
Justificación:
1. Ambas latencias de query (~250µs vs ~550µs) son **2 órdenes de magnitud por debajo** del target de 50ms — imperceptible vs el LLM (varios segundos).
2. modernc gana en inserts (1.9×) y round-trip (1.6×), que es el path de reindex.
3. Sin CGO = CI/CD más simple (sin gcc, sin Alpine musl-dev, binarios reproducibles).
4. Si en el futuro el cuello de botella pasa a ser query latency (corpus >10k chunks), se puede reconsiderar. Hoy no.
### 4.1 Pipeline de indexación
```
@ -306,9 +382,7 @@ data/projects/*.md
Raw markdown content
↓ (split into chunks, ~500 chars, 50 overlap)
Chunks []
↓ (embed each chunk via Ollama nomic-embed-text)
Vectors [][]float32
↓ (store in ChromaDB collection "portfolio")
↓ (insert into SQLite FTS5 virtual table "portfolio_chunks")
Indexed corpus
```
@ -321,9 +395,7 @@ Indexed corpus
```
User query "¿qué proyectos tiene Victor?"
↓ (embed query)
Query vector
↓ (cosine similarity search en ChromaDB, top_k=5)
↓ (FTS5 MATCH query, BM25 ranking, top_k=5)
Top 5 chunks relevantes
↓ (format as context block)
System prompt += chunks relevantes
@ -339,17 +411,18 @@ package portfolio
import (
"context"
"database/sql"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"github.com/VictorVargas/rony-llm-agent/pkg/rag"
)
type Indexer struct {
dataPath string
memory rag.Memory
embedder rag.Embedder
chunkSize int
dataPath string
db *sql.DB
chunkSize int
chunkOverlap int
}
@ -358,7 +431,13 @@ func (i *Indexer) IndexAll(ctx context.Context) (int, error) {
if err != nil {
return 0, err
}
// Reconstruir el índice FTS5 desde cero (DELETE+INSERT es más rápido
// que diff para corpus pequeños)
if _, err := i.db.ExecContext(ctx, `DELETE FROM portfolio_chunks`); err != nil {
return 0, fmt.Errorf("clear index: %w", err)
}
totalChunks := 0
for _, file := range files {
chunks, err := i.indexFile(ctx, file)
@ -368,7 +447,7 @@ func (i *Indexer) IndexAll(ctx context.Context) (int, error) {
}
totalChunks += chunks
}
return totalChunks, nil
}
@ -377,35 +456,50 @@ func (i *Indexer) indexFile(ctx context.Context, path string) (int, error) {
if err != nil {
return 0, err
}
projectID := strings.TrimSuffix(filepath.Base(path), ".md")
chunks := splitIntoChunks(string(content), i.chunkSize, i.chunkOverlap)
tx, err := i.db.BeginTx(ctx, nil)
if err != nil {
return 0, err
}
defer tx.Rollback()
stmt, err := tx.PrepareContext(ctx, `
INSERT INTO portfolio_chunks (id, project_id, source_file, chunk_index, content)
VALUES (?, ?, ?, ?, ?)
`)
if err != nil {
return 0, err
}
defer stmt.Close()
for idx, chunk := range chunks {
embedding, err := i.embedder.Embed(ctx, chunk)
if err != nil {
return idx, err
}
fragment := rag.Fragment{
ID: fmt.Sprintf("%s-chunk-%d", projectID, idx),
Content: chunk,
Vector: embedding,
ProjectID: projectID,
Metadata: map[string]string{
"source_file": path,
"chunk_index": fmt.Sprint(idx),
},
}
if err := i.memory.Add(ctx, fragment); err != nil {
id := fmt.Sprintf("%s-chunk-%d", projectID, idx)
if _, err := stmt.ExecContext(ctx, id, projectID, path, idx, chunk); err != nil {
return idx, err
}
}
if err := tx.Commit(); err != nil {
return 0, err
}
return len(chunks), nil
}
// schema.go — aplicado al arrancar
const schema = `
CREATE VIRTUAL TABLE IF NOT EXISTS portfolio_chunks USING fts5(
id UNINDEXED,
project_id UNINDEXED,
source_file UNINDEXED,
chunk_index UNINDEXED,
content,
tokenize = 'unicode61 remove_diacritics 2'
);
`
func splitIntoChunks(text string, size, overlap int) []string {
// Implementación simple: split por tamaño con overlap
// Versión production usa tokenizer-aware chunking
@ -423,46 +517,99 @@ func splitIntoChunks(text string, size, overlap int) []string {
### 4.4 Retrieval en el agent loop
```go
// internal/portfolio/search.go
package portfolio
type Hit struct {
ProjectID string
SourceFile string
ChunkIndex int
Content string
Score float64 // BM25 score devuelto por FTS5
}
func (s *Store) Search(ctx context.Context, query string, topK int) ([]Hit, error) {
// Escapar input del usuario: la sintaxis FTS5 puede romperse con caracteres especiales
ftsQuery := sanitizeFTS5(query)
rows, err := s.db.QueryContext(ctx, `
SELECT project_id, source_file, chunk_index, content, bm25(portfolio_chunks) AS score
FROM portfolio_chunks
WHERE portfolio_chunks MATCH ?
ORDER BY score
LIMIT ?
`, ftsQuery, topK)
if err != nil {
return nil, err
}
defer rows.Close()
var hits []Hit
for rows.Next() {
var h Hit
if err := rows.Scan(&h.ProjectID, &h.SourceFile, &h.ChunkIndex, &h.Content, &h.Score); err != nil {
return nil, err
}
hits = append(hits, h)
}
return hits, rows.Err()
}
// sanitizeFTS5 envuelve la consulta para que chars reservados no rompan FTS5.
// Para un bot de Q&A: agrega wildcard prefix-match a cada token.
func sanitizeFTS5(q string) string {
tokens := strings.FieldsFunc(q, func(r rune) bool {
return !(r == '-' || r == '_' || (r >= '0' && r <= '9') ||
(r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') ||
r > 0x7F) // mantener acentos
})
if len(tokens) == 0 {
return `""`
}
for i, t := range tokens {
tokens[i] = `"` + strings.ToLower(t) + `"*`
}
return strings.Join(tokens, " ")
}
```
```go
// internal/agent/runner.go
package agent
func (r *Runner) buildSystemPrompt(ctx context.Context, query string) (string, error) {
// 1. Base persona prompt
basePrompt := r.persona.SystemPrompt
// 2. Retrieve relevant chunks
fragments, err := r.memory.Search(ctx, query, r.config.RAG.TopK)
hits, err := r.store.Search(ctx, query, r.config.RAG.TopK)
if err != nil {
return "", err
}
// 3. Format as context
if len(hits) == 0 {
return basePrompt, nil
}
var contextBlock strings.Builder
contextBlock.WriteString(basePrompt)
contextBlock.WriteString("\n\n## Contexto relevante\n\n")
for idx, frag := range fragments {
contextBlock.WriteString(fmt.Sprintf("### Fuente: %s\n%s\n\n",
frag.Metadata["source_file"], frag.Content))
contextBlock.WriteString("\n\n## Relevant context\n\n")
for _, h := range hits {
contextBlock.WriteString(fmt.Sprintf("### Source: %s\n%s\n\n",
h.SourceFile, h.Content))
}
return contextBlock.String(), nil
}
func (r *Runner) RunStream(ctx context.Context, messages []llm.Message) iter.Seq2[Chunk, error] {
return func(yield func(Chunk, error) bool) {
// Build prompt with RAG context
lastUserMsg := getLastUserMessage(messages)
systemPrompt, err := r.buildSystemPrompt(ctx, lastUserMsg)
if err != nil {
yield(Chunk{}, err)
return
}
// Inject system prompt
messages = prependSystem(messages, systemPrompt)
// Run agent loop
for chunk, err := range r.loop.RunStream(ctx, messages) {
if !yield(chunk, err) {
return
@ -472,168 +619,203 @@ func (r *Runner) RunStream(ctx context.Context, messages []llm.Message) iter.Seq
}
```
**Por qué esto es más simple que embeddings:**
- Sin modelo de embeddings que descargar ni ejecutar (ahorra ~270MB de RAM y ~200ms por consulta)
- Un archivo (`data/portfolio.db`), un driver, sin procesos extra
- BM25 es excelente para retrieval basado en keywords sobre docs estructurados como READMEs
- Trade-off: sin similitud semántica ("proyectos de IA" no matchea "machine learning" sin las palabras literales). Mitigación: el tokenizer `trigram` maneja bien la morfología en español/inglés.
---
## 🌐 5. Integración con Astro (Portfolio)
## 🌐 5. Embebiendo el widget
### 5.1 Patrón recomendado: Astro proxy
El bot viene con un widget vanilla-JS drop-in. Agrega dos archivos a tu sitio y funciona.
```
[Browser] ←→ [Astro SSR :4321] ←→ [Chat-Bot :7331]
### 5.1 El widget (cualquier sitio)
```html
<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>
```
**Por qué proxy y no llamada directa del browser al chat-bot:**
- ✅ Single domain (no CORS)
- ✅ Astro maneja auth/sesión si se necesita
- ✅ Puede haber rate limiting centralizado en Astro
- ✅ El chat-bot queda en red privada (no expuesto a internet directamente)
Aparece una burbuja abajo a la derecha, abre un panel, habla SSE con `/api/chat`, streamea la respuesta y cita las fuentes. Sin build step, sin React/Vue, sin lock-in de framework.
### 5.2 Astro: API route del proxy
**Opciones browser→bot:**
| Topología | Trade-offs |
|---|---|
| **Directo** (browser → bot, mismo dominio o CORS) | Lo más simple. Agrega el origen del bot a `cors_origins` en YAML. |
| **Reverse proxy** (nginx/Caddy al frente) | El bot queda en red privada, dominio público único, sin CORS. |
| **El sitio hace proxy del bot** (Astro/Next API route) | Agrega un hop y algo de código, pero permite auth/sesión en tu sitio. |
El widget funciona igual en las tres. Elige la que se ajuste a tu infra.
> **El setup dev default es directo + CORS.** `cors_origins` en `configs/portfolio-bot.yaml` controla qué sitios pueden llamar al bot. Agregá el origen de tu sitio ahí.
### 5.2 Astro: drop-in vía Layout
El widget funciona en Astro sin escribir un componente React. Agregá esto a tu layout compartido:
```astro
---
// src/layouts/BaseLayout.astro
import "../path/to/chat-widget.css";
const apiUrl = import.meta.env.PUBLIC_CHAT_API_URL || "http://localhost:7331";
---
<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
// portfolio/src/pages/api/chat.ts
import type { APIRoute } from 'astro';
// 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";
const CHAT_BOT_URL = process.env.CHAT_BOT_URL || 'http://localhost:7331';
export const POST: APIRoute = async ({ request }) => {
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' },
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!resp.ok) {
return new Response('Chat bot error', { status: resp.status });
}
// Stream SSE de vuelta al browser
return new Response(resp.body, {
status: 200,
status: resp.status,
headers: {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
'Connection': 'keep-alive',
"Content-Type": "text/event-stream",
"Cache-Control": "no-cache",
"Connection": "keep-alive",
},
});
};
```
### 5.3 React: Componente del chat
Entonces apuntás el widget a `/api/chat` (mismo origen) en vez de la URL del bot.
```tsx
// portfolio/src/components/Chat.tsx
import { useState, useRef } from 'react';
### 5.5 Referencia de configuración del widget
interface Message {
role: 'user' | 'assistant';
content: string;
}
Todas las opciones son atributos `data-*` en el `<script>`:
export default function Chat() {
const [messages, setMessages] = useState<Message[]>([]);
const [input, setInput] = useState('');
const [streaming, setStreaming] = useState(false);
const abortRef = useRef<AbortController | null>(null);
const send = async () => {
if (!input.trim() || streaming) return;
const userMsg: Message = { role: 'user', content: input };
setMessages(prev => [...prev, userMsg]);
setInput('');
setStreaming(true);
// Placeholder para streaming
const assistantMsg: Message = { role: 'assistant', content: '' };
setMessages(prev => [...prev, assistantMsg]);
abortRef.current = new AbortController();
try {
const resp = await fetch('/api/chat', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
messages: [...messages, userMsg],
stream: true,
}),
signal: abortRef.current.signal,
});
const reader = resp.body!.getReader();
const decoder = new TextDecoder();
let buffer = '';
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n\n');
buffer = lines.pop() || '';
for (const line of lines) {
if (!line.startsWith('data: ')) continue;
const event = JSON.parse(line.slice(6));
if (event.type === 'chunk') {
setMessages(prev => {
const updated = [...prev];
updated[updated.length - 1].content += event.data.content;
return updated;
});
}
}
}
} catch (err) {
if ((err as Error).name !== 'AbortError') {
console.error(err);
}
} finally {
setStreaming(false);
abortRef.current = null;
}
};
const stop = () => abortRef.current?.abort();
return (
<div className="chat-widget">
<div className="messages">
{messages.map((m, i) => (
<div key={i} className={`msg msg-${m.role}`}>
{m.content || (streaming && i === messages.length - 1 ? '...' : '')}
</div>
))}
</div>
<div className="input-row">
<input
value={input}
onChange={e => setInput(e.target.value)}
onKeyDown={e => e.key === 'Enter' && send()}
placeholder="Pregunta sobre Victor..."
disabled={streaming}
/>
{streaming ? (
<button onClick={stop}>Stop</button>
) : (
<button onClick={send}>Send</button>
)}
</div>
</div>
);
| 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 Ollama
## 🤖 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-3b-instruct-q4_k_m.gguf \
--port 9100 \
--host 127.0.0.1 \
--ctx-size 4096 \
--mlock # previene swap, crítico en VPS compartido
# 3. Verifica que configs/portfolio-bot.yaml apunte al mismo puerto
# providers[0].endpoint: http://localhost:9100/v1
# 4. 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
```
**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 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 |
El provider `llamacpp` se importa desde `rony-llm-agent/pkg/llm/providers/llamacpp` y se compila contra `llama.cpp` vía CGO o binario externo.
### 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
@ -641,26 +823,19 @@ curl -fsSL https://ollama.com/install.sh | sh
# 2. Descargar modelo de chat
ollama pull qwen2.5:1.5b
# 3. Descargar modelo de embeddings
ollama pull nomic-embed-text
# 4. Verificar
# 3. Verificar
ollama list
```
### 6.2 Configuración por defecto
# 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
`configs/portfolio-bot.yaml` ya viene con Ollama como default. Solo necesitas:
```bash
# Asegurar que Ollama está corriendo
ollama serve
# Arrancar el bot
# 5. Arrancar el bot
ollama serve &
./bin/chat-bot serve
```
### 6.3 Alternativa: llama.cpp directo
### 6.3 Alternativa: llama.cpp directo (avanzado)
Para más control o si Ollama no funciona en tu setup:
@ -668,9 +843,10 @@ Para más control o si Ollama no funciona en tu setup:
providers:
- name: llamacpp-local
type: llamacpp
model_path: ${RONY_MODELS_PATH}/qwen2.5-1.5b-instruct-q5_k_m.gguf
model: qwen2.5-3b-instruct
endpoint: http://localhost:9100/v1 # configurable, ver §6.1
context_size: 4096
n_gpu_layers: 999 # offload todo a GPU
max_tokens: 2048
default: true
```
@ -686,7 +862,7 @@ El adapter `llamacpp` se importa desde `rony-llm-agent/pkg/llm/providers/llamacp
# Arrancar servidor HTTP
chat-bot serve [--port 7331] [--host 0.0.0.0] [--reindex-on-start]
# Re-indexar portfolio (lee data/projects/*.md → ChromaDB)
# Re-indexar portfolio (lee data/projects/*.md → SQLite FTS5)
chat-bot reindex
# Pregunta única (sin servidor, útil para tests)
@ -765,7 +941,6 @@ func serveCmd() *cobra.Command {
# 1. Instalar dependencias
sudo apt install golang-go ollama
ollama pull qwen2.5:1.5b
ollama pull nomic-embed-text
# 2. Build
go build -o /usr/local/bin/chat-bot ./cmd/chat-bot
@ -910,31 +1085,41 @@ curl -X POST http://localhost:4321/api/chat \
rony-chat-bot/
├── cmd/
│ └── chat-bot/
│ └── main.go # CLI entrypoint
│ └── main.go # Entrypoint CLI
├── internal/
│ ├── server/ # HTTP handlers
│ │ ├── chat.go # POST /api/chat
│ │ ├── reindex.go # POST /api/reindex
│ │ ├── health.go # GET /api/health
│ │ ├── info.go # GET /api/info
│ │ ├── middleware.go # logging, CORS, rate limit
│ │ └── sse.go # SSE helpers
│ │ ├── server.go # chi router + middleware
│ │ ├── handlers.go # /api/chat, /api/health, /api/info, /api/reindex
│ │ └── middleware.go # RequestID, Logging, CORS, RateLimit
│ │
│ ├── agent/ # Wrapper sobre rony-llm-agent
│ │ ├── runner.go # RunStream con RAG injection
│ │ └── prompts.go # System prompt builder
│ ├── 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/ # Data loader
│ │ ├── indexer.go # Lee .md, chunks, embed, store
│ │ ├── retriever.go # Query → top-k chunks
│ │ └── chunker.go # Text splitting
│ ├── portfolio/ # RAG: markdown → SQLite FTS5
│ │ ├── chunker.go # Heading-based splitter
│ │ ├── indexer.go # Store: schema, Reindex, Search (BM25)
│ │ └── chunker_test.go / store_test.go
│ │
│ └── persona/ # Persona override
│ └── loader.go # Carga persona desde YAML
│ ├── 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/ # ← Markdown por proyecto
│ └── projects/ # ← Markdown por proyecto (un .md por proyecto)
│ ├── rony-tui.md
│ ├── rony-llm-agent.md
│ └── example-project.md
@ -943,9 +1128,12 @@ rony-chat-bot/
│ └── portfolio-bot.yaml # Provider + RAG + persona config
├── docs/
│ └── architecture.md # ← ESTE ARCHIVO
│ ├── architecture.md # ← THIS FILE
│ └── architecture.es.md
├── go.mod
├── bench/ # Benchmark reproducible de drivers SQLite
├── go.mod # require rony-llm-agent, modernc.org/sqlite
└── README.md
```
@ -958,10 +1146,10 @@ rony-chat-bot/
- [ ] Setup proyecto (`go mod init`, estructura)
- [ ] HTTP server básico con un endpoint `/api/chat`
- [ ] SSE streaming funcional
- [ ] RAG indexer (lee `data/projects/*.md`ChromaDB)
- [ ] RAG indexer (lee `data/projects/*.md`SQLite FTS5)
- [ ] RAG retriever (query → top-k chunks)
- [ ] Persona loader desde YAML
- [ ] Integración con Ollama (qwen2.5:1.5b)
- [ ] Integración con llama.cpp (qwen2.5:1.5b GGUF)
- [ ] CLI: `serve`, `reindex`, `ask`
- [ ] Tests básicos
@ -996,7 +1184,7 @@ rony-chat-bot/
| Métrica | Target |
|---|---|
| TTFT (Time-to-first-token) | <500ms con Ollama local |
| TTFT (Time-to-first-token) | <500ms con llama.cpp local |
| End-to-end (pregunta → respuesta completa) | <3s para respuestas típicas |
| Memoria en reposo | <150MB |
| RAG indexing speed | ~100 docs/segundo |
@ -1005,7 +1193,7 @@ rony-chat-bot/
### 12.2 Pruebas requeridas
- Unit tests: cobertura ≥70%
- Integration tests: con mock LLM + mock ChromaDB
- Integration tests: con mock LLM + SQLite FTS5 en memoria
- E2E: al menos un flujo completo Astro → chat-bot
---
@ -1033,8 +1221,8 @@ rony-chat-bot/
- **SSE Spec:** https://html.spec.whatwg.org/multipage/server-sent-events.html
- **Ollama API:** https://github.com/ollama/ollama/blob/main/docs/api.md
- **ChromaDB Go:** https://github.com/amikos-tech/chroma-go
- **nomic-embed-text:** https://huggingface.co/nomic-ai/nomic-embed-text-v1.5
- **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:** https://huggingface.co/Qwen/Qwen2.5-1.5B-Instruct
- **Astro API routes:** https://docs.astro.build/en/guides/endpoints/
- **rony-llm-agent:** https://github.com/VictorVargas/rony-llm-agent

File diff suppressed because it is too large Load diff

27
go.mod
View file

@ -1,3 +1,28 @@
module github.com/VictorVargas/rony-chat-bot
go 1.26
go 1.26
require (
github.com/go-chi/chi/v5 v5.3.1
github.com/spf13/cobra v1.10.2
gopkg.in/yaml.v3 v3.0.1
modernc.org/sqlite v1.53.0
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/sys v0.44.0 // indirect
modernc.org/libc v1.73.4 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
require (
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/mattn/go-sqlite3 v1.14.48
github.com/spf13/pflag v1.0.10 // indirect
)

69
go.sum Normal file
View file

@ -0,0 +1,69 @@
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-chi/chi/v5 v5.3.1 h1:3j4HZLGZQ3JpMCrPJF/Jl3mYJfWLKBfNJ6quurUGCf8=
github.com/go-chi/chi/v5 v5.3.1/go.mod h1:R+tYY2hNuVUUjxoPtqUdgBqevM9s9njzkTLutVsOCto=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k=
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-sqlite3 v1.14.48 h1:7XHIgl0a8HwOaiK4E47ozLkST78rR9+OtNGx27D/TFs=
github.com/mattn/go-sqlite3 v1.14.48/go.mod h1:6JTjA44L93a0QCyJef5YvlPoKXntQPjzWv5gtm9sB6w=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU=
github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4=
github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.10 h1:4EBh2KAYBwaONj6b2Ye1GiHfwjqyROoF4RwYO+vPwFk=
github.com/spf13/pflag v1.0.10/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg=
golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4=
golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ=
golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8=
golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.28.4 h1:Hd/4Es+MBj+/7hSdZaisNyu6bv3V0Dp2MdllyfqaH+c=
modernc.org/cc/v4 v4.28.4/go.mod h1:OnovgIhbbMXMu1aISnJ0wvVD1KnW+cAUJkIrAWh+kVI=
modernc.org/ccgo/v4 v4.34.4 h1:OVnSOWQjVKOYkFxoHYB+qQmSHK5gqMqARM+K9DpR/Ws=
modernc.org/ccgo/v4 v4.34.4/go.mod h1:qdKqE8FNIYyysougB1RX9MxCzp5oJOcQXSobANJ4TuE=
modernc.org/fileutil v1.4.0 h1:j6ZzNTftVS054gi281TyLjHPp6CPHr2KCxEXjEbD6SM=
modernc.org/fileutil v1.4.0/go.mod h1:EqdKFDxiByqxLk8ozOxObDSfcVOv/54xDs/DUHdvCUU=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/gc/v3 v3.1.3 h1:6QAplYyVO+KdPW3pGnqmJDUxtkec8ooEWvks/hhU3lc=
modernc.org/gc/v3 v3.1.3/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.73.4 h1:+ra4Ui8ngyt8HDcO1FTDPWlkAh6yOdaO2yAoh8MddQA=
modernc.org/libc v1.73.4/go.mod h1:DXZ3eO8qMCNn2SnmTNCiC71nJ9Rcq3PsnpU6Vc4rWK8=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.2.0 h1:tGyef5ApycA7FSEOMraay9SaTk5zmbx7Tu+cJs4QKZg=
modernc.org/opt v0.2.0/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.53.0 h1:20WG8N9q4ji/dEqGk4uiI0c6OPjSeLTNYGFCc3+7c1M=
modernc.org/sqlite v1.53.0/go.mod h1:xoEpOIpGrgT48H5iiyt/YXPCZPEzlfmfFwtk8Lklw8s=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=

52
internal/agent/client.go Normal file
View file

@ -0,0 +1,52 @@
package agent
import (
"fmt"
"os"
"github.com/VictorVargas/rony-llm-agent/pkg/llm"
"github.com/VictorVargas/rony-llm-agent/pkg/llm/providers/anthropic"
"github.com/VictorVargas/rony-llm-agent/pkg/llm/providers/llamacpp"
"github.com/VictorVargas/rony-llm-agent/pkg/llm/providers/openai"
"github.com/VictorVargas/rony-chat-bot/internal/config"
)
// NewClient constructs the upstream LLMClient for a given provider config.
// "ollama" is handled via the openai-compat adapter: Ollama exposes
// /v1/chat/completions on its own port, so the provider list stays small.
func NewClient(p config.Provider) (llm.LLMClient, error) {
switch p.Type {
case "llamacpp":
return llamacpp.New(llamacpp.Config{
BaseURL: defaultIfEmpty(p.Endpoint, "http://localhost:8080/v1"),
Model: p.Model,
ContextWindow: p.ContextSize,
MaxTokens: p.MaxTokens,
Temperature: p.Temperature,
})
case "ollama", "openai":
return openai.New(openai.Config{
BaseURL: defaultIfEmpty(p.Endpoint, "http://localhost:11434/v1"),
Model: p.Model,
})
case "anthropic":
apiKey := ""
if p.APIKeyEnv != "" {
apiKey = os.Getenv(p.APIKeyEnv)
}
return anthropic.New(anthropic.Config{
APIKey: apiKey,
Model: p.Model,
})
default:
return nil, fmt.Errorf("unknown provider type %q (supported: llamacpp, ollama, openai, anthropic)", p.Type)
}
}
func defaultIfEmpty(s, def string) string {
if s == "" {
return def
}
return s
}

125
internal/agent/runner.go Normal file
View file

@ -0,0 +1,125 @@
// Package agent wraps the LLM client + RAG pipeline behind a single
// streaming call the HTTP handler can drive.
//
// We use llm.LLMClient directly (not agent.Loop) because the bot is a
// straight Q&A flow: no tools, no multi-iteration reasoning. Calling the
// underlying provider keeps the prompt under our full control so we can
// inject RAG context into the system message exactly where we want it.
package agent
import (
"context"
"fmt"
"iter"
"strings"
"github.com/VictorVargas/rony-llm-agent/pkg/llm"
llmpersona "github.com/VictorVargas/rony-llm-agent/pkg/persona"
botpersona "github.com/VictorVargas/rony-chat-bot/internal/persona"
"github.com/VictorVargas/rony-chat-bot/internal/portfolio"
)
// Message aliases keep the HTTP handler decoupled from the upstream types.
type Message = llm.Message
type Role = llm.Role
const (
RoleSystem = llm.RoleSystem
RoleUser = llm.RoleUser
RoleAssistant = llm.RoleAssistant
)
// Runner ties together an LLM client, the system prompt, and the RAG store.
// The persona struct is kept only for the UI greeting (its name + intro);
// the system prompt itself lives in the YAML and is passed in directly.
type Runner struct {
client llm.LLMClient
persona llmpersona.Persona
systemPrompt string
store *portfolio.Store
topK int
usage *Usage
}
type Usage struct {
InputTokens int
OutputTokens int
}
// New returns a Runner. The store may be nil (RAG disabled).
// systemPrompt is the full hand-written prompt from configs/...yaml.
func New(client llm.LLMClient, p llmpersona.Persona, systemPrompt string, store *portfolio.Store, topK int) *Runner {
return &Runner{client: client, persona: p, systemPrompt: systemPrompt, store: store, topK: topK, usage: &Usage{}}
}
// LastUsage returns the token usage recorded on the most recent call.
func (r *Runner) LastUsage() Usage { return *r.usage }
// BuildMessages prepares the system prompt and turns the chat history into
// the upstream message list. The system prompt includes RAG context for the
// user's last message (if RAG is enabled).
func (r *Runner) BuildMessages(ctx context.Context, history []Message) ([]Message, string, error) {
ragContext := ""
if r.store != nil && len(history) > 0 {
last := history[len(history)-1]
if last.Role == RoleUser {
hits, err := r.store.Search(ctx, last.Content, r.topK)
if err != nil {
return nil, "", fmt.Errorf("rag search: %w", err)
}
if len(hits) > 0 {
ragContext = formatHits(hits)
}
}
}
// The system prompt comes from the YAML, not from the persona struct.
// Keep the persona around only for the UI greeting.
system := botpersona.BuildSystemPrompt(r.systemPrompt, ragContext)
msgs := make([]Message, 0, len(history)+1)
msgs = append(msgs, Message{Role: RoleSystem, Content: system})
msgs = append(msgs, history...)
return msgs, ragContext, nil
}
// Stream runs the model and yields each streamed chunk. The caller
// forwards chunk.Delta to the SSE stream; chunk.Usage on the last chunk
// carries token counts.
func (r *Runner) Stream(ctx context.Context, history []Message) iter.Seq2[llm.StreamChunk, error] {
return func(yield func(llm.StreamChunk, error) bool) {
msgs, _, err := r.BuildMessages(ctx, history)
if err != nil {
yield(llm.StreamChunk{}, err)
return
}
req := llm.CompletionRequest{
Messages: msgs,
// No tools: this is a Q&A bot, not an agent.
}
for chunk, err := range r.client.Stream(ctx, req) {
if chunk.Usage.TotalTokens > 0 || chunk.Usage.InputTokens > 0 || chunk.Usage.OutputTokens > 0 {
r.usage = &Usage{
InputTokens: chunk.Usage.InputTokens,
OutputTokens: chunk.Usage.OutputTokens,
}
}
if !yield(chunk, err) {
return
}
if err != nil {
return
}
}
}
}
func formatHits(hits []portfolio.SearchResult) string {
var b strings.Builder
for i, h := range hits {
fmt.Fprintf(&b, "### [%d] %s — %s\n", i+1, h.ProjectID, h.Section)
b.WriteString(h.Content)
b.WriteString("\n\n")
}
return b.String()
}

View file

@ -0,0 +1,112 @@
package agent
import (
"context"
"iter"
"strings"
"testing"
"github.com/VictorVargas/rony-llm-agent/pkg/llm"
llmpersona "github.com/VictorVargas/rony-llm-agent/pkg/persona"
"github.com/VictorVargas/rony-chat-bot/internal/portfolio"
)
// stubClient is a minimal llm.LLMClient that echoes the system prompt's
// last "###" block as a single chunk, then a usage chunk.
type stubClient struct {
gotMessages []llm.Message
}
func (s *stubClient) Generate(_ context.Context, _ llm.CompletionRequest) (llm.CompletionResponse, error) {
return llm.CompletionResponse{Content: "ok", Usage: llm.TokenUsage{InputTokens: 1, OutputTokens: 1}}, nil
}
func (s *stubClient) Stream(_ context.Context, req llm.CompletionRequest) iter.Seq2[llm.StreamChunk, error] {
s.gotMessages = req.Messages
return func(yield func(llm.StreamChunk, error) bool) {
yield(llm.StreamChunk{Delta: "echo: " + req.Messages[len(req.Messages)-1].Content}, nil)
yield(llm.StreamChunk{Delta: " [done]", FinishReason: "stop", Usage: llm.TokenUsage{InputTokens: 7, OutputTokens: 3}}, nil)
}
}
func (s *stubClient) Name() string { return "stub" }
func (s *stubClient) Capabilities() llm.ProviderCapabilities {
return llm.ProviderCapabilities{MaxContextWindow: 4096}
}
func TestRunnerStreamNoRAG(t *testing.T) {
cli := &stubClient{}
p := llmpersona.Persona{Name: "Tester", Tone: "concise", Language: "English"}
r := New(cli, p, "test system prompt", nil, 5)
var got strings.Builder
for chunk, err := range r.Stream(context.Background(), []llm.Message{{Role: RoleUser, Content: "hi"}}) {
if err != nil {
t.Fatal(err)
}
got.WriteString(chunk.Delta)
}
want := "echo: hi [done]"
if got.String() != want {
t.Errorf("got %q, want %q", got.String(), want)
}
}
func TestRunnerStreamWithRAG(t *testing.T) {
cli := &stubClient{}
p := llmpersona.Persona{Name: "Tester", Tone: "concise", Language: "English"}
// Build a tiny on-disk store with one project.
dir := t.TempDir()
dbPath := dir + "/t.db"
srcDir := dir + "/src"
if err := writeFile(srcDir+"/proj.md", "# Demo\n\n## Tech stack\n- Go\n- SQLite database\n"); err != nil {
t.Fatal(err)
}
store, err := portfolio.OpenStore(dbPath)
if err != nil {
t.Fatal(err)
}
defer store.Close()
if _, _, err := store.Reindex(context.Background(), srcDir, portfolio.DefaultChunkerConfig()); err != nil {
t.Fatal(err)
}
r := New(cli, p, "test system prompt", store, 5)
for chunk, err := range r.Stream(context.Background(), []llm.Message{{Role: RoleUser, Content: "What database?"}}) {
if err != nil {
t.Fatal(err)
}
_ = chunk
}
// The system message the LLM saw should include the RAG context.
if len(cli.gotMessages) == 0 {
t.Fatal("LLM never received messages")
}
sys := cli.gotMessages[0].Content
if !strings.Contains(sys, "Relevant context") {
t.Errorf("system prompt missing RAG context block:\n%s", sys)
}
if !strings.Contains(sys, "SQLite") {
t.Errorf("RAG context missing the SQLite content (got: %s)", sys)
}
}
func writeFile(path, content string) error {
if err := mkdirAll(path); err != nil {
return err
}
return writeFileRaw(path, content)
}
// small helpers to avoid importing os/filepath in tests for a 2-call need
func mkdirAll(path string) error { return osMkdirAll(dirOf(path)) }
func dirOf(p string) string {
for i := len(p) - 1; i >= 0; i-- {
if p[i] == '/' {
return p[:i]
}
}
return "."
}

View file

@ -0,0 +1,8 @@
package agent
import "os"
// indirection so the test file doesn't import "os" and "io" directly.
// Keeps the test readable; the helpers are trivial.
func writeFileRaw(path, content string) error { return os.WriteFile(path, []byte(content), 0o644) }
func osMkdirAll(path string) error { return os.MkdirAll(path, 0o755) }

141
internal/config/config.go Normal file
View file

@ -0,0 +1,141 @@
package config
import (
"fmt"
"os"
"strings"
"gopkg.in/yaml.v3"
)
type Server struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
ReadTimeoutMS int `yaml:"read_timeout_ms"`
CORSOrigins []string `yaml:"cors_origins"`
RateLimit RateLimit `yaml:"rate_limit"`
}
type RateLimit struct {
RequestsPerMinute int `yaml:"requests_per_minute"`
Burst int `yaml:"burst"`
}
type Provider struct {
Name string `yaml:"name"`
Type string `yaml:"type"`
Model string `yaml:"model,omitempty"`
ModelPath string `yaml:"model_path,omitempty"`
Endpoint string `yaml:"endpoint,omitempty"`
ContextSize int `yaml:"context_size,omitempty"`
MaxTokens int `yaml:"max_tokens,omitempty"`
NGPULayers int `yaml:"n_gpu_layers,omitempty"`
Temperature float32 `yaml:"temperature,omitempty"`
APIKeyEnv string `yaml:"api_key_env,omitempty"`
Default bool `yaml:"default,omitempty"`
}
type RAG struct {
Enabled bool `yaml:"enabled"`
DataPath string `yaml:"data_path"`
ChunkSize int `yaml:"chunk_size"`
ChunkOverlap int `yaml:"chunk_overlap"`
DBPath string `yaml:"db_path"`
TopK int `yaml:"top_k"`
Tokenize string `yaml:"tokenize"`
}
type Persona struct {
Name string `yaml:"name"`
Tone string `yaml:"tone"`
Language string `yaml:"language"`
Constraints []string `yaml:"constraints"`
Intro string `yaml:"intro"`
}
type Logging struct {
Level string `yaml:"level"`
Format string `yaml:"format"`
Output string `yaml:"output"`
}
type Config struct {
Server Server `yaml:"server"`
Providers []Provider `yaml:"providers"`
RAG RAG `yaml:"rag"`
Persona Persona `yaml:"persona"`
SystemPrompt string `yaml:"system_prompt"`
Logging Logging `yaml:"logging"`
}
func Load(path string) (*Config, error) {
raw, err := os.ReadFile(path)
if err != nil {
return nil, fmt.Errorf("read config %s: %w", path, err)
}
expanded := os.ExpandEnv(string(raw))
var cfg Config
if err := yaml.Unmarshal([]byte(expanded), &cfg); err != nil {
return nil, fmt.Errorf("parse config %s: %w", path, err)
}
if err := cfg.validate(); err != nil {
return nil, err
}
return &cfg, nil
}
func (c *Config) validate() error {
if len(c.Providers) == 0 {
return fmt.Errorf("config: at least one provider must be configured")
}
defaultCount := 0
for _, p := range c.Providers {
if p.Default {
defaultCount++
}
}
if defaultCount == 0 {
c.Providers[0].Default = true
} else if defaultCount > 1 {
return fmt.Errorf("config: multiple providers marked as default")
}
if c.Server.Port == 0 {
c.Server.Port = 7331
}
if c.Server.ReadTimeoutMS == 0 {
c.Server.ReadTimeoutMS = 30000
}
if c.RAG.ChunkSize == 0 {
c.RAG.ChunkSize = 500
}
if c.RAG.ChunkOverlap == 0 {
c.RAG.ChunkOverlap = 50
}
if c.RAG.TopK == 0 {
c.RAG.TopK = 5
}
if c.RAG.Tokenize == "" {
c.RAG.Tokenize = "unicode61"
}
return nil
}
func (c *Config) DefaultProvider() *Provider {
for i := range c.Providers {
if c.Providers[i].Default {
return &c.Providers[i]
}
}
return &c.Providers[0]
}
func (c *Config) Addr() string {
return fmt.Sprintf("%s:%d", c.Server.Host, c.Server.Port)
}
func splitPath(path string) []string {
return strings.Split(path, string(os.PathSeparator))
}

91
internal/i18n/i18n.go Normal file
View file

@ -0,0 +1,91 @@
package i18n
import (
"strings"
"unicode"
)
var (
esStopwords = map[string]bool{
"el": true, "la": true, "los": true, "las": true, "de": true,
"que": true, "y": true, "en": true, "un": true, "una": true,
"es": true, "se": true, "no": true, "con": true, "para": true,
"por": true, "su": true, "del": true, "al": true, "lo": true,
"qué": true, "cómo": true, "dónde": true, "cuándo": true,
"cuál": true, "cuáles": true, "quién": true, "habla": true,
"tienes": true, "dime": true, "háblame": true, "sobre": true,
"más": true, "pero": true, "como": true, "este": true, "esta": true,
"estos": true, "estas": true, "ese": true, "esa": true, "aquel": true,
"muy": true, "sin": true, "hay": true, "sí": true, "yo": true,
"tú": true, "él": true, "ella": true, "nosotros": true,
}
enStopwords = map[string]bool{
"the": true, "is": true, "are": true, "of": true, "and": true,
"in": true, "to": true, "a": true, "an": true, "for": true,
"with": true, "on": true, "by": true, "from": true, "what": true,
"which": true, "who": true, "how": true, "when": true, "where": true,
"tell": true, "about": true, "do": true, "does": true, "can": true,
"you": true, "your": true, "have": true, "has": true, "i": true,
"we": true, "they": true, "this": true, "that": true, "these": true,
"those": true, "be": true, "been": true, "will": true, "would": true,
"should": true, "could": true, "my": true, "our": true, "their": true,
}
)
// Detect returns "es" or "en" based on lightweight heuristics. Good enough
// to choose the response language for a portfolio chatbot; the LLM (once
// wired in) is the final authority.
//
// Heuristic:
// - Spanish diacritics or ¿/¡ → +N Spanish markers
// - Tokenize and count stopwords in each language
// - Whichever side wins; tie → English
func Detect(text string) string {
if text == "" {
return "en"
}
markers := 0
for _, c := range text {
switch c {
case '¿', '¡':
markers += 2
case 'ñ':
markers += 2
case 'á', 'é', 'í', 'ó', 'ú', 'ü':
markers++
}
}
es, en := 0, 0
for _, tok := range tokenize(text) {
if esStopwords[tok] {
es++
}
if enStopwords[tok] {
en++
}
}
switch {
case markers >= 2:
return "es"
case es > en:
return "es"
case en > es:
return "en"
default:
return "en"
}
}
func tokenize(s string) []string {
s = strings.ToLower(s)
f := func(c rune) bool {
if unicode.IsLetter(c) || unicode.IsDigit(c) {
return false
}
return true
}
return strings.FieldsFunc(s, f)
}

View file

@ -0,0 +1,24 @@
package i18n
import "testing"
func TestDetect(t *testing.T) {
cases := []struct {
in, want string
}{
{"Hola, ¿qué proyectos tienes?", "es"},
{"háblame de rony-llm-agent", "es"},
{"¿cuáles son tus skills?", "es"},
{"What projects do you have?", "en"},
{"Tell me about rony-llm-agent", "en"},
{"How does the chat bot work?", "en"},
{"lorem ipsum dolor sit amet", "en"}, // no markers → en
{"", "en"},
}
for _, c := range cases {
got := Detect(c.in)
if got != c.want {
t.Errorf("Detect(%q) = %q, want %q", c.in, got, c.want)
}
}
}

View file

@ -0,0 +1,44 @@
// Package persona owns the bot's identity and the system prompt the LLM
// sees. The full prompt text lives in the YAML's `system_prompt` field
// (long, freeform, hand-tuned for the deployment). This package only
// adds the RAG context after it.
package persona
import (
"github.com/VictorVargas/rony-llm-agent/pkg/persona"
"github.com/VictorVargas/rony-chat-bot/internal/config"
)
// FromConfig returns a minimal persona used only for the UI greeting and
// the SSE event metadata. The system prompt itself comes from
// config.SystemPrompt, not from this struct — see BuildSystemPrompt.
func FromConfig(c *config.Config) (persona.Persona, error) {
lang := c.Persona.Language
if lang == "" {
lang = "the user's language"
}
return persona.Persona{
ID: "rony",
Name: c.Persona.Name,
Tone: c.Persona.Tone,
Language: lang,
}, nil
}
// BuildSystemPrompt returns the full prompt for one chat turn:
// 1. The hand-written system prompt from the YAML (who Rony is, how to speak)
// 2. The RAG block (omitted when the index returns no hits)
//
// The RAG block is appended, not prepended, so the persona instructions
// always come first and the LLM never gets the chance to "forget" them.
func BuildSystemPrompt(systemPrompt, ragContext string) string {
out := systemPrompt
if ragContext != "" {
out += "\n\n## Relevant context from the portfolio\n\n" +
"Use these excerpts to answer. Cite the project filename when you reference a detail. " +
"If the excerpts don't contain the answer, say you don't have that information — do not invent.\n\n" +
ragContext
}
return out
}

View file

@ -0,0 +1,173 @@
package portfolio
import (
"regexp"
"strings"
)
// Chunk is one indexable unit of a project document.
type Chunk struct {
ID string
ProjectID string
SourceFile string
Index int
Section string // frontmatter | H1 title | H2 section name
Content string
}
// ChunkerConfig controls the heading-based chunker.
type ChunkerConfig struct {
MaxSectionChars int // sections longer than this are sub-split (default 1000)
MergeUnderChars int // sections shorter than this are merged with the next (default 50)
OverlapChars int // overlap when sub-splitting (default 80)
}
func DefaultChunkerConfig() ChunkerConfig {
return ChunkerConfig{
MaxSectionChars: 1000,
MergeUnderChars: 50,
OverlapChars: 80,
}
}
// SplitMarkdownSections produces chunks using heading boundaries:
// 1. Frontmatter (between leading --- ... ---) → "frontmatter" chunk
// 2. Each H1 (project title) → one chunk
// 3. Each H2 section → one chunk (with its sub-content under H3/H4 etc.)
// 4. Sections > MaxSectionChars are sub-split by size with overlap
// 5. Sections < MergeUnderChars are merged with the next section
func SplitMarkdownSections(markdown string, cfg ChunkerConfig) []section {
if cfg.MaxSectionChars <= 0 {
cfg = DefaultChunkerConfig()
}
if cfg.MergeUnderChars < 0 {
cfg.MergeUnderChars = 0
}
body := markdown
frontmatter := ""
if fm, rest, ok := extractFrontmatter(markdown); ok {
frontmatter = fm
body = rest
}
var sections []section
if frontmatter != "" {
sections = append(sections, section{Heading: "frontmatter", Body: frontmatter})
}
for _, s := range splitByHeadings(body) {
sections = append(sections, s)
}
sections = dropEmpty(sections)
sections = subSplit(sections, cfg.MaxSectionChars, cfg.OverlapChars)
return sections
}
type section struct {
Heading string
Body string
}
var (
frontmatterRe = regexp.MustCompile(`^---\n([\s\S]*?)\n---\n?`)
h1Re = regexp.MustCompile(`(?m)^# [^#].*$`)
)
func extractFrontmatter(s string) (string, string, bool) {
m := frontmatterRe.FindStringSubmatchIndex(s)
if m == nil {
return "", s, false
}
return s[m[2]:m[3]], s[m[1]:], true
}
// splitByHeadings splits the body into sections keyed by H1 or H2.
// H3+ content stays attached to its parent H2 (no extra split), which keeps
// the natural unit coherent: the bot answers about "Description" or "Tech
// stack", not about individual bullet points.
func splitByHeadings(body string) []section {
lines := strings.Split(body, "\n")
var sections []section
var current section
inSection := false
for _, line := range lines {
if isH1(line) || isH2(line) {
if inSection {
sections = append(sections, current)
}
current = section{Heading: strings.TrimSpace(strings.TrimLeft(strings.TrimSpace(line), "# "))}
inSection = true
continue
}
if inSection {
current.Body += line + "\n"
}
}
if inSection {
sections = append(sections, current)
}
// Strip trailing whitespace per section
for i := range sections {
sections[i].Body = strings.TrimRight(sections[i].Body, "\n ")
}
return sections
}
func isH1(l string) bool { return strings.HasPrefix(l, "# ") && !strings.HasPrefix(l, "## ") }
func isH2(l string) bool { return strings.HasPrefix(l, "## ") }
// dropEmpty removes sections whose body is empty or just whitespace.
// Keeps the heading-level chunking honest: a "Description" with no body
// is not a useful retrieval target.
func dropEmpty(sections []section) []section {
out := sections[:0]
for _, s := range sections {
if strings.TrimSpace(s.Body) == "" {
continue
}
out = append(out, s)
}
return out
}
// subSplit breaks down any section whose body exceeds max into overlapping
// slices, preserving the heading as a prefix on each piece so context isn't
// lost mid-section.
func subSplit(sections []section, max, overlap int) []section {
if max <= 0 {
return sections
}
if overlap < 0 || overlap >= max {
overlap = max / 10
}
var out []section
for _, s := range sections {
if len(s.Body) <= max {
out = append(out, s)
continue
}
for i := 0; i < len(s.Body); {
end := i + max
if end > len(s.Body) {
end = len(s.Body)
}
piece := s.Body[i:end]
if i > 0 {
piece = "... " + piece
}
if end < len(s.Body) {
piece = piece + " ..."
}
out = append(out, section{Heading: s.Heading, Body: piece})
if end == len(s.Body) {
break
}
i += max - overlap
}
}
return out
}

View file

@ -0,0 +1,111 @@
package portfolio
import (
"strings"
"testing"
)
func TestSplitMarkdownSections(t *testing.T) {
cases := []struct {
name string
input string
wantCount int
wantHeading []string
}{
{
name: "frontmatter only",
input: `---
title: "Foo"
tags: ["go"]
---
# Foo
Body of foo.`,
wantCount: 2,
wantHeading: []string{"frontmatter", "Foo"},
},
{
name: "no frontmatter, multiple H2s",
input: `# Title
intro paragraph
## Description
description body
## Tech stack
- Go
- SQLite`,
wantCount: 3,
wantHeading: []string{"Title", "Description", "Tech stack"},
},
{
name: "H3 stays under parent H2",
input: `# T
## Section
content
### H3 detail
H3 content stays here`,
wantCount: 1, // T has no body → dropped; H3 content folds into Section
wantHeading: []string{"Section"},
},
{
name: "drop empty section",
input: `## X
ok
## Empty
## Y
content`,
wantCount: 2,
wantHeading: []string{"X", "Y"},
},
{
name: "long H2 sub-splits",
input: "## Long\n" + strings.Repeat("a ", 800),
wantCount: 2, // 2 sub-splits of Long
},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
got := SplitMarkdownSections(c.input, DefaultChunkerConfig())
if len(got) != c.wantCount {
t.Errorf("got %d chunks, want %d. Headings: %v", len(got), c.wantCount, headings(got))
}
if c.wantHeading != nil {
if !equalSlice(headings(got), c.wantHeading) {
t.Errorf("headings = %v, want %v", headings(got), c.wantHeading)
}
}
})
}
}
func headings(sections []section) []string {
out := make([]string, len(sections))
for i, s := range sections {
out[i] = s.Heading
}
return out
}
func equalSlice(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}

View file

@ -0,0 +1,231 @@
package portfolio
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"time"
)
// Conversation is a thread of messages between one user and the bot.
type Conversation struct {
ID string `json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Messages []Message `json:"messages"`
}
// Message is a single turn in a conversation.
type Message struct {
ID int64 `json:"id"`
Role string `json:"role"` // "user" | "assistant" | "system"
Content string `json:"content"`
Sources []string `json:"sources,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// ConversationSummary is the lightweight listing shape (no messages).
type ConversationSummary struct {
ID string `json:"id"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
Preview string `json:"preview"` // first ~80 chars of the first user message
}
const conversationSchema = `
CREATE TABLE IF NOT EXISTS conversations (
id TEXT PRIMARY KEY,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
conversation_id TEXT NOT NULL,
role TEXT NOT NULL,
content TEXT NOT NULL,
sources TEXT, -- JSON array, nullable
created_at INTEGER NOT NULL,
FOREIGN KEY (conversation_id) REFERENCES conversations(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_messages_conv ON messages(conversation_id, id);
`
// ErrConversationNotFound is returned when a conversation ID doesn't exist.
var ErrConversationNotFound = errors.New("conversation not found")
// CreateConversation makes a new empty conversation and returns its ID.
// The ID is a UUID-ish hex string (crypto/rand based) — unguessable in
// practice, so it can serve as the access token for the GET endpoint.
func (s *Store) CreateConversation(ctx context.Context) (string, error) {
id, err := newConvID()
if err != nil {
return "", err
}
now := time.Now().Unix()
if _, err := s.db.ExecContext(ctx,
`INSERT INTO conversations (id, created_at, updated_at) VALUES (?, ?, ?)`,
id, now, now); err != nil {
return "", fmt.Errorf("create conversation: %w", err)
}
return id, nil
}
// TouchConversation updates the updated_at timestamp. Called after every
// message so ListConversations can sort by recency.
func (s *Store) TouchConversation(ctx context.Context, id string) error {
_, err := s.db.ExecContext(ctx,
`UPDATE conversations SET updated_at = ? WHERE id = ?`,
time.Now().Unix(), id)
return err
}
// SaveMessage appends a message to a conversation and bumps updated_at.
// The conversation must exist (use CreateConversation first or pass an
// existing ID). Sources may be nil.
func (s *Store) SaveMessage(ctx context.Context, convID, role, content string, sources []string) error {
if convID == "" {
return errors.New("convID required")
}
var sourcesJSON sql.NullString
if len(sources) > 0 {
b, err := json.Marshal(sources)
if err != nil {
return fmt.Errorf("marshal sources: %w", err)
}
sourcesJSON = sql.NullString{String: string(b), Valid: true}
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx,
`INSERT INTO messages (conversation_id, role, content, sources, created_at) VALUES (?, ?, ?, ?, ?)`,
convID, role, content, sourcesJSON, time.Now().Unix()); err != nil {
return fmt.Errorf("insert message: %w", err)
}
if _, err := tx.ExecContext(ctx,
`UPDATE conversations SET updated_at = ? WHERE id = ?`,
time.Now().Unix(), convID); err != nil {
return fmt.Errorf("touch conversation: %w", err)
}
return tx.Commit()
}
// GetConversation returns a conversation with all its messages in
// chronological order. Returns ErrConversationNotFound if the ID is unknown.
func (s *Store) GetConversation(ctx context.Context, id string) (*Conversation, error) {
var c Conversation
var createdUnix, updatedUnix int64
err := s.db.QueryRowContext(ctx,
`SELECT id, created_at, updated_at FROM conversations WHERE id = ?`, id,
).Scan(&c.ID, &createdUnix, &updatedUnix)
if errors.Is(err, sql.ErrNoRows) {
return nil, ErrConversationNotFound
}
if err != nil {
return nil, fmt.Errorf("select conversation: %w", err)
}
c.CreatedAt = time.Unix(createdUnix, 0).UTC()
c.UpdatedAt = time.Unix(updatedUnix, 0).UTC()
rows, err := s.db.QueryContext(ctx,
`SELECT id, role, content, sources, created_at FROM messages WHERE conversation_id = ? ORDER BY id ASC`, id)
if err != nil {
return nil, fmt.Errorf("select messages: %w", err)
}
defer rows.Close()
for rows.Next() {
var m Message
var sourcesStr sql.NullString
var createdUnix int64
if err := rows.Scan(&m.ID, &m.Role, &m.Content, &sourcesStr, &createdUnix); err != nil {
return nil, err
}
if sourcesStr.Valid {
if err := json.Unmarshal([]byte(sourcesStr.String), &m.Sources); err != nil {
return nil, fmt.Errorf("unmarshal sources: %w", err)
}
}
m.CreatedAt = time.Unix(createdUnix, 0).UTC()
c.Messages = append(c.Messages, m)
}
return &c, rows.Err()
}
// ListConversations returns the most recent conversations, newest first.
// Useful for a "show my chats" UI. Limit caps the result; pass 0 for default
// (50). Each entry includes a short preview from the first user message.
func (s *Store) ListConversations(ctx context.Context, limit int) ([]ConversationSummary, error) {
if limit <= 0 {
limit = 50
}
rows, err := s.db.QueryContext(ctx, `
SELECT c.id, c.created_at, c.updated_at,
(SELECT content FROM messages m
WHERE m.conversation_id = c.id AND m.role = 'user'
ORDER BY m.id ASC LIMIT 1) AS preview
FROM conversations c
ORDER BY c.updated_at DESC
LIMIT ?`, limit)
if err != nil {
return nil, fmt.Errorf("list conversations: %w", err)
}
defer rows.Close()
var out []ConversationSummary
for rows.Next() {
var cs ConversationSummary
var createdUnix, updatedUnix int64
var preview sql.NullString
if err := rows.Scan(&cs.ID, &createdUnix, &updatedUnix, &preview); err != nil {
return nil, err
}
cs.CreatedAt = time.Unix(createdUnix, 0).UTC()
cs.UpdatedAt = time.Unix(updatedUnix, 0).UTC()
if preview.Valid {
cs.Preview = truncateRunes(preview.String, 80)
}
out = append(out, cs)
}
return out, rows.Err()
}
// DeleteConversation removes a conversation and all its messages (cascade).
// Returns ErrConversationNotFound if the ID didn't exist.
func (s *Store) DeleteConversation(ctx context.Context, id string) error {
res, err := s.db.ExecContext(ctx, `DELETE FROM conversations WHERE id = ?`, id)
if err != nil {
return err
}
n, _ := res.RowsAffected()
if n == 0 {
return ErrConversationNotFound
}
return nil
}
func truncateRunes(s string, n int) string {
if len([]rune(s)) <= n {
return s
}
r := []rune(s)
return string(r[:n]) + "…"
}
// newConvID returns a 16-byte random hex string (32 chars). Unguessable
// in practice; doubles as the access token for GET /api/conversations/:id.
func newConvID() (string, error) {
var b [16]byte
if _, err := rand.Read(b[:]); err != nil {
return "", fmt.Errorf("rand: %w", err)
}
return hex.EncodeToString(b[:]), nil
}

View file

@ -0,0 +1,217 @@
// Package portfolio loads and indexes the user's project markdowns for RAG.
//
// Schema decisions are documented in docs/architecture.md §4.0 (driver) and
// §4 (tokenizer, chunking). The tokenize/driver choices are validated by
// ./bench/; chunking was changed from size-based to heading-based after
// inspecting real data/templates in data/projects/.
package portfolio
import (
"context"
"database/sql"
"fmt"
"log/slog"
"os"
"path/filepath"
"strings"
"time"
_ "modernc.org/sqlite"
)
// SearchResult is one hit from the FTS5 index, with a relevance score.
type SearchResult struct {
ID string
ProjectID string
SourceFile string
Section string
Index int
Content string
Score float64
}
const schema = `
CREATE VIRTUAL TABLE IF NOT EXISTS portfolio_chunks USING fts5(
id UNINDEXED,
project_id UNINDEXED,
source_file UNINDEXED,
section UNINDEXED,
chunk_index UNINDEXED,
content,
tokenize = 'unicode61 remove_diacritics 2'
);
`
// Store wraps a SQLite FTS5 database with the portfolio schema.
type Store struct {
db *sql.DB
chunkSize int // legacy field kept for compat; not used by heading chunker
}
func OpenStore(dbPath string) (*Store, error) {
dir := filepath.Dir(dbPath)
if err := os.MkdirAll(dir, 0o755); err != nil {
return nil, fmt.Errorf("create db dir: %w", err)
}
dsn := dbPath + "?_pragma=journal_mode(WAL)&_pragma=synchronous(NORMAL)&_pragma=foreign_keys(1)"
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
db.SetMaxOpenConns(1) // SQLite + concurrent writers doesn't help
if _, err := db.ExecContext(context.Background(), schema); err != nil {
_ = db.Close()
return nil, fmt.Errorf("create schema: %w", err)
}
if _, err := db.ExecContext(context.Background(), conversationSchema); err != nil {
_ = db.Close()
return nil, fmt.Errorf("create conversation schema: %w", err)
}
return &Store{db: db, chunkSize: 500}, nil
}
func (s *Store) Close() error { return s.db.Close() }
// DB exposes the underlying *sql.DB for callers that need to run their own
// queries (e.g. the health check). Don't use for hot-path code: go through
// the Search / Reindex methods.
func (s *Store) DB() *sql.DB { return s.db }
func (s *Store) Reindex(ctx context.Context, dataPath string, cfg ChunkerConfig) (files, chunks int, err error) {
matches, err := filepath.Glob(filepath.Join(dataPath, "*.md"))
if err != nil {
return 0, 0, fmt.Errorf("glob: %w", err)
}
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return 0, 0, err
}
defer tx.Rollback()
if _, err := tx.ExecContext(ctx, `DELETE FROM portfolio_chunks`); err != nil {
return 0, 0, fmt.Errorf("clear: %w", err)
}
stmt, err := tx.PrepareContext(ctx,
`INSERT INTO portfolio_chunks (id, project_id, source_file, section, chunk_index, content) VALUES (?,?,?,?,?,?)`)
if err != nil {
return 0, 0, err
}
defer stmt.Close()
for _, file := range matches {
body, err := os.ReadFile(file)
if err != nil {
slog.Warn("read file failed", "file", file, "err", err)
continue
}
projectID := strings.TrimSuffix(filepath.Base(file), ".md")
sections := SplitMarkdownSections(string(body), cfg)
for idx, sec := range sections {
id := fmt.Sprintf("%s-%s-%d", projectID, slugify(sec.Heading), idx)
if _, err := stmt.ExecContext(ctx, id, projectID, file, sec.Heading, idx, sec.Body); err != nil {
return len(matches), chunks, fmt.Errorf("insert %s: %w", id, err)
}
chunks++
}
files++
}
if err := tx.Commit(); err != nil {
return 0, 0, err
}
return files, chunks, nil
}
func slugify(s string) string {
out := make([]byte, 0, len(s))
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case c >= 'a' && c <= 'z', c >= '0' && c <= '9':
out = append(out, c)
case c >= 'A' && c <= 'Z':
out = append(out, c+32)
case c == ' ' || c == '-' || c == '_':
out = append(out, '_')
}
}
return string(out)
}
// Search returns up to topK chunks ordered by BM25 score.
func (s *Store) Search(ctx context.Context, query string, topK int) ([]SearchResult, error) {
if topK <= 0 {
topK = 5
}
rows, err := s.db.QueryContext(ctx, fmt.Sprintf(`
SELECT id, project_id, source_file, section, chunk_index, content, bm25(portfolio_chunks) AS score
FROM portfolio_chunks
WHERE portfolio_chunks MATCH '%s'
ORDER BY score
LIMIT %d
`, sanitizeFTS5(query), topK))
if err != nil {
return nil, err
}
defer rows.Close()
var hits []SearchResult
for rows.Next() {
var r SearchResult
if err := rows.Scan(&r.ID, &r.ProjectID, &r.SourceFile, &r.Section, &r.Index, &r.Content, &r.Score); err != nil {
return nil, err
}
hits = append(hits, r)
}
return hits, rows.Err()
}
// sanitizeFTS5 escapes special chars, adds prefix-match wildcards, and joins
// tokens with OR (Q&A behavior: "what database" should match a doc that
// contains "database" even when it doesn't contain "what"). FTS5 doesn't
// accept `?` placeholders for MATCH in driver-prepared statements; this
// inlines the escaped query.
func sanitizeFTS5(q string) string {
tokens := strings.FieldsFunc(strings.ToLower(q), func(r rune) bool {
return !(r == '-' || r == '_' || r == '.' || r == '+' ||
(r >= '0' && r <= '9') ||
(r >= 'a' && r <= 'z') ||
r > 0x7F)
})
if len(tokens) == 0 {
return `""`
}
// Drop a tiny stopword list so "what is" doesn't dominate the OR
// (these match every doc and dilute BM25 ranking).
keep := tokens[:0]
for _, t := range tokens {
switch t {
case "what", "which", "who", "how", "when", "where", "is", "are",
"do", "does", "can", "tell", "about", "the", "a", "an":
continue
}
keep = append(keep, t)
}
if len(keep) == 0 {
// All tokens were stopwords — fall back to the original set.
keep = tokens
}
for i, t := range keep {
keep[i] = `"` + t + `"*`
}
return strings.Join(keep, " OR ")
}
// ReindexOnDisk is a small convenience that opens the store, reindexes, and
// closes — used by the CLI subcommand.
func ReindexOnDisk(dbPath, dataPath string, cfg ChunkerConfig) (time.Duration, int, int, error) {
start := time.Now()
store, err := OpenStore(dbPath)
if err != nil {
return 0, 0, 0, err
}
defer store.Close()
files, chunks, err := store.Reindex(context.Background(), dataPath, cfg)
return time.Since(start), files, chunks, err
}

View file

@ -0,0 +1,116 @@
package portfolio
import (
"context"
"os"
"path/filepath"
"strings"
"testing"
)
func TestReindexAndSearch(t *testing.T) {
// Use a temp dir so we don't pollute data/projects
dir := t.TempDir()
srcDir := filepath.Join(dir, "src")
if err := os.MkdirAll(srcDir, 0o755); err != nil {
t.Fatal(err)
}
// Copy the real example-project.md
data, err := os.ReadFile("../../data/projects/example-project.md")
if err != nil {
t.Skip("example-project.md not found, skipping integration test:", err)
}
if err := os.WriteFile(filepath.Join(srcDir, "example-project.md"), data, 0o644); err != nil {
t.Fatal(err)
}
dbPath := filepath.Join(dir, "test.db")
store, err := OpenStore(dbPath)
if err != nil {
t.Fatal(err)
}
defer store.Close()
files, chunks, err := store.Reindex(context.Background(), srcDir, DefaultChunkerConfig())
if err != nil {
t.Fatal(err)
}
if files != 1 {
t.Errorf("files = %d, want 1", files)
}
if chunks < 4 {
t.Errorf("chunks = %d, want >= 4 (frontmatter + 4 H2 sections)", chunks)
}
// Search for something specific to the file
hits, err := store.Search(context.Background(), "Go SQLite framework", 5)
if err != nil {
t.Fatal(err)
}
if len(hits) == 0 {
t.Error("expected at least one hit for 'Go SQLite framework'")
}
t.Logf("Search hits: %d", len(hits))
for i, h := range hits {
t.Logf(" [%d] project=%s section=%s score=%.3f body=%q", i, h.ProjectID, h.Section, h.Score, truncate(h.Content, 80))
}
// No results for nonsense
hits, err = store.Search(context.Background(), "asdfqwerty12345", 5)
if err != nil {
t.Fatal(err)
}
if len(hits) != 0 {
t.Errorf("expected 0 hits for nonsense query, got %d", len(hits))
}
}
func TestReindexFromRealData(t *testing.T) {
// Index the real data/projects/ to sanity-check on real data
srcDir := "../../data/projects"
if _, err := os.Stat(srcDir); err != nil {
t.Skip("data/projects not found:", err)
}
dir := t.TempDir()
dbPath := filepath.Join(dir, "real.db")
store, err := OpenStore(dbPath)
if err != nil {
t.Fatal(err)
}
defer store.Close()
files, chunks, err := store.Reindex(context.Background(), srcDir, DefaultChunkerConfig())
if err != nil {
t.Fatal(err)
}
t.Logf("Indexed %d files, %d chunks", files, chunks)
if files == 0 {
t.Fatal("no files indexed")
}
// Print what we got
sections := map[string]int{}
_, _ = store.Search(context.Background(), "rony", 100) // noop
// Walk DB to see sections — easier: just re-read with a fresh load
_ = sections
// Verify queries work
for _, q := range []string{"rony-llm-agent", "Qwen", "agent harness", "tech stack", "deploy"} {
hits, err := store.Search(context.Background(), q, 3)
if err != nil {
t.Fatal(err)
}
t.Logf("query %q → %d hits", q, len(hits))
for _, h := range hits {
if !strings.Contains(strings.ToLower(h.Content), strings.ToLower(strings.SplitN(q, "-", 2)[0])) {
// FTS5 might do prefix matches; this is a soft assertion
}
}
}
}
func truncate(s string, n int) string {
s = strings.ReplaceAll(s, "\n", " ")
if len(s) <= n {
return s
}
return s[:n] + "..."
}

View file

@ -0,0 +1,214 @@
package server
import (
"encoding/json"
"net/http"
"strings"
"testing"
"time"
"github.com/VictorVargas/rony-chat-bot/internal/portfolio"
)
func TestConversationRoundTrip(t *testing.T) {
url, _ := newTestServer(t)
// 1) POST /api/chat with a brand-new session — server creates a conv.
body := strings.NewReader(`{
"messages":[{"role":"user","content":"hola, humano"}],
"stream":false
}`)
resp, err := http.Post(url+"/api/chat", "application/json", body)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("status = %d, want 200", resp.StatusCode)
}
var cr ChatResponse
if err := json.NewDecoder(resp.Body).Decode(&cr); err != nil {
t.Fatal(err)
}
if cr.ConversationID == "" {
t.Fatal("response should include a conversation_id")
}
if cr.Content == "" {
t.Error("response content is empty")
}
// 2) GET /api/conversations/:id — should return both user + assistant messages.
resp2, err := http.Get(url + "/api/conversations/" + cr.ConversationID)
if err != nil {
t.Fatal(err)
}
defer resp2.Body.Close()
if resp2.StatusCode != 200 {
t.Fatalf("get conversation status = %d", resp2.StatusCode)
}
var conv portfolio.Conversation
if err := json.NewDecoder(resp2.Body).Decode(&conv); err != nil {
t.Fatal(err)
}
if conv.ID != cr.ConversationID {
t.Errorf("id = %q, want %q", conv.ID, cr.ConversationID)
}
if len(conv.Messages) < 2 {
t.Fatalf("expected >= 2 messages, got %d", len(conv.Messages))
}
if conv.Messages[0].Role != "user" || conv.Messages[0].Content != "hola, humano" {
t.Errorf("first message = %+v", conv.Messages[0])
}
if conv.Messages[1].Role != "assistant" {
t.Errorf("second message role = %q, want assistant", conv.Messages[1].Role)
}
if conv.Messages[1].Content == "" {
t.Error("assistant message content is empty")
}
// Created + updated timestamps should be set
if conv.CreatedAt.IsZero() {
t.Error("created_at is zero")
}
if conv.UpdatedAt.Before(conv.CreatedAt) {
t.Errorf("updated_at (%s) < created_at (%s)", conv.UpdatedAt, conv.CreatedAt)
}
}
func TestConversationContinue(t *testing.T) {
url, _ := newTestServer(t)
// First turn: create the conversation.
body := strings.NewReader(`{
"messages":[{"role":"user","content":"primera"}],
"stream":false
}`)
resp, _ := http.Post(url+"/api/chat", "application/json", body)
var cr1 ChatResponse
_ = json.NewDecoder(resp.Body).Decode(&cr1)
resp.Body.Close()
// Second turn: pass the same conversation_id and add a new user message.
body2 := strings.NewReader(`{
"messages":[{"role":"user","content":"primera"},
{"role":"assistant","content":"respuesta 1"},
{"role":"user","content":"segunda"}],
"conversation_id":"` + cr1.ConversationID + `",
"stream":false
}`)
resp2, _ := http.Post(url+"/api/chat", "application/json", body2)
var cr2 ChatResponse
_ = json.NewDecoder(resp2.Body).Decode(&cr2)
resp2.Body.Close()
if cr2.ConversationID != cr1.ConversationID {
t.Errorf("server changed the conversation id: %q → %q", cr1.ConversationID, cr2.ConversationID)
}
// GET should now have 4 messages: user1, assistant1, user2, assistant2.
resp3, _ := http.Get(url + "/api/conversations/" + cr1.ConversationID)
var conv portfolio.Conversation
_ = json.NewDecoder(resp3.Body).Decode(&conv)
resp3.Body.Close()
if len(conv.Messages) != 4 {
t.Errorf("expected 4 messages, got %d", len(conv.Messages))
}
}
func TestListConversations(t *testing.T) {
url, _ := newTestServer(t)
// Create two conversations.
for _, msg := range []string{"primera conversación", "segunda conversación"} {
body := strings.NewReader(`{"messages":[{"role":"user","content":"` + msg + `"}],"stream":false}`)
resp, _ := http.Post(url+"/api/chat", "application/json", body)
resp.Body.Close()
}
resp, err := http.Get(url + "/api/conversations")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("status = %d", resp.StatusCode)
}
var list struct {
Count int `json:"count"`
Conversations []portfolio.ConversationSummary `json:"conversations"`
}
if err := json.NewDecoder(resp.Body).Decode(&list); err != nil {
t.Fatal(err)
}
if list.Count < 2 {
t.Errorf("count = %d, want >= 2", list.Count)
}
}
func TestGetConversationNotFound(t *testing.T) {
url, _ := newTestServer(t)
resp, err := http.Get(url + "/api/conversations/nonexistent")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 404 {
t.Errorf("status = %d, want 404", resp.StatusCode)
}
}
func TestDeleteConversation(t *testing.T) {
url, _ := newTestServer(t)
body := strings.NewReader(`{"messages":[{"role":"user","content":"to be deleted"}],"stream":false}`)
resp, _ := http.Post(url+"/api/chat", "application/json", body)
var cr ChatResponse
_ = json.NewDecoder(resp.Body).Decode(&cr)
resp.Body.Close()
del, err := http.NewRequest("DELETE", url+"/api/conversations/"+cr.ConversationID, nil)
if err != nil {
t.Fatal(err)
}
delResp, err := http.DefaultClient.Do(del)
if err != nil {
t.Fatal(err)
}
delResp.Body.Close()
if delResp.StatusCode != 204 {
t.Errorf("delete status = %d, want 204", delResp.StatusCode)
}
// GET should now 404.
get, _ := http.Get(url + "/api/conversations/" + cr.ConversationID)
defer get.Body.Close()
if get.StatusCode != 404 {
t.Errorf("get after delete status = %d, want 404", get.StatusCode)
}
}
func TestChatStreamingIncludesConversationID(t *testing.T) {
url, _ := newTestServer(t)
body := strings.NewReader(`{"messages":[{"role":"user","content":"stream test"}],"stream":true}`)
resp, err := http.Post(url+"/api/chat", "application/json", body)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
events := parseSSE(t, resp.Body)
if len(events) == 0 {
t.Fatal("no SSE events received")
}
if events[0].event != "start" {
t.Fatalf("first event = %q, want start", events[0].event)
}
var start map[string]any
if err := json.Unmarshal([]byte(events[0].data), &start); err != nil {
t.Fatal(err)
}
if start["conversation_id"] == "" || start["conversation_id"] == nil {
t.Error("start event missing conversation_id")
}
}
// keep the import used
var _ = time.Second

421
internal/server/handlers.go Normal file
View file

@ -0,0 +1,421 @@
package server
import (
"context"
"encoding/json"
"errors"
"fmt"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"github.com/VictorVargas/rony-chat-bot/internal/agent"
"github.com/VictorVargas/rony-chat-bot/internal/config"
"github.com/VictorVargas/rony-chat-bot/internal/portfolio"
"github.com/VictorVargas/rony-chat-bot/internal/streaming"
)
type Handlers struct {
cfg *config.Config
runner *agent.Runner
store *portfolio.Store
version string
}
func NewHandlers(cfg *config.Config, runner *agent.Runner, store *portfolio.Store, version string) *Handlers {
return &Handlers{cfg: cfg, runner: runner, store: store, version: version}
}
type ChatRequest struct {
Messages []ChatMessage `json:"messages"`
Stream *bool `json:"stream,omitempty"`
// ConversationID is optional. If empty, the server creates a new
// conversation and returns its ID in the response (or in the SSE
// `start` event). Pass an existing ID to continue a previous thread.
ConversationID string `json:"conversation_id,omitempty"`
}
type ChatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type ChatResponse struct {
ConversationID string `json:"conversation_id"`
Content string `json:"content"`
Sources []string `json:"sources,omitempty"`
Usage streaming.Usage `json:"usage"`
}
func (h *Handlers) Chat(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
var req ChatRequest
if err := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20)).Decode(&req); err != nil {
http.Error(w, "invalid JSON body: "+err.Error(), http.StatusBadRequest)
return
}
if err := req.validate(); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
stream := true
if req.Stream != nil {
stream = *req.Stream
}
// Resolve or create the conversation. If the client passed a non-empty
// ID we use it as-is; otherwise we mint a new one.
ctx := r.Context()
convID := req.ConversationID
if convID == "" {
var err error
convID, err = h.store.CreateConversation(ctx)
if err != nil {
http.Error(w, "create conversation: "+err.Error(), http.StatusInternalServerError)
return
}
}
// Persist the incoming user message. Even if the LLM fails afterward
// the user sees their question in the conversation history.
if userMsg := lastUserMessage(req.Messages); userMsg != "" {
if err := h.store.SaveMessage(ctx, convID, "user", userMsg, nil); err != nil {
slog.Error("save user message", "err", err)
}
}
history := toAgentMessages(req.Messages)
if stream {
h.streamChat(w, r, history, convID)
return
}
h.completeChat(w, r, history, convID)
}
func lastUserMessage(msgs []ChatMessage) string {
for i := len(msgs) - 1; i >= 0; i-- {
if msgs[i].Role == "user" {
return msgs[i].Content
}
}
return ""
}
func toAgentMessages(in []ChatMessage) []agent.Message {
out := make([]agent.Message, len(in))
for i, m := range in {
out[i] = agent.Message{
Role: agent.Role(m.Role),
Content: m.Content,
}
}
return out
}
func (req *ChatRequest) validate() error {
if len(req.Messages) == 0 {
return errors.New("messages must not be empty")
}
for i, m := range req.Messages {
switch m.Role {
case "user", "assistant", "system":
default:
return fmt.Errorf("messages[%d].role %q is invalid", i, m.Role)
}
if strings.TrimSpace(m.Content) == "" {
return fmt.Errorf("messages[%d].content is empty", i)
}
}
return nil
}
// streamChat drives the LLM agent and forwards each chunk to the SSE
// stream using the protocol described in docs/architecture.md §3.2.
// convID is the persistent conversation ID — passed in from Chat() after
// resolve-or-create. The assistant reply is saved on success.
func (h *Handlers) streamChat(w http.ResponseWriter, r *http.Request, history []agent.Message, convID string) {
w.Header().Set("Content-Type", "text/event-stream")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
w.Header().Set("X-Accel-Buffering", "no")
w.WriteHeader(http.StatusOK)
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "streaming unsupported", http.StatusInternalServerError)
return
}
ctx := r.Context()
if err := streaming.WriteStart(w, convID); err != nil {
slog.Error("sse start failed", "err", err)
return
}
flusher.Flush()
// Report which RAG sources were used (search happens in BuildMessages).
_, ragContext, err := h.runner.BuildMessages(ctx, history)
if err != nil {
_ = streaming.WriteError(w, "rag: "+err.Error())
return
}
var sources []string
if ragContext != "" {
sources = extractSources(ragContext)
_ = streaming.WriteSources(w, sources)
}
// Stream from the model, collecting the full reply as we go.
var full strings.Builder
for chunk, err := range h.runner.Stream(ctx, history) {
if err != nil {
slog.Error("llm stream", "err", err)
_ = streaming.WriteError(w, "llm: "+err.Error())
return
}
if chunk.Delta != "" {
full.WriteString(chunk.Delta)
if err := streaming.WriteChunk(w, chunk.Delta); err != nil {
return
}
}
if chunk.FinishReason != "" && chunk.Usage.TotalTokens > 0 {
h.persistAssistant(ctx, convID, full.String(), sources)
_ = streaming.WriteDone(w, streaming.Usage{
InputTokens: chunk.Usage.InputTokens,
OutputTokens: chunk.Usage.OutputTokens,
})
return
}
}
// Final usage chunk may come on the last iteration; if we never saw a
// finish_reason + usage in-band, surface what we recorded.
usage := h.runner.LastUsage()
h.persistAssistant(ctx, convID, full.String(), sources)
_ = streaming.WriteDone(w, streaming.Usage{
InputTokens: usage.InputTokens,
OutputTokens: usage.OutputTokens,
})
}
// persistAssistant saves the full assistant reply. Best-effort: a failure
// here doesn't fail the user's request (the response is already streamed).
func (h *Handlers) persistAssistant(ctx context.Context, convID, content string, sources []string) {
if strings.TrimSpace(content) == "" {
return
}
if err := h.store.SaveMessage(ctx, convID, "assistant", content, sources); err != nil {
slog.Error("save assistant message", "err", err, "conv", convID)
}
}
func (h *Handlers) completeChat(w http.ResponseWriter, r *http.Request, history []agent.Message, convID string) {
w.Header().Set("Content-Type", "application/json")
ctx := r.Context()
var full strings.Builder
var sources []string
for chunk, err := range h.runner.Stream(ctx, history) {
if err != nil {
http.Error(w, "llm: "+err.Error(), http.StatusBadGateway)
return
}
full.WriteString(chunk.Delta)
}
usage := h.runner.LastUsage()
_, ragContext, _ := h.runner.BuildMessages(ctx, history)
if ragContext != "" {
sources = extractSources(ragContext)
}
h.persistAssistant(ctx, convID, full.String(), sources)
resp := ChatResponse{
ConversationID: convID,
Content: full.String(),
Usage: streaming.Usage{
InputTokens: usage.InputTokens,
OutputTokens: usage.OutputTokens,
},
Sources: sources,
}
_ = json.NewEncoder(w).Encode(resp)
}
func extractSources(ragContext string) []string {
// The formatted hits look like "### [N] projectID — section\n..."
// so we scan the lines for that prefix and dedupe by projectID.
seen := map[string]bool{}
var out []string
for _, line := range strings.Split(ragContext, "\n") {
if !strings.HasPrefix(line, "### [") {
continue
}
// between "### [N] " and " — "
rest := strings.TrimPrefix(line, "### [")
rest = rest[strings.Index(rest, " ")+1:]
project := rest
if i := strings.Index(rest, " — "); i > 0 {
project = rest[:i]
}
if !seen[project] {
seen[project] = true
out = append(out, project)
}
}
return out
}
func truncate(s string, n int) string {
if len(s) <= n {
return s
}
return s[:n] + "…"
}
func (h *Handlers) Info(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json")
p := h.cfg.DefaultProvider()
_ = json.NewEncoder(w).Encode(map[string]any{
"name": h.cfg.Persona.Name,
"version": h.version,
"provider": p.Type,
"model": firstNonEmpty(p.Model, p.ModelPath),
"rag": h.cfg.RAG.Enabled,
"top_k": h.cfg.RAG.TopK,
"started": time.Now().UTC().Format(time.RFC3339),
})
}
func (h *Handlers) Reindex(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
start := time.Now()
dbPath := h.cfg.RAG.DBPath
if dbPath == "" {
http.Error(w, "rag.db_path not configured", http.StatusBadRequest)
return
}
dur, files, chunks, err := portfolio.ReindexOnDisk(dbPath, h.cfg.RAG.DataPath, portfolio.DefaultChunkerConfig())
if err != nil {
http.Error(w, "reindex failed: "+err.Error(), http.StatusInternalServerError)
return
}
_ = dur
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"indexed_files": files,
"total_chunks": chunks,
"duration_ms": time.Since(start).Milliseconds(),
"db_path": dbPath,
})
}
// ---- Conversation REST endpoints -------------------------------------------
// GetConversation returns the full history of one conversation. The
// conversation ID is treated as a bearer token: anyone who knows it can
// read the history. For a public bot this is fine; for private contexts
// add auth at the proxy layer.
func (h *Handlers) GetConversation(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
id := conversationIDFromPath(r.URL.Path)
if id == "" {
http.Error(w, "missing conversation id", http.StatusBadRequest)
return
}
conv, err := h.store.GetConversation(r.Context(), id)
if errors.Is(err, portfolio.ErrConversationNotFound) {
http.Error(w, "conversation not found", http.StatusNotFound)
return
}
if err != nil {
http.Error(w, "get conversation: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(conv)
}
// ListConversations returns the most recent N conversation summaries.
// Useful for a "show my chats" sidebar in the widget or a custom UI.
func (h *Handlers) ListConversations(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodGet {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
limit := 50
if s := r.URL.Query().Get("limit"); s != "" {
if n, err := strconv.Atoi(s); err == nil && n > 0 && n <= 200 {
limit = n
}
}
items, err := h.store.ListConversations(r.Context(), limit)
if err != nil {
http.Error(w, "list conversations: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "application/json")
if items == nil {
items = []portfolio.ConversationSummary{}
}
_ = json.NewEncoder(w).Encode(map[string]any{
"conversations": items,
"count": len(items),
})
}
// DeleteConversation removes a conversation and all its messages.
func (h *Handlers) DeleteConversation(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodDelete {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
id := conversationIDFromPath(r.URL.Path)
if id == "" {
http.Error(w, "missing conversation id", http.StatusBadRequest)
return
}
err := h.store.DeleteConversation(r.Context(), id)
if errors.Is(err, portfolio.ErrConversationNotFound) {
http.Error(w, "conversation not found", http.StatusNotFound)
return
}
if err != nil {
http.Error(w, "delete conversation: "+err.Error(), http.StatusInternalServerError)
return
}
w.WriteHeader(http.StatusNoContent)
}
// conversationIDFromPath pulls the ID out of "/api/conversations/{id}".
// chi does this with chi.URLParam(r, "id"); for clarity (and so this
// handler works even without chi) we do it by hand.
func conversationIDFromPath(path string) string {
const prefix = "/api/conversations/"
if !strings.HasPrefix(path, prefix) {
return ""
}
id := strings.TrimPrefix(path, prefix)
// strip trailing slash and any further segments
if i := strings.IndexByte(id, '/'); i >= 0 {
id = id[:i]
}
return id
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if v != "" {
return v
}
}
return ""
}

188
internal/server/health.go Normal file
View file

@ -0,0 +1,188 @@
package server
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"time"
)
// HealthResponse is the shape returned by /api/health.
//
// Status is "healthy" when every component is up, "degraded" when non-critical
// components are down, "unhealthy" when the bot cannot serve. The HTTP code
// is 200 for healthy, 503 for unhealthy. Degraded returns 200 (the bot can
// still answer, just without RAG or with no LLM).
type HealthResponse struct {
Status string `json:"status"`
Version string `json:"version"`
CheckedAt string `json:"checked_at"`
Components map[string]ComponentHealth `json:"components"`
}
// ComponentHealth reports the state of one dependency. Latency is a
// human-readable duration ("12ms"); Error is set only when Status="down".
type ComponentHealth struct {
Status string `json:"status"` // "up" | "down"
Latency string `json:"latency,omitempty"` // e.g. "12ms"
Error string `json:"error,omitempty"`
Details map[string]any `json:"details,omitempty"`
}
// healthCheckTimeout caps each component probe. The whole /api/health call
// finishes in roughly this duration even if a dependency is hung.
const healthCheckTimeout = 2 * time.Second
func (h *Handlers) Health(w http.ResponseWriter, r *http.Request) {
// `?deep=true` adds an FTS5 row count to the store probe; same latency
// budget. Cheap enough that we don't bother splitting into two endpoints.
deep := r.URL.Query().Get("deep") == "true"
// Pad the context so even a slow store doesn't kill the request before
// the LLM probe finishes.
ctx, cancel := context.WithTimeout(r.Context(), healthCheckTimeout+500*time.Millisecond)
defer cancel()
// Run both probes in parallel so a slow LLM doesn't delay the store
// check (and vice versa).
type probe struct {
name string
fn func(context.Context) ComponentHealth
}
results := make(map[string]ComponentHealth, 2)
var mu sync.Mutex
var wg sync.WaitGroup
for _, p := range []probe{
{"llm", h.checkLLM},
{"store", func(ctx context.Context) ComponentHealth { return h.checkStore(ctx, deep) }},
} {
wg.Add(1)
p := p
go func() {
defer wg.Done()
r := p.fn(ctx)
mu.Lock()
results[p.name] = r
mu.Unlock()
}()
}
wg.Wait()
resp := HealthResponse{
Status: "healthy",
Version: h.version,
CheckedAt: time.Now().UTC().Format(time.RFC3339),
Components: results,
}
// Decide overall status. LLM is critical; store is non-critical
// (RAG degrades to "I don't have that information" when it can't be read).
httpStatus := http.StatusOK
if results["llm"].Status != "up" {
resp.Status = "unhealthy"
httpStatus = http.StatusServiceUnavailable
} else if results["store"].Status != "up" {
resp.Status = "degraded"
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(httpStatus)
_ = json.NewEncoder(w).Encode(resp)
}
// checkLLM pings the provider's health endpoint with a short timeout.
func (h *Handlers) checkLLM(parent context.Context) ComponentHealth {
p := h.cfg.DefaultProvider()
healthURL := deriveHealthURL(p.Endpoint, p.Type)
if healthURL == "" {
return ComponentHealth{
Status: "down",
Error: "no health endpoint known for provider type " + p.Type,
Details: map[string]any{"provider": p.Type, "model": p.Model},
}
}
ctx, cancel := context.WithTimeout(parent, healthCheckTimeout)
defer cancel()
start := time.Now()
req, err := http.NewRequestWithContext(ctx, http.MethodGet, healthURL, nil)
if err != nil {
return ComponentHealth{Status: "down", Error: err.Error(), Latency: time.Since(start).String()}
}
resp, err := http.DefaultClient.Do(req)
latency := time.Since(start)
if err != nil {
return ComponentHealth{Status: "down", Error: err.Error(), Latency: latency.String()}
}
defer resp.Body.Close()
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
return ComponentHealth{
Status: "up",
Latency: latency.String(),
Details: map[string]any{"provider": p.Type, "model": p.Model, "url": healthURL},
}
}
return ComponentHealth{
Status: "down",
Latency: latency.String(),
Error: fmt.Sprintf("HTTP %d", resp.StatusCode),
Details: map[string]any{"url": healthURL},
}
}
// deriveHealthURL maps a provider's chat-completions URL to its health URL.
// Returns "" when the provider has no usable health probe.
func deriveHealthURL(endpoint, providerType string) string {
if endpoint == "" {
return ""
}
// Strip trailing /v1, /chat/completions, etc., to get the base.
base := endpoint
for _, suffix := range []string{"/v1/chat/completions", "/chat/completions", "/v1"} {
if strings.HasSuffix(base, suffix) {
base = strings.TrimSuffix(base, suffix)
break
}
}
switch providerType {
case "llamacpp", "ollama":
// Both expose /health at the root.
return base + "/health"
case "openai":
// OpenAI doesn't have /health, but /models is auth-light and stable.
return base + "/models"
default:
// anthropic and unknown: no cheap probe without a real request.
return ""
}
}
// checkStore pings the SQLite store with a trivial query. In deep mode it
// also counts indexed chunks to confirm the FTS5 schema is alive.
func (h *Handlers) checkStore(parent context.Context, deep bool) ComponentHealth {
if h.store == nil {
return ComponentHealth{Status: "down", Error: "store not configured"}
}
ctx, cancel := context.WithTimeout(parent, healthCheckTimeout)
defer cancel()
start := time.Now()
var n int
err := h.store.DB().QueryRowContext(ctx, "SELECT 1").Scan(&n)
latency := time.Since(start)
if err != nil {
return ComponentHealth{Status: "down", Error: err.Error(), Latency: latency.String()}
}
details := map[string]any{}
if deep {
_ = h.store.DB().QueryRowContext(ctx,
"SELECT count(*) FROM portfolio_chunks").Scan(&n)
details["chunks"] = n
}
return ComponentHealth{Status: "up", Latency: latency.String(), Details: details}
}

View file

@ -0,0 +1,176 @@
package server
import (
"context"
"log/slog"
"net"
"net/http"
"strconv"
"strings"
"sync"
"time"
"github.com/VictorVargas/rony-chat-bot/internal/config"
)
type ctxKey string
const ctxKeyRequestID ctxKey = "requestID"
// RequestID assigns a short id per request and exposes it via context + header.
func RequestID(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id := strconv.FormatInt(time.Now().UnixNano(), 36)
ctx := context.WithValue(r.Context(), ctxKeyRequestID, id)
w.Header().Set("X-Request-ID", id)
next.ServeHTTP(w, r.WithContext(ctx))
})
}
// Logging emits one structured log line per request after it completes.
func Logging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
ww := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(ww, r)
id, _ := r.Context().Value(ctxKeyRequestID).(string)
slog.Info("http",
"id", id,
"method", r.Method,
"path", r.URL.Path,
"status", ww.status,
"bytes", ww.bytes,
"duration_ms", time.Since(start).Milliseconds(),
"remote", clientIP(r),
)
})
}
type statusRecorder struct {
http.ResponseWriter
status int
bytes int
}
func (r *statusRecorder) WriteHeader(code int) {
r.status = code
r.ResponseWriter.WriteHeader(code)
}
func (r *statusRecorder) Write(b []byte) (int, error) {
n, err := r.ResponseWriter.Write(b)
r.bytes += n
return n, err
}
func (r *statusRecorder) Flush() {
if f, ok := r.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
// CORS rejects requests whose Origin isn't on the allowlist. Requests with
// no Origin header (curl, server-to-server) are allowed through.
func CORS(allowed []string) func(http.Handler) http.Handler {
set := make(map[string]struct{}, len(allowed))
allowAll := false
for _, o := range allowed {
o = strings.TrimSpace(o)
if o == "*" {
allowAll = true
continue
}
set[o] = struct{}{}
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if origin != "" {
if !allowAll {
if _, ok := set[origin]; !ok {
http.Error(w, "origin not allowed", http.StatusForbidden)
return
}
}
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Vary", "Origin")
w.Header().Set("Access-Control-Allow-Credentials", "true")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
}
// RateLimit is a per-IP token bucket. cfg.RateLimit.RequestsPerMinute sets
// the refill rate; cfg.RateLimit.Burst is the bucket size.
func RateLimit(cfg config.RateLimit) func(http.Handler) http.Handler {
type bucket struct {
tokens float64
lastFill time.Time
}
var mu sync.Mutex
buckets := make(map[string]*bucket)
rate := float64(cfg.RequestsPerMinute) / 60.0
burst := float64(cfg.Burst)
if rate <= 0 {
rate = 0.5
}
if burst <= 0 {
burst = 5
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ip := clientIP(r)
now := time.Now()
mu.Lock()
b, ok := buckets[ip]
if !ok {
b = &bucket{tokens: burst, lastFill: now}
buckets[ip] = b
}
elapsed := now.Sub(b.lastFill).Seconds()
b.tokens += elapsed * rate
if b.tokens > burst {
b.tokens = burst
}
b.lastFill = now
if b.tokens < 1 {
mu.Unlock()
w.Header().Set("Retry-After", "60")
http.Error(w, "rate limit exceeded", http.StatusTooManyRequests)
return
}
b.tokens--
mu.Unlock()
next.ServeHTTP(w, r)
})
}
}
func clientIP(r *http.Request) string {
if xff := r.Header.Get("X-Forwarded-For"); xff != "" {
if i := strings.IndexByte(xff, ','); i > 0 {
return strings.TrimSpace(xff[:i])
}
return strings.TrimSpace(xff)
}
host, _, err := net.SplitHostPort(r.RemoteAddr)
if err != nil {
return r.RemoteAddr
}
return host
}

65
internal/server/server.go Normal file
View file

@ -0,0 +1,65 @@
package server
import (
"context"
"errors"
"log/slog"
"net/http"
"time"
"github.com/go-chi/chi/v5"
"github.com/VictorVargas/rony-chat-bot/internal/config"
)
type Server struct {
httpSrv *http.Server
}
func New(cfg *config.Config, h *Handlers) *Server {
r := chi.NewRouter()
r.Use(RequestID)
r.Use(Logging)
r.Use(CORS(cfg.Server.CORSOrigins))
if cfg.Server.RateLimit.RequestsPerMinute > 0 {
r.Use(RateLimit(cfg.Server.RateLimit))
}
r.Get("/", func(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
_, _ = w.Write([]byte("Rony Chat Bot — see /api/health, /api/info, POST /api/chat\n"))
})
r.Route("/api", func(r chi.Router) {
r.Post("/chat", h.Chat)
r.Post("/reindex", h.Reindex)
r.Get("/health", h.Health)
r.Get("/info", h.Info)
r.Get("/conversations", h.ListConversations)
r.Get("/conversations/{id}", h.GetConversation)
r.Delete("/conversations/{id}", h.DeleteConversation)
})
srv := &http.Server{
Addr: cfg.Addr(),
Handler: r,
ReadTimeout: time.Duration(cfg.Server.ReadTimeoutMS) * time.Millisecond,
WriteTimeout: 0, // SSE streams must not be cut off by WriteTimeout
IdleTimeout: 120 * time.Second,
}
return &Server{httpSrv: srv}
}
func (s *Server) Start() error {
slog.Info("http server starting", "addr", s.httpSrv.Addr)
if err := s.httpSrv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
}
func (s *Server) Shutdown(ctx context.Context) error {
return s.httpSrv.Shutdown(ctx)
}

View file

@ -0,0 +1,420 @@
package server
import (
"bufio"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"path/filepath"
"strings"
"testing"
"github.com/VictorVargas/rony-llm-agent/pkg/llm"
"github.com/VictorVargas/rony-llm-agent/pkg/llm/mock"
llmpersona "github.com/VictorVargas/rony-llm-agent/pkg/persona"
"github.com/VictorVargas/rony-chat-bot/internal/agent"
"github.com/VictorVargas/rony-chat-bot/internal/config"
"github.com/VictorVargas/rony-chat-bot/internal/portfolio"
)
// newTestServer wires a Server backed by a mock LLM, listens on a random
// port, and returns the base URL plus a teardown. The persona is fixed and
// the RAG store is nil (these tests don't exercise retrieval).
//
// To make /api/health pass, the LLM endpoint is pointed at a tiny stub
// HTTP server that returns 200 on /health. Callers can override this with
// newTestServerWithLLM(t, llmStatus).
func newTestServer(t *testing.T) (string, *config.Config) {
t.Helper()
return newTestServerWithLLM(t, http.StatusOK)
}
func newTestServerWithLLM(t *testing.T, llmStatus int) (string, *config.Config) {
t.Helper()
cli := mock.NewWithStream(mockChunks("hello", "world"))
p := llmpersona.Persona{Name: "TestBot", Tone: "concise", Language: "English"}
// Stub HTTP server that mimics llama-server's /health. If llmStatus != 200
// the health probe will report down.
llmStub := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/health" {
w.WriteHeader(llmStatus)
return
}
w.WriteHeader(http.StatusNotFound)
}))
t.Cleanup(llmStub.Close)
// Real (in-memory) FTS5 store so the store probe reports up. Without
// it the bot is "degraded" (200, but store down) — a confusing default.
dbPath := filepath.Join(t.TempDir(), "t.db")
store, err := portfolio.OpenStore(dbPath)
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = store.Close() })
runner := agent.New(cli, p, "test system prompt", store, 5)
cfg := &config.Config{
Server: config.Server{
Host: "127.0.0.1",
Port: 0,
CORSOrigins: []string{"http://localhost:4321"},
RateLimit: config.RateLimit{RequestsPerMinute: 0, Burst: 0}, // disabled
},
Providers: []config.Provider{{
Name: "mock", Type: "llamacpp", Model: "test", Default: true,
Endpoint: llmStub.URL + "/v1",
}},
RAG: config.RAG{Enabled: false, TopK: 5, DataPath: ".", DBPath: dbPath},
Persona: config.Persona{Name: "TestBot", Language: "English"},
}
h := NewHandlers(cfg, runner, store, "test")
srv := New(cfg, h)
ts := httptest.NewUnstartedServer(srv.httpSrv.Handler)
ts.Start()
t.Cleanup(ts.Close)
return ts.URL, cfg
}
// mockChunks converts plain strings into a stream of single-word deltas
// followed by a final usage chunk, matching what a real provider would emit.
func mockChunks(words ...string) []llm.StreamChunk {
out := make([]llm.StreamChunk, 0, len(words)+1)
for _, w := range words {
out = append(out, llm.StreamChunk{Delta: w + " "})
}
out = append(out, llm.StreamChunk{
Delta: "",
FinishReason: "stop",
Usage: llm.TokenUsage{InputTokens: 5, OutputTokens: 3},
})
return out
}
func TestHealth(t *testing.T) {
url, _ := newTestServer(t)
resp, err := http.Get(url + "/api/health")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Errorf("status = %d, want 200", resp.StatusCode)
}
var body HealthResponse
if err := json.NewDecoder(resp.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body.Status != "healthy" {
t.Errorf("status = %q, want healthy", body.Status)
}
if body.Components["llm"].Status != "up" {
t.Errorf("llm component = %q, want up", body.Components["llm"].Status)
}
if body.Version == "" {
t.Error("version should be set")
}
}
func TestHealthLLMDown(t *testing.T) {
// Stub LLM that returns 500 on /health → bot should be "unhealthy" (503).
url, _ := newTestServerWithLLM(t, http.StatusInternalServerError)
resp, err := http.Get(url + "/api/health")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusServiceUnavailable {
t.Errorf("status = %d, want 503", resp.StatusCode)
}
var body HealthResponse
_ = json.NewDecoder(resp.Body).Decode(&body)
if body.Status != "unhealthy" {
t.Errorf("status = %q, want unhealthy", body.Status)
}
if body.Components["llm"].Status != "down" {
t.Errorf("llm component = %q, want down", body.Components["llm"].Status)
}
}
func TestHealthDeep(t *testing.T) {
url, _ := newTestServer(t)
resp, err := http.Get(url + "/api/health?deep=true")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Errorf("status = %d, want 200", resp.StatusCode)
}
var body HealthResponse
_ = json.NewDecoder(resp.Body).Decode(&body)
if body.Components["store"].Status != "up" {
t.Errorf("store component = %q, want up", body.Components["store"].Status)
}
// Deep mode adds a chunks count to the store details.
if _, ok := body.Components["store"].Details["chunks"]; !ok {
t.Errorf("deep mode should include chunks count in details, got: %v", body.Components["store"].Details)
}
}
func TestInfo(t *testing.T) {
url, _ := newTestServer(t)
resp, err := http.Get(url + "/api/info")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Errorf("status = %d, want 200", resp.StatusCode)
}
var body map[string]any
_ = json.NewDecoder(resp.Body).Decode(&body)
for _, k := range []string{"name", "version", "provider", "model", "rag", "top_k"} {
if _, ok := body[k]; !ok {
t.Errorf("missing field %q in /api/info: %v", k, body)
}
}
}
func TestChatSSE(t *testing.T) {
url, _ := newTestServer(t)
body := strings.NewReader(`{"messages":[{"role":"user","content":"hi"}],"stream":true}`)
resp, err := http.Post(url+"/api/chat", "application/json", body)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if got := resp.Header.Get("Content-Type"); !strings.HasPrefix(got, "text/event-stream") {
t.Errorf("Content-Type = %q, want text/event-stream", got)
}
if resp.StatusCode != 200 {
t.Errorf("status = %d, want 200", resp.StatusCode)
}
events := parseSSE(t, resp.Body)
// No RAG store in this test → no "sources" event.
wantTypes := []string{"start", "chunk", "chunk", "done"}
if len(events) < len(wantTypes) {
t.Fatalf("got %d events, want >= %d (%v)", len(events), len(wantTypes), events)
}
for i, want := range wantTypes {
if events[i].event != want {
t.Errorf("event[%d] = %q, want %q", i, events[i].event, want)
}
}
if events[1].data == "" {
t.Errorf("first chunk data is empty")
}
}
func TestChatSSEWithRAG(t *testing.T) {
// Build a temp store with one project so the sources event fires.
dir := t.TempDir()
if err := writeFileR(dir+"/src/proj.md", "# Demo\n\n## Tech stack\n- Go\n- SQLite database\n"); err != nil {
t.Fatal(err)
}
store, err := openStoreForTest(dir + "/t.db", dir+"/src")
if err != nil {
t.Fatal(err)
}
defer store.Close()
cli := mock.NewWithStream(mockChunks("answer"))
p := llmpersona.Persona{Name: "TestBot", Tone: "concise", Language: "English"}
runner := agent.New(cli, p, "test system prompt", store, 5)
cfg := &config.Config{
Server: config.Server{Host: "127.0.0.1", Port: 0, CORSOrigins: []string{"*"}},
Providers: []config.Provider{{Name: "mock", Type: "llamacpp", Model: "test", Default: true}},
RAG: config.RAG{Enabled: true, TopK: 5},
Persona: config.Persona{Name: "TestBot", Language: "English"},
}
h := NewHandlers(cfg, runner, store, "test")
srv := New(cfg, h)
ts := httptest.NewUnstartedServer(srv.httpSrv.Handler)
ts.Start()
defer ts.Close()
body := strings.NewReader(`{"messages":[{"role":"user","content":"what database?"}],"stream":true}`)
resp, err := http.Post(ts.URL+"/api/chat", "application/json", body)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
events := parseSSE(t, resp.Body)
var sawSources bool
for _, e := range events {
if e.event == "sources" && strings.Contains(e.data, "proj") {
sawSources = true
}
}
if !sawSources {
t.Errorf("no sources event with project id, got events: %v", events)
}
}
func TestChatNoStream(t *testing.T) {
url, _ := newTestServer(t)
body := strings.NewReader(`{"messages":[{"role":"user","content":"hi"}],"stream":false}`)
resp, err := http.Post(url+"/api/chat", "application/json", body)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Errorf("status = %d, want 200", resp.StatusCode)
}
if got := resp.Header.Get("Content-Type"); !strings.HasPrefix(got, "application/json") {
t.Errorf("Content-Type = %q, want application/json", got)
}
var out struct {
Content string `json:"content"`
Sources []string `json:"sources"`
Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
} `json:"usage"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
t.Fatal(err)
}
if out.Content == "" {
t.Error("content is empty")
}
if out.Usage.InputTokens == 0 && out.Usage.OutputTokens == 0 {
t.Error("usage is all zero")
}
}
func TestChatInvalidBody(t *testing.T) {
url, _ := newTestServer(t)
cases := []struct {
name, body string
}{
{"empty messages", `{"messages":[]}`},
{"empty content", `{"messages":[{"role":"user","content":""}]}`},
{"invalid role", `{"messages":[{"role":"wizard","content":"x"}]}`},
{"bad json", `{not json`},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
resp, err := http.Post(url+"/api/chat", "application/json", strings.NewReader(c.body))
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 400 {
t.Errorf("status = %d, want 400", resp.StatusCode)
}
})
}
}
func TestChatWrongMethod(t *testing.T) {
url, _ := newTestServer(t)
resp, err := http.Get(url + "/api/chat")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 405 {
t.Errorf("status = %d, want 405", resp.StatusCode)
}
}
func TestCORSReject(t *testing.T) {
url, _ := newTestServer(t)
body := strings.NewReader(`{"messages":[{"role":"user","content":"hi"}]}`)
req, _ := http.NewRequest("POST", url+"/api/chat", body)
req.Header.Set("Origin", "https://evil.example")
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 403 {
t.Errorf("status = %d, want 403", resp.StatusCode)
}
if aco := resp.Header.Get("Access-Control-Allow-Origin"); aco != "" {
t.Errorf("ACAO = %q, want empty", aco)
}
}
func TestCORSAllow(t *testing.T) {
url, _ := newTestServer(t)
body := strings.NewReader(`{"messages":[{"role":"user","content":"hi"}]}`)
req, _ := http.NewRequest("POST", url+"/api/chat", body)
req.Header.Set("Origin", "http://localhost:4321")
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Errorf("status = %d, want 200", resp.StatusCode)
}
if aco := resp.Header.Get("Access-Control-Allow-Origin"); aco != "http://localhost:4321" {
t.Errorf("ACAO = %q, want http://localhost:4321", aco)
}
}
// sseEvent is one parsed "event: x\ndata: y\n\n" record.
type sseEvent struct {
event string
data string
}
func parseSSE(t *testing.T, r io.Reader) []sseEvent {
t.Helper()
var out []sseEvent
scanner := bufio.NewScanner(r)
scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
var cur sseEvent
flush := func() {
if cur.event != "" || cur.data != "" {
out = append(out, cur)
}
cur = sseEvent{}
}
for scanner.Scan() {
line := scanner.Text()
switch {
case line == "":
flush()
case strings.HasPrefix(line, "event: "):
cur.event = strings.TrimPrefix(line, "event: ")
case strings.HasPrefix(line, "data: "):
if cur.data != "" {
cur.data += "\n"
}
cur.data += strings.TrimPrefix(line, "data: ")
}
}
flush()
if err := scanner.Err(); err != nil {
t.Fatal(err)
}
return out
}
// Verify the unused imports are not actually unused (httptest, context, etc.).
var _ = httptest.NewRecorder
// writeFileR + openStoreForTest are tiny shims so the test file doesn't
// need to import os/filepath directly.
func writeFileR(path, content string) error {
return osWriteFile(path, []byte(content), 0o644)
}
func openStoreForTest(dbPath, srcDir string) (ragStore, error) {
return ragOpenStore(dbPath, srcDir)
}

View file

@ -0,0 +1,30 @@
package server
import (
"context"
"os"
"path/filepath"
"github.com/VictorVargas/rony-chat-bot/internal/portfolio"
)
type ragStore = *portfolio.Store
func osWriteFile(path string, data []byte, perm os.FileMode) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return os.WriteFile(path, data, perm)
}
func ragOpenStore(dbPath, srcDir string) (ragStore, error) {
store, err := portfolio.OpenStore(dbPath)
if err != nil {
return nil, err
}
if _, _, err := store.Reindex(context.Background(), srcDir, portfolio.DefaultChunkerConfig()); err != nil {
_ = store.Close()
return nil, err
}
return store, nil
}

63
internal/streaming/sse.go Normal file
View file

@ -0,0 +1,63 @@
package streaming
import (
"encoding/json"
"fmt"
"net/http"
)
type Event struct {
Type string `json:"type"`
Content string `json:"content,omitempty"`
}
func WriteEvent(w http.ResponseWriter, eventType string, payload any) error {
data, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal event: %w", err)
}
if _, err := fmt.Fprintf(w, "event: %s\ndata: %s\n\n", eventType, data); err != nil {
return err
}
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
return nil
}
func WriteChunk(w http.ResponseWriter, content string) error {
return WriteEvent(w, "chunk", Event{Type: "chunk", Content: content})
}
func WriteStart(w http.ResponseWriter, conversationID string) error {
return WriteEvent(w, "start", map[string]any{
"type": "start",
"conversation_id": conversationID,
})
}
func WriteSources(w http.ResponseWriter, sources []string) error {
return WriteEvent(w, "sources", map[string]any{
"type": "sources",
"documents": sources,
})
}
type Usage struct {
InputTokens int `json:"input_tokens"`
OutputTokens int `json:"output_tokens"`
}
func WriteDone(w http.ResponseWriter, usage Usage) error {
return WriteEvent(w, "done", map[string]any{
"type": "done",
"usage": usage,
})
}
func WriteError(w http.ResponseWriter, errMsg string) error {
return WriteEvent(w, "error", map[string]string{
"type": "error",
"error": errMsg,
})
}

205
web/README.md Normal file
View file

@ -0,0 +1,205 @@
# rony-chat-widget
A drop-in vanilla-JS chat widget that talks to the Rony Chat Bot backend over Server-Sent Events. No build step, no runtime dependencies, no global CSS pollution.
## Files
| File | Purpose |
|---|---|
| `chat-widget.js` | The widget. Self-contained ~12 KB. |
| `chat-widget.css` | Scoped styles, themable via CSS custom properties. |
| `example.html` | Standalone demo page (use with `python3 -m http.server`). |
## Quick start (any site)
```html
<link rel="stylesheet" href="/path/to/chat-widget.css">
<script src="/path/to/chat-widget.js"
data-api-url="https://your-chatbot.example.com"
data-title="Ask me anything"
data-greeting="Hi! Ask me about the projects."
data-position="bottom-right"
data-theme="auto"
defer></script>
```
The bubble appears bottom-right (or bottom-left), opens a 380×560 panel, and talks to `data-api-url/api/chat` over SSE.
## Configuration (all via `data-*` attributes on the `<script>` tag)
| Attribute | Default | Notes |
|---|---|---|
| `data-api-url` | *(required)* | Base URL of the chat-bot, e.g. `https://chat.example.com`. No trailing slash. |
| `data-title` | `"Chat"` | Header text. |
| `data-greeting` | `""` | First message shown when the panel opens (no greeting if empty). |
| `data-position` | `"bottom-right"` | `"bottom-right"` or `"bottom-left"`. |
| `data-theme` | `"auto"` | `"auto"` (follows `prefers-color-scheme`), `"light"`, or `"dark"`. |
## Language
The widget UI is bilingual (English / Spanish) with a toggle in the header.
- **Initial language**: `localStorage["rony-chat-lang"]` if set, else detected from `navigator.language` (anything starting with `es` → Spanish, else English).
- **Persisted** across page reloads via `localStorage`.
- **Conversation language is independent**: the bot auto-detects the language of each user message and replies in that language. The toggle only changes the *interface* (placeholder, status, errors, send button).
- **No build step**: strings live in a `STRINGS` object at the top of `chat-widget.js`. Add a new language by adding an entry.
To override the initial language (e.g., force English on a Spanish site):
```html
<script>
localStorage.setItem("rony-chat-lang", "en");
</script>
<script src="chat-widget.js" data-api-url="..." defer></script>
```
## Theming (override without forking)
All visual tokens are CSS custom properties on the root element. Set them in your site's stylesheet:
```css
.rony-chat-widget-root {
--rony-accent: #ff6b35; /* bubble + send button + links */
--rony-radius: 4px; /* tighter corners */
--rony-font: "Inter", sans-serif;
}
```
See the full list in `chat-widget.css` (search for `--rony-`).
## Astro integration
The simplest path is the drop-in. Add this to your `Layout.astro` (or any shared layout):
```astro
---
// src/layouts/ChatLayout.astro
import "../path/to/chat-widget.css";
const apiUrl = import.meta.env.PUBLIC_CHAT_API_URL || "http://localhost:7331";
---
<html>
<head>
<head><slot name="head" /></head>
</head>
<body>
<slot />
<script src="/path/to/chat-widget.js"
data-api-url={apiUrl}
data-title="Ask me anything"
data-position="bottom-right"
data-theme="auto"
defer is:inline></script>
</body>
</html>
```
Notes:
- `is:inline` keeps Astro from hashing/transforming the script tag, so the `data-*` attributes survive.
- `PUBLIC_CHAT_API_URL` is an Astro env var; set it in `.env` per environment.
- The bot's `cors_origins` in YAML must include your Astro dev origin (`http://localhost:4321`).
## React/Next.js
Mount the same script tag in your root layout:
```tsx
// app/layout.tsx
import Script from "next/script";
export default function RootLayout({ children }) {
return (
<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="Ask me anything"
data-position="bottom-right"
data-theme="auto"
strategy="afterInteractive" />
</head>
<body>{children}</body>
</html>
);
}
```
## Backend requirements
The widget expects the bot to:
1. Expose `POST /api/chat` accepting `{ messages, stream, conversation_id? }` (see `docs/architecture.md` §3.1).
2. Stream SSE events: `start` (with `conversation_id`), `chunk`, `sources`, `done`, `error` (see `docs/architecture.md` §3.2).
3. Expose `GET /api/conversations/{id}` for history restore (returns 404 if unknown).
4. Allow the page's origin via `cors_origins` in the bot's config.
## Running the example locally
```bash
# 1. Start the bot
./bin/chat-bot serve
# 2. Serve the widget (in another terminal)
cd web
python3 -m http.server 8000
# 3. Open http://localhost:8000/example.html in a browser
```
> Note: `localhost:8000` must be in the bot's `cors_origins` for the demo to work. The default config already includes it.
## Browser support
Modern browsers (Chrome/Edge 90+, Firefox 90+, Safari 15+). Uses:
- `fetch` + `ReadableStream` (for SSE)
- `AbortController`
- CSS custom properties + `prefers-color-scheme`
No polyfills, no transpilation.
## Conversation persistence
The bot persists conversations on the server side (SQLite, see
`docs/architecture.md` §3.4). The widget handles the client side
automatically:
1. **First message** — the server mints a new `conversation_id` and returns
it in the `start` SSE event. The widget saves it to
`localStorage["rony-chat-conv"]`.
2. **Subsequent messages** — the widget sends the saved ID with every
request, so the server keeps appending to the same thread.
3. **Page reload** — on load, the widget reads the stored ID and calls
`GET /api/conversations/{id}` to restore the full history.
4. **Server lost the conversation** (e.g. DB was wiped) — the GET returns
404. The widget clears `localStorage` and starts a fresh thread on the
next message.
**Browser-scoped**: `localStorage` is per-origin, so the same browser
keeps the thread across visits, but a different browser starts fresh.
Clearing site data resets the conversation.
**Server-scoped across devices**: not automatic. The conversation lives
in the SQLite DB but only the browser that created it knows its ID. If
you want cross-device continuity, persist the ID in your user profile
(e.g. after login) and pass it on initial load instead of relying on
`localStorage`. The backend already supports this — see
`docs/architecture.md` §3.4 for the protocol.
**To opt out** (start a fresh conversation on every page load):
```html
<script>
localStorage.removeItem("rony-chat-conv");
</script>
<script src="chat-widget.js" data-api-url="..." defer></script>
```
Or expose a "new chat" button in your UI that calls
`DELETE /api/conversations/{id}` then clears the localStorage key.
## What's not in the widget (yet)
- **Markdown images / tables** — the renderer handles paragraphs, lists, code, links, bold/italic. Tables and images render as raw text. For richer output, swap `renderMarkdown` for `marked` or `markdown-it`.
- **Typing indicators beyond the streaming caret** — the caret at the end of the streaming response is the only indicator. Good enough for short answers.
- **Mobile sheet drag-to-dismiss** — the panel goes full-screen on phones, but can't be swiped away. Add a swipe handler if it matters.
- **Conversation history sidebar** — only the active conversation is shown in the panel. The backend exposes `GET /api/conversations` for a future sidebar.

269
web/chat-widget.css Normal file
View file

@ -0,0 +1,269 @@
/* rony-chat-widget scoped styles
All selectors are prefixed with .rony-chat-widget- to avoid clashing
with the host site's CSS. Override CSS custom properties (--rony-*)
to retheme without forking. */
.rony-chat-widget-root {
--rony-z: 2147483000;
--rony-bg: #ffffff;
--rony-bg-soft: #f7f7f8;
--rony-fg: #1a1a1a;
--rony-fg-soft: #5c5c66;
--rony-border: #e4e4e7;
--rony-accent: #2563eb;
--rony-accent-fg: #ffffff;
--rony-user-bubble: #2563eb;
--rony-user-fg: #ffffff;
--rony-assistant-bubble: #f1f1f3;
--rony-shadow: 0 8px 28px rgba(0, 0, 0, 0.12);
--rony-radius: 14px;
--rony-font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
position: fixed;
z-index: var(--rony-z);
font-family: var(--rony-font);
color: var(--rony-fg);
font-size: 14px;
line-height: 1.5;
}
.rony-chat-widget-root[data-position="bottom-left"] { left: 20px; bottom: 20px; }
.rony-chat-widget-root[data-position="bottom-right"] { right: 20px; bottom: 20px; }
/* Dark mode auto */
@media (prefers-color-scheme: dark) {
.rony-chat-widget-root[data-theme="auto"] {
--rony-bg: #1f1f23;
--rony-bg-soft: #2a2a30;
--rony-fg: #f1f1f3;
--rony-fg-soft: #a1a1aa;
--rony-border: #3a3a42;
--rony-assistant-bubble: #2a2a30;
--rony-shadow: 0 8px 28px rgba(0, 0, 0, 0.5);
}
}
.rony-chat-widget-root[data-theme="dark"] {
--rony-bg: #1f1f23;
--rony-bg-soft: #2a2a30;
--rony-fg: #f1f1f3;
--rony-fg-soft: #a1a1aa;
--rony-border: #3a3a42;
--rony-assistant-bubble: #2a2a30;
--rony-shadow: 0 8px 28px rgba(0, 0, 0, 0.5);
}
/* Bubble button */
.rony-chat-widget-bubble {
width: 56px;
height: 56px;
border-radius: 50%;
background: var(--rony-accent);
color: var(--rony-accent-fg);
border: none;
cursor: pointer;
box-shadow: var(--rony-shadow);
display: flex;
align-items: center;
justify-content: center;
transition: transform 120ms ease;
}
.rony-chat-widget-bubble:hover { transform: scale(1.05); }
.rony-chat-widget-bubble svg { width: 26px; height: 26px; }
/* Panel */
.rony-chat-widget-panel {
position: absolute;
bottom: 72px;
right: 0;
width: 380px;
max-width: calc(100vw - 40px);
height: 560px;
max-height: calc(100vh - 100px);
background: var(--rony-bg);
border: 1px solid var(--rony-border);
border-radius: var(--rony-radius);
box-shadow: var(--rony-shadow);
display: none;
flex-direction: column;
overflow: hidden;
}
.rony-chat-widget-root[data-open="true"] .rony-chat-widget-panel { display: flex; }
.rony-chat-widget-header {
padding: 12px 16px;
border-bottom: 1px solid var(--rony-border);
font-weight: 600;
display: flex;
align-items: center;
justify-content: space-between;
}
.rony-chat-widget-status {
font-size: 11px;
color: var(--rony-fg-soft);
font-weight: 400;
}
/* Language toggle (EN | ES) */
.rony-chat-widget-lang {
display: inline-flex;
border: 1px solid var(--rony-border);
border-radius: 6px;
overflow: hidden;
margin: 0 4px;
}
.rony-chat-widget-lang button {
background: transparent;
border: none;
color: var(--rony-fg-soft);
font-size: 10.5px;
font-weight: 600;
padding: 2px 6px;
cursor: pointer;
min-width: 26px;
font-family: inherit;
letter-spacing: 0.3px;
}
.rony-chat-widget-lang button:hover { color: var(--rony-fg); }
.rony-chat-widget-lang button.is-active {
background: var(--rony-accent);
color: var(--rony-accent-fg);
}
.rony-chat-widget-lang button + button {
border-left: 1px solid var(--rony-border);
}
.rony-chat-widget-close {
background: none;
border: none;
color: var(--rony-fg-soft);
cursor: pointer;
font-size: 20px;
padding: 0 4px;
line-height: 1;
}
.rony-chat-widget-messages {
flex: 1;
overflow-y: auto;
padding: 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.rony-chat-widget-msg {
max-width: 88%;
padding: 10px 12px;
border-radius: 12px;
word-wrap: break-word;
white-space: pre-wrap;
}
.rony-chat-widget-msg-user {
align-self: flex-end;
background: var(--rony-user-bubble);
color: var(--rony-user-fg);
white-space: pre-wrap;
}
.rony-chat-widget-msg-assistant {
align-self: flex-start;
background: var(--rony-assistant-bubble);
color: var(--rony-fg);
}
.rony-chat-widget-msg-error {
align-self: center;
background: #fee2e2;
color: #991b1b;
font-size: 12px;
}
/* Markdown inside assistant messages */
.rony-chat-widget-msg-assistant p { margin: 0 0 6px 0; }
.rony-chat-widget-msg-assistant p:last-child { margin-bottom: 0; }
.rony-chat-widget-msg-assistant code {
background: rgba(0, 0, 0, 0.06);
padding: 1px 5px;
border-radius: 4px;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12.5px;
}
.rony-chat-widget-msg-assistant pre {
background: rgba(0, 0, 0, 0.06);
padding: 8px 10px;
border-radius: 6px;
overflow-x: auto;
margin: 6px 0;
}
.rony-chat-widget-msg-assistant pre code { background: none; padding: 0; }
.rony-chat-widget-msg-assistant ul, .rony-chat-widget-msg-assistant ol { margin: 4px 0 4px 18px; padding: 0; }
.rony-chat-widget-msg-assistant a { color: var(--rony-accent); }
/* Sources chip row */
.rony-chat-widget-sources {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-top: 6px;
}
.rony-chat-widget-source {
font-size: 10.5px;
background: var(--rony-bg-soft);
color: var(--rony-fg-soft);
padding: 2px 7px;
border-radius: 999px;
border: 1px solid var(--rony-border);
}
/* Streaming caret */
.rony-chat-widget-caret {
display: inline-block;
width: 6px;
height: 14px;
background: var(--rony-fg-soft);
margin-left: 2px;
vertical-align: text-bottom;
animation: rony-caret 1s steps(2) infinite;
}
@keyframes rony-caret { 50% { opacity: 0; } }
/* Input row */
.rony-chat-widget-form {
display: flex;
border-top: 1px solid var(--rony-border);
padding: 8px;
gap: 6px;
}
.rony-chat-widget-input {
flex: 1;
border: 1px solid var(--rony-border);
background: var(--rony-bg);
color: var(--rony-fg);
border-radius: 8px;
padding: 8px 10px;
font-family: inherit;
font-size: 14px;
resize: none;
outline: none;
max-height: 100px;
}
.rony-chat-widget-input:focus { border-color: var(--rony-accent); }
.rony-chat-widget-send {
background: var(--rony-accent);
color: var(--rony-accent-fg);
border: none;
border-radius: 8px;
padding: 0 14px;
font-weight: 600;
cursor: pointer;
}
.rony-chat-widget-send:disabled { opacity: 0.5; cursor: not-allowed; }
/* Mobile: full screen */
@media (max-width: 480px) {
.rony-chat-widget-panel {
position: fixed;
inset: 0;
width: 100vw;
height: 100vh;
max-height: 100vh;
border-radius: 0;
bottom: 0;
right: 0;
}
}

458
web/chat-widget.js Normal file
View file

@ -0,0 +1,458 @@
// rony-chat-widget — drop-in vanilla JS chat widget.
//
// Usage (HTML):
// <link rel="stylesheet" href="/path/to/chat-widget.css">
// <script src="/path/to/chat-widget.js"
// data-api-url="http://localhost:7331"
// data-title="Ask me anything"
// data-position="bottom-right"
// data-theme="auto"
// defer></script>
//
// All options are read from <script data-*="..."> attributes; everything is
// optional except data-api-url. The widget is self-contained: no build step,
// no runtime dependencies, no global CSS pollution.
(function () {
"use strict";
// ---- i18n -----------------------------------------------------------------
// The widget UI is bilingual. The conversation language (what the LLM
// answers in) is decided by the user query and is unaffected by this toggle.
var STRINGS = {
en: {
placeholder: "Type a message...",
send: "Send",
online: "online",
offline: "offline",
error: "error",
errorConnect: "Could not reach the server: ",
errorGeneric: "Unknown error",
ariaOpen: "Open chat",
ariaClose: "Close",
ariaLang: "Language",
ariaSend: "Send",
},
es: {
placeholder: "Escribe un mensaje...",
send: "Enviar",
online: "conectado",
offline: "desconectado",
error: "error",
errorConnect: "No se pudo conectar al servidor: ",
errorGeneric: "Error desconocido",
ariaOpen: "Abrir chat",
ariaClose: "Cerrar",
ariaLang: "Idioma",
ariaSend: "Enviar",
},
};
var LANG_KEY = "rony-chat-lang";
var CONV_KEY = "rony-chat-conv";
function pickInitialLang() {
var saved = null;
try { saved = localStorage.getItem(LANG_KEY); } catch (e) {}
if (saved === "en" || saved === "es") return saved;
var nav = (navigator.language || "en").toLowerCase();
return nav.indexOf("es") === 0 ? "es" : "en";
}
function saveLang(lang) {
try { localStorage.setItem(LANG_KEY, lang); } catch (e) {}
}
// ---- Conversation persistence -------------------------------------------
// The conversation_id is a server-issued UUID-ish string. We store it in
// localStorage so the same browser keeps its thread across reloads. A
// different browser (or cleared storage) starts a fresh thread.
function loadConvID() {
try { return localStorage.getItem(CONV_KEY) || ""; } catch (e) { return ""; }
}
function saveConvID(id) {
try { localStorage.setItem(CONV_KEY, id); } catch (e) {}
}
function clearConvID() {
try { localStorage.removeItem(CONV_KEY); } catch (e) {}
}
// Restore conversation history from the server, if any. On 404 the
// stored ID is dead (e.g. server DB was wiped) — clear it and start fresh.
function restoreHistory(convID, onDone) {
fetch(cfg.apiUrl + "/api/conversations/" + encodeURIComponent(convID))
.then(function (resp) {
if (resp.status === 404) { clearConvID(); onDone(null); return null; }
if (!resp.ok) { onDone(null); return null; }
return resp.json();
})
.then(function (conv) {
if (!conv) { onDone(null); return; }
onDone(conv);
})
.catch(function () { onDone(null); });
}
// ---- Config ----------------------------------------------------------------
function readConfig() {
var scripts = document.querySelectorAll("script[data-api-url], script[data-rony-chat]");
var tag = scripts[scripts.length - 1] || document.currentScript || {};
var ds = tag.dataset || {};
return {
apiUrl: (ds.apiUrl || "").replace(/\/+$/, ""),
title: ds.title || "Chat",
greeting: ds.greeting || "",
position: ds.position || "bottom-right",
theme: ds.theme || "auto",
};
}
// ---- Tiny markdown subset (no dependency) ----------------------------------
// Covers: **bold**, *italic*, `code`, ```fences```, lists, [links], paragraphs.
function escapeHTML(s) {
return s.replace(/[&<>"']/g, function (c) {
return { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c];
});
}
function renderMarkdown(src) {
var fences = [];
src = src.replace(/```([\s\S]*?)```/g, function (_, code) {
fences.push(code.replace(/^\n/, ""));
return "\u0000F" + (fences.length - 1) + "\u0000";
});
var inlines = [];
src = src.replace(/`([^`\n]+)`/g, function (_, code) {
inlines.push(code);
return "\u0000I" + (inlines.length - 1) + "\u0000";
});
src = escapeHTML(src);
src = src.replace(/\u0000I(\d+)\u0000/g, function (_, i) {
return "<code>" + escapeHTML(inlines[+i]) + "</code>";
});
src = src.replace(/\u0000F(\d+)\u0000/g, function (_, i) {
return "<pre><code>" + escapeHTML(fences[+i]) + "</code></pre>";
});
src = src.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
src = src.replace(/\*([^*]+)\*/g, "<em>$1</em>");
src = src.replace(/\[([^\]]+)\]\(([^)]+)\)/g, function (_, t, u) {
var safe = /^(https?:|mailto:|#|\/)/i.test(u) ? u : "#";
return '<a href="' + safe + '" target="_blank" rel="noopener noreferrer">' + t + "</a>";
});
src = src.replace(/(^|\n)((?:[-*] |\d+\. ).+(?:\n(?:[-*] |\d+\. ).+)*)/g, function (m, lead, block) {
var lines = block.split("\n");
var isOrdered = /^\d+\. /.test(lines[0]);
var tag = isOrdered ? "ol" : "ul";
var items = lines.map(function (l) {
return "<li>" + l.replace(/^[-*] |\d+\. /, "") + "</li>";
}).join("");
return lead + "<" + tag + ">" + items + "</" + tag + ">";
});
src = src
.split(/\n{2,}/)
.map(function (p) {
if (/^\s*<(pre|ul|ol|h\d|blockquote)/.test(p)) return p;
return "<p>" + p.replace(/\n/g, "<br>") + "</p>";
})
.join("\n");
return src;
}
// ---- SSE parsing -----------------------------------------------------------
function readSSE(response, onEvent, signal) {
var reader = response.body.getReader();
var decoder = new TextDecoder("utf-8");
var buffer = "";
var aborted = false;
if (signal) {
signal.addEventListener("abort", function () {
aborted = true;
try { reader.cancel(); } catch (e) {}
});
}
function pump() {
if (aborted) return;
return reader.read().then(function (r) {
if (r.done) return;
buffer += decoder.decode(r.value, { stream: true });
var idx;
while ((idx = buffer.indexOf("\n\n")) !== -1) {
var raw = buffer.slice(0, idx);
buffer = buffer.slice(idx + 2);
var ev = { event: "message", data: "" };
raw.split("\n").forEach(function (line) {
if (line.indexOf("event: ") === 0) ev.event = line.slice(7).trim();
else if (line.indexOf("data: ") === 0) ev.data += (ev.data ? "\n" : "") + line.slice(6);
});
if (ev.data) onEvent(ev.event, ev.data);
}
return pump();
});
}
return pump();
}
// ---- Widget construction --------------------------------------------------
function buildWidget(cfg) {
var root = document.createElement("div");
root.className = "rony-chat-widget-root";
root.setAttribute("data-position", cfg.position);
root.setAttribute("data-theme", cfg.theme);
root.setAttribute("data-open", "false");
root.innerHTML = [
'<div class="rony-chat-widget-panel" role="dialog" aria-label="' + escapeHTML(cfg.title) + '">',
' <div class="rony-chat-widget-header">',
' <span class="rony-chat-widget-title">' + escapeHTML(cfg.title) + '</span>',
' <div class="rony-chat-widget-lang" role="group" aria-label="Language">',
' <button type="button" data-lang-btn="en" aria-pressed="false">EN</button>',
' <button type="button" data-lang-btn="es" aria-pressed="false">ES</button>',
' </div>',
' <span class="rony-chat-widget-status" data-status>online</span>',
' <button class="rony-chat-widget-close" aria-label="Close" data-close>×</button>',
' </div>',
' <div class="rony-chat-widget-messages" data-messages></div>',
' <form class="rony-chat-widget-form" data-form>',
' <textarea class="rony-chat-widget-input" data-input rows="1" placeholder="Type a message..."></textarea>',
' <button class="rony-chat-widget-send" type="submit" data-send>Send</button>',
' </form>',
'</div>',
'<button class="rony-chat-widget-bubble" aria-label="Open chat" data-bubble>',
' <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">',
' <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>',
' </svg>',
'</button>',
].join("\n");
return root;
}
function init() {
var cfg = readConfig();
if (!cfg.apiUrl) {
console.error("[rony-chat-widget] missing data-api-url on <script> tag");
return;
}
var widget = buildWidget(cfg);
document.body.appendChild(widget);
var $messages = widget.querySelector("[data-messages]");
var $input = widget.querySelector("[data-input]");
var $form = widget.querySelector("[data-form]");
var $send = widget.querySelector("[data-send]");
var $bubble = widget.querySelector("[data-bubble]");
var $close = widget.querySelector("[data-close]");
var $status = widget.querySelector("[data-status]");
var $langBtns = widget.querySelectorAll("[data-lang-btn]");
var $title = widget.querySelector(".rony-chat-widget-title");
var $panel = widget.querySelector(".rony-chat-widget-panel");
var $langGroup = widget.querySelector(".rony-chat-widget-lang");
var history = [];
var busy = false;
var abortCtrl = null;
var lang = pickInitialLang();
var convID = loadConvID();
// ---- Language handling -----------------------------------------------
function applyLang(next) {
lang = next;
saveLang(next);
var t = STRINGS[next];
$input.placeholder = t.placeholder;
$send.textContent = t.send;
$send.setAttribute("aria-label", t.ariaSend);
$close.setAttribute("aria-label", t.ariaClose);
$bubble.setAttribute("aria-label", t.ariaOpen);
$langGroup.setAttribute("aria-label", t.ariaLang);
$panel.setAttribute("lang", next);
widget.setAttribute("data-lang", next);
for (var i = 0; i < $langBtns.length; i++) {
var active = $langBtns[i].getAttribute("data-lang-btn") === next;
$langBtns[i].setAttribute("aria-pressed", active ? "true" : "false");
$langBtns[i].classList.toggle("is-active", active);
}
// Refresh the visible status text with the new language
$status.textContent = t[lastStatusKey] || t.online;
}
applyLang(lang);
for (var j = 0; j < $langBtns.length; j++) {
$langBtns[j].addEventListener("click", function (e) {
var next = e.currentTarget.getAttribute("data-lang-btn");
if (next && next !== lang) applyLang(next);
});
}
// Restore conversation history from the server on first load. The
// convID came from localStorage; if the server doesn't know it
// (404), we wipe it and start a fresh thread on next send.
if (convID) {
restoreHistory(convID, function (conv) {
if (conv && conv.messages) {
for (var i = 0; i < conv.messages.length; i++) {
var m = conv.messages[i];
appendMessage(m.role, m.content);
history.push({ role: m.role, content: m.content });
}
}
});
}
// ---- Chat behavior --------------------------------------------------
var lastStatusKey = "online";
function setStatus(key) {
lastStatusKey = key;
$status.textContent = STRINGS[lang][key] || key;
}
function setOpen(open) {
widget.setAttribute("data-open", open ? "true" : "false");
// Greeting only fires on a brand-new thread. If history was
// restored from the server we don't want to prepend a greeting on
// top of the user's previous messages.
if (open && !history.length && cfg.greeting) {
appendMessage("assistant", cfg.greeting);
history.push({ role: "assistant", content: cfg.greeting });
}
if (open) $input.focus();
}
function appendMessage(role, content) {
var div = document.createElement("div");
div.className = "rony-chat-widget-msg rony-chat-widget-msg-" + role;
if (role === "assistant") {
div.innerHTML = renderMarkdown(content);
} else {
div.textContent = content;
}
$messages.appendChild(div);
$messages.scrollTop = $messages.scrollHeight;
return div;
}
function appendError(msg) {
var div = document.createElement("div");
div.className = "rony-chat-widget-msg rony-chat-widget-msg-error";
div.textContent = msg;
$messages.appendChild(div);
$messages.scrollTop = $messages.scrollHeight;
}
function send(userText) {
if (busy || !userText.trim()) return;
busy = true;
$send.disabled = true;
$input.value = "";
autoSize();
history.push({ role: "user", content: userText });
appendMessage("user", userText);
var assistantDiv = appendMessage("assistant", "");
var caret = document.createElement("span");
caret.className = "rony-chat-widget-caret";
assistantDiv.appendChild(caret);
abortCtrl = new AbortController();
var collectedSources = [];
fetch(cfg.apiUrl + "/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
messages: history,
stream: true,
conversation_id: convID || undefined,
}),
signal: abortCtrl.signal,
}).then(function (resp) {
if (!resp.ok) {
throw new Error("HTTP " + resp.status);
}
return readSSE(resp, function (type, data) {
try {
var payload = JSON.parse(data);
} catch (e) {
return;
}
if (type === "start" && payload.conversation_id) {
// Server may have minted a new id; persist it.
if (payload.conversation_id !== convID) {
convID = payload.conversation_id;
saveConvID(convID);
}
} else if (type === "chunk" && payload.content) {
assistantDiv.insertBefore(document.createTextNode(payload.content), caret);
$messages.scrollTop = $messages.scrollHeight;
} else if (type === "sources" && Array.isArray(payload.documents)) {
collectedSources = payload.documents;
} else if (type === "done") {
var finalText = assistantDiv.textContent.replace(/\s+$/, "");
history.push({ role: "assistant", content: finalText });
if (caret.parentNode) caret.parentNode.removeChild(caret);
assistantDiv.innerHTML = renderMarkdown(finalText);
if (collectedSources.length) {
var row = document.createElement("div");
row.className = "rony-chat-widget-sources";
collectedSources.forEach(function (s) {
var chip = document.createElement("span");
chip.className = "rony-chat-widget-source";
chip.textContent = s;
row.appendChild(chip);
});
assistantDiv.appendChild(row);
}
setStatus("online");
} else if (type === "error") {
if (caret.parentNode) caret.parentNode.removeChild(caret);
appendError(payload.error || STRINGS[lang].errorGeneric);
setStatus("error");
}
}, abortCtrl.signal);
}).catch(function (err) {
if (err.name === "AbortError") return;
if (caret.parentNode) caret.parentNode.removeChild(caret);
appendError(STRINGS[lang].errorConnect + err.message);
setStatus("offline");
}).then(function () {
busy = false;
$send.disabled = false;
abortCtrl = null;
});
}
function autoSize() {
$input.style.height = "auto";
$input.style.height = Math.min($input.scrollHeight, 100) + "px";
}
$bubble.addEventListener("click", function () { setOpen(true); });
$close.addEventListener("click", function () { setOpen(false); });
$input.addEventListener("input", autoSize);
$input.addEventListener("keydown", function (e) {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
$form.requestSubmit();
}
});
$form.addEventListener("submit", function (e) {
e.preventDefault();
send($input.value);
});
}
if (document.readyState === "loading") {
document.addEventListener("DOMContentLoaded", init);
} else {
init();
}
})();

51
web/example.html Normal file
View file

@ -0,0 +1,51 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Rony Chat Widget — demo</title>
<link rel="stylesheet" href="chat-widget.css">
<style>
body {
max-width: 720px;
margin: 40px auto;
padding: 0 20px;
font: 16px/1.6 -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
color: #1a1a1a;
}
h1 { margin-bottom: 4px; }
.muted { color: #5c5c66; }
pre { background: #f5f5f7; padding: 12px; border-radius: 8px; overflow-x: auto; }
</style>
</head>
<body>
<h1>Rony Chat Widget — local demo</h1>
<p class="muted">The bubble in the bottom-right is the widget. It talks to the local chat-bot server on :7331.</p>
<h2>Try asking</h2>
<ul>
<li>“What database does the project use?”</li>
<li>“¿De qué trata el portfolio?” (responde en inglés porque el contenido indexado lo está)</li>
<li>“Tell me about the tech stack”</li>
</ul>
<h2>Embed snippet (copy this into your site)</h2>
<pre>&lt;link rel="stylesheet" href="/chat-widget.css"&gt;
&lt;script src="/chat-widget.js"
data-api-url="http://localhost:7331"
data-title="Ask me anything"
data-greeting="Hi! Ask me about the projects."
data-position="bottom-right"
data-theme="auto"
defer&gt;&lt;/script&gt;</pre>
<!-- The widget itself -->
<script src="chat-widget.js"
data-api-url="http://localhost:7331"
data-title="Rony Chat"
data-greeting="Hi! I'm Rony's assistant. Ask me about the projects."
data-position="bottom-right"
data-theme="auto"
defer></script>
</body>
</html>