706 lines
27 KiB
Go
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
|
|
}
|