Files
backend_fiesta/config/config.go
abhishek 8e1549764b Nearle Buddy answers a typed question
Phase 2: the loop and the model gateway. The composer in the console has
said "Not connected yet" since it was built, because there was no
assistant endpoint anywhere. There is one now.

- utils/chat.go   the gateway, a sibling of embedding.go: one small
                  interface, a provider switch, the shared postJSON, no
                  framework. Agents name a TIER (fast/balanced/deep) and
                  config maps tier to model, so changing provider does not
                  touch an agent.
- services/assistantService.go  one loop for every agent. An agent is a
                  name, a tier, a prompt and an allow-list — data, not a
                  class — so a sixth is config rather than a subclass.
- the endpoint under /v1/web, inheriting middleware.WebAuth along with
  every other console route. The assistant reads the same data the console
  does and must read it as the same person.

What the model does not get to decide:

  whose data      the caller is built from the verified session in the
                  controller, never from the request body — there is no
                  tenant field to fill in. A test scripts the model calling
                  a tool with {"tenantid": 916} and asserts it ran for 1147.
  which tools     the registry enforces the agent's allow-list; a test
                  scripts a call to a tool the agent lacks and asserts the
                  handler never ran.
  when to stop    steps and tool calls are counted here. A model that keeps
                  calling tools is stopped by arithmetic, not by being
                  asked nicely.

Two quiet failures have tests of their own. A finish_reason of "length"
means the provider cut the reply off mid-sentence, which reads exactly
like a complete answer unless it is flagged. And a truncated tool result
reaches the model in words it will repeat — otherwise it describes a
capped list and an empty one identically.

A refused tool goes back as a message, not an error: a model told "that
tool needs a tenant" can explain it, where a model handed nothing says
"something went wrong".

Optional, like the embedder. Without ASSISTANT_PROVIDER the endpoint
answers "not switched on here", the composer stays disabled, and the tools
still work — they are ordinary Go functions, and only turning a sentence
into a tool call needs a model.

14 tests, against a scripted model rather than a live provider: these are
about what the loop refuses to let a model do, and that has to hold for
any model, including one behaving badly.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-23 13:13:20 +05:30

424 lines
14 KiB
Go

// Package config is the one place the process reads its environment.
//
// Two jobs, in order:
//
// 1. Pick the right `.env` file for the environment we are in and load it.
// 2. Read every setting into a typed Config and refuse to start if anything
// required is missing — all of it, in one message, before a single
// connection is attempted.
//
// Before this, `main.go` loaded `.env` and nothing else. `.env.local` and
// `.env.production` described an `APP_ENV` switch that did not exist, so the
// only way to run against production was to overwrite `.env` by hand, and the
// only way to find out a variable was missing was a `log.Fatalf` from inside
// `db.Connect()` — one variable per restart.
//
// # Which file loads
//
// `APP_ENV` names the environment and defaults to "local":
//
// go run . → .env.local, then .env
// APP_ENV=production go run . → .env.production, then .env
//
// `.env` is a shared base loaded after the environment file. godotenv never
// overwrites a variable that is already set, so the order of precedence is:
//
// real environment > .env.<APP_ENV> > .env
//
// Neither file has to exist. On the deployed host every value comes from the
// platform's environment settings (Dokploy today, ConfigMaps/Secrets under
// Kubernetes) and there is no file at all — which is exactly why the
// Dockerfile's `ENV APP_ENV=production` and the .dockerignore matter: the
// image carries no `.env.*`, so it cannot fall back to localhost values that
// happen to be lying around in the build context.
package config
import (
"errors"
"fmt"
"log"
"os"
"strconv"
"strings"
"github.com/joho/godotenv"
)
// Environment names. Anything else is accepted (a staging file works the same
// way) but only these two change behaviour.
const (
EnvLocal = "local"
EnvProduction = "production"
)
// Config is everything the process reads from its environment.
//
// A few settings are still read directly with os.Getenv at the point of use,
// because they are consulted per request or per connection rather than once
// at boot: POS_TOKEN_SECRET (utils/postoken.go), POS_AUTH_REQUIRED
// (middleware/posauth.go), GEOCODER_API_KEY (utils/geocode.go), and the MQTT_*
// and POS_* settings in package messaging. They are listed and validated here
// so that a misconfiguration is still caught at startup.
type Config struct {
// AppEnv is the value of APP_ENV: "local" or "production".
AppEnv string
// Port the API listens on. APP_PORT, default 1122 (production sets 1009).
Port string
DB DBConfig
Catalogue DBConfig // Host empty → catalogue endpoints disabled.
Redis RedisConfig
S3 S3Config
MQTT MQTTConfig
Embedding EmbeddingConfig
// Assistant is the model behind Nearle Buddy. Empty provider = no typed
// questions; the tools still work.
Assistant AssistantConfig
// POSTokenSecret signs terminal sessions. Falls back to JWTSecret when
// unset, matching utils/postoken.go.
POSTokenSecret string
JWTSecret string
UserContextKey string
GeocoderAPIKey string
}
// DBConfig is one Postgres connection.
type DBConfig struct {
Host string
Port string
Name string
User string
Password string
}
// Enabled reports whether a host was configured at all. Only meaningful for
// the optional catalogue connection; the main database is required.
func (d DBConfig) Enabled() bool { return d.Host != "" }
// RedisConfig is the shared Redis used for POS terminal presence. Optional:
// Host empty means presence is disabled and bills still commit.
type RedisConfig struct {
Host string
Port string
User string
Password string
DB int
}
func (r RedisConfig) Enabled() bool { return r.Host != "" }
// S3Config is the DigitalOcean Spaces bucket holding catalogue product images.
type S3Config struct {
Enabled bool // USE_S3=true
Endpoint string
Bucket string
AccessKey string
SecretKey string
Region string
}
// MQTTConfig is the broker the in-store tills publish to. Optional: URL empty
// means the MQTT ingest and the console live stream stay quiet.
type MQTTConfig struct {
URL string
User string
Password string
ClientID string
}
func (m MQTTConfig) Enabled() bool { return m.URL != "" }
// EmbeddingConfig is the text-embedding model behind the scan-to-product
// search (services/scanService.go). It MUST be the model that filled the
// catalogue's `embedding` column — vectors from two different models are not
// comparable, and pgvector will happily rank garbage. Optional: with no
// provider the search falls back to plain text matching.
type EmbeddingConfig struct {
Provider string // "openai" (any OpenAI-compatible endpoint) or "gemini"
Model string
APIKey string
BaseURL string // OpenAI-compatible only; default https://api.openai.com/v1
Dimensions int // 0 = the model's default
}
func (e EmbeddingConfig) Enabled() bool { return e.Provider != "" }
// AssistantConfig is the model behind Nearle Buddy.
//
// Optional, like the embedder. With no provider the assistant refuses typed
// questions and says so — the tools still work and still answer correctly,
// because they are ordinary Go functions; only the part that turns a sentence
// into a tool call is missing.
//
// ── Why three models and not one ────────────────────────────────────────────
//
// An agent names a TIER, never a model. "Which branch is underperforming?"
// and "why is the cancel rate high?" want different amounts of thinking, and
// wiring a model name into an agent means changing every agent to change
// provider. The tiers are the stable vocabulary; this map is the only place a
// model name appears.
//
// `ASSISTANT_MODEL` alone sets all three, which is the sane default for a
// deployment that has not thought about it yet.
type AssistantConfig struct {
Provider string // "openai" — any OpenAI-compatible endpoint
BaseURL string // default https://api.openai.com/v1
APIKey string
// Tier → model name. Empty falls back to Balanced, which falls back to
// ASSISTANT_MODEL.
Fast string
Balanced string
Deep string
}
func (a AssistantConfig) Enabled() bool { return a.Provider != "" && a.Balanced != "" }
// ModelFor resolves a tier to a model name, falling back rather than failing.
//
// A missing `fast` model should answer a cheap question with the balanced one,
// not refuse it. A deployment that sets one model gets one model everywhere.
func (a AssistantConfig) ModelFor(tier string) string {
switch tier {
case "fast":
if a.Fast != "" {
return a.Fast
}
case "deep":
if a.Deep != "" {
return a.Deep
}
}
return a.Balanced
}
// IsProduction is true under APP_ENV=production.
func (c *Config) IsProduction() bool { return c.AppEnv == EnvProduction }
// Load picks and loads the environment files, reads every setting and
// validates them. The returned error lists every problem at once.
func Load() (*Config, error) {
loadEnvFiles()
cfg := &Config{
AppEnv: env("APP_ENV", EnvLocal),
Port: env("APP_PORT", "1122"),
DB: DBConfig{
Host: env("DB_HOST", ""),
Port: env("DB_PORT", "5433"),
Name: env("DB_NAME", ""),
User: env("DB_USER", ""),
Password: env("DB_PASSWORD", ""),
},
Catalogue: DBConfig{
Host: env("CATALOGUE_DB_HOST", ""),
Port: env("CATALOGUE_DB_PORT", "5432"),
Name: env("CATALOGUE_DB_NAME", ""),
User: env("CATALOGUE_DB_USER", ""),
Password: env("CATALOGUE_DB_PASSWORD", ""),
},
Redis: RedisConfig{
Host: env("REDIS_HOST", ""),
Port: env("REDIS_PORT", "6379"),
User: env("REDIS_USER", "default"),
Password: env("REDIS_PASSWORD", ""),
},
S3: S3Config{
Enabled: strings.EqualFold(env("USE_S3", ""), "true"),
Endpoint: env("S3_ENDPOINT", ""),
Bucket: env("S3_BUCKET", ""),
AccessKey: env("S3_ACCESS_KEY", ""),
SecretKey: env("S3_SECRET_KEY", ""),
Region: env("S3_REGION", ""),
},
MQTT: MQTTConfig{
URL: env("MQTT_URL", ""),
// MQTT_USERNAME is accepted because livehub.go read that name for
// a while, so an existing deployment may still set it.
User: env("MQTT_USER", env("MQTT_USERNAME", "")),
Password: env("MQTT_PASSWORD", ""),
ClientID: env("MQTT_CLIENT_ID", ""),
},
Embedding: EmbeddingConfig{
Provider: strings.ToLower(env("EMBEDDING_PROVIDER", "")),
Model: env("EMBEDDING_MODEL", ""),
APIKey: env("EMBEDDING_API_KEY", ""),
BaseURL: env("EMBEDDING_BASE_URL", ""),
},
Assistant: AssistantConfig{
Provider: strings.ToLower(env("ASSISTANT_PROVIDER", "")),
BaseURL: env("ASSISTANT_BASE_URL", ""),
APIKey: env("ASSISTANT_API_KEY", ""),
Fast: env("ASSISTANT_MODEL_FAST", ""),
// ASSISTANT_MODEL alone sets every tier, for a deployment that has
// not thought about tiers yet.
Balanced: env("ASSISTANT_MODEL_BALANCED", env("ASSISTANT_MODEL", "")),
Deep: env("ASSISTANT_MODEL_DEEP", ""),
},
POSTokenSecret: env("POS_TOKEN_SECRET", ""),
JWTSecret: env("JWT_SECRET_KEY", ""),
UserContextKey: env("USER_CONTEXT_KEY", "nearle"),
GeocoderAPIKey: env("GEOCODER_API_KEY", ""),
}
if db, err := strconv.Atoi(env("REDIS_DB", "0")); err == nil {
cfg.Redis.DB = db
} else {
return nil, fmt.Errorf("REDIS_DB must be a number, got %q", env("REDIS_DB", ""))
}
if dims := env("EMBEDDING_DIMENSIONS", "0"); dims != "0" {
n, err := strconv.Atoi(dims)
if err != nil || n < 0 {
return nil, fmt.Errorf("EMBEDDING_DIMENSIONS must be a number, got %q", dims)
}
cfg.Embedding.Dimensions = n
}
if err := cfg.validate(); err != nil {
return nil, err
}
return cfg, nil
}
// MustLoad is Load for main(): every problem is printed and the process exits.
func MustLoad() *Config {
cfg, err := Load()
if err != nil {
log.Fatalf("❌ configuration is not usable:\n%v\n\nSee .env.example for every setting.", err)
}
log.Printf("config: APP_ENV=%s, listening on :%s, database %s@%s:%s/%s",
cfg.AppEnv, cfg.Port, cfg.DB.User, cfg.DB.Host, cfg.DB.Port, cfg.DB.Name)
return cfg
}
// validate collects every problem rather than stopping at the first, so one
// restart is enough to learn everything that is wrong.
func (c *Config) validate() error {
var problems []string
missing := func(key string) { problems = append(problems, " - "+key+" is required") }
if c.DB.Host == "" {
missing("DB_HOST")
}
if c.DB.User == "" {
missing("DB_USER")
}
if c.DB.Password == "" {
missing("DB_PASSWORD")
}
if c.DB.Name == "" {
missing("DB_NAME")
}
// Optional subsystems are either fully configured or absent. Half a
// configuration used to be skipped with a warning, which reads as "fine"
// in a log and turns into "why are there no images" a week later.
if c.Catalogue.Enabled() {
if c.Catalogue.User == "" {
missing("CATALOGUE_DB_USER (CATALOGUE_DB_HOST is set)")
}
if c.Catalogue.Password == "" {
missing("CATALOGUE_DB_PASSWORD (CATALOGUE_DB_HOST is set)")
}
if c.Catalogue.Name == "" {
missing("CATALOGUE_DB_NAME (CATALOGUE_DB_HOST is set)")
}
}
if c.S3.Enabled {
if c.S3.Endpoint == "" {
missing("S3_ENDPOINT (USE_S3=true)")
}
if c.S3.Bucket == "" {
missing("S3_BUCKET (USE_S3=true)")
}
if c.S3.AccessKey == "" {
missing("S3_ACCESS_KEY (USE_S3=true)")
}
if c.S3.SecretKey == "" {
missing("S3_SECRET_KEY (USE_S3=true)")
}
if c.S3.Region == "" {
missing("S3_REGION (USE_S3=true)")
}
}
if c.Embedding.Enabled() {
switch c.Embedding.Provider {
case "openai", "gemini":
default:
problems = append(problems, " - EMBEDDING_PROVIDER must be openai or gemini, got "+c.Embedding.Provider)
}
if c.Embedding.Model == "" {
missing("EMBEDDING_MODEL (EMBEDDING_PROVIDER is set)")
}
if c.Embedding.APIKey == "" {
missing("EMBEDDING_API_KEY (EMBEDDING_PROVIDER is set)")
}
}
if c.IsProduction() {
// utils/postoken.go refuses to sign with a short secret at request
// time; catching it here means the first till login is not the first
// anyone hears of it.
secret := c.POSTokenSecret
if secret == "" {
secret = c.JWTSecret
}
if strings.TrimSpace(secret) == "" {
missing("POS_TOKEN_SECRET (production; JWT_SECRET_KEY is accepted as a fallback)")
} else if len(strings.TrimSpace(secret)) < 16 {
problems = append(problems, " - POS_TOKEN_SECRET must be at least 16 characters")
}
} else if c.DB.Host != "" && !isLocalHost(c.DB.Host) {
// Not fatal: a dump restored on another machine on the LAN is a valid
// local setup. But `.env.local` pointing at the live host is the
// mistake every comment in that file warns about, so say it out loud.
log.Printf("⚠️ APP_ENV=%s but DB_HOST=%s is not a local address — every write goes to that database for real",
c.AppEnv, c.DB.Host)
}
if len(problems) == 0 {
return nil
}
return errors.New(strings.Join(problems, "\n"))
}
// loadEnvFiles loads `.env.<APP_ENV>` and then `.env`, each only if present.
//
// APP_ENV is read from the real environment before any file, so a file cannot
// change which environment it is loaded for.
func loadEnvFiles() {
appEnv := env("APP_ENV", EnvLocal)
for _, name := range []string{".env." + appEnv, ".env"} {
if _, err := os.Stat(name); err != nil {
continue
}
if err := godotenv.Load(name); err != nil {
log.Printf("config: could not read %s: %v", name, err)
continue
}
log.Printf("config: loaded %s", name)
}
}
func env(key, fallback string) string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
}
return fallback
}
func isLocalHost(host string) bool {
switch strings.ToLower(host) {
case "localhost", "127.0.0.1", "::1", "host.docker.internal":
return true
}
return strings.HasPrefix(host, "127.")
}