Files
glpi-ai-agent/internal/config/config.go
T
groot a10225d74c
release-tag / release-image (push) Successful in 1m41s
AI-Label hinzugefügt
2026-07-29 14:53:12 +02:00

706 lines
27 KiB
Go

package config
import (
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"strconv"
"strings"
"time"
)
type Config struct {
HTTPAddr string
DataDir string
DryRun bool
LogLevel string
WebUsername string
WebPassword string
WebAllowAnonymous bool
WebhookSecret string
GLPIURL string
GLPIAPIVersion string
GLPIClientID string
GLPIClientSecret string
GLPIUsername string
GLPIPassword string
GLPIPollInterval time.Duration
GLPIPollLimit int
GLPITicketFilter string
GLPITimeout time.Duration
GLPIAgentUserID int64
GLPIAllowInsecureHTTP bool
GLPIAllowedStatusIDs []int64
OllamaURL string
OllamaModel string
OllamaEmbeddingModel string
OllamaTimeout time.Duration
OllamaNumPredict int
OllamaKeepAlive time.Duration
OllamaThink bool
OllamaMaxConcurrent int
OllamaJSONRetries int
KnowledgeDir string
RAGEnabled bool
KnowledgeTopK int
KnowledgeAuditTopK int
KnowledgeCandidateMaxGap float64
CategoryPromptLimit int
KnowledgeAllowedSources []string
KnowledgeAutoReplySources []string
KnowledgeWebEditEnabled bool
KnowledgeCategoryMode string
KnowledgeCategoryMapFile string
KnowledgeIgnoreGlobs []string
KnowledgeSemanticWeight float64
KnowledgeTitleWeight float64
KnowledgeLexicalWeight float64
KnowledgeKeywordWeight float64
KnowledgeCategoryWeight float64
KnowledgeEmbeddingProfile string
KnowledgeChunkWords int
KnowledgeChunkOverlapWords int
KnowledgeMaxChunksPerDoc int
KnowledgeMaxQueryChunks int
KnowledgeIndexMode string
KnowledgeEmbedBatchSize int
KnowledgeIndexScanInterval time.Duration
GLPIKBEnabled bool
GLPIKBPath string
GLPIKBFilter string
GLPIKBLimit int
GLPIKBSyncInterval time.Duration
GLPIKBSource string
GLPIKBAutoReply bool
GLPIKBAutoReplyCategoryIDs []int64
LearningEnabled bool
LearningMaxExamples int
LearningExamplesPerCategory int
CommunicationLanguage string
CommunicationStyle string
CommunicationSalutation string
CommunicationClosing string
CommunicationSignature string
AIContentLabelEnabled bool
AutoCategory bool
AutoReply bool
CategoryConfidence float64
ReplyConfidence float64
KnowledgeMinScore float64
KnowledgeRetrievalFloor float64
KnowledgeEvidenceRetrievalWeight float64
KnowledgeEvidenceAIWeight float64
KnowledgeEvidenceCategoryWeight float64
ContextEnabled bool
ContextTimeout time.Duration
ContextRelevanceMinScore float64
ContextBlockReplyOnError bool
ContextBlockReplyOnIncident bool
ChangeCalendarEnabled bool
GLPIChangePath string
GLPIChangeFilter string
GLPIChangeLimit int
ChangeLookback time.Duration
ChangeLookahead time.Duration
MajorIncidentsEnabled bool
GLPIMajorIncidentFilter string
GLPIMajorIncidentLimit int
UserDeviceContextEnabled bool
GLPIUserDevicePaths []string
GLPIUserDeviceFilterTemplate string
GLPIUserDeviceLimit int
UptimeKumaEnabled bool
UptimeKumaURL string
UptimeKumaMode string
UptimeKumaAPIKey string
UptimeKumaStatusPages []string
UptimeKumaTimeout time.Duration
UptimeKumaMaxIssues int
UptimeKumaIncludeMaintenance bool
QueueSize int
Workers int
}
func Load() (Config, error) {
c := Config{
HTTPAddr: env("HTTP_ADDR", ":8080"),
DataDir: env("DATA_DIR", "./data"),
DryRun: envBool("DRY_RUN", true),
LogLevel: env("LOG_LEVEL", "info"),
WebUsername: os.Getenv("WEB_USERNAME"),
WebPassword: os.Getenv("WEB_PASSWORD"),
WebAllowAnonymous: envBool("WEB_ALLOW_ANONYMOUS", false),
WebhookSecret: os.Getenv("WEBHOOK_SECRET"),
GLPIURL: strings.TrimRight(os.Getenv("GLPI_URL"), "/"),
GLPIAPIVersion: env("GLPI_API_VERSION", "v2.3"),
GLPIClientID: os.Getenv("GLPI_CLIENT_ID"),
GLPIClientSecret: os.Getenv("GLPI_CLIENT_SECRET"),
GLPIUsername: os.Getenv("GLPI_USERNAME"),
GLPIPassword: os.Getenv("GLPI_PASSWORD"),
GLPIPollInterval: envDuration("GLPI_POLL_INTERVAL", 30*time.Second),
GLPIPollLimit: envInt("GLPI_POLL_LIMIT", 50),
GLPITicketFilter: os.Getenv("GLPI_TICKET_FILTER"),
GLPITimeout: envDuration("GLPI_TIMEOUT", 20*time.Second),
GLPIAgentUserID: envInt64("GLPI_AGENT_USER_ID", 0),
GLPIAllowInsecureHTTP: envBool("GLPI_ALLOW_INSECURE_HTTP", false),
GLPIAllowedStatusIDs: envInt64List("GLPI_ALLOWED_STATUS_IDS", "1"),
OllamaURL: strings.TrimRight(env("OLLAMA_URL", "http://ollama:11434"), "/"),
OllamaModel: env("OLLAMA_MODEL", "qwen3:8b"),
OllamaEmbeddingModel: env("OLLAMA_EMBEDDING_MODEL", "embeddinggemma"),
OllamaTimeout: envDuration("OLLAMA_TIMEOUT", 10*time.Minute),
OllamaNumPredict: envInt("OLLAMA_NUM_PREDICT", 768),
OllamaKeepAlive: envDuration("OLLAMA_KEEP_ALIVE", 10*time.Minute),
OllamaThink: envBool("OLLAMA_THINK", false),
OllamaMaxConcurrent: envInt("OLLAMA_MAX_CONCURRENT", 1),
OllamaJSONRetries: envInt("OLLAMA_JSON_RETRIES", 1),
KnowledgeDir: env("KNOWLEDGE_DIR", "./knowledge"),
RAGEnabled: envBool("RAG_ENABLED", true),
KnowledgeTopK: envInt("KNOWLEDGE_TOP_K", 6),
KnowledgeAuditTopK: envInt("KNOWLEDGE_AUDIT_TOP_K", 10),
KnowledgeCandidateMaxGap: envFloat("KNOWLEDGE_CANDIDATE_MAX_GAP", 0.20),
CategoryPromptLimit: envInt("CATEGORY_PROMPT_LIMIT", 80),
KnowledgeAllowedSources: envStringList("KNOWLEDGE_ALLOWED_SOURCES", "internal-kb"),
KnowledgeAutoReplySources: envStringList("KNOWLEDGE_AUTO_REPLY_SOURCES", "internal-kb"),
KnowledgeWebEditEnabled: envBool("KNOWLEDGE_WEB_EDIT_ENABLED", false),
KnowledgeCategoryMode: envNormalizedLower("KNOWLEDGE_CATEGORY_MODE", "unscoped"),
KnowledgeCategoryMapFile: strings.TrimSpace(os.Getenv("KNOWLEDGE_CATEGORY_MAP_FILE")),
KnowledgeIgnoreGlobs: envStringListPreserveCase("KNOWLEDGE_IGNORE_GLOBS", ""),
KnowledgeSemanticWeight: envFloat("KNOWLEDGE_WEIGHT_SEMANTIC", 0.45),
KnowledgeTitleWeight: envFloat("KNOWLEDGE_WEIGHT_TITLE", 0.20),
KnowledgeLexicalWeight: envFloat("KNOWLEDGE_WEIGHT_LEXICAL", 0.20),
KnowledgeKeywordWeight: envFloat("KNOWLEDGE_WEIGHT_KEYWORDS", 0.075),
KnowledgeCategoryWeight: envFloat("KNOWLEDGE_WEIGHT_CATEGORY", 0.075),
KnowledgeEmbeddingProfile: envNormalizedLower("KNOWLEDGE_EMBEDDING_PROFILE", "auto"),
KnowledgeChunkWords: envInt("KNOWLEDGE_CHUNK_WORDS", 160),
KnowledgeChunkOverlapWords: envInt("KNOWLEDGE_CHUNK_OVERLAP_WORDS", 30),
KnowledgeMaxChunksPerDoc: envInt("KNOWLEDGE_MAX_CHUNKS_PER_DOC", 24),
KnowledgeMaxQueryChunks: envInt("KNOWLEDGE_MAX_QUERY_CHUNKS", 64),
KnowledgeIndexMode: envNormalizedLower("KNOWLEDGE_INDEX_MODE", "incremental"),
KnowledgeEmbedBatchSize: envInt("KNOWLEDGE_EMBED_BATCH_SIZE", 64),
KnowledgeIndexScanInterval: envDuration("KNOWLEDGE_INDEX_SCAN_INTERVAL", 5*time.Minute),
GLPIKBEnabled: envBool("GLPI_KB_ENABLED", false),
GLPIKBPath: env("GLPI_KB_PATH", "auto"),
GLPIKBFilter: strings.TrimSpace(os.Getenv("GLPI_KB_FILTER")),
GLPIKBLimit: envInt("GLPI_KB_LIMIT", 500),
GLPIKBSyncInterval: envDuration("GLPI_KB_SYNC_INTERVAL", 10*time.Minute),
GLPIKBSource: strings.ToLower(env("GLPI_KB_SOURCE", "glpi-kb")),
GLPIKBAutoReply: envBool("GLPI_KB_AUTO_REPLY", false),
GLPIKBAutoReplyCategoryIDs: envInt64ListAllowEmpty("GLPI_KB_AUTO_REPLY_CATEGORY_IDS"),
LearningEnabled: envBool("LEARNING_ENABLED", true),
LearningMaxExamples: envInt("LEARNING_MAX_EXAMPLES", 500),
LearningExamplesPerCategory: envInt("LEARNING_EXAMPLES_PER_CATEGORY", 5),
CommunicationLanguage: env("COMMUNICATION_LANGUAGE", "de-DE"),
CommunicationStyle: strings.ToLower(env("COMMUNICATION_STYLE", "formal")),
CommunicationSalutation: env("COMMUNICATION_SALUTATION", "Guten Tag,"),
CommunicationClosing: env("COMMUNICATION_CLOSING", "Mit freundlichen Grüßen"),
CommunicationSignature: env("COMMUNICATION_SIGNATURE", "IT-Service"),
AutoCategory: envBool("AUTO_CATEGORY", true),
AutoReply: envBool("AUTO_REPLY", false),
CategoryConfidence: envFloat("CATEGORY_CONFIDENCE", 0.90),
ReplyConfidence: envFloat("REPLY_CONFIDENCE", 0.97),
KnowledgeMinScore: envFloat("KNOWLEDGE_MIN_SCORE", 0.70),
KnowledgeRetrievalFloor: envFloat("KNOWLEDGE_RETRIEVAL_FLOOR", 0.30),
KnowledgeEvidenceRetrievalWeight: envFloat("KNOWLEDGE_EVIDENCE_WEIGHT_RETRIEVAL", 0.45),
KnowledgeEvidenceAIWeight: envFloat("KNOWLEDGE_EVIDENCE_WEIGHT_AI", 0.35),
KnowledgeEvidenceCategoryWeight: envFloat("KNOWLEDGE_EVIDENCE_WEIGHT_CATEGORY", 0.20),
ContextEnabled: envBool("CONTEXT_ENABLED", true),
ContextTimeout: envDuration("CONTEXT_TIMEOUT", 12*time.Second),
ContextRelevanceMinScore: envFloat("CONTEXT_RELEVANCE_MIN_SCORE", 0.20),
ContextBlockReplyOnError: envBool("CONTEXT_BLOCK_AUTO_REPLY_ON_ERRORS", true),
ContextBlockReplyOnIncident: envBool("CONTEXT_BLOCK_AUTO_REPLY_ON_INCIDENT", true),
ChangeCalendarEnabled: envBool("CHANGE_CALENDAR_ENABLED", true),
GLPIChangePath: env("GLPI_CHANGE_PATH", "/Assistance/Change"),
GLPIChangeFilter: os.Getenv("GLPI_CHANGE_FILTER"),
GLPIChangeLimit: envInt("GLPI_CHANGE_LIMIT", 100),
ChangeLookback: envDuration("CHANGE_LOOKBACK", 48*time.Hour),
ChangeLookahead: envDuration("CHANGE_LOOKAHEAD", 24*time.Hour),
MajorIncidentsEnabled: envBool("MAJOR_INCIDENTS_ENABLED", false),
GLPIMajorIncidentFilter: strings.TrimSpace(os.Getenv("GLPI_MAJOR_INCIDENT_FILTER")),
GLPIMajorIncidentLimit: envInt("GLPI_MAJOR_INCIDENT_LIMIT", 20),
UserDeviceContextEnabled: envBool("USER_DEVICE_CONTEXT_ENABLED", true),
GLPIUserDevicePaths: envPathList("GLPI_USER_DEVICE_PATHS", "/Assets/Computer"),
GLPIUserDeviceFilterTemplate: env("GLPI_USER_DEVICE_FILTER_TEMPLATE", "user.id=={{user_id}}"),
GLPIUserDeviceLimit: envInt("GLPI_USER_DEVICE_LIMIT", 20),
UptimeKumaEnabled: envBool("UPTIME_KUMA_ENABLED", false),
UptimeKumaURL: strings.TrimRight(os.Getenv("UPTIME_KUMA_URL"), "/"),
UptimeKumaMode: envNormalizedLower("UPTIME_KUMA_MODE", "metrics"),
UptimeKumaAPIKey: os.Getenv("UPTIME_KUMA_API_KEY"),
UptimeKumaStatusPages: envStringListPreserveCase("UPTIME_KUMA_STATUS_PAGES", ""),
UptimeKumaTimeout: envDuration("UPTIME_KUMA_TIMEOUT", 10*time.Second),
UptimeKumaMaxIssues: envInt("UPTIME_KUMA_MAX_ISSUES", 20),
UptimeKumaIncludeMaintenance: envBool("UPTIME_KUMA_INCLUDE_MAINTENANCE", true),
QueueSize: envInt("QUEUE_SIZE", 256),
Workers: envInt("WORKERS", 2),
}
return c, c.Validate()
}
func (c Config) Validate() error {
var missing []string
for name, value := range map[string]string{
"GLPI_URL": c.GLPIURL,
"GLPI_CLIENT_ID": c.GLPIClientID,
"GLPI_CLIENT_SECRET": c.GLPIClientSecret,
"GLPI_USERNAME": c.GLPIUsername,
"GLPI_PASSWORD": c.GLPIPassword,
} {
if strings.TrimSpace(value) == "" {
missing = append(missing, name)
}
}
if len(missing) > 0 {
return fmt.Errorf("missing required environment variables: %s", strings.Join(missing, ", "))
}
if !c.WebAllowAnonymous && (c.WebUsername == "" || c.WebPassword == "") {
return errors.New("WEB_USERNAME and WEB_PASSWORD are required unless WEB_ALLOW_ANONYMOUS=true")
}
if !c.WebAllowAnonymous && len(c.WebPassword) < 12 {
return errors.New("WEB_PASSWORD must contain at least 12 characters")
}
if !c.WebAllowAnonymous && isPlaceholder(c.WebPassword) {
return errors.New("WEB_PASSWORD still contains a CHANGE_ME placeholder")
}
if c.WebhookSecret != "" && len(c.WebhookSecret) < 24 {
return errors.New("WEBHOOK_SECRET must contain at least 24 characters when enabled")
}
if c.WebhookSecret != "" && isPlaceholder(c.WebhookSecret) {
return errors.New("WEBHOOK_SECRET still contains a CHANGE_ME placeholder")
}
for name, value := range map[string]string{"GLPI_CLIENT_ID": c.GLPIClientID, "GLPI_CLIENT_SECRET": c.GLPIClientSecret, "GLPI_PASSWORD": c.GLPIPassword} {
if isPlaceholder(value) {
return fmt.Errorf("%s still contains a CHANGE_ME placeholder", name)
}
}
u, err := url.Parse(c.GLPIURL)
if err != nil || u.Host == "" {
return errors.New("GLPI_URL must be a valid absolute URL")
}
if u.Scheme != "https" && u.Scheme != "http" {
return errors.New("GLPI_URL scheme must be http or https")
}
if u.Scheme == "http" && !c.GLPIAllowInsecureHTTP {
return errors.New("GLPI_URL must use https unless GLPI_ALLOW_INSECURE_HTTP=true")
}
if c.OllamaTimeout <= 0 {
return errors.New("OLLAMA_TIMEOUT must be > 0")
}
if c.OllamaNumPredict <= 0 || c.OllamaNumPredict > 4096 {
return errors.New("OLLAMA_NUM_PREDICT must be between 1 and 4096")
}
if c.OllamaKeepAlive < 0 {
return errors.New("OLLAMA_KEEP_ALIVE must be >= 0")
}
if c.OllamaMaxConcurrent <= 0 || c.OllamaMaxConcurrent > 32 {
return errors.New("OLLAMA_MAX_CONCURRENT must be between 1 and 32")
}
if c.OllamaJSONRetries < 0 || c.OllamaJSONRetries > 3 {
return errors.New("OLLAMA_JSON_RETRIES must be between 0 and 3")
}
if c.KnowledgeWebEditEnabled && c.WebAllowAnonymous {
return errors.New("KNOWLEDGE_WEB_EDIT_ENABLED requires authenticated dashboard access; WEB_ALLOW_ANONYMOUS must be false")
}
switch strings.ToLower(strings.TrimSpace(c.KnowledgeCategoryMode)) {
case "", "unscoped", "skip", "strict":
default:
return fmt.Errorf("KNOWLEDGE_CATEGORY_MODE must be one of: unscoped, skip, strict (got %q)", c.KnowledgeCategoryMode)
}
for _, pattern := range c.KnowledgeIgnoreGlobs {
if _, err := filepath.Match(pattern, "probe.json"); err != nil {
return fmt.Errorf("invalid KNOWLEDGE_IGNORE_GLOBS pattern %q: %w", pattern, err)
}
}
if c.KnowledgeTopK != 0 && (c.KnowledgeTopK < 1 || c.KnowledgeTopK > 20) {
return errors.New("KNOWLEDGE_TOP_K must be between 1 and 20")
}
if c.KnowledgeAuditTopK != 0 {
if c.KnowledgeAuditTopK > 50 || (c.KnowledgeTopK > 0 && c.KnowledgeAuditTopK < c.KnowledgeTopK) {
return errors.New("KNOWLEDGE_AUDIT_TOP_K must be >= KNOWLEDGE_TOP_K and <= 50")
}
}
if c.KnowledgeCandidateMaxGap < 0 || c.KnowledgeCandidateMaxGap > 1 {
return errors.New("KNOWLEDGE_CANDIDATE_MAX_GAP must be between 0 and 1")
}
weights := []float64{c.KnowledgeSemanticWeight, c.KnowledgeTitleWeight, c.KnowledgeLexicalWeight, c.KnowledgeKeywordWeight, c.KnowledgeCategoryWeight}
weightSum := 0.0
for _, w := range weights {
if w < 0 || w > 1 {
return errors.New("KNOWLEDGE_WEIGHT_* values must be between 0 and 1")
}
weightSum += w
}
// All-zero values are allowed for Config values constructed directly in tests/embedders;
// the knowledge store then applies its safe defaults. Values loaded from ENV are explicit.
_ = weightSum
if c.KnowledgeEmbeddingProfile != "" && c.KnowledgeEmbeddingProfile != "auto" && c.KnowledgeEmbeddingProfile != "plain" && c.KnowledgeEmbeddingProfile != "embeddinggemma" {
return errors.New("KNOWLEDGE_EMBEDDING_PROFILE must be auto, plain, or embeddinggemma")
}
if c.KnowledgeChunkWords != 0 && (c.KnowledgeChunkWords < 40 || c.KnowledgeChunkWords > 1000) {
return errors.New("KNOWLEDGE_CHUNK_WORDS must be between 40 and 1000")
}
if c.KnowledgeChunkOverlapWords < 0 || (c.KnowledgeChunkWords > 0 && c.KnowledgeChunkOverlapWords >= c.KnowledgeChunkWords) {
return errors.New("KNOWLEDGE_CHUNK_OVERLAP_WORDS must be >= 0 and smaller than KNOWLEDGE_CHUNK_WORDS")
}
if c.KnowledgeMaxChunksPerDoc != 0 && (c.KnowledgeMaxChunksPerDoc < 1 || c.KnowledgeMaxChunksPerDoc > 100) {
return errors.New("KNOWLEDGE_MAX_CHUNKS_PER_DOC must be between 1 and 100")
}
if c.KnowledgeMaxQueryChunks != 0 && (c.KnowledgeMaxQueryChunks < 1 || c.KnowledgeMaxQueryChunks > 200) {
return errors.New("KNOWLEDGE_MAX_QUERY_CHUNKS must be between 1 and 200")
}
switch c.KnowledgeIndexMode {
case "", "incremental", "rebuild", "readonly":
default:
return errors.New("KNOWLEDGE_INDEX_MODE must be one of: incremental, rebuild, readonly")
}
if c.KnowledgeEmbedBatchSize != 0 && (c.KnowledgeEmbedBatchSize < 1 || c.KnowledgeEmbedBatchSize > 256) {
return errors.New("KNOWLEDGE_EMBED_BATCH_SIZE must be between 1 and 256")
}
if c.KnowledgeIndexScanInterval < 0 {
return errors.New("KNOWLEDGE_INDEX_SCAN_INTERVAL must be >= 0")
}
if c.LearningEnabled {
if c.LearningMaxExamples < 1 || c.LearningMaxExamples > 10000 {
return errors.New("LEARNING_MAX_EXAMPLES must be between 1 and 10000")
}
if c.LearningExamplesPerCategory < 1 || c.LearningExamplesPerCategory > 20 {
return errors.New("LEARNING_EXAMPLES_PER_CATEGORY must be between 1 and 20")
}
}
if len(c.GLPIAllowedStatusIDs) == 0 {
return errors.New("GLPI_ALLOWED_STATUS_IDS must contain at least one positive status id")
}
for _, id := range c.GLPIAllowedStatusIDs {
if id <= 0 {
return errors.New("GLPI_ALLOWED_STATUS_IDS may contain only positive integers")
}
}
if c.AutoReply && c.GLPIAgentUserID <= 0 {
return errors.New("GLPI_AGENT_USER_ID must be set when AUTO_REPLY=true")
}
if strings.TrimSpace(c.CommunicationLanguage) == "" {
return errors.New("COMMUNICATION_LANGUAGE must not be empty")
}
switch c.CommunicationStyle {
case "formal", "neutral", "informal":
default:
return errors.New("COMMUNICATION_STYLE must be one of: formal, neutral, informal")
}
if len(c.KnowledgeAllowedSources) == 0 {
return errors.New("KNOWLEDGE_ALLOWED_SOURCES must contain at least one source")
}
allowedSources := make(map[string]struct{}, len(c.KnowledgeAllowedSources))
for _, source := range c.KnowledgeAllowedSources {
if strings.TrimSpace(source) == "" {
return errors.New("KNOWLEDGE_ALLOWED_SOURCES may not contain empty source names")
}
allowedSources[strings.ToLower(source)] = struct{}{}
}
for _, source := range c.KnowledgeAutoReplySources {
if _, ok := allowedSources[strings.ToLower(source)]; !ok {
return fmt.Errorf("KNOWLEDGE_AUTO_REPLY_SOURCES source %q is not present in KNOWLEDGE_ALLOWED_SOURCES", source)
}
}
if c.GLPIKBEnabled {
if c.GLPIKBLimit < 1 || c.GLPIKBLimit > 5000 {
return errors.New("GLPI_KB_LIMIT must be between 1 and 5000")
}
if c.GLPIKBSyncInterval < time.Minute {
return errors.New("GLPI_KB_SYNC_INTERVAL must be at least 1m")
}
if c.GLPIKBPath != "auto" && !validAPIPath(c.GLPIKBPath) {
return errors.New("GLPI_KB_PATH must be 'auto' or an absolute API path")
}
if strings.TrimSpace(c.GLPIKBSource) == "" {
return errors.New("GLPI_KB_SOURCE must not be empty")
}
if _, ok := allowedSources[c.GLPIKBSource]; !ok {
return fmt.Errorf("GLPI_KB_SOURCE %q must be present in KNOWLEDGE_ALLOWED_SOURCES", c.GLPIKBSource)
}
if c.GLPIKBAutoReply {
if len(c.GLPIKBAutoReplyCategoryIDs) == 0 {
return errors.New("GLPI_KB_AUTO_REPLY_CATEGORY_IDS must contain at least one GLPI KB category when GLPI_KB_AUTO_REPLY=true")
}
found := false
for _, source := range c.KnowledgeAutoReplySources {
if strings.EqualFold(source, c.GLPIKBSource) {
found = true
break
}
}
if !found {
return fmt.Errorf("GLPI_KB_SOURCE %q must be present in KNOWLEDGE_AUTO_REPLY_SOURCES when GLPI_KB_AUTO_REPLY=true", c.GLPIKBSource)
}
}
}
if c.AutoReply && len(c.KnowledgeAutoReplySources) == 0 {
return errors.New("KNOWLEDGE_AUTO_REPLY_SOURCES must contain at least one source when AUTO_REPLY=true")
}
if c.Workers < 1 || c.QueueSize < 1 {
return errors.New("WORKERS and QUEUE_SIZE must be >= 1")
}
if c.CategoryConfidence < 0 || c.CategoryConfidence > 1 || c.ReplyConfidence < 0 || c.ReplyConfidence > 1 || c.KnowledgeMinScore < 0 || c.KnowledgeMinScore > 1 || c.KnowledgeRetrievalFloor < 0 || c.KnowledgeRetrievalFloor > 1 || c.ContextRelevanceMinScore < 0 || c.ContextRelevanceMinScore > 1 {
return errors.New("confidence/score thresholds must be between 0 and 1")
}
if c.KnowledgeEvidenceRetrievalWeight < 0 || c.KnowledgeEvidenceAIWeight < 0 || c.KnowledgeEvidenceCategoryWeight < 0 {
return errors.New("KNOWLEDGE_EVIDENCE_WEIGHT_* values must be >= 0")
}
if c.ContextEnabled {
if c.ContextTimeout <= 0 {
return errors.New("CONTEXT_TIMEOUT must be > 0")
}
if c.ChangeCalendarEnabled {
if !validAPIPath(c.GLPIChangePath) {
return errors.New("GLPI_CHANGE_PATH must be an absolute API path such as /Assistance/Change")
}
if c.GLPIChangeLimit < 1 || c.GLPIChangeLimit > 1000 {
return errors.New("GLPI_CHANGE_LIMIT must be between 1 and 1000")
}
}
if c.MajorIncidentsEnabled {
if c.GLPIMajorIncidentFilter == "" {
return errors.New("GLPI_MAJOR_INCIDENT_FILTER is required when MAJOR_INCIDENTS_ENABLED=true")
}
if c.GLPIMajorIncidentLimit < 1 || c.GLPIMajorIncidentLimit > 500 {
return errors.New("GLPI_MAJOR_INCIDENT_LIMIT must be between 1 and 500")
}
}
if c.UserDeviceContextEnabled {
if len(c.GLPIUserDevicePaths) == 0 {
return errors.New("GLPI_USER_DEVICE_PATHS must contain at least one API path when USER_DEVICE_CONTEXT_ENABLED=true")
}
for _, p := range c.GLPIUserDevicePaths {
if !validAPIPath(p) {
return fmt.Errorf("invalid GLPI user-device API path %q", p)
}
}
if !strings.Contains(c.GLPIUserDeviceFilterTemplate, "{{user_id}}") {
return errors.New("GLPI_USER_DEVICE_FILTER_TEMPLATE must contain {{user_id}}")
}
if c.GLPIUserDeviceLimit < 1 || c.GLPIUserDeviceLimit > 500 {
return errors.New("GLPI_USER_DEVICE_LIMIT must be between 1 and 500")
}
}
if c.UptimeKumaEnabled {
if c.UptimeKumaURL == "" {
return errors.New("UPTIME_KUMA_URL is required when UPTIME_KUMA_ENABLED=true")
}
u, err := url.Parse(c.UptimeKumaURL)
if err != nil || u.Host == "" || (u.Scheme != "https" && u.Scheme != "http") {
return errors.New("UPTIME_KUMA_URL must be an absolute http/https URL")
}
switch c.UptimeKumaMode {
case "metrics":
if strings.TrimSpace(c.UptimeKumaAPIKey) == "" {
return errors.New("UPTIME_KUMA_API_KEY is required for UPTIME_KUMA_MODE=metrics")
}
case "status_page":
if len(c.UptimeKumaStatusPages) == 0 {
return errors.New("UPTIME_KUMA_STATUS_PAGES is required for UPTIME_KUMA_MODE=status_page")
}
default:
return errors.New("UPTIME_KUMA_MODE must be metrics or status_page")
}
if c.UptimeKumaMaxIssues < 1 || c.UptimeKumaMaxIssues > 200 {
return errors.New("UPTIME_KUMA_MAX_ISSUES must be between 1 and 200")
}
}
}
return nil
}
func validAPIPath(v string) bool {
v = strings.TrimSpace(v)
return strings.HasPrefix(v, "/") && !strings.Contains(v, "..") && !strings.ContainsAny(v, "\r\n")
}
func env(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
func envNormalizedLower(key, def string) string {
v := strings.ToLower(strings.TrimSpace(os.Getenv(key)))
if v == "" {
return strings.ToLower(strings.TrimSpace(def))
}
return v
}
func isPlaceholder(v string) bool {
return strings.Contains(strings.ToUpper(strings.TrimSpace(v)), "CHANGE_ME")
}
func envInt64List(key, def string) []int64 {
raw := os.Getenv(key)
if strings.TrimSpace(raw) == "" {
raw = def
}
parts := strings.Split(raw, ",")
out := make([]int64, 0, len(parts))
seen := make(map[int64]struct{}, len(parts))
for _, part := range parts {
n, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64)
if err != nil || n <= 0 {
return nil
}
if _, ok := seen[n]; ok {
continue
}
seen[n] = struct{}{}
out = append(out, n)
}
return out
}
func envInt64ListAllowEmpty(key string) []int64 {
raw := strings.TrimSpace(os.Getenv(key))
if raw == "" || strings.EqualFold(raw, "none") {
return nil
}
parts := strings.Split(raw, ",")
out := make([]int64, 0, len(parts))
seen := map[int64]struct{}{}
for _, part := range parts {
n, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64)
if err != nil || n <= 0 {
return nil
}
if _, ok := seen[n]; ok {
continue
}
seen[n] = struct{}{}
out = append(out, n)
}
return out
}
func envStringList(key, def string) []string {
raw, ok := os.LookupEnv(key)
if !ok {
raw = def
}
raw = strings.TrimSpace(raw)
if raw == "" || strings.EqualFold(raw, "none") {
return nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
seen := make(map[string]struct{}, len(parts))
for _, part := range parts {
v := strings.ToLower(strings.TrimSpace(part))
if v == "" {
continue
}
if _, ok := seen[v]; ok {
continue
}
seen[v] = struct{}{}
out = append(out, v)
}
return out
}
func envStringListPreserveCase(key, def string) []string {
raw, ok := os.LookupEnv(key)
if !ok {
raw = def
}
raw = strings.TrimSpace(raw)
if raw == "" || strings.EqualFold(raw, "none") {
return nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
seen := make(map[string]struct{}, len(parts))
for _, part := range parts {
v := strings.TrimSpace(part)
if v == "" {
continue
}
key := strings.ToLower(v)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, v)
}
return out
}
func envPathList(key, def string) []string {
vals := envStringListPreserveCase(key, def)
for i := range vals {
vals[i] = "/" + strings.TrimLeft(strings.TrimSpace(vals[i]), "/")
}
return vals
}
func envBool(key string, def bool) bool {
v := os.Getenv(key)
if v == "" {
return def
}
b, e := strconv.ParseBool(v)
if e != nil {
return def
}
return b
}
func envInt(key string, def int) int {
v := os.Getenv(key)
if v == "" {
return def
}
n, e := strconv.Atoi(v)
if e != nil {
return def
}
return n
}
func envInt64(key string, def int64) int64 {
v := os.Getenv(key)
if v == "" {
return def
}
n, e := strconv.ParseInt(v, 10, 64)
if e != nil {
return def
}
return n
}
func envFloat(key string, def float64) float64 {
v := os.Getenv(key)
if v == "" {
return def
}
n, e := strconv.ParseFloat(v, 64)
if e != nil {
return def
}
return n
}
func envDuration(key string, def time.Duration) time.Duration {
v := os.Getenv(key)
if v == "" {
return def
}
d, e := time.ParseDuration(v)
if e != nil {
return def
}
return d
}