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>
424 lines
14 KiB
Go
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.")
|
|
}
|