AI-Label hinzugefügt
release-tag / release-image (push) Successful in 1m41s

This commit is contained in:
2026-07-29 14:53:12 +02:00
parent 827c348e69
commit a10225d74c
10 changed files with 129 additions and 18 deletions
+1 -1
View File
@@ -56,7 +56,7 @@ type Service struct {
}
func New(cfg config.Config, g GLPI, ai AI, k *knowledge.Store, l *learning.Store, s *state.Store, q *queue.Queue, m *metrics.Metrics, contextCollector ContextCollector) *Service {
return &Service{cfg: cfg, glpi: g, ai: ai, knowledge: k, learning: l, state: s, q: q, metrics: m, context: contextCollector, policy: NewPolicy(cfg.AutoCategory, cfg.AutoReply, cfg.CategoryConfidence, cfg.ReplyConfidence, cfg.KnowledgeMinScore, cfg.KnowledgeRetrievalFloor, cfg.KnowledgeEvidenceRetrievalWeight, cfg.KnowledgeEvidenceAIWeight, cfg.KnowledgeEvidenceCategoryWeight, cfg.KnowledgeAllowedSources, cfg.KnowledgeAutoReplySources, cfg.CommunicationLanguage, cfg.CommunicationStyle, cfg.CommunicationSalutation, cfg.CommunicationClosing, cfg.CommunicationSignature, cfg.ContextBlockReplyOnError, cfg.ContextBlockReplyOnIncident, cfg.ContextRelevanceMinScore)}
return &Service{cfg: cfg, glpi: g, ai: ai, knowledge: k, learning: l, state: s, q: q, metrics: m, context: contextCollector, policy: NewPolicy(cfg.AutoCategory, cfg.AutoReply, cfg.CategoryConfidence, cfg.ReplyConfidence, cfg.KnowledgeMinScore, cfg.KnowledgeRetrievalFloor, cfg.KnowledgeEvidenceRetrievalWeight, cfg.KnowledgeEvidenceAIWeight, cfg.KnowledgeEvidenceCategoryWeight, cfg.KnowledgeAllowedSources, cfg.KnowledgeAutoReplySources, cfg.CommunicationLanguage, cfg.CommunicationStyle, cfg.CommunicationSalutation, cfg.CommunicationClosing, cfg.CommunicationSignature, cfg.AIContentLabelEnabled, cfg.ContextBlockReplyOnError, cfg.ContextBlockReplyOnIncident, cfg.ContextRelevanceMinScore)}
}
func (s *Service) Queue() *queue.Queue { return s.q }
func (s *Service) Start(ctx context.Context) {
+1 -1
View File
@@ -81,7 +81,7 @@ func newTestService(t *testing.T, g *fakeGLPI, d model.Decision, autoReply bool)
if err != nil {
t.Fatal(err)
}
cfg := config.Config{DryRun: false, AutoCategory: true, AutoReply: autoReply, CategoryConfidence: .9, ReplyConfidence: .9, KnowledgeMinScore: 0, KnowledgeTopK: 1, CategoryPromptLimit: 20, Workers: 1, GLPIAllowedStatusIDs: []int64{1}, KnowledgeAllowedSources: []string{"internal-kb"}, KnowledgeAutoReplySources: []string{"internal-kb"}, CommunicationLanguage: "de-DE", CommunicationStyle: "formal", CommunicationSalutation: "Guten Tag,", CommunicationClosing: "Mit freundlichen Grüßen", CommunicationSignature: "IT-Service"}
cfg := config.Config{DryRun: false, AutoCategory: true, AutoReply: autoReply, CategoryConfidence: .9, ReplyConfidence: .9, KnowledgeMinScore: 0, KnowledgeTopK: 1, CategoryPromptLimit: 20, Workers: 1, GLPIAllowedStatusIDs: []int64{1}, KnowledgeAllowedSources: []string{"internal-kb"}, KnowledgeAutoReplySources: []string{"internal-kb"}, CommunicationLanguage: "de-DE", CommunicationStyle: "formal", CommunicationSalutation: "Guten Tag,", CommunicationClosing: "Mit freundlichen Grüßen", CommunicationSignature: "IT-Service", AIContentLabelEnabled: true}
return New(cfg, g, fakeAI{d: d}, k, nil, st, queue.New(8), metrics.New(), nil)
}
+40 -3
View File
@@ -7,6 +7,8 @@ import (
"github.com/example/glpi-ai-agent/internal/model"
)
const AIContentLabelHTML = `<a href="https://ai.trustednet.eu/declaration?preset=full&component=text&extent=full&activities=generation&review=expert&lang=de&assurance=selfDeclared&subject=https%3A%2F%2Fglpi.hilden.de&substantialHumanReview=true&editorialResponsibilityConfirmed=true&responsible=Stadt+Hilden&responsibleUrl=https%3A%2F%2Fwww.hilden.de%2Fde%2Fservice%2Fimpressum-datenschutz&customDescription=Die+KI+hat+den+Inhalt+auf+Basis+des+Ticket-Inhaltes+ausgew%C3%A4hlt.+Der+Inhalt+wurde+sachlich+und+fachlich+von+einem+Menschen+erzeugt.&badgeLabel=KI-Beitrag&badgeMessage=Die+KI+hat+die+Antwort+ausgew%C3%A4hlt%2C+jedoch+nicht+verfasst.&leftColor=%23036715&rightColor=%23535454"><img src="https://ai.trustednet.eu/v1/badge.svg?preset=full&component=text&extent=full&activities=generation&review=expert&lang=de&assurance=selfDeclared&subject=https%3A%2F%2Fglpi.hilden.de&substantialHumanReview=true&editorialResponsibilityConfirmed=true&responsible=Stadt+Hilden&responsibleUrl=https%3A%2F%2Fwww.hilden.de%2Fde%2Fservice%2Fimpressum-datenschutz&customDescription=Die+KI+hat+den+Inhalt+auf+Basis+des+Ticket-Inhaltes+ausgew%C3%A4hlt.+Der+Inhalt+wurde+sachlich+und+fachlich+von+einem+Menschen+erzeugt.&badgeLabel=KI-Beitrag&badgeMessage=Die+KI+hat+die+Antwort+ausgew%C3%A4hlt%2C+jedoch+nicht+verfasst.&leftColor=%23036715&rightColor=%23535454&theme=color&link=https%3A%2F%2Fai.trustednet.eu%2Fdeclaration%3Fpreset%3Dfull%26component%3Dtext%26extent%3Dfull%26activities%3Dgeneration%26review%3Dexpert%26lang%3Dde%26assurance%3DselfDeclared%26subject%3Dhttps%253A%252F%252Fglpi.hilden.de%26substantialHumanReview%3Dtrue%26editorialResponsibilityConfirmed%3Dtrue%26responsible%3DStadt%2BHilden%26responsibleUrl%3Dhttps%253A%252F%252Fwww.hilden.de%252Fde%252Fservice%252Fimpressum-datenschutz%26customDescription%3DDie%2BKI%2Bhat%2Bden%2BInhalt%2Bauf%2BBasis%2Bdes%2BTicket-Inhaltes%2Bausgew%25C3%25A4hlt.%2BDer%2BInhalt%2Bwurde%2Bsachlich%2Bund%2Bfachlich%2Bvon%2Beinem%2BMenschen%2Berzeugt.%26badgeLabel%3DKI-Beitrag%26badgeMessage%3DDie%2BKI%2Bhat%2Bdie%2BAntwort%2Bausgew%25C3%25A4hlt%252C%2Bjedoch%2Bnicht%2Bverfasst.%26leftColor%3D%2523036715%26rightColor%3D%2523535454" alt="UCNG"></a>`
type Policy struct {
AutoCategory, AutoReply bool
CategoryConfidence, ReplyConfidence, KnowledgeMinScore float64
@@ -18,9 +20,10 @@ type Policy struct {
CommunicationSignature string
BlockReplyOnContextError, BlockReplyOnIncident bool
ContextRelevanceMinScore float64
AIContentLabelEnabled bool
}
func NewPolicy(autoCategory, autoReply bool, categoryConfidence, replyConfidence, knowledgeMinScore, knowledgeRetrievalFloor, evidenceRetrievalWeight, evidenceAIWeight, evidenceCategoryWeight float64, allowedSources, autoReplySources []string, language, style, salutation, closing, signature string, blockReplyOnContextError, blockReplyOnIncident bool, contextRelevanceMinScore float64) Policy {
func NewPolicy(autoCategory, autoReply bool, categoryConfidence, replyConfidence, knowledgeMinScore, knowledgeRetrievalFloor, evidenceRetrievalWeight, evidenceAIWeight, evidenceCategoryWeight float64, allowedSources, autoReplySources []string, language, style, salutation, closing, signature string, aiContentLabelEnabled, blockReplyOnContextError, blockReplyOnIncident bool, contextRelevanceMinScore float64) Policy {
if evidenceRetrievalWeight+evidenceAIWeight+evidenceCategoryWeight <= 0 {
evidenceRetrievalWeight, evidenceAIWeight, evidenceCategoryWeight = .45, .35, .20
}
@@ -42,6 +45,7 @@ func NewPolicy(autoCategory, autoReply bool, categoryConfidence, replyConfidence
CommunicationSalutation: strings.TrimSpace(salutation),
CommunicationClosing: strings.TrimSpace(closing),
CommunicationSignature: strings.TrimSpace(signature),
AIContentLabelEnabled: aiContentLabelEnabled,
BlockReplyOnContextError: blockReplyOnContextError,
BlockReplyOnIncident: blockReplyOnIncident,
ContextRelevanceMinScore: contextRelevanceMinScore,
@@ -202,7 +206,16 @@ func (p Policy) Evaluate(t model.Ticket, d model.Decision, categories []model.Ca
}
}
res.Reply = true
if strings.TrimSpace(hit.Doc.AnswerHTML) != "" {
if p.AIContentLabelEnabled {
// The disclosure is HTML, therefore every AI-selected automatic reply is
// sent as HTML. Plain-text KB answers are escaped before wrapping.
if strings.TrimSpace(hit.Doc.AnswerHTML) != "" {
res.ReplyText = p.formatRichReply(hit.Doc.AnswerHTML)
} else {
res.ReplyText = p.formatRichReply(p.plainTextToHTML(hit.Doc.Answer))
}
res.ReplyIsHTML = true
} else if strings.TrimSpace(hit.Doc.AnswerHTML) != "" {
res.ReplyText = p.formatRichReply(hit.Doc.AnswerHTML)
res.ReplyIsHTML = true
} else {
@@ -244,7 +257,12 @@ func (p Policy) formatReply(body string) string {
}
func (p Policy) formatRichReply(bodyHTML string) string {
parts := make([]string, 0, 3)
parts := make([]string, 0, 4)
if p.AIContentLabelEnabled {
// Keep this block byte-for-byte unchanged. It is the externally defined
// declaration that must be the first content in every AI-selected reply.
parts = append(parts, AIContentLabelHTML)
}
if strings.TrimSpace(p.CommunicationSalutation) != "" {
parts = append(parts, "<p>"+html.EscapeString(strings.TrimSpace(p.CommunicationSalutation))+"</p>")
}
@@ -260,6 +278,25 @@ func (p Policy) formatRichReply(bodyHTML string) string {
return strings.Join(parts, "\n")
}
func (p Policy) plainTextToHTML(body string) string {
normalized := strings.ReplaceAll(strings.ReplaceAll(strings.TrimSpace(body), "\r\n", "\n"), "\r", "\n")
if normalized == "" {
return ""
}
paragraphs := strings.Split(normalized, "\\n\\n")
out := make([]string, 0, len(paragraphs))
for _, paragraph := range paragraphs {
paragraph = strings.TrimSpace(paragraph)
if paragraph == "" {
continue
}
escaped := html.EscapeString(paragraph)
escaped = strings.ReplaceAll(escaped, "\n", "<br>")
out = append(out, "<p>"+escaped+"</p>")
}
return strings.Join(out, "\n")
}
func sourceSet(values []string) map[string]struct{} {
out := make(map[string]struct{}, len(values))
for _, v := range values {
+40 -5
View File
@@ -8,7 +8,7 @@ import (
)
func productionTestPolicy() Policy {
return NewPolicy(true, true, .9, .97, .88, .30, .45, .35, .20, []string{"internal-kb", "vendor-docs"}, []string{"internal-kb"}, "de-DE", "formal", "Guten Tag,", "Mit freundlichen Grüßen", "IT-Service", true, true, .2)
return NewPolicy(true, true, .9, .97, .88, .30, .45, .35, .20, []string{"internal-kb", "vendor-docs"}, []string{"internal-kb"}, "de-DE", "formal", "Guten Tag,", "Mit freundlichen Grüßen", "IT-Service", true, true, true, .2)
}
func approvedHit(source, language, style string) []model.KnowledgeHit {
@@ -33,6 +33,12 @@ func TestPolicyAutoReplyUsesApprovedKnowledge(t *testing.T) {
if !r.Reply || !r.ChangeCategory {
t.Fatalf("unexpected result: %+v", r)
}
if !r.ReplyIsHTML {
t.Fatalf("AI-labelled reply must be HTML: %+v", r)
}
if !strings.HasPrefix(r.ReplyText, AIContentLabelHTML) {
t.Fatalf("AI content label is not the exact first content: %s", r.ReplyText)
}
for _, expected := range []string{"Guten Tag,", "Bitte starten Sie", "Mit freundlichen Grüßen", "IT-Service"} {
if !strings.Contains(r.ReplyText, expected) {
t.Fatalf("reply missing %q: %q", expected, r.ReplyText)
@@ -92,7 +98,7 @@ func TestPolicyRejectsUnknownCategoryWithoutFailingRun(t *testing.T) {
var d model.Decision
d.Category.ID = 99
d.Category.Confidence = 1
p := NewPolicy(true, false, .9, .9, .8, .30, .45, .35, .20, []string{"internal-kb"}, nil, "de-DE", "formal", "", "", "", true, true, .2)
p := NewPolicy(true, false, .9, .9, .8, .30, .45, .35, .20, []string{"internal-kb"}, nil, "de-DE", "formal", "", "", "", true, true, true, .2)
r, err := p.Evaluate(model.Ticket{CategoryID: 1}, d, []model.Category{{ID: 1}}, nil, model.ContextSnapshot{})
if err != nil {
t.Fatal(err)
@@ -103,7 +109,7 @@ func TestPolicyRejectsUnknownCategoryWithoutFailingRun(t *testing.T) {
}
func TestPolicyCategoryDecisionIsDeterministic(t *testing.T) {
p := NewPolicy(true, false, .9, .9, .8, .30, .45, .35, .20, []string{"internal-kb"}, nil, "de-DE", "formal", "", "", "", true, true, .2)
p := NewPolicy(true, false, .9, .9, .8, .30, .45, .35, .20, []string{"internal-kb"}, nil, "de-DE", "formal", "", "", "", true, true, true, .2)
var d model.Decision
d.Category.ID = 2
d.Category.Confidence = .89
@@ -148,7 +154,7 @@ func TestPolicyBlocksAutoReplyOnIncompleteContext(t *testing.T) {
func TestPolicyUsesTwoStageEvidenceForShortButUnambiguousTicket(t *testing.T) {
p := NewPolicy(true, true, .70, .70, .70, .30, .45, .35, .20,
[]string{"internal-kb"}, []string{"internal-kb"}, "de-DE", "formal", "", "", "", true, true, .2)
[]string{"internal-kb"}, []string{"internal-kb"}, "de-DE", "formal", "", "", "", true, true, true, .2)
d := replyDecision()
d.Reply.Confidence = .95
hits := []model.KnowledgeHit{{
@@ -170,7 +176,7 @@ func TestPolicyUsesTwoStageEvidenceForShortButUnambiguousTicket(t *testing.T) {
func TestPolicyStillRejectsWeakRetrievalEvenWithHighAIConfidence(t *testing.T) {
p := NewPolicy(true, true, .70, .70, .70, .30, .45, .35, .20,
[]string{"internal-kb"}, []string{"internal-kb"}, "de-DE", "formal", "", "", "", true, true, .2)
[]string{"internal-kb"}, []string{"internal-kb"}, "de-DE", "formal", "", "", "", true, true, true, .2)
d := replyDecision()
d.Reply.Confidence = .99
hits := []model.KnowledgeHit{{
@@ -185,3 +191,32 @@ func TestPolicyStillRejectsWeakRetrievalEvenWithHighAIConfidence(t *testing.T) {
t.Fatalf("weak retrieval must remain blocked: %+v", r)
}
}
func TestPolicyAIContentLabelEscapesPlainTextKnowledge(t *testing.T) {
p := productionTestPolicy()
d := replyDecision()
hits := approvedHit("internal-kb", "de-DE", "formal")
hits[0].Doc.Answer = "Bitte <script>alert('x')</script> prüfen.\\nZweite Zeile."
r, err := p.Evaluate(model.Ticket{CategoryID: 2}, d, []model.Category{{ID: 2}}, hits, model.ContextSnapshot{})
if err != nil {
t.Fatal(err)
}
if !r.Reply || !r.ReplyIsHTML || !strings.HasPrefix(r.ReplyText, AIContentLabelHTML) {
t.Fatalf("unexpected labelled reply: %+v", r)
}
if strings.Contains(r.ReplyText, "<script>") || !strings.Contains(r.ReplyText, "&lt;script&gt;") {
t.Fatalf("plain-text KB was not safely escaped: %s", r.ReplyText)
}
}
func TestPolicyCanDisableAIContentLabel(t *testing.T) {
p := NewPolicy(true, true, .9, .97, .88, .30, .45, .35, .20,
[]string{"internal-kb"}, []string{"internal-kb"}, "de-DE", "formal", "Guten Tag,", "Mit freundlichen Grüßen", "IT-Service", false, true, true, .2)
r, err := p.Evaluate(model.Ticket{CategoryID: 1}, replyDecision(), []model.Category{{ID: 1}, {ID: 2}}, approvedHit("internal-kb", "de-DE", "formal"), model.ContextSnapshot{})
if err != nil {
t.Fatal(err)
}
if r.ReplyIsHTML || strings.Contains(r.ReplyText, AIContentLabelHTML) {
t.Fatalf("disabled AI content label must preserve plain reply behaviour: %+v", r)
}
}