From a10225d74cb3aa4629d76c3061925ed770b8fe92 Mon Sep 17 00:00:00 2001 From: groot Date: Wed, 29 Jul 2026 14:53:12 +0200 Subject: [PATCH] =?UTF-8?q?AI-Label=20hinzugef=C3=BCgt?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 6 ++++ UPGRADE.md | 5 +++ internal/agent/agent.go | 2 +- internal/agent/agent_test.go | 2 +- internal/agent/policy.go | 43 +++++++++++++++++++++++-- internal/agent/policy_test.go | 45 ++++++++++++++++++++++++--- internal/config/config.go | 21 +++++++++---- internal/config/config_test.go | 19 +++++++++++ internal/web/server.go | 2 +- internal/web/templates/dashboard.html | 2 +- 10 files changed, 129 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index 3eeee85..7ef6236 100644 --- a/README.md +++ b/README.md @@ -225,6 +225,7 @@ COMMUNICATION_STYLE=formal COMMUNICATION_SALUTATION=Guten Tag, COMMUNICATION_CLOSING=Mit freundlichen Grüßen COMMUNICATION_SIGNATURE=IT-Service +AI_CONTENT_LABEL_ENABLED=true ``` Ein Auto-Reply ist nur erlaubt, wenn `language` und `communication_style` des freigegebenen Knowledge-Dokuments exakt zur aktiven Policy passen. Das Feld `answer` enthält nur den fachlich freigegebenen Nachrichtentext; Anrede, Grußformel und Signatur werden von der Go-Policy zentral ergänzt. Damit kann das Modell diese Kommunikationsvorgaben nicht überschreiben. @@ -538,3 +539,8 @@ Index-Modi: `/api/status` und das Dashboard zeigen unter anderem Snapshot-Zeitpunkt, letzten Delta-Scan, geänderte/gelöschte Dateien, wiederverwendete Vektoren, Embedding-Batchgröße und Scanintervall. Das komplette Vektorindex-Map wird bei einer Suche nicht mehr pro Ticket kopiert; Suchläufe lesen den warmen Index direkt unter einem Read-Lock. Beim allerersten Aufbau ohne Snapshot startet das Dashboard weiterhin sofort und zeigt Scan-/Embedding-Fortschritt. Die Ticketverarbeitung wartet in diesem Fall, bis der erste konsistente Index fertig ist. + + +### KI-Kennzeichnung automatischer Antworten + +Mit `AI_CONTENT_LABEL_ENABLED=true` wird der konfigurierte TrustedNet-Kennzeichnungsblock unveraendert an den Anfang jeder automatisch vom Agenten ausgewaehlten Antwort gesetzt. Da der Badge HTML verwendet, werden auch Plaintext-KB-Antworten fuer den Versand sicher nach HTML escaped. diff --git a/UPGRADE.md b/UPGRADE.md index 5bb4591..9c05272 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -182,3 +182,8 @@ Normaler Neustart in `incremental`: 6. Geänderten Snapshot atomar ersetzen. `KNOWLEDGE_INDEX_MODE=rebuild` erzwingt einen vollständigen Quellen-Scan. `readonly` verwendet ausschließlich den vorhandenen Snapshot und führt keine lokalen Delta-Scans aus. + + +## KI-Kennzeichnung + +Automatische Antworten tragen standardmaessig den TrustedNet-Kennzeichnungsblock am Anfang. Zum expliziten Aktivieren/Deaktivieren: `AI_CONTENT_LABEL_ENABLED=true|false`. diff --git a/internal/agent/agent.go b/internal/agent/agent.go index a8034f9..a04f485 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -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) { diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 3d540a4..67ea357 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -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) } diff --git a/internal/agent/policy.go b/internal/agent/policy.go index 1dfa07c..31d896e 100644 --- a/internal/agent/policy.go +++ b/internal/agent/policy.go @@ -7,6 +7,8 @@ import ( "github.com/example/glpi-ai-agent/internal/model" ) +const AIContentLabelHTML = `UCNG` + 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, "

"+html.EscapeString(strings.TrimSpace(p.CommunicationSalutation))+"

") } @@ -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", "
") + out = append(out, "

"+escaped+"

") + } + return strings.Join(out, "\n") +} + func sourceSet(values []string) map[string]struct{} { out := make(map[string]struct{}, len(values)) for _, v := range values { diff --git a/internal/agent/policy_test.go b/internal/agent/policy_test.go index fc16f53..1b2a35a 100644 --- a/internal/agent/policy_test.go +++ b/internal/agent/policy_test.go @@ -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 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, "