diff --git a/README.md b/README.md index 78771f5..c51fe87 100644 --- a/README.md +++ b/README.md @@ -199,7 +199,6 @@ 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 ``` 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. @@ -440,3 +439,9 @@ Für die semantische Relevanz werden **beide Seiten** in überlappende Abschnitt Der Ollama-Embedding-Aufruf verwendet `truncate:false`. Ein Text, der trotz Chunking das Kontextfenster des Embedding-Modells überschreitet, führt damit zu einem sichtbaren Fehler statt zu stiller Kürzung. + +## Dashboard / Control Center + +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. diff --git a/UPGRADE.md b/UPGRADE.md index e5ce0d1..72bd34a 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -77,3 +77,29 @@ KNOWLEDGE_MAX_CHUNKS_PER_DOC=24 ``` 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 + +Das Dashboard wurde grundlegend überarbeitet. Es zeigt jetzt: + +- eine Betriebsübersicht mit GLPI-/Ollama-/GLPI-KB-Gesundheit, +- die effektiven, nicht geheimen ENV-Werte gruppiert nach Agent, Ollama, RAG, GLPI-KB und Kontextquellen, +- eine Detailansicht je Verarbeitung mit KI- und Policy-Entscheidung, +- die Top-Knowledge-Kandidaten inklusive Hybrid-, Semantik-, Titel-, Keyword- und Kategorie/Lernscore, +- die tatsächlich verwendeten Ticket-/KB-Chunks, +- kompakte Details zu Changes, Major Incidents, Uptime-Kuma-Störungen und Benutzergeräten, +- Filter für Verarbeitungen, Knowledge Base und Lernbeispiele. + +### Geänderte Knowledge-API + +Der Webeditor verwendet jetzt explizite CRUD-Semantik: + +- `GET /api/knowledge/{id}` lädt einen Artikel frisch vom Server. +- `POST /api/knowledge` legt einen neuen Web-Artikel an und liefert bei einer bereits existierenden ID `409 Conflict`. +- `PUT /api/knowledge/{id}` aktualisiert ausschließlich einen bestehenden, Web-verwalteten Artikel. +- Die ID eines Artikels kann beim Bearbeiten nicht geändert werden. +- `DELETE /api/knowledge/{id}` löscht weiterhin nur Web-verwaltete Artikel. + +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. diff --git a/internal/agent/agent.go b/internal/agent/agent.go index 6a76e7d..cdb7332 100644 --- a/internal/agent/agent.go +++ b/internal/agent/agent.go @@ -193,6 +193,7 @@ func (s *Service) Process(ctx context.Context, id int64) error { return err } if len(hits) > 0 { + run.KnowledgeCandidates = auditKnowledgeCandidates(hits, s.cfg.KnowledgeMinScore, 5) run.KnowledgeTopID = hits[0].Doc.ID run.KnowledgeTopTitle = hits[0].Doc.Title run.KnowledgeScore = hits[0].Score @@ -218,6 +219,7 @@ func (s *Service) Process(ctx context.Context, id int64) error { run.ContextIssues = len(contextData.ServiceIssues) run.ContextDevices = len(contextData.UserDevices) run.ContextWarnings = append([]string(nil), contextData.Warnings...) + run.ContextDetails = auditContextDetails(contextData, 5) if contextData.Incomplete { s.metrics.ContextErrors.Add(1) } @@ -367,6 +369,82 @@ 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 { + if limit <= 0 || limit > len(hits) { + limit = len(hits) + } + out := make([]model.KnowledgeCandidateAudit, 0, limit) + for _, h := range hits[:limit] { + required := globalMin + if h.Doc.MinScore > required { + required = h.Doc.MinScore + } + 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, KeywordScore: h.KeywordScore, + CategoryScore: h.CategoryScore, RequiredScore: required, AutoReply: h.Doc.AutoReply, + BestChunkExcerpt: h.BestChunkExcerpt, BestQueryExcerpt: h.BestQueryExcerpt, + QueryChunkCount: h.QueryChunkCount, DocumentChunkCount: h.DocumentChunkCount, + }) + } + return out +} + +func auditContextDetails(c model.ContextSnapshot, limit int) []model.ContextAuditItem { + if limit <= 0 { + limit = 5 + } + out := make([]model.ContextAuditItem, 0, limit*4) + for i, x := range c.Changes { + if i >= limit { + break + } + out = append(out, model.ContextAuditItem{Kind: "change", ID: x.ID, Name: x.Name, Relevance: x.Relevance, Detail: strings.TrimSpace(x.PlannedBegin + " – " + x.PlannedEnd)}) + } + for i, x := range c.MajorIncidents { + if i >= limit { + break + } + out = append(out, model.ContextAuditItem{Kind: "incident", ID: x.ID, Name: x.Name, Relevance: x.Relevance, Status: fmt.Sprint(x.StatusID), Detail: auditExcerpt(x.Content, 320)}) + } + for i, x := range c.ServiceIssues { + if i >= limit { + break + } + name := x.MonitorName + if name == "" { + name = x.IncidentTitle + } + out = append(out, model.ContextAuditItem{Kind: "uptime", ID: x.MonitorID, Name: name, Relevance: x.Relevance, Status: x.Status, Detail: auditExcerpt(x.Message, 320)}) + } + for i, x := range c.UserDevices { + if i >= limit { + break + } + name := x.Name + if name == "" { + name = fmt.Sprintf("%s #%d", x.ItemType, x.ID) + } + parts := make([]string, 0, 3) + for _, v := range []string{x.Serial, x.InventoryNumber, x.Location} { + if strings.TrimSpace(v) != "" { + parts = append(parts, strings.TrimSpace(v)) + } + } + detail := strings.Join(parts, " · ") + out = append(out, model.ContextAuditItem{Kind: "device", ID: x.ID, Name: name, Status: x.Status, Detail: detail}) + } + return out +} + +func auditExcerpt(v string, max int) string { + v = strings.Join(strings.Fields(v), " ") + if max <= 0 || len(v) <= max { + return v + } + return strings.TrimSpace(v[:max]) + "…" +} + func (s *Service) getCategories(ctx context.Context) ([]model.Category, error) { s.catMu.RLock() if len(s.categories) > 0 && time.Since(s.catAt) < 10*time.Minute { diff --git a/internal/model/model.go b/internal/model/model.go index 2e048b8..d4674f7 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -208,52 +208,83 @@ type PolicyResult struct { AIReason string `json:"ai_reason,omitempty"` } +// KnowledgeCandidateAudit captures the top retrieval candidates used for a run. +// It intentionally stores only normalized, non-secret diagnostic information. +type KnowledgeCandidateAudit struct { + ID string `json:"id"` + Title string `json:"title"` + Source string `json:"source"` + Score float64 `json:"score"` + SemanticScore float64 `json:"semantic_score,omitempty"` + TitleScore float64 `json:"title_score,omitempty"` + KeywordScore float64 `json:"keyword_score,omitempty"` + CategoryScore float64 `json:"category_score,omitempty"` + RequiredScore float64 `json:"required_score,omitempty"` + AutoReply bool `json:"auto_reply"` + BestChunkExcerpt string `json:"best_chunk_excerpt,omitempty"` + BestQueryExcerpt string `json:"best_query_excerpt,omitempty"` + QueryChunkCount int `json:"query_chunk_count,omitempty"` + DocumentChunkCount int `json:"document_chunk_count,omitempty"` +} + +// ContextAuditItem is a compact snapshot of context that influenced a run. +type ContextAuditItem struct { + Kind string `json:"kind"` + ID int64 `json:"id,omitempty"` + Name string `json:"name"` + Status string `json:"status,omitempty"` + Relevance float64 `json:"relevance,omitempty"` + Detail string `json:"detail,omitempty"` +} + type RunRecord struct { - RunID string `json:"run_id"` - TicketID int64 `json:"ticket_id"` - TicketName string `json:"ticket_name"` - SourceVersion string `json:"source_version"` - StartedAt time.Time `json:"started_at"` - FinishedAt time.Time `json:"finished_at"` - Outcome string `json:"outcome"` - Reason string `json:"reason"` - AIReason string `json:"ai_reason,omitempty"` - PolicyReason string `json:"policy_reason,omitempty"` - CategoryBefore int64 `json:"category_before"` - CategoryBeforeName string `json:"category_before_name,omitempty"` - AIRecommendedCategoryID int64 `json:"ai_recommended_category_id,omitempty"` - AIRecommendedCategoryName string `json:"ai_recommended_category_name,omitempty"` - AICategoryConfidence float64 `json:"ai_category_confidence,omitempty"` - CategoryThreshold float64 `json:"category_threshold,omitempty"` - CategoryDecision string `json:"category_decision,omitempty"` - CategoryProposed int64 `json:"category_proposed"` - CategoryWouldChange bool `json:"category_would_change"` - CategoryChanged bool `json:"category_changed"` - AIReplyRecommended bool `json:"ai_reply_recommended,omitempty"` - AIReplyConfidence float64 `json:"ai_reply_confidence,omitempty"` - ReplyThreshold float64 `json:"reply_threshold,omitempty"` - AIKnowledgeID string `json:"ai_knowledge_id,omitempty"` - ReplyDecision string `json:"reply_decision,omitempty"` - ReplyProposed bool `json:"reply_proposed"` - ReplyWritten bool `json:"reply_written"` - KnowledgeID string `json:"knowledge_id,omitempty"` - KnowledgeTopID string `json:"knowledge_top_id,omitempty"` - KnowledgeTopTitle string `json:"knowledge_top_title,omitempty"` - KnowledgeScore float64 `json:"knowledge_score,omitempty"` - KnowledgeSemanticScore float64 `json:"knowledge_semantic_score,omitempty"` - KnowledgeTitleScore float64 `json:"knowledge_title_score,omitempty"` - KnowledgeKeywordScore float64 `json:"knowledge_keyword_score,omitempty"` - KnowledgeCategoryScore float64 `json:"knowledge_category_score,omitempty"` - KnowledgeThreshold float64 `json:"knowledge_threshold,omitempty"` - KnowledgeBestChunk string `json:"knowledge_best_chunk,omitempty"` - KnowledgeBestQueryChunk string `json:"knowledge_best_query_chunk,omitempty"` - KnowledgeQueryChunks int `json:"knowledge_query_chunks,omitempty"` - KnowledgeDocumentChunks int `json:"knowledge_document_chunks,omitempty"` - ContextChanges int `json:"context_changes,omitempty"` - ContextIncidents int `json:"context_incidents,omitempty"` - ContextIssues int `json:"context_issues,omitempty"` - ContextDevices int `json:"context_devices,omitempty"` - ContextWarnings []string `json:"context_warnings,omitempty"` - DryRun bool `json:"dry_run"` - Error string `json:"error,omitempty"` + RunID string `json:"run_id"` + TicketID int64 `json:"ticket_id"` + TicketName string `json:"ticket_name"` + SourceVersion string `json:"source_version"` + StartedAt time.Time `json:"started_at"` + FinishedAt time.Time `json:"finished_at"` + Outcome string `json:"outcome"` + Reason string `json:"reason"` + AIReason string `json:"ai_reason,omitempty"` + PolicyReason string `json:"policy_reason,omitempty"` + CategoryBefore int64 `json:"category_before"` + CategoryBeforeName string `json:"category_before_name,omitempty"` + AIRecommendedCategoryID int64 `json:"ai_recommended_category_id,omitempty"` + AIRecommendedCategoryName string `json:"ai_recommended_category_name,omitempty"` + AICategoryConfidence float64 `json:"ai_category_confidence,omitempty"` + CategoryThreshold float64 `json:"category_threshold,omitempty"` + CategoryDecision string `json:"category_decision,omitempty"` + CategoryProposed int64 `json:"category_proposed"` + CategoryWouldChange bool `json:"category_would_change"` + CategoryChanged bool `json:"category_changed"` + AIReplyRecommended bool `json:"ai_reply_recommended,omitempty"` + AIReplyConfidence float64 `json:"ai_reply_confidence,omitempty"` + ReplyThreshold float64 `json:"reply_threshold,omitempty"` + AIKnowledgeID string `json:"ai_knowledge_id,omitempty"` + ReplyDecision string `json:"reply_decision,omitempty"` + ReplyProposed bool `json:"reply_proposed"` + ReplyWritten bool `json:"reply_written"` + KnowledgeID string `json:"knowledge_id,omitempty"` + KnowledgeTopID string `json:"knowledge_top_id,omitempty"` + KnowledgeTopTitle string `json:"knowledge_top_title,omitempty"` + KnowledgeScore float64 `json:"knowledge_score,omitempty"` + KnowledgeSemanticScore float64 `json:"knowledge_semantic_score,omitempty"` + KnowledgeTitleScore float64 `json:"knowledge_title_score,omitempty"` + KnowledgeKeywordScore float64 `json:"knowledge_keyword_score,omitempty"` + KnowledgeCategoryScore float64 `json:"knowledge_category_score,omitempty"` + KnowledgeThreshold float64 `json:"knowledge_threshold,omitempty"` + KnowledgeBestChunk string `json:"knowledge_best_chunk,omitempty"` + KnowledgeBestQueryChunk string `json:"knowledge_best_query_chunk,omitempty"` + KnowledgeQueryChunks int `json:"knowledge_query_chunks,omitempty"` + KnowledgeDocumentChunks int `json:"knowledge_document_chunks,omitempty"` + ContextChanges int `json:"context_changes,omitempty"` + ContextIncidents int `json:"context_incidents,omitempty"` + ContextIssues int `json:"context_issues,omitempty"` + ContextDevices int `json:"context_devices,omitempty"` + ContextWarnings []string `json:"context_warnings,omitempty"` + KnowledgeCandidates []KnowledgeCandidateAudit `json:"knowledge_candidates,omitempty"` + ContextDetails []ContextAuditItem `json:"context_details,omitempty"` + DryRun bool `json:"dry_run"` + Error string `json:"error,omitempty"` } diff --git a/internal/web/server.go b/internal/web/server.go index 5e95023..99b04f2 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -29,6 +29,7 @@ var files embed.FS type KnowledgeManager interface { List() []model.KnowledgeDoc + ByID(string) (model.KnowledgeDoc, bool) Upsert(context.Context, model.KnowledgeDoc) error Delete(string) error IsManaged(string) bool @@ -69,7 +70,9 @@ func (s *Server) Handler() http.Handler { mux.Handle("GET /api/runs", s.auth(http.HandlerFunc(s.runs))) mux.Handle("GET /api/categories", s.auth(http.HandlerFunc(s.categories))) mux.Handle("GET /api/knowledge", s.auth(http.HandlerFunc(s.knowledgeList))) - mux.Handle("POST /api/knowledge", s.auth(s.mutation(http.HandlerFunc(s.knowledgeUpsert)))) + mux.Handle("GET /api/knowledge/{id}", s.auth(http.HandlerFunc(s.knowledgeGet))) + mux.Handle("POST /api/knowledge", s.auth(s.mutation(http.HandlerFunc(s.knowledgeCreate)))) + mux.Handle("PUT /api/knowledge/{id}", s.auth(s.mutation(http.HandlerFunc(s.knowledgeUpdate)))) mux.Handle("DELETE /api/knowledge/{id}", s.auth(s.mutation(http.HandlerFunc(s.knowledgeDelete)))) mux.Handle("GET /api/learning", s.auth(http.HandlerFunc(s.learningList))) mux.Handle("POST /api/learning", s.auth(s.mutation(http.HandlerFunc(s.learningAdd)))) @@ -117,6 +120,12 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request) { "knowledge_edit_enabled": s.cfg.KnowledgeWebEditEnabled, "learning_enabled": s.cfg.LearningEnabled, "learning_examples": s.feedback.LearningCount(), "glpi_kb_enabled": s.cfg.GLPIKBEnabled, "glpi_kb_ok": kbOK, "glpi_kb_documents": kbDocs, "glpi_kb_last_sync": kbLastSync, "glpi_kb_last_error": kbLastErr, "glpi_kb_source": s.cfg.GLPIKBSource, "glpi_kb_sync_interval": s.cfg.GLPIKBSyncInterval.String(), "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, + "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(), }) } func (s *Server) runs(w http.ResponseWriter, r *http.Request) { @@ -159,17 +168,22 @@ func (s *Server) knowledgeList(w http.ResponseWriter, r *http.Request) { } respondJSON(w, out) } -func (s *Server) knowledgeUpsert(w http.ResponseWriter, r *http.Request) { - if !s.cfg.KnowledgeWebEditEnabled { - http.Error(w, "knowledge editing disabled", http.StatusForbidden) +func (s *Server) knowledgeGet(w http.ResponseWriter, r *http.Request) { + id := strings.TrimSpace(r.PathValue("id")) + d, ok := s.knowledge.ByID(id) + if !ok { + http.Error(w, "not found", http.StatusNotFound) return } + respondJSON(w, map[string]any{"document": d, "managed": s.knowledge.IsManaged(id), "origin": s.knowledge.Origin(id)}) +} + +func (s *Server) decodeKnowledge(r *http.Request) (model.KnowledgeDoc, error) { var d model.KnowledgeDoc dec := json.NewDecoder(io.LimitReader(r.Body, 1<<20)) dec.DisallowUnknownFields() if err := dec.Decode(&d); err != nil { - http.Error(w, "invalid knowledge document: "+err.Error(), 400) - return + return d, fmt.Errorf("invalid knowledge document: %w", err) } if strings.TrimSpace(d.Source) == "" { d.Source = "internal-kb" @@ -180,30 +194,91 @@ func (s *Server) knowledgeUpsert(w http.ResponseWriter, r *http.Request) { if strings.TrimSpace(d.CommunicationStyle) == "" { d.CommunicationStyle = s.cfg.CommunicationStyle } - if len(d.Categories) > 0 { - cats, err := s.feedback.Categories(r.Context()) - if err != nil { - http.Error(w, "cannot validate categories: "+err.Error(), http.StatusBadGateway) - return - } - valid := make(map[int64]struct{}, len(cats)) - for _, c := range cats { - valid[c.ID] = struct{}{} - } - for _, id := range d.Categories { - if _, ok := valid[id]; !ok { - http.Error(w, fmt.Sprintf("unknown GLPI category id %d", id), http.StatusUnprocessableEntity) - return - } + return d, nil +} + +func (s *Server) validateKnowledgeCategories(ctx context.Context, d model.KnowledgeDoc) error { + if len(d.Categories) == 0 { + return nil + } + cats, err := s.feedback.Categories(ctx) + if err != nil { + return fmt.Errorf("cannot validate categories: %w", err) + } + valid := make(map[int64]struct{}, len(cats)) + for _, c := range cats { + valid[c.ID] = struct{}{} + } + for _, id := range d.Categories { + if _, ok := valid[id]; !ok { + return fmt.Errorf("unknown GLPI category id %d", id) } } + return nil +} + +func (s *Server) knowledgeCreate(w http.ResponseWriter, r *http.Request) { + if !s.cfg.KnowledgeWebEditEnabled { + http.Error(w, "knowledge editing disabled", http.StatusForbidden) + return + } + d, err := s.decodeKnowledge(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if _, exists := s.knowledge.ByID(strings.TrimSpace(d.ID)); exists { + http.Error(w, "knowledge id already exists; use update instead", http.StatusConflict) + return + } + if err := s.validateKnowledgeCategories(r.Context(), d); err != nil { + http.Error(w, err.Error(), http.StatusUnprocessableEntity) + return + } if err := s.knowledge.Upsert(r.Context(), d); err != nil { - http.Error(w, err.Error(), 422) + http.Error(w, err.Error(), http.StatusUnprocessableEntity) + return + } + s.metrics.SetKnowledgeDocs(len(s.knowledge.List())) + respondJSONStatus(w, http.StatusCreated, d) +} + +func (s *Server) knowledgeUpdate(w http.ResponseWriter, r *http.Request) { + if !s.cfg.KnowledgeWebEditEnabled { + http.Error(w, "knowledge editing disabled", http.StatusForbidden) + return + } + id := strings.TrimSpace(r.PathValue("id")) + if _, exists := s.knowledge.ByID(id); !exists { + http.Error(w, "not found", http.StatusNotFound) + return + } + if !s.knowledge.IsManaged(id) { + http.Error(w, "knowledge entry is read-only", http.StatusConflict) + return + } + d, err := s.decodeKnowledge(r) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + if bodyID := strings.TrimSpace(d.ID); bodyID != "" && bodyID != id { + http.Error(w, "knowledge id cannot be changed while editing", http.StatusConflict) + return + } + d.ID = id + if err := s.validateKnowledgeCategories(r.Context(), d); err != nil { + http.Error(w, err.Error(), http.StatusUnprocessableEntity) + return + } + if err := s.knowledge.Upsert(r.Context(), d); err != nil { + http.Error(w, err.Error(), http.StatusUnprocessableEntity) return } s.metrics.SetKnowledgeDocs(len(s.knowledge.List())) respondJSON(w, d) } + func (s *Server) knowledgeDelete(w http.ResponseWriter, r *http.Request) { if !s.cfg.KnowledgeWebEditEnabled { http.Error(w, "knowledge editing disabled", http.StatusForbidden) @@ -359,8 +434,10 @@ func num(v any) int64 { } return 0 } -func respondJSON(w http.ResponseWriter, v any) { +func respondJSON(w http.ResponseWriter, v any) { respondJSONStatus(w, http.StatusOK, v) } +func respondJSONStatus(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } func (s *Server) auth(next http.Handler) http.Handler { diff --git a/internal/web/server_test.go b/internal/web/server_test.go index 050379e..1385c15 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -1,8 +1,21 @@ package web import ( + "bytes" + "context" + "encoding/json" "html/template" + "net/http" + "net/http/httptest" + "os" + "path/filepath" "testing" + + "github.com/example/glpi-ai-agent/internal/config" + "github.com/example/glpi-ai-agent/internal/knowledge" + "github.com/example/glpi-ai-agent/internal/metrics" + "github.com/example/glpi-ai-agent/internal/model" + "github.com/example/glpi-ai-agent/internal/queue" ) func TestExtractTicketID(t *testing.T) { @@ -33,3 +46,128 @@ func TestDashboardTemplateParses(t *testing.T) { t.Fatal(err) } } + +type fakeFeedback struct{ cats []model.Category } + +func (f fakeFeedback) Categories(context.Context) ([]model.Category, error) { return f.cats, nil } +func (f fakeFeedback) RecordCategoryFeedback(context.Context, string, int64) (model.LearningExample, error) { + return model.LearningExample{}, nil +} +func (f fakeFeedback) LearningExamples() []model.LearningExample { return nil } +func (f fakeFeedback) DeleteLearning(string) error { return os.ErrNotExist } +func (f fakeFeedback) LearningCount() int { return 0 } + +func newKnowledgeTestServer(t *testing.T) (http.Handler, *knowledge.Store) { + t.Helper() + root := t.TempDir() + staticDir := filepath.Join(root, "knowledge") + dataDir := filepath.Join(root, "data") + if err := os.MkdirAll(staticDir, 0o750); err != nil { + t.Fatal(err) + } + store, err := knowledge.Load(context.Background(), staticDir, dataDir, nil, false, []string{"internal-kb"}) + if err != nil { + t.Fatal(err) + } + cfg := config.Config{WebAllowAnonymous: true, KnowledgeWebEditEnabled: true, CommunicationLanguage: "de-DE", CommunicationStyle: "formal", KnowledgeAllowedSources: []string{"internal-kb"}} + srv, err := New(cfg, metrics.New(), nil, queue.New(8), store, fakeFeedback{cats: []model.Category{{ID: 2, Name: "Active Directory", CompleteName: "IT > Active Directory"}}}) + if err != nil { + t.Fatal(err) + } + return srv.Handler(), store +} + +func mutationRequest(t *testing.T, h http.Handler, method, path string, body any) *httptest.ResponseRecorder { + t.Helper() + var b bytes.Buffer + if body != nil { + if err := json.NewEncoder(&b).Encode(body); err != nil { + t.Fatal(err) + } + } + req := httptest.NewRequest(method, path, &b) + req.Header.Set("X-Requested-With", "GLPI-AI-Agent") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + return rr +} + +func TestKnowledgeCreateUpdateDeleteFlow(t *testing.T) { + h, store := newKnowledgeTestServer(t) + doc := model.KnowledgeDoc{ID: "KB-TEST-1", Title: "Benutzerkonto gesperrt", Text: "Konto ist gesperrt.", Answer: "Bitte versuchen Sie die Anmeldung erneut.", Source: "internal-kb", Language: "de-DE", CommunicationStyle: "formal", MinScore: .7, Categories: []int64{2}} + if rr := mutationRequest(t, h, http.MethodPost, "/api/knowledge", doc); rr.Code != http.StatusCreated { + t.Fatalf("create = %d: %s", rr.Code, rr.Body.String()) + } + if _, ok := store.ByID(doc.ID); !ok { + t.Fatal("created document missing") + } + if rr := mutationRequest(t, h, http.MethodPost, "/api/knowledge", doc); rr.Code != http.StatusConflict { + t.Fatalf("duplicate create = %d, want 409", rr.Code) + } + + doc.Title = "Benutzerkonto dauerhaft gesperrt" + if rr := mutationRequest(t, h, http.MethodPut, "/api/knowledge/KB-TEST-1", doc); rr.Code != http.StatusOK { + t.Fatalf("update = %d: %s", rr.Code, rr.Body.String()) + } + got, ok := store.ByID(doc.ID) + if !ok || got.Title != doc.Title { + t.Fatalf("updated doc = %#v, ok=%v", got, ok) + } + + renamed := doc + renamed.ID = "KB-RENAMED" + if rr := mutationRequest(t, h, http.MethodPut, "/api/knowledge/KB-TEST-1", renamed); rr.Code != http.StatusConflict { + t.Fatalf("rename update = %d, want 409", rr.Code) + } + if _, ok := store.ByID("KB-RENAMED"); ok { + t.Fatal("rename unexpectedly created a second document") + } + + if rr := mutationRequest(t, h, http.MethodDelete, "/api/knowledge/KB-TEST-1", nil); rr.Code != http.StatusNoContent { + t.Fatalf("delete = %d: %s", rr.Code, rr.Body.String()) + } + if _, ok := store.ByID(doc.ID); ok { + t.Fatal("deleted document still present") + } +} + +func TestKnowledgeGetReturnsManagedState(t *testing.T) { + h, _ := newKnowledgeTestServer(t) + doc := model.KnowledgeDoc{ID: "KB-TEST-2", Title: "VPN", Text: "VPN Hilfe", Source: "internal-kb", Language: "de-DE", CommunicationStyle: "formal", MinScore: .7} + if rr := mutationRequest(t, h, http.MethodPost, "/api/knowledge", doc); rr.Code != http.StatusCreated { + t.Fatalf("create = %d: %s", rr.Code, rr.Body.String()) + } + rr := httptest.NewRecorder() + h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/api/knowledge/KB-TEST-2", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("get = %d: %s", rr.Code, rr.Body.String()) + } + var got struct { + Managed bool `json:"managed"` + Document model.KnowledgeDoc `json:"document"` + } + if err := json.Unmarshal(rr.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if !got.Managed || got.Document.ID != doc.ID { + t.Fatalf("unexpected get response: %#v", got) + } +} + +func TestDashboardContainsControlCenterSections(t *testing.T) { + h, _ := newKnowledgeTestServer(t) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil)) + if rr.Code != http.StatusOK { + t.Fatalf("dashboard = %d: %s", rr.Code, rr.Body.String()) + } + body := rr.Body.String() + for _, want := range []string{"Control Center", "Verarbeitungen", "Knowledge Base", "Effektive Konfiguration", "Knowledge-Ranking"} { + if !bytes.Contains([]byte(body), []byte(want)) { + t.Fatalf("dashboard missing %q", want) + } + } +} diff --git a/internal/web/templates/dashboard.html b/internal/web/templates/dashboard.html index f3a9f4f..0db85b0 100644 --- a/internal/web/templates/dashboard.html +++ b/internal/web/templates/dashboard.html @@ -3,114 +3,170 @@ -GLPI AI Agent + +GLPI AI Agent · Control Center -
-
-

GLPI AI Agent

Status, Audit, Lernen & Knowledge Base
-
{{if .DryRun}}DRY RUN{{else}}LIVE{{end}} {{if .AutoReply}}Auto-Reply an{{else}}Auto-Reply aus{{end}}
-
-
-
+
+ -

Letzte Verarbeitungen

-
- - -
ZeitTicketErgebnisKategorieentscheidungAntwortentscheidungKontextBegründung / Policy
Lade…
+
+
+
Operations & Diagnose

Übersicht

Gesundheit, Verarbeitung und die wichtigsten Stellschrauben auf einen Blick.
+
GLPIOllama↻ –
+
-
-
-

Interne Knowledge Base

-
-

Knowledge-Eintrag anlegen

-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
+
+
+
+
Letzte Entscheidungen
Klick auf einen Lauf öffnet die vollständige Diagnose.
+
Betriebsdiagnose
Hinweise aus der effektiven Konfiguration.
+
+
+
Integrationen
Aktueller Zustand der Kontextquellen.
+
Scoring-Profil
Aktive Gewichte und Schwellwerte des RAG.
-
ID / TitelQuelleAuto-ReplyKategorienAktionen
Lade…
-
-

Bestätigtes Lernen

-
Nur von Ihnen bestätigte Zuordnungen werden als Beispiele an die KI weitergegeben. KI-Entscheidungen lernen niemals automatisch aus sich selbst.
-
BeispielKategorieZeit
Lade…
+
+

Verarbeitungen

Jeder Lauf enthält KI-Empfehlung, Policy-Entscheidung, KB-Ranking und Kontext. Klick auf eine Zeile für Details.
+
+
Zeit / TicketErgebnisKategorieAntwort / KBKontext
-
+ +
+

Knowledge Base

Interne Artikel verwalten und synchronisierte Quellen kontrollieren.
+
+
+
+
+ +
+

Bestätigtes Lernen

Nur menschlich bestätigte oder korrigierte Zuordnungen werden als Beispiele an die KI weitergegeben.
+
+
+
Ticket / BeispielKategorieTypZeit
+
+ +
+

Effektive Konfiguration

Read-only Ansicht der wirksamen Werte. Secrets werden bewusst nicht angezeigt. Änderungen erfolgen weiterhin über ENV/Deployment.
+
+
+
- +
+ + + + +