Neues UI
release-tag / release-image (push) Successful in 1m31s

This commit is contained in:
2026-07-28 18:55:55 +02:00
parent de813aad3c
commit 51995f9275
7 changed files with 575 additions and 164 deletions
+6 -1
View File
@@ -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.
+26
View File
@@ -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.
+78
View File
@@ -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 {
+78 -47
View File
@@ -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"`
}
+100 -23
View File
@@ -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 {
+138
View File
@@ -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)
}
}
}
File diff suppressed because one or more lines are too long