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
-
+
+
-
-
- Interne Knowledge Base
-
-
-
-
+
+
+
+
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.
-
-
- Bestätigtes Lernen
- Nur von Ihnen bestätigte Zuordnungen werden als Beispiele an die KI weitergegeben. KI-Entscheidungen lernen niemals automatisch aus sich selbst.
-
+
+ Verarbeitungen
Jeder Lauf enthält KI-Empfehlung, Policy-Entscheidung, KB-Ranking und Kontext. Klick auf eine Zeile für Details.
+
+ | Zeit / Ticket | Ergebnis | Kategorie | Antwort / KB | Kontext | |
|---|
-
+
+
+ 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 / Beispiel | Kategorie | Typ | Zeit | |
|---|
+
+
+
+ Effektive Konfiguration
Read-only Ansicht der wirksamen Werte. Secrets werden bewusst nicht angezeigt. Änderungen erfolgen weiterhin über ENV/Deployment.
+
+
+
-
-
Kategorie als Lernbeispiel bestätigen
-
+
+
+Interne Knowledge Base
Neuen Artikel anlegen
+
+Human-in-the-loop
Kategorie bestätigen / korrigieren
+