This commit is contained in:
2026-08-05 19:13:45 +02:00
parent 31fa84f243
commit d415863604
22 changed files with 941 additions and 250 deletions
+1 -1
View File
@@ -1010,7 +1010,7 @@ func auditKnowledgeCandidates(hits []model.KnowledgeHit, globalMin float64, limi
SemanticScore: h.SemanticScore, TitleScore: h.TitleScore, LexicalScore: h.LexicalScore, KeywordScore: h.KeywordScore,
CategoryScore: h.CategoryScore, RequiredScore: required, AutoReply: h.Doc.AutoReply,
AutoReplyDecision: h.Doc.AutoReplyDecision, AutoReplyDetail: h.Doc.AutoReplyDetail,
BestChunkExcerpt: h.BestChunkExcerpt, BestQueryExcerpt: h.BestQueryExcerpt,
BestChunkExcerpt: h.BestChunkExcerpt, BestQueryExcerpt: h.BestQueryExcerpt,
QueryChunkCount: h.QueryChunkCount, DocumentChunkCount: h.DocumentChunkCount, SentToAI: wasSent, RetrievalRank: idx + 1, SelectionReason: reason,
})
}
+26 -28
View File
@@ -159,7 +159,12 @@ func (p Policy) Evaluate(t model.Ticket, d model.Decision, categories []model.Ca
res.ReplyChecks = append(res.ReplyChecks, check("knowledge", "reply_knowledge_exists", "Ausgewählter Knowledge-Artikel ist verfügbar", "pass", false, selected.Doc.ID, "Kandidat im übergebenen Set", selected.Doc.Title))
}
selectedAutoReplyAllowed := false
if selected != nil {
effectiveCategoryID := t.CategoryID
if res.ChangeCategory {
effectiveCategoryID = res.CategoryID
}
sourceAllowed := p.sourceAllowed(selected.Doc.Source)
sourceReplyAllowed := p.sourceAllowedForReply(selected.Doc.Source)
langOK := strings.EqualFold(strings.TrimSpace(selected.Doc.Language), p.CommunicationLanguage)
@@ -186,7 +191,8 @@ func (p Policy) Evaluate(t model.Ticket, d model.Decision, categories []model.Ca
}
autoDetail += "Nicht zugeordnete externe Kategorien: " + strings.Join(selected.Doc.UnmappedExternalCategories, ", ")
}
res.ReplyChecks = append(res.ReplyChecks, check("knowledge", "reply_knowledge_auto_reply", "Artikel ist für Auto-Reply freigegeben", passFail(selected.Doc.AutoReply), !selected.Doc.AutoReply, boolText(selected.Doc.AutoReply), "true", autoDetail))
selectedAutoReplyAllowed = knowledgeAutoReplyAllowed(selected.Doc)
res.ReplyChecks = append(res.ReplyChecks, check("knowledge", "reply_knowledge_auto_reply", "Artikel darf für Auto-Reply verwendet werden", passFail(selectedAutoReplyAllowed), !selectedAutoReplyAllowed, boolText(selectedAutoReplyAllowed), "true", autoDetail))
threshold := p.KnowledgeMinScore
if selected.Doc.MinScore > threshold {
@@ -198,12 +204,7 @@ func (p Policy) Evaluate(t model.Ticket, d model.Decision, categories []model.Ca
retrievalOK := selected.Score >= p.KnowledgeRetrievalFloor
res.ReplyChecks = append(res.ReplyChecks, check("knowledge", "reply_retrieval_floor", "Retrieval-Score erreicht Mindestfloor", passFail(retrievalOK), !retrievalOK, percentText(selected.Score), ">= "+percentText(p.KnowledgeRetrievalFloor), "Der Retrieval-Floor entscheidet, ob ein Artikel überhaupt als plausibler Kandidat gilt."))
catIDForEvidence := t.CategoryID
if res.ChangeCategory {
catIDForEvidence = res.CategoryID
} else if d.Category.ID != 0 && d.Category.ID == t.CategoryID {
catIDForEvidence = d.Category.ID
}
catIDForEvidence := effectiveCategoryID
categoryEvidence, categoryAvailable := 0.0, false
if len(selected.Doc.Categories) > 0 && catIDForEvidence != 0 {
categoryAvailable = true
@@ -226,34 +227,23 @@ func (p Policy) Evaluate(t model.Ticket, d model.Decision, categories []model.Ca
answerPresent := strings.TrimSpace(selected.Doc.Answer) != "" || strings.TrimSpace(selected.Doc.AnswerHTML) != ""
res.ReplyChecks = append(res.ReplyChecks, check("knowledge", "reply_answer_present", "Freigegebener Antwortinhalt vorhanden", passFail(answerPresent), !answerPresent, boolText(answerPresent), "true", ""))
categoryAllowed := true
categoryDetail := "Artikel gilt für alle ITIL-Kategorien."
catID := t.CategoryID
if res.ChangeCategory {
catID = res.CategoryID
}
categoryAllowed := knowledgeCategoryAllowed(selected.Doc, t.CategoryID, res.ChangeCategory, res.CategoryID)
categoryDetail := "Keine gemappte ITIL-Kategorie am Artikel verfügbar; die fachliche Eignung wird über Retrieval, KI-Auswahl und Evidenz geprüft."
if len(selected.Doc.Categories) > 0 {
categoryAllowed = false
for _, id := range selected.Doc.Categories {
if id == catID {
categoryAllowed = true
break
}
}
categoryDetail = fmt.Sprintf("Artikel-Kategorien: %v; effektive Ticketkategorie: #%d", selected.Doc.Categories, catID)
categoryDetail = fmt.Sprintf("Gemappte Artikel-ITIL-Kategorien: %v; effektive Ticketkategorie: #%d", selected.Doc.Categories, effectiveCategoryID)
}
res.ReplyChecks = append(res.ReplyChecks, check("knowledge", "reply_category_scope", "Artikel ist für die effektive Ticketkategorie freigegeben", passFail(categoryAllowed), !categoryAllowed, boolText(categoryAllowed), "true", categoryDetail))
res.ReplyChecks = append(res.ReplyChecks, check("knowledge", "reply_category_scope", "Artikel passt zur effektiven Ticketkategorie", passFail(categoryAllowed), !categoryAllowed, boolText(categoryAllowed), "true", categoryDetail))
} else {
for _, spec := range []struct{ code, label string }{
{"reply_source_allowed", "Knowledge-Quelle ist für Retrieval erlaubt"},
{"reply_source_auto_allowed", "Knowledge-Quelle ist für Auto-Reply erlaubt"},
{"reply_language_match", "Sprache des Artikels passt zur Kommunikationspolicy"},
{"reply_style_match", "Stil des Artikels passt zur Kommunikationspolicy"},
{"reply_knowledge_auto_reply", "Artikel ist für Auto-Reply freigegeben"},
{"reply_knowledge_auto_reply", "Artikel darf für Auto-Reply verwendet werden"},
{"reply_retrieval_floor", "Retrieval-Score erreicht Mindestfloor"},
{"reply_evidence_threshold", "Finale Knowledge-Evidenz erreicht Schwellwert"},
{"reply_answer_present", "Freigegebener Antwortinhalt vorhanden"},
{"reply_category_scope", "Artikel ist für die effektive Ticketkategorie freigegeben"},
{"reply_category_scope", "Artikel passt zur effektiven Ticketkategorie"},
} {
res.ReplyChecks = append(res.ReplyChecks, check("knowledge", spec.code, spec.label, "na", false, "–", "Knowledge-Artikel erforderlich", "Kein gültiger Knowledge-Artikel ausgewählt."))
}
@@ -286,8 +276,8 @@ func (p Policy) Evaluate(t model.Ticket, d model.Decision, categories []model.Ca
res.ReplyDecision = "reply_language_mismatch"
case !strings.EqualFold(strings.TrimSpace(selected.Doc.CommunicationStyle), p.CommunicationStyle):
res.ReplyDecision = "reply_style_mismatch"
case !selected.Doc.AutoReply:
res.ReplyDecision = "reply_knowledge_auto_reply_disabled"
case !selectedAutoReplyAllowed:
res.ReplyDecision = "reply_knowledge_auto_reply_not_approved"
case selected.Score < p.KnowledgeRetrievalFloor:
res.ReplyDecision = "reply_knowledge_retrieval_below_floor"
case res.KnowledgeEvidenceScore < res.KnowledgeThreshold:
@@ -345,8 +335,16 @@ func knowledgeCategoryAllowed(d model.KnowledgeDoc, currentCategory int64, chang
if len(d.Categories) == 0 {
return true
}
for _, id := range d.Categories {
if id == catID {
return containsPolicyInt64(d.Categories, catID)
}
func knowledgeAutoReplyAllowed(d model.KnowledgeDoc) bool {
return d.AutoReply
}
func containsPolicyInt64(values []int64, target int64) bool {
for _, value := range values {
if value == target {
return true
}
}
+50
View File
@@ -220,3 +220,53 @@ func TestPolicyCanDisableAIContentLabel(t *testing.T) {
t.Fatalf("disabled AI content label must preserve plain reply behaviour: %+v", r)
}
}
func TestPolicyAllowsExplicitlyApprovedUncategorizedGLPIArticle(t *testing.T) {
p := NewPolicy(true, true, .70, .70, .70, .30, .45, .35, .20,
[]string{"glpi-kb"}, []string{"glpi-kb"}, "de-DE", "formal", "", "", "", false, false, false, .2)
d := replyDecision()
d.Category.ID = 67
d.Category.Confidence = .99
d.Reply.Confidence = .99
hits := []model.KnowledgeHit{{
Doc: model.KnowledgeDoc{
ID: "GLPI-KB-21", Answer: "Drucker neu verbinden.", AutoReply: true,
AutoReplyDecision: "glpi_kb_uncategorized_article_approved",
Source: "glpi-kb", Language: "de-DE", CommunicationStyle: "formal",
},
Score: .95,
}}
d.Reply.KnowledgeID = "GLPI-KB-21"
r, err := p.Evaluate(model.Ticket{CategoryID: 67}, d, []model.Category{{ID: 67}}, hits, model.ContextSnapshot{})
if err != nil {
t.Fatal(err)
}
if !r.Reply || r.ReplyDecision != "reply_accepted" {
t.Fatalf("expected explicitly approved uncategorized GLPI article to be accepted: %+v", r)
}
}
func TestPolicyDoesNotApplyLegacyITILAutoReplyGateToUncategorizedArticle(t *testing.T) {
p := NewPolicy(true, true, .70, .70, .70, .30, .45, .35, .20,
[]string{"glpi-kb"}, []string{"glpi-kb"}, "de-DE", "formal", "", "", "", false, false, false, .2)
d := replyDecision()
d.Category.ID = 66
d.Category.Confidence = .99
d.Reply.Confidence = .99
d.Reply.KnowledgeID = "GLPI-KB-21"
hits := []model.KnowledgeHit{{
Doc: model.KnowledgeDoc{
ID: "GLPI-KB-21", Answer: "Drucker neu verbinden.", AutoReply: true,
AutoReplyDecision: "glpi_kb_uncategorized_article_approved",
Source: "glpi-kb", Language: "de-DE", CommunicationStyle: "formal",
},
Score: .95,
}}
r, err := p.Evaluate(model.Ticket{CategoryID: 66}, d, []model.Category{{ID: 66}}, hits, model.ContextSnapshot{})
if err != nil {
t.Fatal(err)
}
if !r.Reply || r.ReplyDecision != "reply_accepted" {
t.Fatalf("legacy ITIL auto-reply gate must not block explicitly approved uncategorized article: %+v", r)
}
}
+160 -142
View File
@@ -93,11 +93,18 @@ type Config struct {
GLPIKBSource string
GLPIKBAutoReply bool
GLPIKBAutoReplyCategoryIDs []int64
// GLPIKBAutoReplyITILCategoryIDs optionally whitelists the mapped GLPI
// ticket/ITIL categories for synchronized KB articles. This complements
// GLPIKBAutoReplyCategoryIDs, which contains GLPI knowledge-base category
// IDs. A match in either explicitly configured whitelist is sufficient.
// 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
@@ -201,142 +208,144 @@ type Config struct {
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"),
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),
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),
@@ -668,12 +677,21 @@ func (c Config) Validate() error {
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 len(c.GLPIKBAutoReplyCategoryIDs) == 0 && len(c.GLPIKBAutoReplyITILCategoryIDs) == 0 {
return errors.New("GLPI_KB_AUTO_REPLY_CATEGORY_IDS or GLPI_KB_AUTO_REPLY_ITIL_CATEGORY_IDS must contain at least one category when GLPI_KB_AUTO_REPLY=true")
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 {
+26 -1
View File
@@ -162,8 +162,13 @@ func TestValidateGLPIKBAutoReplyRequiresCategoryWhitelist(t *testing.T) {
}
c.GLPIKBAutoReplyCategoryIDs = nil
c.GLPIKBAutoReplyITILCategoryIDs = []int64{38}
if err := c.Validate(); err == nil {
t.Fatal("expected legacy ITIL category list not to approve GLPI KB auto reply")
}
c.GLPIKBAutoReplyAllowUncategorized = true
c.GLPIKBAutoReplyUncategorizedArticleIDs = []int64{1}
if err := c.Validate(); err != nil {
t.Fatalf("expected explicit ITIL category whitelist to validate: %v", err)
t.Fatalf("expected explicit uncategorized article allowlist to validate: %v", err)
}
}
@@ -490,3 +495,23 @@ func TestValidateOllamaPoolConfiguration(t *testing.T) {
t.Fatal("expected unknown routing mode to be rejected")
}
}
func TestValidateGLPIKBAutoReplyUncategorizedRequiresArticleAllowlist(t *testing.T) {
c := validConfig()
c.GLPIKBEnabled = true
c.GLPIKBPath = "auto"
c.GLPIKBLimit = 100
c.GLPIKBSyncInterval = 10 * time.Minute
c.GLPIKBSource = "glpi-kb"
c.KnowledgeAllowedSources = []string{"internal-kb", "glpi-kb"}
c.KnowledgeAutoReplySources = []string{"internal-kb", "glpi-kb"}
c.GLPIKBAutoReply = true
c.GLPIKBAutoReplyAllowUncategorized = true
if err := c.Validate(); err == nil {
t.Fatal("expected uncategorized auto reply to require an explicit article allowlist")
}
c.GLPIKBAutoReplyUncategorizedArticleIDs = []int64{1}
if err := c.Validate(); err != nil {
t.Fatalf("expected uncategorized auto reply with explicit article allowlist to validate: %v", err)
}
}
+85 -31
View File
@@ -2,6 +2,8 @@ package glpikb
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
stdhtml "html"
@@ -45,10 +47,14 @@ type Syncer struct {
count int
}
const cachePolicyVersion = 2
type cacheFile struct {
SyncedAt time.Time `json:"synced_at"`
Path string `json:"path"`
Documents []model.KnowledgeDoc `json:"documents"`
PolicyVersion int `json:"policy_version"`
ApprovalHash string `json:"approval_hash"`
SyncedAt time.Time `json:"synced_at"`
Path string `json:"path"`
Documents []model.KnowledgeDoc `json:"documents"`
}
type Status struct {
@@ -60,6 +66,13 @@ type Status struct {
}
func New(cfg config.Config, g Source, store Store, m *metrics.Metrics) *Syncer {
if len(cfg.GLPIKBAutoReplyITILCategoryIDs) > 0 {
slog.Warn(
"GLPI_KB_AUTO_REPLY_ITIL_CATEGORY_IDS is deprecated and ignored",
"ids", cfg.GLPIKBAutoReplyITILCategoryIDs,
"hint", "approve categorized articles with GLPI_KB_AUTO_REPLY_CATEGORY_IDS or uncategorized articles with GLPI_KB_AUTO_REPLY_UNCATEGORIZED_ARTICLE_IDS",
)
}
return &Syncer{cfg: cfg, glpi: g, store: store, metrics: m, cachePath: filepath.Join(cfg.DataDir, "glpi-kb-cache.json")}
}
@@ -81,6 +94,23 @@ func (s *Syncer) LoadCache(ctx context.Context) error {
if err := json.Unmarshal(b, &cf); err != nil {
return fmt.Errorf("decode GLPI KB cache: %w", err)
}
if cf.PolicyVersion != cachePolicyVersion {
slog.Warn(
"ignoring GLPI knowledge cache created with an older Auto-Reply policy",
"path", s.cachePath,
"cache_policy_version", cf.PolicyVersion,
"required_policy_version", cachePolicyVersion,
)
return nil
}
currentApprovalHash := approvalConfigHash(s.cfg)
if cf.ApprovalHash == "" || cf.ApprovalHash != currentApprovalHash {
slog.Warn(
"ignoring GLPI knowledge cache because Auto-Reply approval configuration changed",
"path", s.cachePath,
)
return nil
}
if err := s.store.ReplaceExternalSource(ctx, s.cfg.GLPIKBSource, cf.Documents); err != nil {
return fmt.Errorf("load GLPI KB cache into knowledge store: %w", err)
}
@@ -112,8 +142,12 @@ func (s *Syncer) Sync(ctx context.Context) error {
}
cats, err := s.glpi.GetCategories(ctx)
if err != nil {
s.fail(err)
return fmt.Errorf("load GLPI ITIL categories for KB mapping: %w", err)
slog.Warn(
"GLPI ITIL categories unavailable for optional KB relevance mapping",
"error", err,
"impact", "KB synchronization and Auto-Reply approval continue using GLPI knowledge-base categories",
)
cats = nil
}
docs := s.normalize(items, cats)
autoReplyApproved, autoReplyBlocked := autoReplyCounts(docs)
@@ -122,7 +156,7 @@ func (s *Syncer) Sync(ctx context.Context) error {
return fmt.Errorf("replace GLPI knowledge source: %w", err)
}
now := time.Now()
cf := cacheFile{SyncedAt: now, Path: path, Documents: docs}
cf := cacheFile{PolicyVersion: cachePolicyVersion, ApprovalHash: approvalConfigHash(s.cfg), SyncedAt: now, Path: path, Documents: docs}
if err := writeAtomicJSON(s.cachePath, cf); err != nil {
s.fail(err)
return fmt.Errorf("persist GLPI KB cache: %w", err)
@@ -145,6 +179,28 @@ func (s *Syncer) Sync(ctx context.Context) error {
return nil
}
func approvalConfigHash(cfg config.Config) string {
kbIDs := append([]int64(nil), cfg.GLPIKBAutoReplyCategoryIDs...)
articleIDs := append([]int64(nil), cfg.GLPIKBAutoReplyUncategorizedArticleIDs...)
sort.Slice(kbIDs, func(i, j int) bool { return kbIDs[i] < kbIDs[j] })
sort.Slice(articleIDs, func(i, j int) bool { return articleIDs[i] < articleIDs[j] })
payload, _ := json.Marshal(struct {
Enabled bool `json:"enabled"`
Source string `json:"source"`
KBCategoryIDs []int64 `json:"kb_category_ids"`
AllowUncategorized bool `json:"allow_uncategorized"`
ArticleIDs []int64 `json:"article_ids"`
}{
Enabled: cfg.GLPIKBAutoReply,
Source: strings.ToLower(strings.TrimSpace(cfg.GLPIKBSource)),
KBCategoryIDs: kbIDs,
AllowUncategorized: cfg.GLPIKBAutoReplyAllowUncategorized,
ArticleIDs: articleIDs,
})
sum := sha256.Sum256(payload)
return hex.EncodeToString(sum[:])
}
func autoReplyCounts(docs []model.KnowledgeDoc) (approved, blocked int) {
for _, doc := range docs {
if doc.AutoReply {
@@ -193,9 +249,9 @@ func (s *Syncer) normalize(items []model.GLPIKnowledgeItem, cats []model.Categor
for _, id := range s.cfg.GLPIKBAutoReplyCategoryIDs {
autoKBCats[id] = struct{}{}
}
autoITILCats := map[int64]struct{}{}
for _, id := range s.cfg.GLPIKBAutoReplyITILCategoryIDs {
autoITILCats[id] = struct{}{}
autoUncategorizedArticles := map[int64]struct{}{}
for _, id := range s.cfg.GLPIKBAutoReplyUncategorizedArticleIDs {
autoUncategorizedArticles[id] = struct{}{}
}
warnLikelyITILIDs(s.cfg.GLPIKBAutoReply, s.cfg.GLPIKBAutoReplyCategoryIDs, items, itilCategoryIDs)
out := make([]model.KnowledgeDoc, 0, len(items))
@@ -223,10 +279,12 @@ func (s *Syncer) normalize(items []model.GLPIKnowledgeItem, cats []model.Categor
sort.Slice(itilIDs, func(i, j int) bool { return itilIDs[i] < itilIDs[j] })
auto, autoDecision, autoDetail := autoReplyApproval(
s.cfg.GLPIKBAutoReply,
s.cfg.GLPIKBAutoReplyAllowUncategorized,
item.ID,
item.CategoryIDs,
itilIDs,
autoKBCats,
autoITILCats,
autoUncategorizedArticles,
)
language := strings.TrimSpace(item.Language)
if language == "" {
@@ -245,38 +303,34 @@ func (s *Syncer) normalize(items []model.GLPIKnowledgeItem, cats []model.Categor
return out
}
func autoReplyApproval(enabled bool, sourceCategoryIDs, itilCategoryIDs []int64, allowedSourceCategories, allowedITILCategories map[int64]struct{}) (bool, string, string) {
func autoReplyApproval(enabled, allowUncategorized bool, articleID int64, sourceCategoryIDs, itilCategoryIDs []int64, allowedSourceCategories, allowedUncategorizedArticles map[int64]struct{}) (bool, string, string) {
detail := fmt.Sprintf(
"GLPI-KB-Kategorien: %v; gemappte ITIL-Kategorien: %v; freigegebene GLPI-KB-Kategorien: %v; freigegebene ITIL-Kategorien: %v",
"GLPI-Artikel-ID: %d; GLPI-KB-Kategorien: %v; gemappte ITIL-Kategorien (nur fachliches Signal): %v; freigegebene GLPI-KB-Kategorien: %v; freigegebene kategorielose Artikel: %v",
articleID,
sourceCategoryIDs,
itilCategoryIDs,
sortedSetIDs(allowedSourceCategories),
sortedSetIDs(allowedITILCategories),
sortedSetIDs(allowedUncategorizedArticles),
)
if !enabled {
return false, "glpi_kb_auto_reply_disabled", detail + "; GLPI_KB_AUTO_REPLY=false"
}
if len(sourceCategoryIDs) == 0 {
return false, "glpi_kb_article_without_category", detail + "; der Artikel besitzt keine GLPI-KB-Kategorie"
}
if len(itilCategoryIDs) == 0 {
return false, "glpi_kb_category_not_mapped_to_itil", detail + "; keine GLPI-Ticketkategorie ist mit der KB-Kategorie verknüpft"
}
sourceMatch := intersectsSet(sourceCategoryIDs, allowedSourceCategories)
itilMatch := intersectsSet(itilCategoryIDs, allowedITILCategories)
if sourceMatch || itilMatch {
matchedBy := "GLPI-KB-Kategorie"
if itilMatch && !sourceMatch {
matchedBy = "ITIL-/Ticketkategorie"
} else if sourceMatch && itilMatch {
matchedBy = "GLPI-KB- und ITIL-/Ticketkategorie"
if !allowUncategorized {
return false, "glpi_kb_article_without_category", detail + "; der Artikel besitzt keine GLPI-KB-Kategorie und GLPI_KB_AUTO_REPLY_ALLOW_UNCATEGORIZED=false"
}
return true, "glpi_kb_auto_reply_approved", detail + "; Freigabe über " + matchedBy
if _, ok := allowedUncategorizedArticles[articleID]; !ok {
return false, "glpi_kb_uncategorized_article_not_whitelisted", detail + fmt.Sprintf("; GLPI-KB-Artikel #%d ist nicht in GLPI_KB_AUTO_REPLY_UNCATEGORIZED_ARTICLE_IDS freigegeben", articleID)
}
return true, "glpi_kb_uncategorized_article_approved", detail + "; Freigabe über die konkrete GLPI-Artikel-ID; die fachliche Eignung für das Ticket wird separat geprüft"
}
if len(allowedSourceCategories) == 0 && len(allowedITILCategories) == 0 {
return false, "glpi_kb_auto_reply_whitelist_empty", detail + "; keine Freigabe-Whitelist konfiguriert"
if intersectsSet(sourceCategoryIDs, allowedSourceCategories) {
return true, "glpi_kb_auto_reply_approved", detail + "; Freigabe über eine GLPI-Knowledge-Base-Kategorie"
}
return false, "glpi_kb_category_not_whitelisted", detail + "; keine konfigurierte Freigabe-Whitelist trifft zu"
if len(allowedSourceCategories) == 0 {
return false, "glpi_kb_auto_reply_whitelist_empty", detail + "; keine GLPI-Knowledge-Base-Kategorie für Auto-Reply freigegeben"
}
return false, "glpi_kb_category_not_whitelisted", detail + "; keine Artikel-Knowledge-Base-Kategorie ist in GLPI_KB_AUTO_REPLY_CATEGORY_IDS freigegeben"
}
func intersectsSet(ids []int64, allowed map[int64]struct{}) bool {
@@ -320,7 +374,7 @@ func warnLikelyITILIDs(enabled bool, configured []int64, items []model.GLPIKnowl
slog.Warn(
"GLPI_KB_AUTO_REPLY_CATEGORY_IDS appears to contain ITIL/ticket category IDs",
"ids", suspicious,
"hint", "move these IDs to GLPI_KB_AUTO_REPLY_ITIL_CATEGORY_IDS or configure the actual GLPI knowledge-base category IDs",
"hint", "configure the actual GLPI knowledge-base category IDs; ITIL/ticket category IDs do not release articles for Auto-Reply",
)
}
}
+155 -5
View File
@@ -2,6 +2,9 @@ package glpikb
import (
"context"
"encoding/json"
"errors"
"os"
"path/filepath"
"strings"
"testing"
@@ -55,7 +58,7 @@ func TestSyncMapsGLPIKBAndAutoReplyWhitelist(t *testing.T) {
}
}
func TestSyncAllowsAutoReplyByMappedITILCategory(t *testing.T) {
func TestSyncDoesNotUseMappedITILCategoryForAutoReplyApproval(t *testing.T) {
cfg := config.Config{
DataDir: t.TempDir(), GLPIKBEnabled: true, GLPIKBPath: "auto", GLPIKBLimit: 50,
GLPIKBSyncInterval: time.Minute, GLPIKBSource: "glpi-kb", GLPIKBAutoReply: true,
@@ -67,11 +70,11 @@ func TestSyncAllowsAutoReplyByMappedITILCategory(t *testing.T) {
if err := s.Sync(context.Background()); err != nil {
t.Fatal(err)
}
if len(st.docs) != 1 || !st.docs[0].AutoReply {
t.Fatalf("mapped ITIL category should approve auto reply: %+v", st.docs)
if len(st.docs) != 1 || st.docs[0].AutoReply {
t.Fatalf("mapped ITIL category must not approve auto reply: %+v", st.docs)
}
if got := st.docs[0].AutoReplyDetail; !strings.Contains(got, "ITIL-/Ticketkategorie") {
t.Fatalf("approval detail should name ITIL category path, got %q", got)
if got := st.docs[0].AutoReplyDecision; got != "glpi_kb_auto_reply_whitelist_empty" {
t.Fatalf("decision=%q", got)
}
}
@@ -97,3 +100,150 @@ func TestSyncExplainsBlockedAutoReply(t *testing.T) {
t.Fatalf("detail should expose source and configured IDs, got %q", got)
}
}
type categoryUnavailableSource struct{ fakeSource }
func (categoryUnavailableSource) GetCategories(context.Context) ([]model.Category, error) {
return nil, errors.New("category endpoint unavailable")
}
func TestSyncApprovesByKBCategoryWhenITILCategoriesAreUnavailable(t *testing.T) {
cfg := config.Config{
DataDir: t.TempDir(), GLPIKBEnabled: true, GLPIKBPath: "auto", GLPIKBLimit: 50,
GLPIKBSyncInterval: time.Minute, GLPIKBSource: "glpi-kb", GLPIKBAutoReply: true,
GLPIKBAutoReplyCategoryIDs: []int64{9}, CommunicationLanguage: "de-DE",
CommunicationStyle: "formal", GLPITimeout: time.Second,
}
st := &fakeStore{}
s := New(cfg, categoryUnavailableSource{}, st, metrics.New())
if err := s.Sync(context.Background()); err != nil {
t.Fatal(err)
}
if len(st.docs) != 1 || !st.docs[0].AutoReply {
t.Fatalf("KB category approval must not depend on ITIL category loading: %+v", st.docs)
}
if len(st.docs[0].Categories) != 0 {
t.Fatalf("optional ITIL mapping should be absent after category endpoint failure: %v", st.docs[0].Categories)
}
}
func TestLoadCacheIgnoresOlderAutoReplyPolicy(t *testing.T) {
dir := t.TempDir()
cfg := config.Config{DataDir: dir, GLPIKBSource: "glpi-kb"}
legacy := cacheFile{
PolicyVersion: 0,
SyncedAt: time.Now(),
Path: "/Knowledge/KnowbaseItem",
Documents: []model.KnowledgeDoc{{
ID: "GLPI-KB-1", Title: "Legacy", AutoReply: true, Source: "glpi-kb",
}},
}
b, err := json.Marshal(legacy)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "glpi-kb-cache.json"), b, 0o600); err != nil {
t.Fatal(err)
}
st := &fakeStore{}
s := New(cfg, fakeSource{}, st, metrics.New())
if err := s.LoadCache(context.Background()); err != nil {
t.Fatal(err)
}
if len(st.docs) != 0 {
t.Fatalf("legacy approval cache must be ignored: %+v", st.docs)
}
}
type uncategorizedSource struct{}
func TestLoadCacheIgnoresChangedAutoReplyApprovalConfig(t *testing.T) {
dir := t.TempDir()
oldCfg := config.Config{
DataDir: dir, GLPIKBSource: "glpi-kb", GLPIKBAutoReply: true,
GLPIKBAutoReplyCategoryIDs: []int64{9},
}
cf := cacheFile{
PolicyVersion: cachePolicyVersion,
ApprovalHash: approvalConfigHash(oldCfg),
SyncedAt: time.Now(),
Path: "/Knowledge/KnowbaseItem",
Documents: []model.KnowledgeDoc{{
ID: "GLPI-KB-1", Title: "Old config", AutoReply: true, Source: "glpi-kb",
}},
}
b, err := json.Marshal(cf)
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "glpi-kb-cache.json"), b, 0o600); err != nil {
t.Fatal(err)
}
newCfg := oldCfg
newCfg.GLPIKBAutoReplyCategoryIDs = []int64{10}
st := &fakeStore{}
s := New(newCfg, fakeSource{}, st, metrics.New())
if err := s.LoadCache(context.Background()); err != nil {
t.Fatal(err)
}
if len(st.docs) != 0 {
t.Fatalf("cache approved under a different allowlist must be ignored: %+v", st.docs)
}
}
func (uncategorizedSource) DiscoverKnowledgeBasePath(context.Context, string) (string, error) {
return "/Knowledge/KnowbaseItem", nil
}
func (uncategorizedSource) ListKnowledgeBaseItems(context.Context, string, int, string) ([]model.GLPIKnowledgeItem, error) {
return []model.GLPIKnowledgeItem{{ID: 21, Title: "Allgemeine Druckerhilfe", Content: "<p>Drucker neu verbinden.</p>", CategoryIDs: nil, ModifiedAt: "now"}}, nil
}
func (uncategorizedSource) GetCategories(context.Context) ([]model.Category, error) {
return []model.Category{{ID: 67, Name: "Arbeitsplatzdrucker"}, {ID: 68, Name: "Multifunktionsgerät"}}, nil
}
func TestSyncConditionallyApprovesUncategorizedArticle(t *testing.T) {
cfg := config.Config{
DataDir: t.TempDir(), GLPIKBEnabled: true, GLPIKBPath: "auto", GLPIKBLimit: 50,
GLPIKBSyncInterval: time.Minute, GLPIKBSource: "glpi-kb", GLPIKBAutoReply: true,
GLPIKBAutoReplyAllowUncategorized: true,
GLPIKBAutoReplyUncategorizedArticleIDs: []int64{21},
CommunicationLanguage: "de-DE", CommunicationStyle: "formal", GLPITimeout: time.Second,
}
st := &fakeStore{}
s := New(cfg, uncategorizedSource{}, st, metrics.New())
if err := s.Sync(context.Background()); err != nil {
t.Fatal(err)
}
if len(st.docs) != 1 {
t.Fatalf("docs=%d", len(st.docs))
}
d := st.docs[0]
if !d.AutoReply || d.AutoReplyDecision != "glpi_kb_uncategorized_article_approved" {
t.Fatalf("uncategorized article should be explicitly approved: %+v", d)
}
if got := strings.TrimSpace(d.AutoReplyDetail); !strings.Contains(got, "konkrete GLPI-Artikel-ID") {
t.Fatalf("detail should explain article-ID approval, got %q", got)
}
if len(d.Categories) != 0 {
t.Fatalf("uncategorized article must not pretend to have semantic ITIL categories: %v", d.Categories)
}
}
func TestSyncKeepsUncategorizedArticleBlockedByDefault(t *testing.T) {
cfg := config.Config{
DataDir: t.TempDir(), GLPIKBEnabled: true, GLPIKBPath: "auto", GLPIKBLimit: 50,
GLPIKBSyncInterval: time.Minute, GLPIKBSource: "glpi-kb", GLPIKBAutoReply: true,
CommunicationLanguage: "de-DE", CommunicationStyle: "formal", GLPITimeout: time.Second,
}
st := &fakeStore{}
s := New(cfg, uncategorizedSource{}, st, metrics.New())
if err := s.Sync(context.Background()); err != nil {
t.Fatal(err)
}
if len(st.docs) != 1 || st.docs[0].AutoReply {
t.Fatalf("uncategorized article must remain blocked without explicit opt-in: %+v", st.docs)
}
if got := st.docs[0].AutoReplyDecision; got != "glpi_kb_article_without_category" {
t.Fatalf("decision=%q", got)
}
}
+22 -22
View File
@@ -77,8 +77,8 @@ type KnowledgeDoc struct {
// AutoReplyDecision explains why a synchronized source document is or is
// not eligible for automatic replies. Local JSON documents may leave this
// empty because their explicit auto_reply flag is already authoritative.
AutoReplyDecision string `json:"auto_reply_decision,omitempty"`
AutoReplyDetail string `json:"auto_reply_detail,omitempty"`
AutoReplyDecision string `json:"auto_reply_decision,omitempty"`
AutoReplyDetail string `json:"auto_reply_detail,omitempty"`
MinScore float64 `json:"min_score"`
Categories []int64 `json:"categories"`
ExternalCategories []string `json:"external_categories,omitempty"`
@@ -299,26 +299,26 @@ type PolicyResult struct {
// KnowledgeCandidateAudit captures the top retrieval candidates used for a run.
// It intentionally stores only normalized, non-secret diagnostic information.
type KnowledgeCandidateAudit struct {
ID string `json:"id"`
Title string `json:"title"`
Source string `json:"source"`
Score float64 `json:"score"`
SemanticScore float64 `json:"semantic_score,omitempty"`
TitleScore float64 `json:"title_score,omitempty"`
LexicalScore float64 `json:"lexical_score,omitempty"`
KeywordScore float64 `json:"keyword_score,omitempty"`
CategoryScore float64 `json:"category_score,omitempty"`
RequiredScore float64 `json:"required_score,omitempty"`
AutoReply bool `json:"auto_reply"`
AutoReplyDecision string `json:"auto_reply_decision,omitempty"`
AutoReplyDetail string `json:"auto_reply_detail,omitempty"`
BestChunkExcerpt string `json:"best_chunk_excerpt,omitempty"`
BestQueryExcerpt string `json:"best_query_excerpt,omitempty"`
QueryChunkCount int `json:"query_chunk_count,omitempty"`
DocumentChunkCount int `json:"document_chunk_count,omitempty"`
SentToAI bool `json:"sent_to_ai,omitempty"`
RetrievalRank int `json:"retrieval_rank,omitempty"`
SelectionReason string `json:"selection_reason,omitempty"`
ID string `json:"id"`
Title string `json:"title"`
Source string `json:"source"`
Score float64 `json:"score"`
SemanticScore float64 `json:"semantic_score,omitempty"`
TitleScore float64 `json:"title_score,omitempty"`
LexicalScore float64 `json:"lexical_score,omitempty"`
KeywordScore float64 `json:"keyword_score,omitempty"`
CategoryScore float64 `json:"category_score,omitempty"`
RequiredScore float64 `json:"required_score,omitempty"`
AutoReply bool `json:"auto_reply"`
AutoReplyDecision string `json:"auto_reply_decision,omitempty"`
AutoReplyDetail string `json:"auto_reply_detail,omitempty"`
BestChunkExcerpt string `json:"best_chunk_excerpt,omitempty"`
BestQueryExcerpt string `json:"best_query_excerpt,omitempty"`
QueryChunkCount int `json:"query_chunk_count,omitempty"`
DocumentChunkCount int `json:"document_chunk_count,omitempty"`
SentToAI bool `json:"sent_to_ai,omitempty"`
RetrievalRank int `json:"retrieval_rank,omitempty"`
SelectionReason string `json:"selection_reason,omitempty"`
}
// ContextAuditItem is a compact snapshot of context that influenced a run.
+1 -1
View File
@@ -432,7 +432,7 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request) {
"ollama_model": s.cfg.OllamaModel, "ollama_embedding_model": s.cfg.OllamaEmbeddingModel, "ollama_timeout": s.cfg.OllamaTimeout.String(), "ollama_num_predict": s.cfg.OllamaNumPredict, "ollama_keep_alive": s.cfg.OllamaKeepAlive.String(), "ollama_think": s.cfg.OllamaThink, "ollama_max_concurrent": s.cfg.OllamaMaxConcurrent, "ollama_json_retries": s.cfg.OllamaJSONRetries,
"ollama_nodes": ollamaNodes, "ollama_node_count": len(ollamaNodes), "ollama_healthy_nodes": ollamaHealthyNodes, "ollama_available_nodes": ollamaAvailableNodes, "ollama_routing_mode": ollamaRoutingMode, "ollama_node_max_inflight": s.cfg.OllamaNodeMaxInflight, "ollama_node_health_interval": s.cfg.OllamaNodeHealthInterval.String(), "ollama_node_failure_cooldown": s.cfg.OllamaNodeFailureCooldown.String(), "ollama_node_request_timeout": s.cfg.OllamaNodeRequestTimeout.String(), "ollama_failover_enabled": s.cfg.OllamaFailoverEnabled, "ollama_failover_attempts": s.cfg.OllamaFailoverAttempts, "ollama_require_same_model_digest": s.cfg.OllamaRequireSameDigest, "ollama_require_embedding_model": s.cfg.OllamaRequireEmbeddingModel,
"rag_enabled": s.cfg.RAGEnabled, "knowledge_top_k": s.cfg.KnowledgeTopK, "knowledge_audit_top_k": s.cfg.KnowledgeAuditTopK, "knowledge_candidate_max_gap": s.cfg.KnowledgeCandidateMaxGap, "category_prompt_limit": s.cfg.CategoryPromptLimit, "knowledge_max_query_chunks": s.cfg.KnowledgeMaxQueryChunks,
"glpi_kb_path": s.cfg.GLPIKBPath, "glpi_kb_filter_configured": strings.TrimSpace(s.cfg.GLPIKBFilter) != "", "glpi_kb_limit": s.cfg.GLPIKBLimit, "glpi_kb_auto_reply": s.cfg.GLPIKBAutoReply, "glpi_kb_auto_reply_category_ids": s.cfg.GLPIKBAutoReplyCategoryIDs, "glpi_kb_auto_reply_itil_category_ids": s.cfg.GLPIKBAutoReplyITILCategoryIDs,
"glpi_kb_path": s.cfg.GLPIKBPath, "glpi_kb_filter_configured": strings.TrimSpace(s.cfg.GLPIKBFilter) != "", "glpi_kb_limit": s.cfg.GLPIKBLimit, "glpi_kb_auto_reply": s.cfg.GLPIKBAutoReply, "glpi_kb_auto_reply_category_ids": s.cfg.GLPIKBAutoReplyCategoryIDs, "glpi_kb_auto_reply_allow_uncategorized": s.cfg.GLPIKBAutoReplyAllowUncategorized, "glpi_kb_auto_reply_uncategorized_article_ids": s.cfg.GLPIKBAutoReplyUncategorizedArticleIDs,
"learning_max_examples": s.cfg.LearningMaxExamples, "learning_examples_per_category": s.cfg.LearningExamplesPerCategory,
"context_timeout": s.cfg.ContextTimeout.String(), "context_relevance_min_score": s.cfg.ContextRelevanceMinScore, "change_lookback": s.cfg.ChangeLookback.String(), "change_lookahead": s.cfg.ChangeLookahead.String(),
})
File diff suppressed because one or more lines are too long