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.
188 lines
No EOL
5.9 KiB
Go
188 lines
No EOL
5.9 KiB
Go
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}
|
|
} |