From 523e597e7a43a8fcd6c2e3bce6ae24d41d241d21 Mon Sep 17 00:00:00 2001 From: jbergner Date: Tue, 28 Jul 2026 20:15:43 +0200 Subject: [PATCH] RC-2 --- README.md | 35 ++++++---------- SECURITY.md | 5 +++ UPGRADE.md | 37 +++++++++-------- internal/agent/agent.go | 4 +- internal/agent/agent_test.go | 2 +- internal/agent/policy.go | 25 +++++++++++- internal/agent/policy_test.go | 25 ++++++++++++ internal/glpi/client.go | 7 +++- internal/glpi/client_test.go | 58 +++++++++++++++++++++++++++ internal/glpikb/sync.go | 2 +- internal/glpikb/sync_test.go | 2 +- internal/knowledge/store.go | 20 +++++---- internal/model/model.go | 12 ++++-- internal/ollama/client.go | 8 +++- internal/ollama/client_test.go | 25 ++++++++++++ internal/web/server.go | 3 ++ internal/web/templates/dashboard.html | 2 +- 17 files changed, 211 insertions(+), 61 deletions(-) diff --git a/README.md b/README.md index 5578f47..df77c5e 100644 --- a/README.md +++ b/README.md @@ -186,39 +186,24 @@ Ein Auto-Reply ist nur erlaubt, wenn `language` und `communication_style` des fr Bei aktiviertem RAG erzeugt Ollama Embeddings über `/api/embed`; der Cache landet in `data/embeddings.json`. Für Ticket und Knowledge wird dasselbe Embedding-Modell verwendet. -### Zweistufiges Knowledge-Retrieval +### Realistisches Hybrid-Scoring -Knowledge-Suche und Auto-Reply-Freigabe sind bewusst getrennt. Die erste Stufe ist ein breit angelegtes Retrieval/Ranking; die zweite Stufe bewertet den vom Modell explizit ausgewählten KB-Artikel mit zusätzlichen Evidenzen. Dadurch werden kurze Tickets nicht mehr nur deshalb verworfen, weil ihr reiner Embedding-/Hybridscore niedriger ausfällt. +Knowledge-Treffer werden nicht mehr nur über eine einzelne Cosine-Similarity bewertet. Lange Artikel werden in überlappende Abschnitte zerlegt und der beste semantische Abschnitt wird mit Titel-, Keyword- und Kategorie-/Lernsignalen kombiniert. Standardgewichte: ```env -# Finaler Evidenz-Schwellwert nach der KI-Auswahl. KNOWLEDGE_MIN_SCORE=0.70 -# Mindest-Retrievalscore, damit ein Kandidat überhaupt auto-reply-fähig sein kann. -KNOWLEDGE_RETRIEVAL_FLOOR=0.30 -# Finale Evidenz = Retrieval + KI-Confidence + exakte ITIL-Kategoriezuordnung. -KNOWLEDGE_EVIDENCE_WEIGHT_RETRIEVAL=0.45 -KNOWLEDGE_EVIDENCE_WEIGHT_AI=0.35 -KNOWLEDGE_EVIDENCE_WEIGHT_CATEGORY=0.20 - -# Ranking innerhalb der Kandidatensuche. -KNOWLEDGE_WEIGHT_SEMANTIC=0.45 -KNOWLEDGE_WEIGHT_TITLE=0.20 -KNOWLEDGE_WEIGHT_LEXICAL=0.20 -KNOWLEDGE_WEIGHT_KEYWORDS=0.075 -KNOWLEDGE_WEIGHT_CATEGORY=0.075 +KNOWLEDGE_WEIGHT_SEMANTIC=0.50 +KNOWLEDGE_WEIGHT_TITLE=0.25 +KNOWLEDGE_WEIGHT_KEYWORDS=0.15 +KNOWLEDGE_WEIGHT_CATEGORY=0.10 KNOWLEDGE_CHUNK_WORDS=160 KNOWLEDGE_CHUNK_OVERLAP_WORDS=30 KNOWLEDGE_MAX_CHUNKS_PER_DOC=24 -KNOWLEDGE_MAX_QUERY_CHUNKS=64 ``` -Das Retrieval verwendet den besten semantischen Ticket↔KB-Chunk, einen asymmetrischen Titelvergleich, deutsches helpdesk-orientiertes Fuzzy-/Stemming-Matching, Keywords und Kategorie-/Lernsignale. Fehlende Metadaten werden nicht als Nullpunkte bestraft. +Der angezeigte Hybrid-Score ist **keine Wahrscheinlichkeit**. Er ist ein nachvollziehbarer Ranking-Score. Die Semantik verwendet die Ähnlichkeit des besten Body-Chunks; der Titel kombiniert Embedding- und exakten/lexikalischen Titelmatch; Keywords werden explizit gegen den Tickettext geprüft. Ist ein Knowledge-Dokument GLPI-ITIL-Kategorien zugeordnet, fließen deren Namen, semantische Hints und menschlich bestätigte Lernbeispiele als Kategorie-Signal ein. Fehlen einem Artikel Keywords oder Kategoriezuordnungen, wird er nicht pauschal abgestraft: Nur vorhandene Komponenten werden in die Gewichtung aufgenommen. -Nach der Modellentscheidung wird nur der explizit gewählte `knowledge_id` geprüft. Ein Kandidat unter `KNOWLEDGE_RETRIEVAL_FLOOR` bleibt immer blockiert. Oberhalb dieses Floors wird ein **finaler Evidenzscore** aus Retrievalscore, `reply.confidence` des Modells und – sofern vorhanden – der exakten ITIL-Kategoriezuordnung des Artikels gebildet. Der effektive Freigabeschwellwert ist `max(KNOWLEDGE_MIN_SCORE, min_score des Artikels)`. - -Beispiel: Ein sehr kurzer Text wie „Kann mich nicht anmelden“ kann beim Retrieval nur etwa 0,43 erreichen, vom Modell aber eindeutig dem passenden AD-Artikel zugeordnet werden. Mit 0,95 KI-Confidence und exakter AD-Kategoriezuordnung ergibt die Standardgewichtung einen finalen Evidenzscore von rund 0,73 und kann damit einen 0,70-Schwellwert passieren. Ein fachfremder Artikel mit Retrieval 0,22 bleibt dagegen bereits am Retrieval-Floor blockiert. - -Im Audit-Dashboard werden Retrievalscore, rohe Semantik, Titel, Lexik, Keywords, Kategorie/Lernen, Retrieval-Floor, finaler Evidenzscore und der tatsächlich erforderliche Freigabeschwellwert getrennt angezeigt. Keiner dieser Werte ist als Wahrscheinlichkeit zu interpretieren. +Im Audit-Dashboard werden `Hybrid`, `Semantik`, `Titel`, `Keywords`, `Kategorie/Lernen`, der beste gefundene Abschnitt und der tatsächlich erforderliche KB-Schwellwert getrennt angezeigt. Der effektive Schwellwert bleibt `max(KNOWLEDGE_MIN_SCORE, min_score des Artikels)`. ## Operativer Kontext: Changes, Major Incidents, Uptime Kuma und Geräte @@ -460,3 +445,7 @@ Der Ollama-Embedding-Aufruf verwendet `truncate:false`. Ein Text, der trotz Chun Das integrierte Webinterface ist als Betriebs- und Diagnoseoberfläche ausgelegt. Neben Status und Metriken zeigt es die effektiven, nicht geheimen Konfigurationswerte, die einzelnen Policy-Entscheidungen und die Komponenten des Hybrid-RAG-Scores. Eine Verarbeitung kann geöffnet werden, um die Top-KB-Kandidaten, deren Score-Komponenten, die besten Ticket-/KB-Chunks sowie relevante Changes, Incidents, Uptime-Kuma-Störungen und Geräte zu sehen. Die interne Knowledge Base kann bei `KNOWLEDGE_WEB_EDIT_ENABLED=true` direkt im authentifizierten Dashboard angelegt, bearbeitet und gelöscht werden. Der Editor lädt beim Bearbeiten immer den aktuellen Stand vom Server. Artikel-IDs sind nach der Anlage unveränderlich. Statische und aus GLPI synchronisierte Artikel bleiben read-only. + + +### GLPI-KB Rich Text +Rich-Text-Formatierungen aus synchronisierten GLPI-KB-Artikeln bleiben in Ticketantworten erhalten; RAG und LLM sehen weiterhin nur bereinigten Plaintext. diff --git a/SECURITY.md b/SECURITY.md index 7e78da5..06c0e58 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -78,3 +78,8 @@ Kategorie-Lernen ist Human-in-the-loop: Nur eine ausdrückliche Bestätigung/Kor Synchronized GLPI articles are read-only in the agent dashboard. Automatic replies from this source remain disabled unless all of the following are explicitly configured: the source is in `KNOWLEDGE_AUTO_REPLY_SOURCES`, `GLPI_KB_AUTO_REPLY=true`, and the article belongs to a GLPI Knowledge Base category listed in `GLPI_KB_AUTO_REPLY_CATEGORY_IDS`. In addition, the connector requires a GLPI KB-category -> ITIL-category mapping before marking an imported article as auto-reply eligible. The normalized cache is stored in `DATA_DIR/glpi-kb-cache.json`; embeddings remain in `DATA_DIR/embeddings.json`. Treat both as potentially sensitive support data and protect/backup `DATA_DIR` accordingly. + + +## Rich Text aus GLPI KB + +Das Feld `answer_html` wird ausschließlich vom read-only GLPI-KB-Synchronisierer befüllt. Web-verwaltete Knowledge-Einträge können dieses Feld nicht setzen. Rich HTML wird weder an Ollama übertragen noch für Embeddings verwendet. Beim Schreiben eines Followups wird das von derselben GLPI-Instanz gelieferte Rich-Text-Markup an GLPI zurückgegeben; GLPI behält seine eigene serverseitige Rich-Text-/HTML-Validierung bei. diff --git a/UPGRADE.md b/UPGRADE.md index 0a75bb3..ddd6109 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -59,33 +59,24 @@ GLPI_KB_AUTO_REPLY_CATEGORY_IDS= Der sichere Start ist `GLPI_KB_AUTO_REPLY=false`. Erst nachdem die importierten Artikel im Dashboard geprüft wurden, sollte `glpi-kb` optional in `KNOWLEDGE_AUTO_REPLY_SOURCES` aufgenommen und eine explizite Whitelist von GLPI-Knowledge-Base-Kategorie-IDs gesetzt werden. -## Zweistufiges Knowledge-Retrieval +## Hybrid Knowledge Scoring -Die bisherige harte Regel `Hybridscore >= KNOWLEDGE_MIN_SCORE` wurde ersetzt. Der Hybridscore dient jetzt primär zum Finden und Sortieren von Kandidaten. Nach der KI-Auswahl wird ein separater Evidenzscore verwendet. +Diese Version ersetzt den einzelnen Dokument-Cosine-Score durch ein Hybrid-Scoring mit Body-Chunks, Titel, Keywords und Kategorie-/Lernsignalen. Der bestehende `data/embeddings.json` Cache wird bei Bedarf automatisch im neuen Format aufgebaut; ein manuelles Löschen ist nicht erforderlich. -Für bestehende `.env`-Dateien ergänzen: +Für bestehende `.env`-Dateien werden folgende Werte empfohlen: ```env KNOWLEDGE_MIN_SCORE=0.70 -KNOWLEDGE_RETRIEVAL_FLOOR=0.30 -KNOWLEDGE_EVIDENCE_WEIGHT_RETRIEVAL=0.45 -KNOWLEDGE_EVIDENCE_WEIGHT_AI=0.35 -KNOWLEDGE_EVIDENCE_WEIGHT_CATEGORY=0.20 - -KNOWLEDGE_WEIGHT_SEMANTIC=0.45 -KNOWLEDGE_WEIGHT_TITLE=0.20 -KNOWLEDGE_WEIGHT_LEXICAL=0.20 -KNOWLEDGE_WEIGHT_KEYWORDS=0.075 -KNOWLEDGE_WEIGHT_CATEGORY=0.075 +KNOWLEDGE_WEIGHT_SEMANTIC=0.50 +KNOWLEDGE_WEIGHT_TITLE=0.25 +KNOWLEDGE_WEIGHT_KEYWORDS=0.15 +KNOWLEDGE_WEIGHT_CATEGORY=0.10 KNOWLEDGE_CHUNK_WORDS=160 KNOWLEDGE_CHUNK_OVERLAP_WORDS=30 KNOWLEDGE_MAX_CHUNKS_PER_DOC=24 -KNOWLEDGE_MAX_QUERY_CHUNKS=64 ``` -`KNOWLEDGE_MIN_SCORE` ist ab dieser Version der **finale Evidenz-Schwellwert**. `KNOWLEDGE_RETRIEVAL_FLOOR` ist der niedrigere Schutzwert für die erste Kandidatensuche. Das Dashboard zeigt beide Werte getrennt. - -Das lexikalische Matching wurde für deutsche Supportbegriffe verbessert, insbesondere für Flexionen und Komposita wie `anmelden`, `Anmeldung`, `Benutzeranmeldung`, `Nutzerkonto` und `Benutzerkonto`. +Der neue Hybrid-Score ist nicht direkt mit alten Cosine-Scores vergleichbar. Nach dem Upgrade zunächst im Dry-Run beobachten und den Mindestscore anhand realer Tickets kalibrieren. ## Dashboard / Knowledge-Editor v2 @@ -112,3 +103,15 @@ Der Webeditor verwendet jetzt explizite CRUD-Semantik: Statische Git-/Datei-Artikel und synchronisierte GLPI-KB-Artikel bleiben read-only. Neue Läufe speichern zusätzlich die Top-Knowledge-Kandidaten und kompakte Kontextdetails im Audit. Ältere `runs.jsonl`-Einträge bleiben kompatibel; dort sind diese neuen Detailfelder naturgemäß leer. + + +## Rich-Text-Antworten aus der GLPI Knowledge Base + +Synchronisierte GLPI-KB-Artikel behalten ab dieser Version zwei getrennte Darstellungen: + +- `text` / `answer`: bereinigter Plaintext für RAG, Ranking und LLM-Kontext. +- `answer_html`: originales GLPI-Rich-Text-Markup ausschließlich für die spätere Ticketantwort. + +Dadurch bleiben bei Auto-Replies unter anderem Überschriften, Fett/Kursiv, Listen, Tabellen und Links erhalten. Das Rich-Text-Markup wird nicht an Ollama gesendet und beeinflusst keine Embeddings. Anrede und Signatur werden HTML-sicher um den KB-Inhalt ergänzt. + +Es sind keine neuen ENV-Variablen erforderlich. Nach dem Upgrade führt der initiale GLPI-KB-Sync automatisch dazu, dass `answer_html` im lokalen GLPI-KB-Cache ergänzt wird. diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 5433b89..d0926d4 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -28,7 +28,7 @@ type GLPI interface { GetTicket(context.Context, int64) (model.Ticket, error) GetFollowups(context.Context, int64) ([]model.Followup, error) SetCategory(context.Context, int64, int64) error - AddFollowup(context.Context, int64, string) error + AddFollowup(context.Context, int64, string, bool) error GetCategories(context.Context) ([]model.Category, error) } type AI interface { @@ -348,7 +348,7 @@ func (s *Service) Process(ctx context.Context, id int64) error { run.Reason = "followup_appeared_before_write" run.ReplyDecision = "reply_followup_appeared_before_write" } else if !s.cfg.DryRun { - if err := s.glpi.AddFollowup(ctx, id, result.ReplyText); err != nil { + if err := s.glpi.AddFollowup(ctx, id, result.ReplyText, result.ReplyIsHTML); err != nil { run.Reason = "reply_write_failed" run.ReplyDecision = "reply_write_failed" run.PolicyReason = run.CategoryDecision + "; " + run.ReplyDecision diff --git a/internal/agent/agent_test.go b/internal/agent/agent_test.go index 74689eb..3d540a4 100644 --- a/internal/agent/agent_test.go +++ b/internal/agent/agent_test.go @@ -48,7 +48,7 @@ func (f *fakeGLPI) SetCategory(_ context.Context, _ int64, id int64) error { f.ticket.DateMod = "v2" return nil } -func (f *fakeGLPI) AddFollowup(context.Context, int64, string) error { +func (f *fakeGLPI) AddFollowup(context.Context, int64, string, bool) error { f.addReply++ f.ticket.DateMod = "v3" return nil diff --git a/internal/agent/policy.go b/internal/agent/policy.go index 7475a33..1dfa07c 100644 --- a/internal/agent/policy.go +++ b/internal/agent/policy.go @@ -1,6 +1,7 @@ package agent import ( + "html" "strings" "github.com/example/glpi-ai-agent/internal/model" @@ -201,7 +202,12 @@ func (p Policy) Evaluate(t model.Ticket, d model.Decision, categories []model.Ca } } res.Reply = true - res.ReplyText = p.formatReply(hit.Doc.Answer) + if strings.TrimSpace(hit.Doc.AnswerHTML) != "" { + res.ReplyText = p.formatRichReply(hit.Doc.AnswerHTML) + res.ReplyIsHTML = true + } else { + res.ReplyText = p.formatReply(hit.Doc.Answer) + } res.KnowledgeID = hit.Doc.ID res.ReplyDecision = "reply_accepted" return res, nil @@ -237,6 +243,23 @@ func (p Policy) formatReply(body string) string { return strings.Join(parts, "\n\n") } +func (p Policy) formatRichReply(bodyHTML string) string { + parts := make([]string, 0, 3) + if strings.TrimSpace(p.CommunicationSalutation) != "" { + parts = append(parts, "

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

") + } + parts = append(parts, strings.TrimSpace(bodyHTML)) + footer := nonEmpty(p.CommunicationClosing, p.CommunicationSignature) + if len(footer) > 0 { + escaped := make([]string, 0, len(footer)) + for _, line := range footer { + escaped = append(escaped, html.EscapeString(line)) + } + parts = append(parts, "

"+strings.Join(escaped, "
")+"

") + } + return strings.Join(parts, "\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 ee4d54c..fc16f53 100644 --- a/internal/agent/policy_test.go +++ b/internal/agent/policy_test.go @@ -40,6 +40,31 @@ func TestPolicyAutoReplyUsesApprovedKnowledge(t *testing.T) { } } +func TestPolicyPreservesGLPIKnowledgeRichText(t *testing.T) { + p := productionTestPolicy() + d := replyDecision() + hits := []model.KnowledgeHit{{Doc: model.KnowledgeDoc{ + ID: "KB1", Title: "Rich", Answer: "Wichtiger Hinweis Erstens Zweitens", + AnswerHTML: `

Wichtiger Hinweis

Bitte beachten:

Dokumentation

`, + AutoReply: true, MinScore: .9, Categories: []int64{2}, Source: "internal-kb", Language: "de-DE", CommunicationStyle: "formal", + }, Score: .95, CategoryScore: 1}} + 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 { + t.Fatalf("expected rich reply, got %+v", r) + } + for _, want := range []string{"

Wichtiger Hinweis

", "Bitte beachten:", "