package config import ( "fmt" "os" "strings" "gopkg.in/yaml.v3" ) type Server struct { Host string `yaml:"host"` Port int `yaml:"port"` ReadTimeoutMS int `yaml:"read_timeout_ms"` CORSOrigins []string `yaml:"cors_origins"` RateLimit RateLimit `yaml:"rate_limit"` } type RateLimit struct { RequestsPerMinute int `yaml:"requests_per_minute"` Burst int `yaml:"burst"` } // Provider is one upstream LLM. The sampling fields (Temperature through // PresencePenalty) are forwarded verbatim to the llama.cpp adapter; a zero // value means "don't send it", so llama-server's own default applies. They // matter more than they look: small instruct models ship with vendor- // recommended sampling (Gemma 3 wants temp 1.0 / top_k 64 / top_p 0.95) and // drifting off it makes them terse and repetitive. type Provider struct { Name string `yaml:"name"` Type string `yaml:"type"` Model string `yaml:"model,omitempty"` Endpoint string `yaml:"endpoint,omitempty"` ContextSize int `yaml:"context_size,omitempty"` MaxTokens int `yaml:"max_tokens,omitempty"` Temperature float32 `yaml:"temperature,omitempty"` TopK int `yaml:"top_k,omitempty"` TopP float32 `yaml:"top_p,omitempty"` MinP float32 `yaml:"min_p,omitempty"` // RepeatPenalty maps to llama.cpp's repeat_penalty. Leave unset (0) for // Gemma 3 — Google's recommended config is no repetition penalty at all, // and a value >1 visibly degrades its prose. RepeatPenalty float32 `yaml:"repeat_penalty,omitempty"` PresencePenalty float32 `yaml:"presence_penalty,omitempty"` APIKeyEnv string `yaml:"api_key_env,omitempty"` Default bool `yaml:"default,omitempty"` } type RAG struct { Enabled bool `yaml:"enabled"` DataPath string `yaml:"data_path"` // DocsPath is an optional second directory of markdown that is indexed // and searchable but is NOT part of the project catalogue: a CV, an about // page, a FAQ. Without it the only way to make the CV retrievable is to // drop it in data_path, where it then gets announced as one of Victor's // projects. Both directories accept .md and .mdx. DocsPath string `yaml:"docs_path"` DBPath string `yaml:"db_path"` TopK int `yaml:"top_k"` Tokenize string `yaml:"tokenize"` // IncludeCatalog injects the full list of indexed projects into the // system prompt on every turn. Costs a few tokens per project and stops // small models from inventing project names when asked to enumerate — // top-K retrieval can't answer "list everything" by construction. IncludeCatalog bool `yaml:"include_catalog"` } // Embeddings configures the semantic half of retrieval. Disabled by default, // in which case the bot uses keyword search only. // // Run the endpoint with: // // llama-server -m nomic-embed-v2-moe.Q5_K_M.gguf --port 9200 \ // --embedding --pooling mean --ctx-size 2048 --parallel 1 \ // --device none --threads 2 // // Measured at 0.61 GB RSS on CPU. It has to stay resident: the corpus is // embedded once at index time, but every visitor question must be embedded // before it can be compared. type Embeddings struct { Enabled bool `yaml:"enabled"` Endpoint string `yaml:"endpoint"` Model string `yaml:"model,omitempty"` BatchSize int `yaml:"batch_size,omitempty"` TimeoutMS int `yaml:"timeout_ms,omitempty"` } type Persona struct { Name string `yaml:"name"` Tone string `yaml:"tone"` Language string `yaml:"language"` } type Logging struct { Level string `yaml:"level"` Format string `yaml:"format"` Output string `yaml:"output"` } // Compaction controls automatic context-window management. When the most // recent request's input tokens exceed threshold_ratio × the provider's // reported MaxContextWindow, the older portion of the conversation is // summarized by the LLM and replaced with a single system-role note. // Recent turns (keep_recent_turns) are always preserved verbatim. // // Disabled by default; flip enabled: true in the YAML to opt in. The // model's MaxContextWindow comes from llm.LLMClient.Capabilities(), so // the same setting works for llamacpp, ollama, openai and anthropic // without per-provider configuration. type Compaction struct { Enabled bool `yaml:"enabled"` // ThresholdRatio is the fraction of the context window that, once // exceeded by the previous turn's input tokens, triggers compaction. // Range 0–1; default 0.75 (75% of the window). ThresholdRatio float64 `yaml:"threshold_ratio"` // KeepRecentTurns is how many of the most recent user turns (each // being a user message plus everything up to the next user message) // to keep verbatim after compaction. Older turns are folded into the // summary. Default 4. KeepRecentTurns int `yaml:"keep_recent_turns"` // SummarySystemPrompt is the instruction sent to the LLM when it // summarizes the older turns. Empty falls back to a built-in // bilingual prompt that auto-matches the conversation language. SummarySystemPrompt string `yaml:"summary_system_prompt"` } type Config struct { Server Server `yaml:"server"` Providers []Provider `yaml:"providers"` RAG RAG `yaml:"rag"` Embeddings Embeddings `yaml:"embeddings"` Persona Persona `yaml:"persona"` SystemPrompt string `yaml:"system_prompt"` // SystemPromptES is the Spanish rendition of SystemPrompt, used when the // visitor writes in Spanish. Optional; empty falls back to SystemPrompt. // // This is not a convenience — it's the only thing that reliably keeps a // small model answering in Spanish. An instruction like "reply in the // user's language" buried in an otherwise English prompt loses to the // sheer mass of English around it: measured on gemma-3-1b, 1 of 5 Spanish // questions came back in Spanish. Translating the prompt itself took that // to 4 of 5. (Few-shot Spanish examples also score well, but a 1B model // copies them verbatim instead of answering — see the note on // `system_prompt`.) SystemPromptES string `yaml:"system_prompt_es"` Compaction Compaction `yaml:"compaction"` Logging Logging `yaml:"logging"` } func Load(path string) (*Config, error) { raw, err := os.ReadFile(path) if err != nil { return nil, fmt.Errorf("read config %s: %w", path, err) } expanded := os.ExpandEnv(string(raw)) var cfg Config if err := yaml.Unmarshal([]byte(expanded), &cfg); err != nil { return nil, fmt.Errorf("parse config %s: %w", path, err) } if err := cfg.validate(); err != nil { return nil, err } return &cfg, nil } func (c *Config) validate() error { if len(c.Providers) == 0 { return fmt.Errorf("config: at least one provider must be configured") } defaultCount := 0 for _, p := range c.Providers { if p.Default { defaultCount++ } } if defaultCount == 0 { c.Providers[0].Default = true } else if defaultCount > 1 { return fmt.Errorf("config: multiple providers marked as default") } if c.Server.Port == 0 { c.Server.Port = 7331 } if c.Server.ReadTimeoutMS == 0 { c.Server.ReadTimeoutMS = 30000 } if c.RAG.TopK == 0 { c.RAG.TopK = 5 } if c.RAG.Tokenize == "" { c.RAG.Tokenize = "unicode61" } if c.Compaction.Enabled { if c.Compaction.ThresholdRatio <= 0 || c.Compaction.ThresholdRatio > 1 { c.Compaction.ThresholdRatio = 0.75 } if c.Compaction.KeepRecentTurns <= 0 { c.Compaction.KeepRecentTurns = 4 } } return nil } func (c *Config) DefaultProvider() *Provider { for i := range c.Providers { if c.Providers[i].Default { return &c.Providers[i] } } return &c.Providers[0] } func (c *Config) Addr() string { return fmt.Sprintf("%s:%d", c.Server.Host, c.Server.Port) } func splitPath(path string) []string { return strings.Split(path, string(os.PathSeparator)) }