rony-chat-bot/internal/server/health.go
Victor Hugo Vargas f33708534a feat: bootstrap rony-chat-bot Go module
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.
2026-07-17 00:56:06 -07:00

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}
}