@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user