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 = ``
+
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", ""+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, "