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)
}
}