368 lines
14 KiB
Go
368 lines
14 KiB
Go
package config
|
|
|
|
import (
|
|
"fmt"
|
|
"net/url"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
type Config struct {
|
|
ListenAddr string
|
|
DataDir string
|
|
KnowledgeDirs []string
|
|
StagingDirs []string
|
|
AgentRunsFiles []string
|
|
OllamaURL string
|
|
OllamaURLs []string
|
|
OllamaNodeNames []string
|
|
OllamaNodeWeights []int
|
|
OllamaRoutingMode string
|
|
OllamaNodeMaxInflight int
|
|
OllamaHealthInterval time.Duration
|
|
OllamaFailureCooldown time.Duration
|
|
OllamaRequestTimeout time.Duration
|
|
OllamaFailoverEnabled bool
|
|
OllamaFailoverAttempts int
|
|
OllamaRequireSameDigest bool
|
|
OllamaRequireEmbeddingModel bool
|
|
ChatModel string
|
|
EmbeddingModel string
|
|
SearXNGURL string
|
|
ScanInterval time.Duration
|
|
PersistInterval time.Duration
|
|
EnrichInterval time.Duration
|
|
EnrichStepDelay time.Duration
|
|
EnrichBatchSize int
|
|
EnrichAnchors int
|
|
SimilarityThreshold float64
|
|
RelationThreshold float64
|
|
ArticleSynthesisEnabled bool
|
|
ArticleMinSources int
|
|
ArticleMaxSources int
|
|
ArticleMinProductionRatio float64
|
|
ArticleMaxGenerationDepth int
|
|
ArticleMinConfidence float64
|
|
ArticleMinTextChars int
|
|
ArticleMinAnswerChars int
|
|
ArticleMaxResearchQueries int
|
|
ArticleResearchResults int
|
|
TopK int
|
|
MaxContextChars int
|
|
AutoEnrich bool
|
|
ResearchEnabled bool
|
|
APIKey string
|
|
LearningEnabled bool
|
|
ThinkingEnabled bool
|
|
LearningCategories []string
|
|
DisplayCategories []string
|
|
ThinkingCategories []string
|
|
DefaultView string
|
|
MaxDisplayNodes int
|
|
RuntimeDefaultsConfigured bool
|
|
|
|
GLPIKBEnabled bool
|
|
GLPIURL string
|
|
GLPIAPIVersion string
|
|
GLPIClientID string
|
|
GLPIClientSecret string
|
|
GLPIUsername string
|
|
GLPIPassword string
|
|
GLPIAllowInsecure bool
|
|
GLPITimeout time.Duration
|
|
GLPIKBPath string
|
|
GLPIKBFilter string
|
|
GLPIKBLimit int
|
|
GLPIKBSyncInterval time.Duration
|
|
GLPIKBSource string
|
|
}
|
|
|
|
func Load() (Config, error) {
|
|
dataDir := env("BRAIN_DATA_DIR", "./data")
|
|
abs, err := filepath.Abs(dataDir)
|
|
if err != nil {
|
|
return Config{}, err
|
|
}
|
|
legacyOllamaURL := strings.TrimRight(env("OLLAMA_URL", "http://localhost:11434"), "/")
|
|
ollamaURLs := stringList("OLLAMA_URLS")
|
|
if len(ollamaURLs) == 0 {
|
|
ollamaURLs = []string{legacyOllamaURL}
|
|
}
|
|
for i := range ollamaURLs {
|
|
ollamaURLs[i] = strings.TrimRight(ollamaURLs[i], "/")
|
|
}
|
|
cfg := Config{
|
|
ListenAddr: env("BRAIN_LISTEN_ADDR", ":8090"),
|
|
DataDir: abs,
|
|
KnowledgeDirs: paths("BRAIN_KNOWLEDGE_DIRS"),
|
|
StagingDirs: paths("BRAIN_STAGING_DIRS"),
|
|
AgentRunsFiles: paths("BRAIN_AGENT_RUNS_FILES"),
|
|
OllamaURL: legacyOllamaURL,
|
|
OllamaURLs: ollamaURLs,
|
|
OllamaNodeNames: stringList("OLLAMA_NODE_NAMES"),
|
|
OllamaNodeWeights: intList("OLLAMA_NODE_WEIGHTS"),
|
|
OllamaRoutingMode: strings.ToLower(env("OLLAMA_ROUTING_MODE", "least_inflight")),
|
|
OllamaNodeMaxInflight: integer("OLLAMA_NODE_MAX_INFLIGHT", 1),
|
|
OllamaHealthInterval: duration("OLLAMA_NODE_HEALTH_INTERVAL", 15*time.Second),
|
|
OllamaFailureCooldown: duration("OLLAMA_NODE_FAILURE_COOLDOWN", 30*time.Second),
|
|
OllamaRequestTimeout: duration("OLLAMA_NODE_REQUEST_TIMEOUT", 8*time.Minute),
|
|
OllamaFailoverEnabled: boolean("OLLAMA_FAILOVER_ENABLED", true),
|
|
OllamaFailoverAttempts: integer("OLLAMA_FAILOVER_ATTEMPTS", 0),
|
|
OllamaRequireSameDigest: boolean("OLLAMA_REQUIRE_SAME_MODEL_DIGEST", true),
|
|
OllamaRequireEmbeddingModel: boolean("OLLAMA_REQUIRE_EMBEDDING_MODEL", true),
|
|
ChatModel: env("OLLAMA_CHAT_MODEL", "qwen3:8b"),
|
|
EmbeddingModel: env("OLLAMA_EMBEDDING_MODEL", "embeddinggemma"),
|
|
SearXNGURL: strings.TrimRight(strings.TrimSpace(os.Getenv("SEARXNG_URL")), "/"),
|
|
ScanInterval: duration("BRAIN_SCAN_INTERVAL", 20*time.Second),
|
|
PersistInterval: duration("BRAIN_PERSIST_INTERVAL", 5*time.Minute),
|
|
EnrichInterval: duration("BRAIN_ENRICH_INTERVAL", 90*time.Second),
|
|
EnrichStepDelay: duration("BRAIN_ENRICH_STEP_DELAY", 3*time.Second),
|
|
EnrichBatchSize: integer("BRAIN_ENRICH_BATCH_SIZE", 3),
|
|
EnrichAnchors: integer("BRAIN_ENRICH_ANCHORS", 48),
|
|
SimilarityThreshold: number("BRAIN_SIMILARITY_THRESHOLD", 0.68),
|
|
RelationThreshold: number("BRAIN_RELATION_THRESHOLD", 0.72),
|
|
ArticleSynthesisEnabled: boolean("BRAIN_ARTICLE_SYNTHESIS_ENABLED", true),
|
|
ArticleMinSources: integer("BRAIN_ARTICLE_MIN_SOURCES", 3),
|
|
ArticleMaxSources: integer("BRAIN_ARTICLE_MAX_SOURCES", 8),
|
|
ArticleMinProductionRatio: number("BRAIN_ARTICLE_MIN_PRODUCTION_RATIO", 0.70),
|
|
ArticleMaxGenerationDepth: integer("BRAIN_ARTICLE_MAX_GENERATION_DEPTH", 2),
|
|
ArticleMinConfidence: number("BRAIN_ARTICLE_MIN_CONFIDENCE", 0.74),
|
|
ArticleMinTextChars: integer("BRAIN_ARTICLE_MIN_TEXT_CHARS", 180),
|
|
ArticleMinAnswerChars: integer("BRAIN_ARTICLE_MIN_ANSWER_CHARS", 420),
|
|
ArticleMaxResearchQueries: integer("BRAIN_ARTICLE_MAX_RESEARCH_QUERIES", 3),
|
|
ArticleResearchResults: integer("BRAIN_ARTICLE_RESEARCH_RESULTS", 4),
|
|
TopK: integer("BRAIN_TOP_K", 8),
|
|
MaxContextChars: integer("BRAIN_MAX_CONTEXT_CHARS", 16000),
|
|
AutoEnrich: boolean("BRAIN_AUTO_ENRICH", true),
|
|
ResearchEnabled: boolean("BRAIN_RESEARCH_ENABLED", false),
|
|
APIKey: strings.TrimSpace(os.Getenv("BRAIN_API_KEY")),
|
|
LearningEnabled: boolean("BRAIN_LEARNING_ENABLED", true),
|
|
ThinkingEnabled: boolean("BRAIN_THINKING_ENABLED", true),
|
|
LearningCategories: stringList("BRAIN_LEARNING_CATEGORIES"),
|
|
DisplayCategories: stringList("BRAIN_DISPLAY_CATEGORIES"),
|
|
ThinkingCategories: stringList("BRAIN_THINKING_CATEGORIES"),
|
|
DefaultView: strings.ToLower(env("BRAIN_DEFAULT_VIEW", "neural")),
|
|
MaxDisplayNodes: integer("BRAIN_MAX_DISPLAY_NODES", 0),
|
|
RuntimeDefaultsConfigured: true,
|
|
GLPIKBEnabled: boolean("GLPI_KB_ENABLED", false),
|
|
GLPIURL: strings.TrimRight(strings.TrimSpace(os.Getenv("GLPI_URL")), "/"),
|
|
GLPIAPIVersion: env("GLPI_API_VERSION", "v2.3"),
|
|
GLPIClientID: strings.TrimSpace(os.Getenv("GLPI_CLIENT_ID")),
|
|
GLPIClientSecret: strings.TrimSpace(os.Getenv("GLPI_CLIENT_SECRET")),
|
|
GLPIUsername: strings.TrimSpace(os.Getenv("GLPI_USERNAME")),
|
|
GLPIPassword: strings.TrimSpace(os.Getenv("GLPI_PASSWORD")),
|
|
GLPIAllowInsecure: boolean("GLPI_ALLOW_INSECURE_HTTP", false),
|
|
GLPITimeout: duration("GLPI_TIMEOUT", 20*time.Second),
|
|
GLPIKBPath: env("GLPI_KB_PATH", "auto"),
|
|
GLPIKBFilter: strings.TrimSpace(os.Getenv("GLPI_KB_FILTER")),
|
|
GLPIKBLimit: integer("GLPI_KB_LIMIT", 500),
|
|
GLPIKBSyncInterval: duration("GLPI_KB_SYNC_INTERVAL", 10*time.Minute),
|
|
GLPIKBSource: env("GLPI_KB_SOURCE", "GLPI Knowledge Base"),
|
|
}
|
|
if cfg.ScanInterval < 2*time.Second {
|
|
return Config{}, fmt.Errorf("BRAIN_SCAN_INTERVAL must be at least 2s")
|
|
}
|
|
if cfg.PersistInterval < 10*time.Second {
|
|
return Config{}, fmt.Errorf("BRAIN_PERSIST_INTERVAL must be at least 10s")
|
|
}
|
|
if cfg.EnrichInterval < 10*time.Second {
|
|
return Config{}, fmt.Errorf("BRAIN_ENRICH_INTERVAL must be at least 10s")
|
|
}
|
|
if cfg.EnrichStepDelay < 0 {
|
|
return Config{}, fmt.Errorf("BRAIN_ENRICH_STEP_DELAY must not be negative")
|
|
}
|
|
if cfg.EnrichBatchSize < 1 || cfg.EnrichBatchSize > 25 {
|
|
return Config{}, fmt.Errorf("BRAIN_ENRICH_BATCH_SIZE must be between 1 and 25")
|
|
}
|
|
if cfg.EnrichAnchors < 1 || cfg.EnrichAnchors > 2048 {
|
|
return Config{}, fmt.Errorf("BRAIN_ENRICH_ANCHORS must be between 1 and 2048")
|
|
}
|
|
if cfg.SimilarityThreshold < 0 || cfg.SimilarityThreshold > 1 {
|
|
return Config{}, fmt.Errorf("invalid similarity threshold")
|
|
}
|
|
if cfg.RelationThreshold < 0 || cfg.RelationThreshold > 1 {
|
|
return Config{}, fmt.Errorf("invalid relation threshold")
|
|
}
|
|
if cfg.ArticleMinSources < 2 || cfg.ArticleMinSources > 20 {
|
|
return Config{}, fmt.Errorf("BRAIN_ARTICLE_MIN_SOURCES must be between 2 and 20")
|
|
}
|
|
if cfg.ArticleMaxSources < cfg.ArticleMinSources || cfg.ArticleMaxSources > 32 {
|
|
return Config{}, fmt.Errorf("BRAIN_ARTICLE_MAX_SOURCES must be between BRAIN_ARTICLE_MIN_SOURCES and 32")
|
|
}
|
|
if cfg.ArticleMinProductionRatio < 0.5 || cfg.ArticleMinProductionRatio > 1 {
|
|
return Config{}, fmt.Errorf("BRAIN_ARTICLE_MIN_PRODUCTION_RATIO must be between 0.5 and 1")
|
|
}
|
|
if cfg.ArticleMaxGenerationDepth < 1 || cfg.ArticleMaxGenerationDepth > 8 {
|
|
return Config{}, fmt.Errorf("BRAIN_ARTICLE_MAX_GENERATION_DEPTH must be between 1 and 8")
|
|
}
|
|
if cfg.ArticleMinConfidence < 0 || cfg.ArticleMinConfidence > 1 {
|
|
return Config{}, fmt.Errorf("BRAIN_ARTICLE_MIN_CONFIDENCE must be between 0 and 1")
|
|
}
|
|
if cfg.ArticleMinTextChars < 50 || cfg.ArticleMinTextChars > 10000 {
|
|
return Config{}, fmt.Errorf("BRAIN_ARTICLE_MIN_TEXT_CHARS must be between 50 and 10000")
|
|
}
|
|
if cfg.ArticleMinAnswerChars < 100 || cfg.ArticleMinAnswerChars > 50000 {
|
|
return Config{}, fmt.Errorf("BRAIN_ARTICLE_MIN_ANSWER_CHARS must be between 100 and 50000")
|
|
}
|
|
if cfg.ArticleMaxResearchQueries < 1 || cfg.ArticleMaxResearchQueries > 10 {
|
|
return Config{}, fmt.Errorf("BRAIN_ARTICLE_MAX_RESEARCH_QUERIES must be between 1 and 10")
|
|
}
|
|
if cfg.ArticleResearchResults < 1 || cfg.ArticleResearchResults > 10 {
|
|
return Config{}, fmt.Errorf("BRAIN_ARTICLE_RESEARCH_RESULTS must be between 1 and 10")
|
|
}
|
|
if cfg.ResearchEnabled && cfg.SearXNGURL == "" {
|
|
return Config{}, fmt.Errorf("BRAIN_RESEARCH_ENABLED requires SEARXNG_URL")
|
|
}
|
|
if cfg.DefaultView != "neural" && cfg.DefaultView != "honeycomb" {
|
|
return Config{}, fmt.Errorf("BRAIN_DEFAULT_VIEW must be neural or honeycomb")
|
|
}
|
|
if cfg.MaxDisplayNodes < 0 || cfg.MaxDisplayNodes > 500000 {
|
|
return Config{}, fmt.Errorf("BRAIN_MAX_DISPLAY_NODES must be between 0 and 500000")
|
|
}
|
|
if len(cfg.OllamaURLs) < 1 || len(cfg.OllamaURLs) > 64 {
|
|
return Config{}, fmt.Errorf("OLLAMA_URLS must contain between 1 and 64 nodes")
|
|
}
|
|
for _, raw := range cfg.OllamaURLs {
|
|
u, err := url.Parse(raw)
|
|
if err != nil || u.Scheme == "" || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
|
|
return Config{}, fmt.Errorf("invalid Ollama URL %q", raw)
|
|
}
|
|
}
|
|
if len(cfg.OllamaNodeNames) > 0 && len(cfg.OllamaNodeNames) != len(cfg.OllamaURLs) {
|
|
return Config{}, fmt.Errorf("OLLAMA_NODE_NAMES must contain one name per OLLAMA_URLS entry")
|
|
}
|
|
if len(cfg.OllamaNodeWeights) > 0 && len(cfg.OllamaNodeWeights) != len(cfg.OllamaURLs) {
|
|
return Config{}, fmt.Errorf("OLLAMA_NODE_WEIGHTS must contain one weight per OLLAMA_URLS entry")
|
|
}
|
|
for _, weight := range cfg.OllamaNodeWeights {
|
|
if weight < 1 || weight > 1000 {
|
|
return Config{}, fmt.Errorf("OLLAMA_NODE_WEIGHTS values must be between 1 and 1000")
|
|
}
|
|
}
|
|
switch cfg.OllamaRoutingMode {
|
|
case "least_inflight", "round_robin", "weighted", "fastest_recent":
|
|
default:
|
|
return Config{}, fmt.Errorf("invalid OLLAMA_ROUTING_MODE %q", cfg.OllamaRoutingMode)
|
|
}
|
|
if cfg.OllamaNodeMaxInflight < 1 || cfg.OllamaNodeMaxInflight > 64 {
|
|
return Config{}, fmt.Errorf("OLLAMA_NODE_MAX_INFLIGHT must be between 1 and 64")
|
|
}
|
|
if cfg.OllamaHealthInterval < time.Second || cfg.OllamaFailureCooldown < time.Second || cfg.OllamaRequestTimeout < time.Second {
|
|
return Config{}, fmt.Errorf("Ollama health, cooldown and request timeout must be at least 1s")
|
|
}
|
|
if cfg.GLPIKBEnabled {
|
|
u, err := url.Parse(cfg.GLPIURL)
|
|
if err != nil || u.Scheme == "" || u.Host == "" {
|
|
return Config{}, fmt.Errorf("GLPI_URL must be a valid absolute URL when GLPI_KB_ENABLED=true")
|
|
}
|
|
if u.Scheme != "https" && !(u.Scheme == "http" && cfg.GLPIAllowInsecure) {
|
|
return Config{}, fmt.Errorf("GLPI_URL must use https unless GLPI_ALLOW_INSECURE_HTTP=true")
|
|
}
|
|
for name, value := range map[string]string{"GLPI_CLIENT_ID": cfg.GLPIClientID, "GLPI_CLIENT_SECRET": cfg.GLPIClientSecret, "GLPI_USERNAME": cfg.GLPIUsername, "GLPI_PASSWORD": cfg.GLPIPassword} {
|
|
if value == "" {
|
|
return Config{}, fmt.Errorf("%s is required when GLPI_KB_ENABLED=true", name)
|
|
}
|
|
}
|
|
if cfg.GLPIKBLimit < 1 || cfg.GLPIKBLimit > 5000 {
|
|
return Config{}, fmt.Errorf("GLPI_KB_LIMIT must be between 1 and 5000")
|
|
}
|
|
if cfg.GLPIKBSyncInterval < time.Minute {
|
|
return Config{}, fmt.Errorf("GLPI_KB_SYNC_INTERVAL must be at least 1m")
|
|
}
|
|
if cfg.GLPIKBPath != "auto" && !strings.HasPrefix(cfg.GLPIKBPath, "/") {
|
|
return Config{}, fmt.Errorf("GLPI_KB_PATH must be auto or an absolute API path")
|
|
}
|
|
}
|
|
if err := os.MkdirAll(cfg.DataDir, 0o750); err != nil {
|
|
return Config{}, err
|
|
}
|
|
return cfg, nil
|
|
}
|
|
|
|
func env(k, d string) string {
|
|
if v := strings.TrimSpace(os.Getenv(k)); v != "" {
|
|
return v
|
|
}
|
|
return d
|
|
}
|
|
func paths(k string) []string {
|
|
var out []string
|
|
for _, v := range strings.Split(os.Getenv(k), ",") {
|
|
if v = strings.TrimSpace(v); v != "" {
|
|
if a, e := filepath.Abs(v); e == nil {
|
|
out = append(out, a)
|
|
}
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
func stringList(k string) []string {
|
|
var out []string
|
|
for _, value := range strings.Split(os.Getenv(k), ",") {
|
|
if value = strings.TrimSpace(value); value != "" {
|
|
out = append(out, value)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
func intList(k string) []int {
|
|
var out []int
|
|
for _, value := range stringList(k) {
|
|
n, err := strconv.Atoi(value)
|
|
if err != nil {
|
|
out = append(out, 0)
|
|
continue
|
|
}
|
|
out = append(out, n)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func duration(k string, d time.Duration) time.Duration {
|
|
v := strings.TrimSpace(os.Getenv(k))
|
|
if v == "" {
|
|
return d
|
|
}
|
|
x, e := time.ParseDuration(v)
|
|
if e != nil {
|
|
return d
|
|
}
|
|
return x
|
|
}
|
|
func number(k string, d float64) float64 {
|
|
v := strings.TrimSpace(os.Getenv(k))
|
|
if v == "" {
|
|
return d
|
|
}
|
|
x, e := strconv.ParseFloat(v, 64)
|
|
if e != nil {
|
|
return d
|
|
}
|
|
return x
|
|
}
|
|
func integer(k string, d int) int {
|
|
v := strings.TrimSpace(os.Getenv(k))
|
|
if v == "" {
|
|
return d
|
|
}
|
|
x, e := strconv.Atoi(v)
|
|
if e != nil {
|
|
return d
|
|
}
|
|
return x
|
|
}
|
|
func boolean(k string, d bool) bool {
|
|
v := strings.TrimSpace(os.Getenv(k))
|
|
if v == "" {
|
|
return d
|
|
}
|
|
x, e := strconv.ParseBool(v)
|
|
if e != nil {
|
|
return d
|
|
}
|
|
return x
|
|
}
|