RC-3
release-tag / release-image (push) Successful in 1m31s

This commit is contained in:
2026-07-28 23:34:25 +02:00
parent cbe5f753cd
commit 12fcfe1b10
12 changed files with 199 additions and 23 deletions
+7 -1
View File
@@ -48,7 +48,13 @@ OLLAMA_MAX_CONCURRENT=1
# RAG / Knowledge
KNOWLEDGE_DIR=./knowledge
RAG_ENABLED=true
KNOWLEDGE_TOP_K=3
# Maximum number of dynamically selected KB candidates sent to Ollama.
KNOWLEDGE_TOP_K=6
# Keep more candidates in the audit/dashboard than are sent to Ollama. Must be >= KNOWLEDGE_TOP_K.
KNOWLEDGE_AUDIT_TOP_K=10
# Only candidates within this absolute score gap of the best retrieval hit are sent to Ollama.
# Example: best=0.78 and gap=0.20 => candidates below 0.58 are excluded (retrieval floor still applies).
KNOWLEDGE_CANDIDATE_MAX_GAP=0.20
# Final evidence threshold after the model has selected a KB candidate. This is not the raw retrieval score.
KNOWLEDGE_MIN_SCORE=0.70
# Broad-recall floor for the deterministic retrieval/ranking stage. Candidates below this never reach auto-reply.
+2 -2
View File
@@ -1,4 +1,4 @@
FROM golang:1.26-alpine AS build
FROM golang:1.23-alpine AS build
WORKDIR /src
COPY go.mod ./
COPY cmd ./cmd
@@ -7,7 +7,7 @@ RUN CGO_ENABLED=0 GOOS=linux go build -trimpath -ldflags="-s -w" -o /out/glpi-ai
# One-shot helper used by docker compose to prepare the persistent volume for
# the distroless non-root runtime user (UID/GID 65532).
FROM golang:1.26-alpine AS data-init
FROM golang:1.23-alpine AS data-init
ENTRYPOINT ["sh", "-c", "mkdir -p /app/data && chown -R 65532:65532 /app/data && chmod 0750 /app/data"]
FROM gcr.io/distroless/static-debian12:nonroot
+26 -8
View File
@@ -186,24 +186,42 @@ 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.
### Realistisches Hybrid-Scoring
### Realistisches Hybrid-Scoring und dynamische Kandidatenauswahl
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:
Knowledge-Treffer werden nicht nur über eine einzelne Cosine-Similarity bewertet. Lange Artikel werden in überlappende Abschnitte zerlegt und der beste semantische Abschnitt wird mit Titel-, lexikalischen, Keyword- und Kategorie-/Lernsignalen kombiniert. Empfohlene Standardwerte:
```env
KNOWLEDGE_MIN_SCORE=0.70
KNOWLEDGE_WEIGHT_SEMANTIC=0.50
KNOWLEDGE_WEIGHT_TITLE=0.25
KNOWLEDGE_WEIGHT_KEYWORDS=0.15
KNOWLEDGE_WEIGHT_CATEGORY=0.10
KNOWLEDGE_RETRIEVAL_FLOOR=0.30
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_CHUNK_WORDS=160
KNOWLEDGE_CHUNK_OVERLAP_WORDS=30
KNOWLEDGE_MAX_CHUNKS_PER_DOC=24
KNOWLEDGE_MAX_QUERY_CHUNKS=64
# Dynamisches Top-K
KNOWLEDGE_TOP_K=6
KNOWLEDGE_AUDIT_TOP_K=10
KNOWLEDGE_CANDIDATE_MAX_GAP=0.20
```
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.
`KNOWLEDGE_TOP_K` ist jetzt **die maximale Anzahl von Kandidaten, die Ollama sehen darf**, nicht die Anzahl, die blind immer übergeben wird. Nach dem Retrieval wird ein dynamischer Cutoff berechnet:
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)`.
```text
cutoff = max(KNOWLEDGE_RETRIEVAL_FLOOR, bester_score - KNOWLEDGE_CANDIDATE_MAX_GAP)
```
Beispiel: Bei Scores `0.82, 0.79, 0.76, 0.43` und `KNOWLEDGE_CANDIDATE_MAX_GAP=0.20` gehen nur die ersten drei Treffer an Ollama, weil der Cutoff `0.62` beträgt. Bei einem unklareren Fall `0.66, 0.64, 0.63, 0.61, 0.59` dürfen dagegen bis zu fünf Kandidaten in den Modellkontext. Liegt bereits der beste Treffer unter `KNOWLEDGE_RETRIEVAL_FLOOR`, erhält Ollama **keinen** KB-Kandidaten.
`KNOWLEDGE_AUDIT_TOP_K` ist davon getrennt. Das Dashboard kann z. B. die besten zehn Treffer zur Diagnose zeigen, während höchstens sechs und meist deutlich weniger an Ollama gesendet werden. Jeder Audit-Kandidat wird mit `an KI gesendet` oder `nur Audit` gekennzeichnet.
Der angezeigte Retrieval-/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 lexikalischen Titelmatch; Keywords und Kategorie-/Lernsignale dienen als positive Evidenz. Fehlen solche Metadaten, werden sie nicht als Null-Strafe eingerechnet.
Für die spätere Auto-Reply-Freigabe gilt weiterhin die getrennte Evidenzlogik aus Retrieval, KI-Auswahl und Kategorieübereinstimmung. Der effektive finale Schwellwert ist `max(KNOWLEDGE_MIN_SCORE, min_score des Artikels)`.
## Operativer Kontext: Changes, Major Incidents, Uptime Kuma und Geräte
+5
View File
@@ -83,3 +83,8 @@ The normalized cache is stored in `DATA_DIR/glpi-kb-cache.json`; embeddings rema
## 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.
## Dynamische Begrenzung des LLM-Kontexts
Knowledge-Kandidaten werden nicht allein anhand einer festen Anzahl in den Modellkontext übernommen. Der Agent kombiniert einen absoluten Retrieval-Floor, einen maximalen Abstand zum besten Treffer und eine harte Obergrenze. Dadurch werden bei großen Wissensbeständen schwache, themenfremde Artikel aus dem Ollama-Prompt herausgehalten, bleiben aber optional im Audit sichtbar.
+14
View File
@@ -115,3 +115,17 @@ Synchronisierte GLPI-KB-Artikel behalten ab dieser Version zwei getrennte Darste
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.
## Dynamisches Knowledge Top-K
Für Installationen mit vielen Knowledge-Artikeln wird die Kandidatenauswahl ab dieser Version dynamisch begrenzt. Empfohlene Werte:
```env
KNOWLEDGE_TOP_K=6
KNOWLEDGE_AUDIT_TOP_K=10
KNOWLEDGE_CANDIDATE_MAX_GAP=0.20
KNOWLEDGE_RETRIEVAL_FLOOR=0.30
```
`KNOWLEDGE_TOP_K` ist die maximale Anzahl von Artikeln im Ollama-Prompt. Artikel werden nur übergeben, wenn sie mindestens den Retrieval-Floor erreichen und nicht mehr als `KNOWLEDGE_CANDIDATE_MAX_GAP` unter dem besten Treffer liegen. `KNOWLEDGE_AUDIT_TOP_K` steuert separat, wie viele Treffer für Dashboard/Audit aufbewahrt werden. Bestehende `.env`-Dateien sollten die drei neuen/angepassten Werte explizit ergänzen.
+1 -1
View File
@@ -1,3 +1,3 @@
module github.com/example/glpi-ai-agent
go 1.26
go 1.23
+61 -6
View File
@@ -186,12 +186,29 @@ func (s *Service) Process(ctx context.Context, id int64) error {
}
run.CategoryBeforeName = categoryName(categories, t.CategoryID)
promptCats := shortlistCategories(t, categories, s.cfg.CategoryPromptLimit)
hits, err := s.knowledge.Search(ctx, t.Name+"\n"+stripHTML(t.Content), s.cfg.KnowledgeTopK, categories)
auditTopK := s.cfg.KnowledgeAuditTopK
if auditTopK <= 0 {
auditTopK = s.cfg.KnowledgeTopK
if auditTopK <= 0 {
auditTopK = 10
}
}
llmTopK := s.cfg.KnowledgeTopK
if llmTopK <= 0 {
llmTopK = 6
}
retrievalHits, err := s.knowledge.Search(ctx, t.Name+"\n"+stripHTML(t.Content), auditTopK, categories)
if err != nil {
run.Reason = "knowledge_search_failed"
finish(err)
return err
}
llmHits, candidateCutoff := selectKnowledgeCandidates(retrievalHits, llmTopK, s.cfg.KnowledgeRetrievalFloor, s.cfg.KnowledgeCandidateMaxGap)
run.KnowledgeLLMCandidates = len(llmHits)
run.KnowledgeCandidateCutoff = candidateCutoff
run.KnowledgeCandidateMaxGap = s.cfg.KnowledgeCandidateMaxGap
run.KnowledgeAuditTopK = auditTopK
llmCandidateIDs := knowledgeHitIDSet(llmHits)
contextData := model.ContextSnapshot{}
if s.context != nil && s.cfg.ContextEnabled {
s.metrics.ContextFetches.Add(1)
@@ -206,7 +223,7 @@ func (s *Service) Process(ctx context.Context, id int64) error {
s.metrics.ContextErrors.Add(1)
}
}
decision, err := s.ai.Analyse(ctx, t, promptCats, hits, contextData)
decision, err := s.ai.Analyse(ctx, t, promptCats, llmHits, contextData)
if err != nil {
run.Reason = "ai_failed"
finish(err)
@@ -215,9 +232,9 @@ func (s *Service) Process(ctx context.Context, id int64) error {
// The classifier provides an independent category recommendation. Knowledge
// explicitly mapped to that category receives a deterministic post-retrieval
// alignment signal before the final policy gate.
hits = s.knowledge.RerankForCategory(hits, decision.Category.ID)
hits := s.knowledge.RerankForCategory(retrievalHits, decision.Category.ID)
if len(hits) > 0 {
run.KnowledgeCandidates = auditKnowledgeCandidates(hits, s.cfg.KnowledgeMinScore, 5)
run.KnowledgeCandidates = auditKnowledgeCandidates(hits, s.cfg.KnowledgeMinScore, auditTopK, llmCandidateIDs)
run.KnowledgeTopID = hits[0].Doc.ID
run.KnowledgeTopTitle = hits[0].Doc.Title
run.KnowledgeScore = hits[0].Score
@@ -377,7 +394,7 @@ func (s *Service) Process(ctx context.Context, id int64) error {
finish(nil)
return nil
}
func auditKnowledgeCandidates(hits []model.KnowledgeHit, globalMin float64, limit int) []model.KnowledgeCandidateAudit {
func auditKnowledgeCandidates(hits []model.KnowledgeHit, globalMin float64, limit int, sentToAI map[string]struct{}) []model.KnowledgeCandidateAudit {
if limit <= 0 || limit > len(hits) {
limit = len(hits)
}
@@ -387,17 +404,55 @@ func auditKnowledgeCandidates(hits []model.KnowledgeHit, globalMin float64, limi
if h.Doc.MinScore > required {
required = h.Doc.MinScore
}
_, wasSent := sentToAI[h.Doc.ID]
out = append(out, model.KnowledgeCandidateAudit{
ID: h.Doc.ID, Title: h.Doc.Title, Source: h.Doc.Source, Score: h.Score,
SemanticScore: h.SemanticScore, TitleScore: h.TitleScore, LexicalScore: h.LexicalScore, KeywordScore: h.KeywordScore,
CategoryScore: h.CategoryScore, RequiredScore: required, AutoReply: h.Doc.AutoReply,
BestChunkExcerpt: h.BestChunkExcerpt, BestQueryExcerpt: h.BestQueryExcerpt,
QueryChunkCount: h.QueryChunkCount, DocumentChunkCount: h.DocumentChunkCount,
QueryChunkCount: h.QueryChunkCount, DocumentChunkCount: h.DocumentChunkCount, SentToAI: wasSent,
})
}
return out
}
func selectKnowledgeCandidates(hits []model.KnowledgeHit, maxCandidates int, retrievalFloor, maxGap float64) ([]model.KnowledgeHit, float64) {
if len(hits) == 0 || maxCandidates <= 0 {
return nil, retrievalFloor
}
best := hits[0].Score
if best < retrievalFloor {
return nil, retrievalFloor
}
cutoff := best - maxGap
if cutoff < retrievalFloor {
cutoff = retrievalFloor
}
capacity := maxCandidates
if len(hits) < capacity {
capacity = len(hits)
}
out := make([]model.KnowledgeHit, 0, capacity)
for _, h := range hits {
if h.Score < cutoff || h.Score < retrievalFloor {
break
}
out = append(out, h)
if len(out) >= maxCandidates {
break
}
}
return out, cutoff
}
func knowledgeHitIDSet(hits []model.KnowledgeHit) map[string]struct{} {
out := make(map[string]struct{}, len(hits))
for _, h := range hits {
out[h.Doc.ID] = struct{}{}
}
return out
}
func auditContextDetails(c model.ContextSnapshot, limit int) []model.ContextAuditItem {
if limit <= 0 {
limit = 5
+58
View File
@@ -0,0 +1,58 @@
package agent
import (
"math"
"testing"
"github.com/example/glpi-ai-agent/internal/model"
)
func hit(id string, score float64) model.KnowledgeHit {
return model.KnowledgeHit{Doc: model.KnowledgeDoc{ID: id}, Score: score}
}
func TestSelectKnowledgeCandidatesDynamicGap(t *testing.T) {
hits := []model.KnowledgeHit{
hit("a", 0.82), hit("b", 0.79), hit("c", 0.76), hit("d", 0.43), hit("e", 0.39),
}
got, cutoff := selectKnowledgeCandidates(hits, 6, 0.30, 0.20)
if math.Abs(cutoff-0.62) > 1e-9 {
t.Fatalf("cutoff=%v want 0.62", cutoff)
}
if len(got) != 3 {
t.Fatalf("len=%d want 3", len(got))
}
if got[0].Doc.ID != "a" || got[2].Doc.ID != "c" {
t.Fatalf("unexpected candidates: %#v", got)
}
}
func TestSelectKnowledgeCandidatesRespectsMaxAndFloor(t *testing.T) {
hits := []model.KnowledgeHit{
hit("a", 0.66), hit("b", 0.64), hit("c", 0.63), hit("d", 0.61), hit("e", 0.59), hit("f", 0.58), hit("g", 0.57),
}
got, cutoff := selectKnowledgeCandidates(hits, 5, 0.30, 0.20)
if math.Abs(cutoff-0.46) > 1e-9 {
t.Fatalf("cutoff=%v want 0.46", cutoff)
}
if len(got) != 5 {
t.Fatalf("len=%d want max 5", len(got))
}
low := []model.KnowledgeHit{hit("x", 0.29), hit("y", 0.28)}
got, cutoff = selectKnowledgeCandidates(low, 6, 0.30, 0.20)
if len(got) != 0 || math.Abs(cutoff-0.30) > 1e-9 {
t.Fatalf("below floor: len=%d cutoff=%v", len(got), cutoff)
}
}
func TestSelectKnowledgeCandidatesUsesFloorAsCutoff(t *testing.T) {
hits := []model.KnowledgeHit{hit("a", 0.44), hit("b", 0.35), hit("c", 0.31), hit("d", 0.29)}
got, cutoff := selectKnowledgeCandidates(hits, 6, 0.30, 0.20)
if math.Abs(cutoff-0.30) > 1e-9 {
t.Fatalf("cutoff=%v want floor 0.30", cutoff)
}
if len(got) != 3 {
t.Fatalf("len=%d want 3", len(got))
}
}
+16 -1
View File
@@ -48,6 +48,8 @@ type Config struct {
KnowledgeDir string
RAGEnabled bool
KnowledgeTopK int
KnowledgeAuditTopK int
KnowledgeCandidateMaxGap float64
CategoryPromptLimit int
KnowledgeAllowedSources []string
KnowledgeAutoReplySources []string
@@ -156,7 +158,9 @@ func Load() (Config, error) {
OllamaJSONRetries: envInt("OLLAMA_JSON_RETRIES", 1),
KnowledgeDir: env("KNOWLEDGE_DIR", "./knowledge"),
RAGEnabled: envBool("RAG_ENABLED", true),
KnowledgeTopK: envInt("KNOWLEDGE_TOP_K", 3),
KnowledgeTopK: envInt("KNOWLEDGE_TOP_K", 6),
KnowledgeAuditTopK: envInt("KNOWLEDGE_AUDIT_TOP_K", 10),
KnowledgeCandidateMaxGap: envFloat("KNOWLEDGE_CANDIDATE_MAX_GAP", 0.20),
CategoryPromptLimit: envInt("CATEGORY_PROMPT_LIMIT", 80),
KnowledgeAllowedSources: envStringList("KNOWLEDGE_ALLOWED_SOURCES", "internal-kb"),
KnowledgeAutoReplySources: envStringList("KNOWLEDGE_AUTO_REPLY_SOURCES", "internal-kb"),
@@ -294,6 +298,17 @@ func (c Config) Validate() error {
if c.KnowledgeWebEditEnabled && c.WebAllowAnonymous {
return errors.New("KNOWLEDGE_WEB_EDIT_ENABLED requires authenticated dashboard access; WEB_ALLOW_ANONYMOUS must be false")
}
if c.KnowledgeTopK != 0 && (c.KnowledgeTopK < 1 || c.KnowledgeTopK > 20) {
return errors.New("KNOWLEDGE_TOP_K must be between 1 and 20")
}
if c.KnowledgeAuditTopK != 0 {
if c.KnowledgeAuditTopK > 50 || (c.KnowledgeTopK > 0 && c.KnowledgeAuditTopK < c.KnowledgeTopK) {
return errors.New("KNOWLEDGE_AUDIT_TOP_K must be >= KNOWLEDGE_TOP_K and <= 50")
}
}
if c.KnowledgeCandidateMaxGap < 0 || c.KnowledgeCandidateMaxGap > 1 {
return errors.New("KNOWLEDGE_CANDIDATE_MAX_GAP must be between 0 and 1")
}
weights := []float64{c.KnowledgeSemanticWeight, c.KnowledgeTitleWeight, c.KnowledgeLexicalWeight, c.KnowledgeKeywordWeight, c.KnowledgeCategoryWeight}
weightSum := 0.0
for _, w := range weights {
+5
View File
@@ -235,6 +235,7 @@ type KnowledgeCandidateAudit struct {
BestQueryExcerpt string `json:"best_query_excerpt,omitempty"`
QueryChunkCount int `json:"query_chunk_count,omitempty"`
DocumentChunkCount int `json:"document_chunk_count,omitempty"`
SentToAI bool `json:"sent_to_ai,omitempty"`
}
// ContextAuditItem is a compact snapshot of context that influenced a run.
@@ -292,6 +293,10 @@ type RunRecord struct {
KnowledgeBestQueryChunk string `json:"knowledge_best_query_chunk,omitempty"`
KnowledgeQueryChunks int `json:"knowledge_query_chunks,omitempty"`
KnowledgeDocumentChunks int `json:"knowledge_document_chunks,omitempty"`
KnowledgeLLMCandidates int `json:"knowledge_llm_candidates,omitempty"`
KnowledgeCandidateCutoff float64 `json:"knowledge_candidate_cutoff,omitempty"`
KnowledgeCandidateMaxGap float64 `json:"knowledge_candidate_max_gap,omitempty"`
KnowledgeAuditTopK int `json:"knowledge_audit_top_k,omitempty"`
ContextChanges int `json:"context_changes,omitempty"`
ContextIncidents int `json:"context_incidents,omitempty"`
ContextIssues int `json:"context_issues,omitempty"`
+1 -1
View File
@@ -122,7 +122,7 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request) {
"uptime_kuma_enabled": s.cfg.UptimeKumaEnabled, "uptime_kuma_mode": s.cfg.UptimeKumaMode, "uptime_kuma_status_pages": s.cfg.UptimeKumaStatusPages, "context_fail_closed": s.cfg.ContextBlockReplyOnError, "context_incident_block": s.cfg.ContextBlockReplyOnIncident,
"workers": s.cfg.Workers, "queue_size": s.cfg.QueueSize, "glpi_api_version": s.cfg.GLPIAPIVersion, "glpi_poll_interval": s.cfg.GLPIPollInterval.String(), "glpi_poll_limit": s.cfg.GLPIPollLimit, "glpi_allowed_status_ids": s.cfg.GLPIAllowedStatusIDs, "glpi_ticket_filter_configured": strings.TrimSpace(s.cfg.GLPITicketFilter) != "", "glpi_timeout": s.cfg.GLPITimeout.String(),
"ollama_model": s.cfg.OllamaModel, "ollama_embedding_model": s.cfg.OllamaEmbeddingModel, "ollama_timeout": s.cfg.OllamaTimeout.String(), "ollama_num_predict": s.cfg.OllamaNumPredict, "ollama_keep_alive": s.cfg.OllamaKeepAlive.String(), "ollama_think": s.cfg.OllamaThink, "ollama_max_concurrent": s.cfg.OllamaMaxConcurrent, "ollama_json_retries": s.cfg.OllamaJSONRetries,
"rag_enabled": s.cfg.RAGEnabled, "knowledge_top_k": s.cfg.KnowledgeTopK, "category_prompt_limit": s.cfg.CategoryPromptLimit, "knowledge_max_query_chunks": s.cfg.KnowledgeMaxQueryChunks,
"rag_enabled": s.cfg.RAGEnabled, "knowledge_top_k": s.cfg.KnowledgeTopK, "knowledge_audit_top_k": s.cfg.KnowledgeAuditTopK, "knowledge_candidate_max_gap": s.cfg.KnowledgeCandidateMaxGap, "category_prompt_limit": s.cfg.CategoryPromptLimit, "knowledge_max_query_chunks": s.cfg.KnowledgeMaxQueryChunks,
"glpi_kb_path": s.cfg.GLPIKBPath, "glpi_kb_filter_configured": strings.TrimSpace(s.cfg.GLPIKBFilter) != "", "glpi_kb_limit": s.cfg.GLPIKBLimit, "glpi_kb_auto_reply": s.cfg.GLPIKBAutoReply, "glpi_kb_auto_reply_category_ids": s.cfg.GLPIKBAutoReplyCategoryIDs,
"learning_max_examples": s.cfg.LearningMaxExamples, "learning_examples_per_category": s.cfg.LearningExamplesPerCategory,
"context_timeout": s.cfg.ContextTimeout.String(), "context_relevance_min_score": s.cfg.ContextRelevanceMinScore, "change_lookback": s.cfg.ChangeLookback.String(), "change_lookahead": s.cfg.ChangeLookahead.String(),
+3 -3
View File
@@ -126,14 +126,14 @@ function replyMini(x){const p=policyLabel(x.reply_decision);return `<div class="
function filteredRuns(){const q=$('#runSearch').value.trim().toLowerCase(),out=$('#runOutcome').value;return runsData.filter(x=>(!out||x.outcome===out)&&(!q||[x.ticket_id,x.ticket_name,x.ai_reason,x.policy_reason,x.knowledge_top_id,x.knowledge_top_title,x.error].join(' ').toLowerCase().includes(q)))}
function renderRuns(){const rows=filteredRuns();$('#runsTable').innerHTML=rows.length?rows.map(x=>`<tr class="click-row" data-run-id="${esc(x.run_id)}"><td><div class="ticket-title">#${esc(x.ticket_id)} ${esc(x.ticket_name||'')}</div><div class="muted small nowrap">${esc(fmtDate(x.finished_at))}</div></td><td>${outcomeBadge(x.outcome)}${x.dry_run?'<div class="small muted" style="margin-top:5px">Dry Run</div>':''}</td><td>${categoryMini(x)}</td><td>${replyMini(x)}</td><td class="hide-mobile"><div class="small">C:${esc(x.context_changes||0)} · I:${esc(x.context_incidents||0)} · U:${esc(x.context_issues||0)} · D:${esc(x.context_devices||0)}</div>${(x.context_warnings||[]).length?badge(`${x.context_warnings.length} Warnung(en)`,'warn'):''}</td><td></td></tr>`).join(''):'<tr><td colspan="6" class="empty">Keine passenden Läufe.</td></tr>'}
function contextKindLabel(k){return ({change:'Change',incident:'Major Incident',uptime:'Uptime Kuma',device:'Gerät'})[k]||k}
function renderRunDrawer(x){currentRun=x;$('#runDrawerTitle').textContent=`#${x.ticket_id} ${x.ticket_name||''}`;const catP=policyLabel(x.category_decision),repP=policyLabel(x.reply_decision);const candidates=(x.knowledge_candidates||[]).map((c,i)=>`<div class="candidate"><div class="candidate-title"><div><strong>${i+1}. ${esc(c.title)}</strong><div class="muted small">${esc(c.id)} · ${esc(c.source)} ${c.auto_reply?'· Auto-Reply freigegeben':''}</div></div><span class="score ${scoreClass(c.score)}">Retrieval ${esc(pct(c.score))}</span></div>${progress('Semantik (raw)',c.semantic_score)}${progress('Titel',c.title_score)}${progress('Lexikalisch',c.lexical_score)}${progress('Keywords',c.keyword_score)}${progress('Kategorie/Lernen',c.category_score)}<div class="muted small">Evidenz-Schwelle: ${esc(pct(c.required_score))} · Chunks ${esc(c.query_chunk_count||0)} × ${esc(c.document_chunk_count||0)}</div>${c.best_query_excerpt?`<div class="excerpt"><strong>Ticket:</strong> ${esc(c.best_query_excerpt)}</div>`:''}${c.best_chunk_excerpt?`<div class="excerpt"><strong>KB:</strong> ${esc(c.best_chunk_excerpt)}</div>`:''}</div>`).join('')||'<div class="empty">Keine Knowledge-Kandidaten im Audit gespeichert.</div>';
function renderRunDrawer(x){currentRun=x;$('#runDrawerTitle').textContent=`#${x.ticket_id} ${x.ticket_name||''}`;const catP=policyLabel(x.category_decision),repP=policyLabel(x.reply_decision);const candidates=(x.knowledge_candidates||[]).map((c,i)=>`<div class="candidate"><div class="candidate-title"><div><strong>${i+1}. ${esc(c.title)}</strong><div class="muted small">${esc(c.id)} · ${esc(c.source)} ${c.auto_reply?'· Auto-Reply freigegeben':''} · ${c.sent_to_ai?'<span class="badge info">an KI gesendet</span>':'<span class="badge">nur Audit</span>'}</div></div><span class="score ${scoreClass(c.score)}">Retrieval ${esc(pct(c.score))}</span></div>${progress('Semantik (raw)',c.semantic_score)}${progress('Titel',c.title_score)}${progress('Lexikalisch',c.lexical_score)}${progress('Keywords',c.keyword_score)}${progress('Kategorie/Lernen',c.category_score)}<div class="muted small">Evidenz-Schwelle: ${esc(pct(c.required_score))} · Chunks ${esc(c.query_chunk_count||0)} × ${esc(c.document_chunk_count||0)}</div>${c.best_query_excerpt?`<div class="excerpt"><strong>Ticket:</strong> ${esc(c.best_query_excerpt)}</div>`:''}${c.best_chunk_excerpt?`<div class="excerpt"><strong>KB:</strong> ${esc(c.best_chunk_excerpt)}</div>`:''}</div>`).join('')||'<div class="empty">Keine Knowledge-Kandidaten im Audit gespeichert.</div>';
const contexts=(x.context_details||[]).map(c=>`<div class="context-item"><div class="context-kind">${esc(contextKindLabel(c.kind))}</div><strong>${esc(c.name||`#${c.id}`)}</strong>${c.relevance?` <span class="score ${scoreClass(c.relevance)}">${esc(pct(c.relevance))}</span>`:''}${c.status?` ${badge(c.status)}`:''}${c.detail?`<div class="muted small" style="margin-top:3px">${esc(c.detail)}</div>`:''}</div>`).join('')||'<div class="muted small">Keine Kontextdetails gespeichert.</div>';
$('#runDrawerBody').innerHTML=`<div class="detail-grid"><div class="detail-box"><div class="detail-heading">Ergebnis</div>${outcomeBadge(x.outcome)} ${x.dry_run?badge('DRY RUN','warn'):badge('LIVE','good')}<div class="muted small" style="margin-top:8px">${esc(fmtDate(x.finished_at))}</div></div><div class="detail-box"><div class="detail-heading">Quelle</div><div class="mono small">${esc(x.source_version||'')}</div></div>
<div class="detail-box"><div class="detail-heading">Kategorie</div><div><strong>Aktuell:</strong> ${esc(x.category_before_name||'Nicht gesetzt')} (#${esc(x.category_before||0)})</div><div style="margin-top:5px"><strong>KI:</strong> ${x.ai_recommended_category_id?`${esc(x.ai_recommended_category_name||'')} (#${esc(x.ai_recommended_category_id)}) · ${esc(pct(x.ai_category_confidence))}`:'keine Empfehlung'}</div><div style="margin-top:8px">${badge(catP[0],catP[1])} <span class="muted small">Schwellwert ${esc(pct(x.category_threshold))}</span></div></div>
<div class="detail-box"><div class="detail-heading">Antwort</div><div><strong>KI:</strong> ${x.ai_reply_recommended?'Ja':'Nein'} · ${esc(pct(x.ai_reply_confidence))}</div><div style="margin-top:5px"><strong>KB:</strong> ${esc(x.ai_knowledge_id||x.knowledge_top_id||'keine')}</div><div style="margin-top:8px">${badge(repP[0],repP[1])} <span class="muted small">Schwellwert ${esc(pct(x.reply_threshold))}</span></div></div>
<div class="detail-box full"><div class="detail-heading">Top-Knowledge-Kandidat</div>${x.knowledge_top_id?`${progress('Retrieval / Ranking',x.knowledge_score)}${x.knowledge_evidence_score?progress('Finale Evidenz',x.knowledge_evidence_score):''}${progress('Semantik (raw)',x.knowledge_semantic_score)}${progress('Titel',x.knowledge_title_score)}${progress('Lexikalisch',x.knowledge_lexical_score)}${progress('Keywords',x.knowledge_keyword_score)}${progress('Kategorie/Lernen',x.knowledge_category_score)}<div class="muted small">${esc(x.knowledge_top_title)} · ${esc(x.knowledge_top_id)} · Retrieval-Floor ${esc(pct(x.knowledge_retrieval_floor||0))} · Evidenz erforderlich ${esc(pct(x.knowledge_threshold))}${x.knowledge_category_aligned?' · Kategorie exakt zugeordnet':''}</div>`:'<div class="muted">Kein Treffer.</div>'}</div>
<div class="detail-box full"><div class="detail-heading">KI-Begründung</div><div class="reason-text">${esc(x.ai_reason||x.reason||'')}</div></div><div class="detail-box full"><div class="detail-heading">Policy</div><div class="reason-text">${esc(x.policy_reason||'')}</div>${x.error?`<div class="reason-text" style="color:#ffb5b5;margin-top:9px"><strong>Fehler:</strong> ${esc(x.error)}</div>`:''}</div>
<div class="detail-box full"><div class="detail-heading">Knowledge-Ranking</div>${candidates}</div><div class="detail-box full"><div class="detail-heading">Kontextquellen</div>${contexts}${(x.context_warnings||[]).length?`<div class="notice warn" style="margin-top:10px">${x.context_warnings.map(esc).join('<br>')}</div>`:''}</div>
<div class="detail-box full"><div class="detail-heading">Knowledge-Ranking</div><div class="muted small" style="margin-bottom:10px">An KI gesendet: ${esc(x.knowledge_llm_candidates||0)} · Kandidaten-Cutoff ${esc(pct(x.knowledge_candidate_cutoff||0))} · Max. Abstand ${esc(pct(x.knowledge_candidate_max_gap||0))} · Audit Top K ${esc(x.knowledge_audit_top_k||0)}</div>${candidates}</div><div class="detail-box full"><div class="detail-heading">Kontextquellen</div>${contexts}${(x.context_warnings||[]).length?`<div class="notice warn" style="margin-top:10px">${x.context_warnings.map(esc).join('<br>')}</div>`:''}</div>
<div class="detail-box full"><div class="detail-heading">Lernen</div>${categories.length?`<button class="btn small primary" data-learn-run="${esc(x.run_id)}" data-learn-cat="${esc(x.ai_recommended_category_id||0)}">Kategorie bestätigen / korrigieren</button>`:'<span class="muted">Kategorien nicht geladen.</span>'}</div>
<div class="detail-box full"><details><summary class="small muted" style="cursor:pointer">Audit-JSON anzeigen</summary><pre class="raw">${esc(JSON.stringify(x,null,2))}</pre></details></div></div>`;openRunDrawer()}
function openRunDrawer(){ $('#runBackdrop').classList.add('show');$('#runDrawer').classList.add('show') } function closeRunDrawer(){ $('#runBackdrop').classList.remove('show');$('#runDrawer').classList.remove('show');currentRun=null }
@@ -143,7 +143,7 @@ function renderKB(){const st=kbStatsData();$('#kbStats').innerHTML=st.map(x=>`<d
function renderLearning(){const q=$('#learningSearch').value.trim().toLowerCase(),rows=learningRows.filter(x=>!q||[x.ticket_id,x.subject,x.text,x.category_name,x.category_id].join(' ').toLowerCase().includes(q));const corrections=learningRows.filter(x=>x.correction).length;$('#learningStats').innerHTML=[['Gesamt',learningRows.length,'bestätigte Beispiele'],['Korrekturen',corrections,'KI lag anders'],['Bestätigungen',learningRows.length-corrections,'KI wurde bestätigt']].map(x=>`<div class="stat"><div class="stat-label">${esc(x[0])}</div><div class="stat-value">${fmtNum(x[1])}</div><div class="stat-foot">${esc(x[2])}</div></div>`).join('');$('#learningTable').innerHTML=rows.length?rows.map(x=>`<tr><td><div class="ticket-title">#${esc(x.ticket_id)} ${esc(x.subject)}</div><div class="muted small">${esc((x.text||'').slice(0,220))}</div></td><td><strong>${esc(x.category_name)}</strong> (#${esc(x.category_id)})${x.ai_recommended_category_id?`<div class="muted small">KI: #${esc(x.ai_recommended_category_id)} · ${esc(pct(x.ai_confidence))}</div>`:''}</td><td>${x.correction?badge('Korrektur','warn'):badge('Bestätigung','good')}</td><td class="nowrap">${esc(fmtDate(x.created_at))}</td><td><button class="btn small danger" data-learning-delete="${esc(x.id)}">Löschen</button></td></tr>`).join(''):'<tr><td colspan="5" class="empty">Keine Lernbeispiele.</td></tr>'}
function configCard(title,subtitle,rows){return `<div class="panel config-card"><div class="panel-head"><div><div class="panel-title">${esc(title)}</div><div class="panel-sub">${esc(subtitle)}</div></div></div><div class="config-list">${rows.map(([k,v])=>`<div class="config-row"><div class="config-key">${esc(k)}</div><div class="config-val">${v}</div></div>`).join('')}</div></div>`}
function val(v){if(typeof v==='boolean')return v?badge('aktiv','good'):badge('aus','warn');if(Array.isArray(v))return esc(v.length?v.join(', '):'');return esc(v??'')}
function renderConfig(){const s=statusData;const groups=[configCard('Agent & GLPI','Polling, Worker und Schreibmodus',[['Dry Run',val(s.dry_run)],['Auto-Kategorie',val(s.auto_category)],['Auto-Reply',val(s.auto_reply)],['Worker',val(s.workers)],['Queue-Größe',val(s.queue_size)],['API-Version',val(s.glpi_api_version)],['Poll-Intervall',val(s.glpi_poll_interval)],['Poll-Limit',val(s.glpi_poll_limit)],['Ticket-Filter gesetzt',val(s.glpi_ticket_filter_configured)],['Erlaubte Status',val(s.glpi_allowed_status_ids)],['GLPI-Timeout',val(s.glpi_timeout)]]),configCard('Ollama','Modelle und Inferenzbudget',[['Chat-Modell',val(s.ollama_model)],['Embedding-Modell',val(s.ollama_embedding_model)],['Embedding-Profil',val(s.knowledge_embedding_profile)],['Timeout',val(s.ollama_timeout)],['Num Predict',val(s.ollama_num_predict)],['Keep Alive',val(s.ollama_keep_alive)],['Thinking',val(s.ollama_think)],['Max. parallel',val(s.ollama_max_concurrent)],['JSON-Retries',val(s.ollama_json_retries)]]),configCard('Knowledge / RAG','Retrieval, Chunking und Ranking',[['RAG',val(s.rag_enabled)],['Top K',val(s.knowledge_top_k)],['Finaler Evidenz-Schwellwert',`<strong>${pct(s.knowledge_min_score)}</strong>`],['Retrieval-Floor',`<strong>${pct(s.knowledge_retrieval_floor)}</strong>`],['Evidenzgewicht Retrieval',pct(s.knowledge_evidence_weight_retrieval)],['Evidenzgewicht KI',pct(s.knowledge_evidence_weight_ai)],['Evidenzgewicht Kategorie',pct(s.knowledge_evidence_weight_category)],['Retrieval: Semantik',pct(s.knowledge_weight_semantic)],['Retrieval: Titel',pct(s.knowledge_weight_title)],['Retrieval: Lexikalisch',pct(s.knowledge_weight_lexical)],['Retrieval: Keywords',pct(s.knowledge_weight_keywords)],['Retrieval: Kategorie/Lernen',pct(s.knowledge_weight_category)],['Chunk-Wörter',val(s.knowledge_chunk_words)],['Overlap-Wörter',val(s.knowledge_chunk_overlap_words)],['Max. KB-Chunks',val(s.knowledge_max_chunks_per_doc)],['Max. Ticket-Chunks',val(s.knowledge_max_query_chunks)],['Erlaubte Quellen',val(s.knowledge_allowed_sources)],['Auto-Reply-Quellen',val(s.knowledge_auto_reply_sources)]]),configCard('GLPI Knowledge Base','Synchronisation der GLPI-Wissensdatenbank',[['Aktiv',val(s.glpi_kb_enabled)],['Sync OK',val(s.glpi_kb_ok)],['Dokumente',val(s.glpi_kb_documents)],['Letzter Sync',val(fmtDate(s.glpi_kb_last_sync))],['Intervall',val(s.glpi_kb_sync_interval)],['Pfad',val(s.glpi_kb_path)],['Filter gesetzt',val(s.glpi_kb_filter_configured)],['Limit',val(s.glpi_kb_limit)],['Auto-Reply',val(s.glpi_kb_auto_reply)],['Auto-Reply-Kategorien',val(s.glpi_kb_auto_reply_category_ids)],['Letzter Fehler',val(s.glpi_kb_last_error||'')]]),configCard('Policy & Kommunikation','Entscheidungsschwellen und Sprache',[['Kategorie-Confidence',`<strong>${pct(s.category_confidence)}</strong>`],['Reply-Confidence',`<strong>${pct(s.reply_confidence)}</strong>`],['Sprache',val(s.communication_language)],['Stil',val(s.communication_style)],['KB-Webeditor',val(s.knowledge_edit_enabled)],['Lernen',val(s.learning_enabled)],['Max. Lernbeispiele',val(s.learning_max_examples)],['Beispiele/Kategorie',val(s.learning_examples_per_category)]]),configCard('Kontextquellen','Störungen, Changes, Incidents und Geräte',[['Kontext aktiv',val(s.context_enabled)],['Timeout',val(s.context_timeout)],['Relevanz-Minimum',pct(s.context_relevance_min_score)],['Fail-closed',val(s.context_fail_closed)],['Incident blockiert Reply',val(s.context_incident_block)],['Change Calendar',val(s.change_calendar_enabled)],['Lookback',val(s.change_lookback)],['Lookahead',val(s.change_lookahead)],['Major Incidents',val(s.major_incidents_enabled)],['Benutzer-Geräte',val(s.user_device_context_enabled)],['Uptime Kuma',val(s.uptime_kuma_enabled)],['Uptime-Modus',val(s.uptime_kuma_mode)]] )];$('#configGroups').innerHTML=groups.join('')}
function renderConfig(){const s=statusData;const groups=[configCard('Agent & GLPI','Polling, Worker und Schreibmodus',[['Dry Run',val(s.dry_run)],['Auto-Kategorie',val(s.auto_category)],['Auto-Reply',val(s.auto_reply)],['Worker',val(s.workers)],['Queue-Größe',val(s.queue_size)],['API-Version',val(s.glpi_api_version)],['Poll-Intervall',val(s.glpi_poll_interval)],['Poll-Limit',val(s.glpi_poll_limit)],['Ticket-Filter gesetzt',val(s.glpi_ticket_filter_configured)],['Erlaubte Status',val(s.glpi_allowed_status_ids)],['GLPI-Timeout',val(s.glpi_timeout)]]),configCard('Ollama','Modelle und Inferenzbudget',[['Chat-Modell',val(s.ollama_model)],['Embedding-Modell',val(s.ollama_embedding_model)],['Embedding-Profil',val(s.knowledge_embedding_profile)],['Timeout',val(s.ollama_timeout)],['Num Predict',val(s.ollama_num_predict)],['Keep Alive',val(s.ollama_keep_alive)],['Thinking',val(s.ollama_think)],['Max. parallel',val(s.ollama_max_concurrent)],['JSON-Retries',val(s.ollama_json_retries)]]),configCard('Knowledge / RAG','Retrieval, Chunking und Ranking',[['RAG',val(s.rag_enabled)],['Max. Kandidaten an KI',val(s.knowledge_top_k)],['Audit Top K',val(s.knowledge_audit_top_k)],['Max. Abstand zum Top-Treffer',pct(s.knowledge_candidate_max_gap)],['Finaler Evidenz-Schwellwert',`<strong>${pct(s.knowledge_min_score)}</strong>`],['Retrieval-Floor',`<strong>${pct(s.knowledge_retrieval_floor)}</strong>`],['Evidenzgewicht Retrieval',pct(s.knowledge_evidence_weight_retrieval)],['Evidenzgewicht KI',pct(s.knowledge_evidence_weight_ai)],['Evidenzgewicht Kategorie',pct(s.knowledge_evidence_weight_category)],['Retrieval: Semantik',pct(s.knowledge_weight_semantic)],['Retrieval: Titel',pct(s.knowledge_weight_title)],['Retrieval: Lexikalisch',pct(s.knowledge_weight_lexical)],['Retrieval: Keywords',pct(s.knowledge_weight_keywords)],['Retrieval: Kategorie/Lernen',pct(s.knowledge_weight_category)],['Chunk-Wörter',val(s.knowledge_chunk_words)],['Overlap-Wörter',val(s.knowledge_chunk_overlap_words)],['Max. KB-Chunks',val(s.knowledge_max_chunks_per_doc)],['Max. Ticket-Chunks',val(s.knowledge_max_query_chunks)],['Erlaubte Quellen',val(s.knowledge_allowed_sources)],['Auto-Reply-Quellen',val(s.knowledge_auto_reply_sources)]]),configCard('GLPI Knowledge Base','Synchronisation der GLPI-Wissensdatenbank',[['Aktiv',val(s.glpi_kb_enabled)],['Sync OK',val(s.glpi_kb_ok)],['Dokumente',val(s.glpi_kb_documents)],['Letzter Sync',val(fmtDate(s.glpi_kb_last_sync))],['Intervall',val(s.glpi_kb_sync_interval)],['Pfad',val(s.glpi_kb_path)],['Filter gesetzt',val(s.glpi_kb_filter_configured)],['Limit',val(s.glpi_kb_limit)],['Auto-Reply',val(s.glpi_kb_auto_reply)],['Auto-Reply-Kategorien',val(s.glpi_kb_auto_reply_category_ids)],['Letzter Fehler',val(s.glpi_kb_last_error||'')]]),configCard('Policy & Kommunikation','Entscheidungsschwellen und Sprache',[['Kategorie-Confidence',`<strong>${pct(s.category_confidence)}</strong>`],['Reply-Confidence',`<strong>${pct(s.reply_confidence)}</strong>`],['Sprache',val(s.communication_language)],['Stil',val(s.communication_style)],['KB-Webeditor',val(s.knowledge_edit_enabled)],['Lernen',val(s.learning_enabled)],['Max. Lernbeispiele',val(s.learning_max_examples)],['Beispiele/Kategorie',val(s.learning_examples_per_category)]]),configCard('Kontextquellen','Störungen, Changes, Incidents und Geräte',[['Kontext aktiv',val(s.context_enabled)],['Timeout',val(s.context_timeout)],['Relevanz-Minimum',pct(s.context_relevance_min_score)],['Fail-closed',val(s.context_fail_closed)],['Incident blockiert Reply',val(s.context_incident_block)],['Change Calendar',val(s.change_calendar_enabled)],['Lookback',val(s.change_lookback)],['Lookahead',val(s.change_lookahead)],['Major Incidents',val(s.major_incidents_enabled)],['Benutzer-Geräte',val(s.user_device_context_enabled)],['Uptime Kuma',val(s.uptime_kuma_enabled)],['Uptime-Modus',val(s.uptime_kuma_mode)]] )];$('#configGroups').innerHTML=groups.join('')}
function renderSourceOptions(){const filterOld=$('#kbSourceFilter').value,sourceOld=$('#kbSource').value;const sources=[...new Set(kbDocs.map(x=>x.source).filter(Boolean))].sort();$('#kbSourceFilter').innerHTML='<option value="">Alle Quellen</option>'+sources.map(x=>`<option value="${esc(x)}">${esc(x)}</option>`).join('');if([...$('#kbSourceFilter').options].some(o=>o.value===filterOld))$('#kbSourceFilter').value=filterOld;const allowed=statusData.knowledge_allowed_sources||[];$('#kbSource').innerHTML=allowed.map(x=>`<option value="${esc(x)}">${esc(x)}</option>`).join('');if([...$('#kbSource').options].some(o=>o.value===sourceOld))$('#kbSource').value=sourceOld;else if([...$('#kbSource').options].some(o=>o.value==='internal-kb'))$('#kbSource').value='internal-kb'}
function renderCategoryPicker(filter=''){const q=filter.toLowerCase();$('#kbCategoryList').innerHTML=categories.filter(c=>!q||(c.completename||c.name||'').toLowerCase().includes(q)).map(c=>`<label class="category-item"><input type="checkbox" value="${Number(c.id)}" ${kbCategorySelection.has(Number(c.id))?'checked':''}><span>${esc(c.completename||c.name)} <span class="muted">#${Number(c.id)}</span></span></label>`).join('')||'<div class="muted small" style="padding:8px">Keine Kategorie gefunden.</div>'}
function clearKbForm(){currentKbId='';kbCategorySelection=new Set();$('#kbForm').reset();$('#kbId').disabled=false;$('#kbId').value='';$('#kbLanguage').value=statusData.communication_language||'de-DE';$('#kbStyle').value=statusData.communication_style||'formal';$('#kbScore').value=Number(statusData.knowledge_min_score||.70).toFixed(2);if([...$('#kbSource').options].some(x=>x.value==='internal-kb'))$('#kbSource').value='internal-kb';$('#kbModalTitle').textContent='Neuen Artikel anlegen';$('#kbModalEyebrow').textContent='Interne Knowledge Base';$('#kbEditState').textContent='Neuer Artikel';$('#kbFormMessage').className='form-message';$('#kbCategorySearch').value='';renderCategoryPicker();updateCounts()}