Files
glpi-neural-brain/internal/config/config.go
T
groot 5387563e4e
release-tag / release-image (push) Successful in 1m42s
Update 4
2026-08-04 14:23:48 +02:00

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
}