Files
2026-08-05 19:13:45 +02:00

1194 lines
53 KiB
Go

package config
import (
"encoding/json"
"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 // legacy single-node value
OllamaURLs []string
OllamaNodeNames []string
OllamaNodeWeights []int
OllamaModel string
OllamaEmbeddingModel string
OllamaTimeout time.Duration
OllamaNumPredict int
OllamaKeepAlive time.Duration
OllamaThink bool
OllamaMaxConcurrent int // legacy alias for per-node concurrency
OllamaNodeMaxInflight int
OllamaRoutingMode string
OllamaNodeHealthInterval time.Duration
OllamaNodeFailureCooldown time.Duration
OllamaNodeRequestTimeout time.Duration
OllamaFailoverEnabled bool
OllamaFailoverAttempts int
OllamaRequireSameDigest bool
OllamaRequireEmbeddingModel bool
OllamaJSONRetries int
KnowledgeDir string
RAGEnabled bool
KnowledgeTopK int
KnowledgeAuditTopK int
KnowledgeCandidateMaxGap float64
CategoryPromptLimit int
KnowledgeAllowedSources []string
KnowledgeCategorySources []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
// GLPIKBAutoReplyITILCategoryIDs is retained only for configuration
// compatibility. It no longer participates in Auto-Reply approval. Ticket
// categories remain a fachliches retrieval/policy signal, not an article
// release mechanism.
GLPIKBAutoReplyITILCategoryIDs []int64
// GLPIKBAutoReplyAllowUncategorized permits synchronized GLPI KB articles
// without a KB category to be approved only when their concrete KnowbaseItem
// ID is listed in GLPIKBAutoReplyUncategorizedArticleIDs.
GLPIKBAutoReplyAllowUncategorized bool
// GLPIKBAutoReplyUncategorizedArticleIDs is an explicit allowlist of GLPI
// KnowbaseItem IDs that may use the uncategorized runtime fallback.
GLPIKBAutoReplyUncategorizedArticleIDs []int64
LearningEnabled bool
LearningMaxExamples int
LearningExamplesPerCategory int
CommunicationLanguage string
CommunicationStyle string
CommunicationSalutation string
CommunicationClosing string
CommunicationSignature string
AIContentLabelEnabled bool
AutoCategory bool
AutoReply bool
PriorityEnabled bool
AutoPriority bool
PriorityConfidence float64
PriorityAnalysisTimeout time.Duration
PriorityMaxIncrease int64
PriorityAllowedReasonCodes []string
EscalationEnabled bool
AutoEscalation bool
EscalationScanInterval time.Duration
EscalationMinAge time.Duration
EscalationMinInactivity time.Duration
EscalationAnalysisTimeout time.Duration
EscalationConfidence float64
EscalationMaxLevel int
EscalationSLARiskWindow time.Duration
EscalationServiceOwnerMinLevel int
EscalationManagerReviewMinLevel int
EscalationMajorIncidentMinScore float64
EscalationAllowedReasonCodes []string
EscalationAllowedActions []string
EscalationSecondLevelGroupID int64
EscalationSecurityGroupID int64
EscalationServiceOwnerGroupID int64
EscalationServiceOwnerUserID int64
EscalationManagerReviewGroupID int64
EscalationManagerReviewUserID int64
EscalationAddPrivateFollowup bool
EscalationSecondLevelNote string
EscalationSecurityNote string
EscalationServiceOwnerNote string
EscalationMajorIncidentNote string
EscalationManagerReviewNote string
EscalationWebhookURL string
EscalationWebhookBearerToken string
EscalationWebhookTimeout time.Duration
EscalationWebhookAllowInsecureHTTP bool
GLPIEscalationGroupPatchField string
GLPIEscalationUserPatchField string
GLPIEscalationITILLinkPath string
GLPIEscalationITILLinkBody string
GLPIEscalationFilter string
GLPIEscalationLimit int
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
ContextStatusReplyEnabled bool
ContextStatusReplyMinRelevance float64
ContextStatusReplyMinAIConfidence float64
ContextStatusReplyMinFinalScore float64
ContextIncidentReplyText string
ContextMaintenanceReplyText string
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"), "/"),
OllamaURLs: envStringListPreserveCase("OLLAMA_URLS", ""),
OllamaNodeNames: envStringListPreserveCase("OLLAMA_NODE_NAMES", ""),
OllamaNodeWeights: envIntListAllowEmpty("OLLAMA_NODE_WEIGHTS"),
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),
OllamaNodeMaxInflight: envInt("OLLAMA_NODE_MAX_INFLIGHT", 0),
OllamaRoutingMode: envNormalizedLower("OLLAMA_ROUTING_MODE", "least_inflight"),
OllamaNodeHealthInterval: envDuration("OLLAMA_NODE_HEALTH_INTERVAL", 15*time.Second),
OllamaNodeFailureCooldown: envDuration("OLLAMA_NODE_FAILURE_COOLDOWN", 30*time.Second),
OllamaNodeRequestTimeout: envDuration("OLLAMA_NODE_REQUEST_TIMEOUT", 0),
OllamaFailoverEnabled: envBool("OLLAMA_FAILOVER_ENABLED", true),
OllamaFailoverAttempts: envInt("OLLAMA_FAILOVER_ATTEMPTS", 0),
OllamaRequireSameDigest: envBool("OLLAMA_REQUIRE_SAME_MODEL_DIGEST", true),
OllamaRequireEmbeddingModel: envBool("OLLAMA_REQUIRE_EMBEDDING_MODEL", true),
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"),
KnowledgeCategorySources: envStringList("KNOWLEDGE_CATEGORY_SOURCES", ""),
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"),
GLPIKBAutoReplyITILCategoryIDs: envInt64ListAllowEmpty("GLPI_KB_AUTO_REPLY_ITIL_CATEGORY_IDS"),
GLPIKBAutoReplyAllowUncategorized: envBool("GLPI_KB_AUTO_REPLY_ALLOW_UNCATEGORIZED", false),
GLPIKBAutoReplyUncategorizedArticleIDs: envInt64ListAllowEmpty("GLPI_KB_AUTO_REPLY_UNCATEGORIZED_ARTICLE_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),
PriorityEnabled: envBool("PRIORITY_ENABLED", true),
AutoPriority: envBool("AUTO_PRIORITY", false),
PriorityConfidence: envFloat("PRIORITY_CONFIDENCE", 0.88),
PriorityAnalysisTimeout: envDuration("PRIORITY_ANALYSIS_TIMEOUT", 45*time.Second),
PriorityMaxIncrease: envInt64("PRIORITY_MAX_INCREASE", 1),
PriorityAllowedReasonCodes: envStringList("PRIORITY_ALLOWED_REASON_CODES", "multiple_users_affected,site_affected,organization_affected,core_service_unavailable,security_incident_suspected,data_loss_possible,legal_or_regulatory_risk,business_deadline,no_workaround,safety_relevant,exam_or_event_critical"),
EscalationEnabled: envBool("ESCALATION_ENABLED", false),
AutoEscalation: envBool("AUTO_ESCALATION", false),
EscalationScanInterval: envDuration("ESCALATION_SCAN_INTERVAL", 15*time.Minute),
EscalationMinAge: envDuration("ESCALATION_MIN_AGE", 4*time.Hour),
EscalationMinInactivity: envDuration("ESCALATION_MIN_INACTIVITY", 2*time.Hour),
EscalationAnalysisTimeout: envDuration("ESCALATION_ANALYSIS_TIMEOUT", 45*time.Second),
EscalationConfidence: envFloat("ESCALATION_CONFIDENCE", 0.88),
EscalationMaxLevel: envInt("ESCALATION_MAX_LEVEL", 3),
EscalationSLARiskWindow: envDuration("ESCALATION_SLA_RISK_WINDOW", 2*time.Hour),
EscalationServiceOwnerMinLevel: envInt("ESCALATION_SERVICE_OWNER_MIN_LEVEL", 2),
EscalationManagerReviewMinLevel: envInt("ESCALATION_MANAGER_REVIEW_MIN_LEVEL", 3),
EscalationMajorIncidentMinScore: envFloat("ESCALATION_MAJOR_INCIDENT_MIN_RELEVANCE", 0.50),
EscalationAllowedReasonCodes: envStringList("ESCALATION_ALLOWED_REASON_CODES", "no_human_response,sla_at_risk,sla_breached,business_deadline,no_workaround,security_incident_suspected,unassigned,major_incident_candidate"),
EscalationAllowedActions: envStringList("ESCALATION_ALLOWED_ACTIONS", "none,raise_priority"),
EscalationSecondLevelGroupID: envInt64("ESCALATION_SECOND_LEVEL_GROUP_ID", 0),
EscalationSecurityGroupID: envInt64("ESCALATION_SECURITY_GROUP_ID", 0),
EscalationServiceOwnerGroupID: envInt64("ESCALATION_SERVICE_OWNER_GROUP_ID", 0),
EscalationServiceOwnerUserID: envInt64("ESCALATION_SERVICE_OWNER_USER_ID", 0),
EscalationManagerReviewGroupID: envInt64("ESCALATION_MANAGER_REVIEW_GROUP_ID", 0),
EscalationManagerReviewUserID: envInt64("ESCALATION_MANAGER_REVIEW_USER_ID", 0),
EscalationAddPrivateFollowup: envBool("ESCALATION_ADD_PRIVATE_FOLLOWUP", true),
EscalationSecondLevelNote: envTemplate("ESCALATION_SECOND_LEVEL_NOTE", "Automatische Eskalation Stufe {{level}}: Übergabe an den Second-Level-Support. Gründe: {{reason_codes}}. KI-Begründung: {{reason}}"),
EscalationSecurityNote: envTemplate("ESCALATION_SECURITY_NOTE", "Automatische Eskalation Stufe {{level}}: Übergabe an das Security-Team. Gründe: {{reason_codes}}. KI-Begründung: {{reason}}"),
EscalationServiceOwnerNote: envTemplate("ESCALATION_SERVICE_OWNER_NOTE", "Automatische Eskalation Stufe {{level}}: Service Owner wurde zur Prüfung einbezogen. Gründe: {{reason_codes}}. KI-Begründung: {{reason}}"),
EscalationMajorIncidentNote: envTemplate("ESCALATION_MAJOR_INCIDENT_NOTE", "Automatische Eskalation Stufe {{level}}: Verknüpfung mit Major Incident #{{major_incident_id}} ({{major_incident_name}}). Relevanz: {{major_incident_score}}. Gründe: {{reason_codes}}."),
EscalationManagerReviewNote: envTemplate("ESCALATION_MANAGER_REVIEW_NOTE", "Automatische Eskalation Stufe {{level}}: Management-Review angefordert. Gründe: {{reason_codes}}. KI-Begründung: {{reason}}"),
EscalationWebhookURL: strings.TrimSpace(os.Getenv("ESCALATION_WEBHOOK_URL")),
EscalationWebhookBearerToken: strings.TrimSpace(os.Getenv("ESCALATION_WEBHOOK_BEARER_TOKEN")),
EscalationWebhookTimeout: envDuration("ESCALATION_WEBHOOK_TIMEOUT", 10*time.Second),
EscalationWebhookAllowInsecureHTTP: envBool("ESCALATION_WEBHOOK_ALLOW_INSECURE_HTTP", false),
GLPIEscalationGroupPatchField: env("GLPI_ESCALATION_GROUP_PATCH_FIELD", "assigned_groups"),
GLPIEscalationUserPatchField: env("GLPI_ESCALATION_USER_PATCH_FIELD", "assigned_users"),
GLPIEscalationITILLinkPath: strings.TrimSpace(os.Getenv("GLPI_ESCALATION_ITIL_LINK_PATH")),
GLPIEscalationITILLinkBody: envTemplate("GLPI_ESCALATION_ITIL_LINK_BODY", ""),
GLPIEscalationFilter: strings.TrimSpace(os.Getenv("GLPI_ESCALATION_FILTER")),
GLPIEscalationLimit: envInt("GLPI_ESCALATION_LIMIT", 100),
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),
ContextStatusReplyEnabled: envBool("CONTEXT_STATUS_REPLY_ENABLED", false),
ContextStatusReplyMinRelevance: envFloat("CONTEXT_STATUS_REPLY_MIN_RELEVANCE", 0.50),
ContextStatusReplyMinAIConfidence: envFloat("CONTEXT_STATUS_REPLY_MIN_AI_CONFIDENCE", 0.80),
ContextStatusReplyMinFinalScore: envFloat("CONTEXT_STATUS_REPLY_MIN_FINAL_SCORE", 0.45),
ContextIncidentReplyText: envTemplate("CONTEXT_INCIDENT_REPLY_TEXT", ""),
ContextMaintenanceReplyText: envTemplate("CONTEXT_MAINTENANCE_REPLY_TEXT", ""),
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),
}
if len(c.OllamaURLs) == 0 {
c.OllamaURLs = []string{c.OllamaURL}
}
for i := range c.OllamaURLs {
c.OllamaURLs[i] = strings.TrimRight(strings.TrimSpace(c.OllamaURLs[i]), "/")
}
if c.OllamaNodeMaxInflight == 0 {
c.OllamaNodeMaxInflight = c.OllamaMaxConcurrent
}
if c.OllamaNodeRequestTimeout == 0 {
c.OllamaNodeRequestTimeout = c.OllamaTimeout
}
if c.OllamaFailoverAttempts == 0 {
c.OllamaFailoverAttempts = len(c.OllamaURLs)
}
// Backwards compatibility: without an explicit category source list, the
// same sources used for normal knowledge retrieval also inform classification.
if _, configured := os.LookupEnv("KNOWLEDGE_CATEGORY_SOURCES"); !configured {
c.KnowledgeCategorySources = append([]string(nil), c.KnowledgeAllowedSources...)
}
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")
}
ollamaURLs := append([]string(nil), c.OllamaURLs...)
if len(ollamaURLs) == 0 {
legacyURL := strings.TrimSpace(c.OllamaURL)
if legacyURL == "" {
legacyURL = "http://ollama:11434"
}
ollamaURLs = []string{legacyURL}
}
if len(ollamaURLs) > 64 {
return errors.New("OLLAMA_URLS supports at most 64 nodes")
}
seenOllamaURLs := map[string]struct{}{}
for _, raw := range ollamaURLs {
u, err := url.Parse(strings.TrimSpace(raw))
if err != nil || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") || u.User != nil || u.RawQuery != "" || u.Fragment != "" {
return fmt.Errorf("invalid Ollama node URL %q", raw)
}
key := strings.TrimRight(u.String(), "/")
if _, ok := seenOllamaURLs[key]; ok {
return fmt.Errorf("duplicate Ollama node URL %q", key)
}
seenOllamaURLs[key] = struct{}{}
}
if len(c.OllamaNodeNames) > 0 && len(c.OllamaNodeNames) != len(ollamaURLs) {
return errors.New("OLLAMA_NODE_NAMES must contain exactly one name per OLLAMA_URLS entry")
}
seenOllamaNames := map[string]struct{}{}
for _, rawName := range c.OllamaNodeNames {
name := strings.TrimSpace(rawName)
if name == "" {
return errors.New("OLLAMA_NODE_NAMES entries must not be empty")
}
if _, ok := seenOllamaNames[name]; ok {
return fmt.Errorf("duplicate Ollama node name %q", name)
}
seenOllamaNames[name] = struct{}{}
}
if len(c.OllamaNodeWeights) > 0 && len(c.OllamaNodeWeights) != len(ollamaURLs) {
return errors.New("OLLAMA_NODE_WEIGHTS must contain exactly one weight per OLLAMA_URLS entry")
}
for _, weight := range c.OllamaNodeWeights {
if weight < 1 || weight > 100 {
return errors.New("OLLAMA_NODE_WEIGHTS values must be between 1 and 100")
}
}
nodeMaxInflight := c.OllamaNodeMaxInflight
if nodeMaxInflight == 0 {
nodeMaxInflight = c.OllamaMaxConcurrent
}
if nodeMaxInflight < 1 || nodeMaxInflight > 32 {
return errors.New("OLLAMA_NODE_MAX_INFLIGHT must be between 1 and 32")
}
routingMode := c.OllamaRoutingMode
if routingMode == "" {
routingMode = "least_inflight"
}
switch routingMode {
case "least_inflight", "round_robin", "weighted", "fastest_recent":
default:
return errors.New("OLLAMA_ROUTING_MODE must be one of: least_inflight, round_robin, weighted, fastest_recent")
}
if c.OllamaNodeHealthInterval != 0 && c.OllamaNodeHealthInterval < time.Second {
return errors.New("OLLAMA_NODE_HEALTH_INTERVAL must be >= 1s")
}
if c.OllamaNodeFailureCooldown < 0 {
return errors.New("OLLAMA_NODE_FAILURE_COOLDOWN must be >= 0")
}
nodeRequestTimeout := c.OllamaNodeRequestTimeout
if nodeRequestTimeout == 0 {
nodeRequestTimeout = c.OllamaTimeout
}
if nodeRequestTimeout <= 0 {
return errors.New("OLLAMA_NODE_REQUEST_TIMEOUT must be > 0")
}
failoverAttempts := c.OllamaFailoverAttempts
if failoverAttempts == 0 {
failoverAttempts = len(ollamaURLs)
}
if failoverAttempts < 1 || failoverAttempts > len(ollamaURLs) {
return errors.New("OLLAMA_FAILOVER_ATTEMPTS must be between 1 and the number of configured Ollama nodes")
}
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))
indexedSources := make(map[string]struct{}, len(c.KnowledgeAllowedSources)+len(c.KnowledgeCategorySources))
for _, source := range c.KnowledgeAllowedSources {
if strings.TrimSpace(source) == "" {
return errors.New("KNOWLEDGE_ALLOWED_SOURCES may not contain empty source names")
}
normalized := strings.ToLower(strings.TrimSpace(source))
allowedSources[normalized] = struct{}{}
indexedSources[normalized] = struct{}{}
}
for _, source := range c.KnowledgeCategorySources {
if strings.TrimSpace(source) == "" {
return errors.New("KNOWLEDGE_CATEGORY_SOURCES may not contain empty source names")
}
indexedSources[strings.ToLower(strings.TrimSpace(source))] = struct{}{}
}
for _, source := range c.KnowledgeAutoReplySources {
if _, ok := allowedSources[strings.ToLower(strings.TrimSpace(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")
}
glpiKBSource := strings.ToLower(strings.TrimSpace(c.GLPIKBSource))
if _, ok := indexedSources[glpiKBSource]; !ok {
return fmt.Errorf("GLPI_KB_SOURCE %q must be present in KNOWLEDGE_ALLOWED_SOURCES or KNOWLEDGE_CATEGORY_SOURCES", c.GLPIKBSource)
}
if c.GLPIKBAutoReplyAllowUncategorized && !c.GLPIKBAutoReply {
return errors.New("GLPI_KB_AUTO_REPLY_ALLOW_UNCATEGORIZED=true requires GLPI_KB_AUTO_REPLY=true")
}
if len(c.GLPIKBAutoReplyUncategorizedArticleIDs) > 0 && !c.GLPIKBAutoReplyAllowUncategorized {
return errors.New("GLPI_KB_AUTO_REPLY_UNCATEGORIZED_ARTICLE_IDS requires GLPI_KB_AUTO_REPLY_ALLOW_UNCATEGORIZED=true")
}
if c.GLPIKBAutoReply {
if _, ok := allowedSources[glpiKBSource]; !ok {
return fmt.Errorf("GLPI_KB_SOURCE %q must be present in KNOWLEDGE_ALLOWED_SOURCES when GLPI_KB_AUTO_REPLY=true", c.GLPIKBSource)
}
if c.GLPIKBAutoReplyAllowUncategorized && len(c.GLPIKBAutoReplyUncategorizedArticleIDs) == 0 {
return errors.New("GLPI_KB_AUTO_REPLY_ALLOW_UNCATEGORIZED=true requires GLPI_KB_AUTO_REPLY_UNCATEGORIZED_ARTICLE_IDS")
}
if len(c.GLPIKBAutoReplyCategoryIDs) == 0 && (!c.GLPIKBAutoReplyAllowUncategorized || len(c.GLPIKBAutoReplyUncategorizedArticleIDs) == 0) {
return errors.New("GLPI_KB_AUTO_REPLY=true requires GLPI_KB_AUTO_REPLY_CATEGORY_IDS and/or an explicit uncategorized article allowlist")
}
found := false
for _, source := range c.KnowledgeAutoReplySources {
if strings.EqualFold(strings.TrimSpace(source), 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.PriorityConfidence < 0 || c.PriorityConfidence > 1 || c.EscalationConfidence < 0 || c.EscalationConfidence > 1 || c.EscalationMajorIncidentMinScore < 0 || c.EscalationMajorIncidentMinScore > 1 {
return errors.New("PRIORITY_CONFIDENCE, ESCALATION_CONFIDENCE and ESCALATION_MAJOR_INCIDENT_MIN_RELEVANCE must be between 0 and 1")
}
if c.PriorityAnalysisTimeout < 0 {
return errors.New("PRIORITY_ANALYSIS_TIMEOUT must be >= 0")
}
if c.PriorityMaxIncrease < 0 || c.PriorityMaxIncrease > 5 {
return errors.New("PRIORITY_MAX_INCREASE must be between 0 and 5")
}
if c.AutoPriority && !c.PriorityEnabled {
return errors.New("AUTO_PRIORITY=true requires PRIORITY_ENABLED=true")
}
if c.PriorityEnabled && len(c.PriorityAllowedReasonCodes) == 0 {
return errors.New("PRIORITY_ALLOWED_REASON_CODES must contain at least one reason when PRIORITY_ENABLED=true")
}
if c.AutoPriority && c.PriorityMaxIncrease < 1 {
return errors.New("PRIORITY_MAX_INCREASE must be at least 1 when AUTO_PRIORITY=true")
}
if c.EscalationEnabled {
if c.EscalationScanInterval < time.Minute {
return errors.New("ESCALATION_SCAN_INTERVAL must be at least 1m")
}
if c.EscalationMinAge < time.Minute {
return errors.New("ESCALATION_MIN_AGE must be at least 1m")
}
if c.EscalationMinInactivity != 0 && c.EscalationMinInactivity < time.Minute {
return errors.New("ESCALATION_MIN_INACTIVITY must be at least 1m")
}
if c.EscalationAnalysisTimeout < 0 {
return errors.New("ESCALATION_ANALYSIS_TIMEOUT must be >= 0")
}
if c.EscalationSLARiskWindow < 0 {
return errors.New("ESCALATION_SLA_RISK_WINDOW must be >= 0")
}
if c.EscalationMaxLevel < 1 || c.EscalationMaxLevel > 4 {
return errors.New("ESCALATION_MAX_LEVEL must be between 1 and 4")
}
if (c.EscalationServiceOwnerMinLevel != 0 && (c.EscalationServiceOwnerMinLevel < 1 || c.EscalationServiceOwnerMinLevel > 4)) || (c.EscalationManagerReviewMinLevel != 0 && (c.EscalationManagerReviewMinLevel < 1 || c.EscalationManagerReviewMinLevel > 4)) {
return errors.New("ESCALATION_SERVICE_OWNER_MIN_LEVEL and ESCALATION_MANAGER_REVIEW_MIN_LEVEL must be between 1 and 4")
}
if c.GLPIEscalationLimit < 1 || c.GLPIEscalationLimit > 1000 {
return errors.New("GLPI_ESCALATION_LIMIT must be between 1 and 1000")
}
if len(c.EscalationAllowedReasonCodes) == 0 {
return errors.New("ESCALATION_ALLOWED_REASON_CODES must contain at least one reason when ESCALATION_ENABLED=true")
}
if len(c.EscalationAllowedActions) == 0 {
return errors.New("ESCALATION_ALLOWED_ACTIONS must contain at least one action when ESCALATION_ENABLED=true")
}
knownActions := map[string]bool{"none": true, "raise_priority": true, "assign_second_level": true, "assign_security_team": true, "notify_service_owner": true, "link_major_incident": true, "request_manager_review": true}
for _, raw := range c.EscalationAllowedActions {
action := strings.ToLower(strings.TrimSpace(raw))
if !knownActions[action] {
return fmt.Errorf("unknown ESCALATION_ALLOWED_ACTIONS value %q", raw)
}
}
if (strings.TrimSpace(c.GLPIEscalationGroupPatchField) != "" && !safeJSONField(c.GLPIEscalationGroupPatchField)) || (strings.TrimSpace(c.GLPIEscalationUserPatchField) != "" && !safeJSONField(c.GLPIEscalationUserPatchField)) {
return errors.New("GLPI_ESCALATION_GROUP_PATCH_FIELD and GLPI_ESCALATION_USER_PATCH_FIELD must be simple JSON field names")
}
linkPathSet := strings.TrimSpace(c.GLPIEscalationITILLinkPath) != ""
linkBodySet := strings.TrimSpace(c.GLPIEscalationITILLinkBody) != ""
if linkPathSet != linkBodySet {
return errors.New("GLPI_ESCALATION_ITIL_LINK_PATH and GLPI_ESCALATION_ITIL_LINK_BODY must be configured together")
}
if linkPathSet {
if err := validateEscalationLinkAdapter(c.GLPIEscalationITILLinkPath, c.GLPIEscalationITILLinkBody); err != nil {
return err
}
}
if c.EscalationWebhookURL != "" {
parsed, err := url.ParseRequestURI(c.EscalationWebhookURL)
if err != nil || (parsed.Scheme != "https" && parsed.Scheme != "http") || parsed.Host == "" {
return errors.New("ESCALATION_WEBHOOK_URL must be an absolute http(s) URL")
}
if parsed.Scheme == "http" && !c.EscalationWebhookAllowInsecureHTTP {
return errors.New("plain HTTP ESCALATION_WEBHOOK_URL requires ESCALATION_WEBHOOK_ALLOW_INSECURE_HTTP=true")
}
if c.EscalationWebhookTimeout <= 0 {
return errors.New("ESCALATION_WEBHOOK_TIMEOUT must be > 0 when ESCALATION_WEBHOOK_URL is set")
}
}
}
if c.AutoEscalation && !c.EscalationEnabled {
return errors.New("AUTO_ESCALATION=true requires ESCALATION_ENABLED=true")
}
if c.AutoEscalation {
if c.GLPIAgentUserID <= 0 {
return errors.New("GLPI_AGENT_USER_ID must be set when AUTO_ESCALATION=true")
}
hasExecutableAction := false
for _, raw := range c.EscalationAllowedActions {
if action := strings.ToLower(strings.TrimSpace(raw)); action != "" && action != "none" {
hasExecutableAction = true
}
}
if !hasExecutableAction {
return errors.New("AUTO_ESCALATION=true requires at least one executable ESCALATION_ALLOWED_ACTIONS value")
}
for _, raw := range c.EscalationAllowedActions {
switch strings.ToLower(strings.TrimSpace(raw)) {
case "assign_second_level":
if c.EscalationSecondLevelGroupID <= 0 {
return errors.New("ESCALATION_SECOND_LEVEL_GROUP_ID is required for assign_second_level")
}
case "assign_security_team":
if c.EscalationSecurityGroupID <= 0 {
return errors.New("ESCALATION_SECURITY_GROUP_ID is required for assign_security_team")
}
case "notify_service_owner":
if c.EscalationServiceOwnerGroupID <= 0 && c.EscalationServiceOwnerUserID <= 0 && c.EscalationWebhookURL == "" {
return errors.New("notify_service_owner requires a configured service-owner group/user or ESCALATION_WEBHOOK_URL")
}
case "request_manager_review":
if c.EscalationManagerReviewGroupID <= 0 && c.EscalationManagerReviewUserID <= 0 && c.EscalationWebhookURL == "" {
return errors.New("request_manager_review requires a configured manager group/user or ESCALATION_WEBHOOK_URL")
}
case "link_major_incident":
if !c.ContextEnabled || !c.MajorIncidentsEnabled {
return errors.New("link_major_incident requires CONTEXT_ENABLED=true and MAJOR_INCIDENTS_ENABLED=true")
}
if c.GLPIEscalationITILLinkPath == "" || c.GLPIEscalationITILLinkBody == "" {
return errors.New("link_major_incident requires GLPI_ESCALATION_ITIL_LINK_PATH and GLPI_ESCALATION_ITIL_LINK_BODY")
}
}
}
}
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.ContextStatusReplyMinRelevance < 0 || c.ContextStatusReplyMinRelevance > 1 || c.ContextStatusReplyMinAIConfidence < 0 || c.ContextStatusReplyMinAIConfidence > 1 || c.ContextStatusReplyMinFinalScore < 0 || c.ContextStatusReplyMinFinalScore > 1 {
return errors.New("CONTEXT_STATUS_REPLY_* score thresholds must be between 0 and 1")
}
if c.ContextStatusReplyEnabled {
if !c.ContextEnabled || !c.UptimeKumaEnabled {
return errors.New("CONTEXT_STATUS_REPLY_ENABLED requires CONTEXT_ENABLED=true and UPTIME_KUMA_ENABLED=true")
}
if strings.TrimSpace(c.ContextIncidentReplyText) == "" {
return errors.New("CONTEXT_INCIDENT_REPLY_TEXT is required when CONTEXT_STATUS_REPLY_ENABLED=true")
}
if strings.TrimSpace(c.ContextMaintenanceReplyText) == "" {
return errors.New("CONTEXT_MAINTENANCE_REPLY_TEXT is required when CONTEXT_STATUS_REPLY_ENABLED=true")
}
}
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 validateEscalationLinkAdapter(pathTemplate, bodyTemplate string) error {
pathTemplate = strings.TrimSpace(pathTemplate)
bodyTemplate = strings.TrimSpace(bodyTemplate)
if !validAPIPath(pathTemplate) || strings.ContainsAny(pathTemplate, "?#") {
return errors.New("GLPI_ESCALATION_ITIL_LINK_PATH must be an absolute API route without query or fragment")
}
combined := pathTemplate + "\n" + bodyTemplate
hasSource := strings.Contains(combined, "{{ticket_id}}") || strings.Contains(combined, "{{source_ticket_id}}")
hasTarget := strings.Contains(combined, "{{major_incident_id}}") || strings.Contains(combined, "{{target_ticket_id}}")
if !hasSource || !hasTarget {
return errors.New("GLPI escalation link adapter must reference both source ticket and major incident placeholders")
}
replacer := strings.NewReplacer(
"{{ticket_id}}", "1",
"{{source_ticket_id}}", "1",
"{{major_incident_id}}", "2",
"{{target_ticket_id}}", "2",
)
var body any
if err := json.Unmarshal([]byte(replacer.Replace(bodyTemplate)), &body); err != nil {
return fmt.Errorf("GLPI_ESCALATION_ITIL_LINK_BODY must be valid JSON after placeholder expansion: %w", err)
}
return nil
}
func validAPIPath(v string) bool {
v = strings.TrimSpace(v)
return strings.HasPrefix(v, "/") && !strings.Contains(v, "..") && !strings.ContainsAny(v, "\r\n")
}
func safeJSONField(v string) bool {
v = strings.TrimSpace(v)
if v == "" {
return false
}
for _, r := range v {
if (r >= 'a' && r <= 'z') || (r >= 'A' && r <= 'Z') || (r >= '0' && r <= '9') || r == '_' {
continue
}
return false
}
return true
}
func env(key, def string) string {
if v := os.Getenv(key); v != "" {
return v
}
return def
}
// envTemplate allows readable one-line .env values while preserving an exact,
// operator-defined message. Only escaped newlines are expanded.
func envTemplate(key, def string) string {
v := env(key, def)
v = strings.ReplaceAll(v, `\n`, "\n")
return strings.TrimSpace(v)
}
func envNormalizedLower(key, def string) string {
v := strings.ToLower(strings.TrimSpace(os.Getenv(key)))
if v == "" {
return strings.ToLower(strings.TrimSpace(def))
}
return v
}
// KnowledgeIndexSources returns the union of normal retrieval sources and
// category-only sources. The store indexes this union, while the agent applies
// the narrower source scopes when building the two model input sets.
func (c Config) KnowledgeIndexSources() []string {
seen := make(map[string]struct{}, len(c.KnowledgeAllowedSources)+len(c.KnowledgeCategorySources))
out := make([]string, 0, len(c.KnowledgeAllowedSources)+len(c.KnowledgeCategorySources))
for _, list := range [][]string{c.KnowledgeAllowedSources, c.KnowledgeCategorySources} {
for _, source := range list {
normalized := strings.ToLower(strings.TrimSpace(source))
if normalized == "" {
continue
}
if _, ok := seen[normalized]; ok {
continue
}
seen[normalized] = struct{}{}
out = append(out, normalized)
}
}
return out
}
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 envIntListAllowEmpty(key string) []int {
raw := strings.TrimSpace(os.Getenv(key))
if raw == "" || strings.EqualFold(raw, "none") {
return nil
}
parts := strings.Split(raw, ",")
out := make([]int, 0, len(parts))
for _, part := range parts {
n, err := strconv.Atoi(strings.TrimSpace(part))
if err != nil {
return nil
}
out = append(out, n)
}
return out
}
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
}