Initial implementation of the bot: - cmd/chat-bot: CLI entrypoint (serve, reindex, ask, version) - internal/agent: LLM provider client + agent runner with RAG injection - internal/config: YAML config loader (providers, RAG, persona, server) - internal/i18n: response-language detection (EN/ES) - internal/persona: persona system prompt assembly from YAML - internal/portfolio: heading-based chunker + SQLite FTS5 indexer - internal/server: chi router with /api/chat (SSE), /api/health, /api/info, /api/reindex, middleware (RequestID, Logging, CORS, RateLimit) - internal/streaming: SSE protocol helpers (start, chunk, sources, done, error) - web/: drop-in vanilla-JS chat widget (no build, no deps) + demo + README - bench/: reproducible driver benchmark (modernc vs mattn SQLite) - configs/portfolio-bot.yaml: llama.cpp default provider, SQLite RAG, canine persona - docs/architecture.md / .es.md: aligned with SQLite FTS5 + llama.cpp decisions - data/projects/README*.md: project data documentation - README.md / .es.md: updated for current implementation All tests pass (go test ./...). Bot is functional end-to-end with the configured LLM provider.
176 lines
No EOL
4.1 KiB
Go
176 lines
No EOL
4.1 KiB
Go
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
|
|
} |