+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