From 4cfdac042d42237bbf93a56e77b3fbf20a339bb1 Mon Sep 17 00:00:00 2001 From: groot Date: Tue, 28 Jul 2026 14:35:03 +0200 Subject: [PATCH] Update GLPI-Knowledge --- .env.example | 19 +- README.md | 29 ++- SECURITY.md | 8 + UPGRADE.md | 17 ++ cmd/agent/main.go | 20 ++ docker-compose.yml | 21 +- internal/config/config.go | 84 +++++++- internal/config/config_test.go | 35 ++++ internal/glpi/client.go | 169 ++++++++++++++- internal/glpi/client_test.go | 36 ++++ internal/glpikb/sync.go | 284 ++++++++++++++++++++++++++ internal/glpikb/sync_test.go | 52 +++++ internal/knowledge/store.go | 170 ++++++++++++++- internal/knowledge/store_test.go | 19 ++ internal/metrics/metrics.go | 20 ++ internal/model/model.go | 26 ++- internal/web/server.go | 8 +- internal/web/templates/dashboard.html | 4 +- 18 files changed, 992 insertions(+), 29 deletions(-) create mode 100644 internal/glpikb/sync.go create mode 100644 internal/glpikb/sync_test.go diff --git a/.env.example b/.env.example index 7ce4ecc..4b82cea 100644 --- a/.env.example +++ b/.env.example @@ -52,13 +52,30 @@ KNOWLEDGE_TOP_K=3 KNOWLEDGE_MIN_SCORE=0.88 CATEGORY_PROMPT_LIMIT=80 # Fail-closed source policy. Only documents carrying one of these source labels are indexed/searched. -KNOWLEDGE_ALLOWED_SOURCES=internal-kb +KNOWLEDGE_ALLOWED_SOURCES=internal-kb,glpi-kb # Must be a subset of KNOWLEDGE_ALLOWED_SOURCES. Set to "none" to disable source-based auto-replies. KNOWLEDGE_AUTO_REPLY_SOURCES=internal-kb # Enables authenticated CRUD in the dashboard. Managed articles are written below DATA_DIR/knowledge-managed; # the static KNOWLEDGE_DIR stays read-only. KNOWLEDGE_WEB_EDIT_ENABLED=false +# GLPI Knowledge Base (read-only connector). The service account's GLPI rights +# determine which articles are visible. "auto" discovers the KnowbaseItem +# collection route from /api.php/doc.json at runtime. +GLPI_KB_ENABLED=false +GLPI_KB_PATH=auto +# Optional GLPI high-level API filter, validated by your installed GLPI. +GLPI_KB_FILTER= +GLPI_KB_LIMIT=500 +GLPI_KB_SYNC_INTERVAL=10m +GLPI_KB_SOURCE=glpi-kb +# Safe default: synchronized GLPI articles are research/RAG context only. +GLPI_KB_AUTO_REPLY=false +# Required when GLPI_KB_AUTO_REPLY=true. These are GLPI *Knowledge Base* +# category IDs, not ITIL category IDs. Auto-reply is additionally disabled if +# GLPI cannot map the KB category to an ITIL category. +GLPI_KB_AUTO_REPLY_CATEGORY_IDS= + # Human-confirmed category learning. Feedback is stored in DATA_DIR/category-learning.json. LEARNING_ENABLED=true LEARNING_MAX_EXAMPLES=500 diff --git a/README.md b/README.md index ecc74ae..a186282 100644 --- a/README.md +++ b/README.md @@ -143,7 +143,32 @@ KNOWLEDGE_AUTO_REPLY_SOURCES=internal-kb,glpi-kb `KNOWLEDGE_AUTO_REPLY_SOURCES` muss eine Teilmenge von `KNOWLEDGE_ALLOWED_SOURCES` sein. Mit `KNOWLEDGE_AUTO_REPLY_SOURCES=none` kann die Quellenfreigabe für Auto-Replies vollständig deaktiviert werden. Dokumente aus nicht erlaubten Quellen werden nicht in die Suchmenge aufgenommen und damit auch nicht an Ollama übergeben. Ein Knowledge-Dokument ohne `source` führt absichtlich zu einem Startfehler, damit die Herkunft nicht implizit geraten wird. -> In dieser Version ist `knowledge/` weiterhin der einzige physische Knowledge-Connector. `source` ist ein verbindliches Herkunfts-/Vertrauenslabel für importierte Dokumente. Ein späterer GLPI-KB-, Wiki- oder Hersteller-Connector kann dieselbe Policy unverändert verwenden. +### GLPI Knowledge Base als echter Connector + +Die GLPI-Wissensdatenbank kann jetzt direkt read-only synchronisiert werden. Der Agent ermittelt bei `GLPI_KB_PATH=auto` den lesbaren `KnowbaseItem`-Collection-Endpunkt aus `/api.php/doc.json`. GLPI selbst entscheidet anhand der Rechte des OAuth-Service-Accounts, welche Artikel sichtbar sind. + +```env +KNOWLEDGE_ALLOWED_SOURCES=internal-kb,glpi-kb +GLPI_KB_ENABLED=true +GLPI_KB_PATH=auto +GLPI_KB_SYNC_INTERVAL=10m +GLPI_KB_LIMIT=500 +GLPI_KB_SOURCE=glpi-kb +``` + +Erfolgreich synchronisierte Artikel werden normalisiert, lokal unter `DATA_DIR/glpi-kb-cache.json` gecacht und in denselben RAG-Index wie lokale Knowledge-Dokumente aufgenommen. Unveränderte Dokumente behalten ihre gecachten Embeddings; nur neue oder geänderte Artikel werden erneut eingebettet. Fällt GLPI bei einem späteren Start/Sync aus, kann der zuletzt gespeicherte Cache weiter als read-only Wissensstand geladen werden. + +GLPI-KB-Auto-Replies sind absichtlich separat gesperrt. Für eine Freigabe müssen **alle** Bedingungen erfüllt sein: `glpi-kb` ist in `KNOWLEDGE_AUTO_REPLY_SOURCES`, `GLPI_KB_AUTO_REPLY=true`, die GLPI-KB-Kategorie des Artikels steht in `GLPI_KB_AUTO_REPLY_CATEGORY_IDS`, und GLPI liefert eine Zuordnung dieser KB-Kategorie zu mindestens einer ITIL-Kategorie. Beispiel: + +```env +KNOWLEDGE_AUTO_REPLY_SOURCES=internal-kb,glpi-kb +GLPI_KB_AUTO_REPLY=true +GLPI_KB_AUTO_REPLY_CATEGORY_IDS=4,7 +``` + +`GLPI_KB_AUTO_REPLY_CATEGORY_IDS` enthält **Knowledge-Base-Kategorie-IDs aus GLPI**, nicht Ticket-/ITIL-Kategorie-IDs. Ohne diese explizite Whitelist dienen GLPI-Artikel nur Recherche, RAG und Kategorie-Hinweisen. + +Optional kann `GLPI_KB_FILTER` gesetzt werden, um die von GLPI gelieferte Collection zusätzlich serverseitig einzuschränken. Die konkrete Filtersyntax und verfügbare Felder stammen aus der OpenAPI-Dokumentation deiner installierten GLPI-Version. Für die Kommunikation gelten zentrale Vorgaben: @@ -350,6 +375,8 @@ LEARNING_MAX_EXAMPLES=500 LEARNING_EXAMPLES_PER_CATEGORY=5 ``` +Das Dashboard zeigt außerdem den Status und die Anzahl der synchronisierten GLPI-KB-Artikel. Synchronisierte GLPI-Artikel sind read-only und als GLPI-Sync gekennzeichnet. + Das Dashboard enthält außerdem einen CRUD-Editor für interne Knowledge-Einträge. Er ist absichtlich nur bei authentifiziertem Dashboard aktiv: ```env diff --git a/SECURITY.md b/SECURITY.md index 3177d62..7e78da5 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -70,3 +70,11 @@ Without a GLPI API primitive that atomically combines "no followup exists" and " `KNOWLEDGE_WEB_EDIT_ENABLED=true` darf nicht zusammen mit `WEB_ALLOW_ANONYMOUS=true` verwendet werden; die Konfiguration wird beim Start abgelehnt. Mutierende Dashboard-Endpunkte verlangen zusätzlich den Same-App-Request-Header `X-Requested-With: GLPI-AI-Agent`. Statische Knowledge-Dateien aus `KNOWLEDGE_DIR` bleiben read-only; Web-Inhalte werden ausschließlich unter `DATA_DIR/knowledge-managed/` persistiert. Kategorie-Lernen ist Human-in-the-loop: Nur eine ausdrückliche Bestätigung/Korrektur im Dashboard wird als Lernbeispiel gespeichert. Der Agent übernimmt seine eigenen KI-Empfehlungen oder automatisch geschriebenen Kategorien niemals selbständig in den Lernbestand. + +## GLPI Knowledge Base Connector + +`GLPI_KB_ENABLED=true` creates a read-only synchronization path from the GLPI knowledge base into the local RAG store. The connector never creates, updates or deletes GLPI knowledge articles. GLPI's own authorization for the OAuth service account is the first visibility boundary; only articles returned to that account can enter the local cache/index. + +Synchronized GLPI articles are read-only in the agent dashboard. Automatic replies from this source remain disabled unless all of the following are explicitly configured: the source is in `KNOWLEDGE_AUTO_REPLY_SOURCES`, `GLPI_KB_AUTO_REPLY=true`, and the article belongs to a GLPI Knowledge Base category listed in `GLPI_KB_AUTO_REPLY_CATEGORY_IDS`. In addition, the connector requires a GLPI KB-category -> ITIL-category mapping before marking an imported article as auto-reply eligible. + +The normalized cache is stored in `DATA_DIR/glpi-kb-cache.json`; embeddings remain in `DATA_DIR/embeddings.json`. Treat both as potentially sensitive support data and protect/backup `DATA_DIR` accordingly. diff --git a/UPGRADE.md b/UPGRADE.md index 3bb8494..a2da7fb 100644 --- a/UPGRADE.md +++ b/UPGRADE.md @@ -41,3 +41,20 @@ CATEGORY_CONFIDENCE=0.90 ``` Im Dashboard anschließend Entscheidungen bestätigen/korrigieren. Erst nach genügend beobachteten Tickets Schwellwerte oder Schreibrechte anpassen. + +## GLPI Knowledge Base Connector + +Für den neuen read-only GLPI-KB-Sync ergänzen Sie bei Bedarf: + +```env +KNOWLEDGE_ALLOWED_SOURCES=internal-kb,glpi-kb +GLPI_KB_ENABLED=true +GLPI_KB_PATH=auto +GLPI_KB_SYNC_INTERVAL=10m +GLPI_KB_LIMIT=500 +GLPI_KB_SOURCE=glpi-kb +GLPI_KB_AUTO_REPLY=false +GLPI_KB_AUTO_REPLY_CATEGORY_IDS= +``` + +Der sichere Start ist `GLPI_KB_AUTO_REPLY=false`. Erst nachdem die importierten Artikel im Dashboard geprüft wurden, sollte `glpi-kb` optional in `KNOWLEDGE_AUTO_REPLY_SOURCES` aufgenommen und eine explizite Whitelist von GLPI-Knowledge-Base-Kategorie-IDs gesetzt werden. diff --git a/cmd/agent/main.go b/cmd/agent/main.go index 67c7ab6..9e8e381 100644 --- a/cmd/agent/main.go +++ b/cmd/agent/main.go @@ -15,6 +15,7 @@ import ( "github.com/example/glpi-ai-agent/internal/config" "github.com/example/glpi-ai-agent/internal/contextdata" "github.com/example/glpi-ai-agent/internal/glpi" + "github.com/example/glpi-ai-agent/internal/glpikb" "github.com/example/glpi-ai-agent/internal/knowledge" "github.com/example/glpi-ai-agent/internal/learning" "github.com/example/glpi-ai-agent/internal/metrics" @@ -84,6 +85,18 @@ func main() { } m := metrics.New() m.SetKnowledgeDocs(k.Count()) + if cfg.GLPIKBEnabled { + kbSync := glpikb.New(cfg, g, k, m) + if err := kbSync.LoadCache(ctx); err != nil { + slog.Warn("GLPI knowledge cache unavailable", "error", err) + } + syncCtx, syncCancel := context.WithTimeout(ctx, maxDuration(cfg.GLPITimeout*3, 30*time.Second)) + if err := kbSync.Sync(syncCtx); err != nil { + slog.Error("initial GLPI knowledge base sync failed; continuing with local/cache knowledge", "error", err) + } + syncCancel() + kbSync.Start(ctx) + } q := queue.New(cfg.QueueSize) var kuma *uptimekuma.Client if cfg.UptimeKumaEnabled { @@ -111,3 +124,10 @@ func main() { _ = srv.Shutdown(shutdownCtx) slog.Info("shutdown complete") } + +func maxDuration(a, b time.Duration) time.Duration { + if a > b { + return a + } + return b +} diff --git a/docker-compose.yml b/docker-compose.yml index c1d2c0a..6223562 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,16 +1,19 @@ services: agent-data-init: - image: alpine:3.22 - user: 0:0 - command: - - sh - - -c - - | - mkdir -p /app/data - chown -R 65532:65532 /app/data + build: + context: . + target: data-init + restart: "no" + user: "0:0" volumes: - agent-data:/app/data - restart: no + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + cap_add: + - CHOWN + - FOWNER agent: build: . diff --git a/internal/config/config.go b/internal/config/config.go index dbfaaec..261152d 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -45,13 +45,21 @@ type Config struct { OllamaMaxConcurrent int OllamaJSONRetries int - KnowledgeDir string - RAGEnabled bool - KnowledgeTopK int - CategoryPromptLimit int - KnowledgeAllowedSources []string - KnowledgeAutoReplySources []string - KnowledgeWebEditEnabled bool + KnowledgeDir string + RAGEnabled bool + KnowledgeTopK int + CategoryPromptLimit int + KnowledgeAllowedSources []string + KnowledgeAutoReplySources []string + KnowledgeWebEditEnabled bool + GLPIKBEnabled bool + GLPIKBPath string + GLPIKBFilter string + GLPIKBLimit int + GLPIKBSyncInterval time.Duration + GLPIKBSource string + GLPIKBAutoReply bool + GLPIKBAutoReplyCategoryIDs []int64 LearningEnabled bool LearningMaxExamples int @@ -139,6 +147,14 @@ func Load() (Config, error) { KnowledgeAllowedSources: envStringList("KNOWLEDGE_ALLOWED_SOURCES", "internal-kb"), KnowledgeAutoReplySources: envStringList("KNOWLEDGE_AUTO_REPLY_SOURCES", "internal-kb"), KnowledgeWebEditEnabled: envBool("KNOWLEDGE_WEB_EDIT_ENABLED", false), + GLPIKBEnabled: envBool("GLPI_KB_ENABLED", false), + GLPIKBPath: env("GLPI_KB_PATH", "auto"), + GLPIKBFilter: strings.TrimSpace(os.Getenv("GLPI_KB_FILTER")), + GLPIKBLimit: envInt("GLPI_KB_LIMIT", 500), + GLPIKBSyncInterval: envDuration("GLPI_KB_SYNC_INTERVAL", 10*time.Minute), + GLPIKBSource: strings.ToLower(env("GLPI_KB_SOURCE", "glpi-kb")), + GLPIKBAutoReply: envBool("GLPI_KB_AUTO_REPLY", false), + GLPIKBAutoReplyCategoryIDs: envInt64ListAllowEmpty("GLPI_KB_AUTO_REPLY_CATEGORY_IDS"), LearningEnabled: envBool("LEARNING_ENABLED", true), LearningMaxExamples: envInt("LEARNING_MAX_EXAMPLES", 500), LearningExamplesPerCategory: envInt("LEARNING_EXAMPLES_PER_CATEGORY", 5), @@ -292,6 +308,38 @@ func (c Config) Validate() error { return fmt.Errorf("KNOWLEDGE_AUTO_REPLY_SOURCES source %q is not present in KNOWLEDGE_ALLOWED_SOURCES", source) } } + if c.GLPIKBEnabled { + if c.GLPIKBLimit < 1 || c.GLPIKBLimit > 5000 { + return errors.New("GLPI_KB_LIMIT must be between 1 and 5000") + } + if c.GLPIKBSyncInterval < time.Minute { + return errors.New("GLPI_KB_SYNC_INTERVAL must be at least 1m") + } + if c.GLPIKBPath != "auto" && !validAPIPath(c.GLPIKBPath) { + return errors.New("GLPI_KB_PATH must be 'auto' or an absolute API path") + } + if strings.TrimSpace(c.GLPIKBSource) == "" { + return errors.New("GLPI_KB_SOURCE must not be empty") + } + if _, ok := allowedSources[c.GLPIKBSource]; !ok { + return fmt.Errorf("GLPI_KB_SOURCE %q must be present in KNOWLEDGE_ALLOWED_SOURCES", c.GLPIKBSource) + } + if c.GLPIKBAutoReply { + if len(c.GLPIKBAutoReplyCategoryIDs) == 0 { + return errors.New("GLPI_KB_AUTO_REPLY_CATEGORY_IDS must contain at least one GLPI KB category when GLPI_KB_AUTO_REPLY=true") + } + found := false + for _, source := range c.KnowledgeAutoReplySources { + if strings.EqualFold(source, c.GLPIKBSource) { + found = true + break + } + } + if !found { + return fmt.Errorf("GLPI_KB_SOURCE %q must be present in KNOWLEDGE_AUTO_REPLY_SOURCES when GLPI_KB_AUTO_REPLY=true", c.GLPIKBSource) + } + } + } if c.AutoReply && len(c.KnowledgeAutoReplySources) == 0 { return errors.New("KNOWLEDGE_AUTO_REPLY_SOURCES must contain at least one source when AUTO_REPLY=true") } @@ -402,6 +450,28 @@ func envInt64List(key, def string) []int64 { return out } +func envInt64ListAllowEmpty(key string) []int64 { + raw := strings.TrimSpace(os.Getenv(key)) + if raw == "" || strings.EqualFold(raw, "none") { + return nil + } + parts := strings.Split(raw, ",") + out := make([]int64, 0, len(parts)) + seen := map[int64]struct{}{} + for _, part := range parts { + n, err := strconv.ParseInt(strings.TrimSpace(part), 10, 64) + if err != nil || n <= 0 { + return nil + } + if _, ok := seen[n]; ok { + continue + } + seen[n] = struct{}{} + out = append(out, n) + } + return out +} + func envStringList(key, def string) []string { raw, ok := os.LookupEnv(key) if !ok { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 087cbc0..6617587 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -124,3 +124,38 @@ func TestKnowledgeWebEditRequiresAuthentication(t *testing.T) { t.Fatal("expected anonymous KB editing to be rejected") } } + +func TestValidateGLPIKBRequiresAllowedSource(t *testing.T) { + c := validConfig() + c.GLPIKBEnabled = true + c.GLPIKBPath = "auto" + c.GLPIKBLimit = 100 + c.GLPIKBSyncInterval = 10 * time.Minute + c.GLPIKBSource = "glpi-kb" + if err := c.Validate(); err == nil { + t.Fatal("expected GLPI KB source outside allowlist to be rejected") + } + c.KnowledgeAllowedSources = []string{"internal-kb", "glpi-kb"} + if err := c.Validate(); err != nil { + t.Fatalf("expected GLPI KB config to validate: %v", err) + } +} + +func TestValidateGLPIKBAutoReplyRequiresCategoryWhitelist(t *testing.T) { + c := validConfig() + c.GLPIKBEnabled = true + c.GLPIKBPath = "auto" + c.GLPIKBLimit = 100 + c.GLPIKBSyncInterval = 10 * time.Minute + c.GLPIKBSource = "glpi-kb" + c.KnowledgeAllowedSources = []string{"internal-kb", "glpi-kb"} + c.KnowledgeAutoReplySources = []string{"internal-kb", "glpi-kb"} + c.GLPIKBAutoReply = true + if err := c.Validate(); err == nil { + t.Fatal("expected auto reply category whitelist to be required") + } + c.GLPIKBAutoReplyCategoryIDs = []int64{3} + if err := c.Validate(); err != nil { + t.Fatalf("expected explicit category whitelist to validate: %v", err) + } +} diff --git a/internal/glpi/client.go b/internal/glpi/client.go index ebd097e..c9dca6d 100644 --- a/internal/glpi/client.go +++ b/internal/glpi/client.go @@ -10,6 +10,7 @@ import ( "io" "net/http" "net/url" + "sort" "strconv" "strings" "sync" @@ -247,11 +248,177 @@ func (c *Client) GetCategories(ctx context.Context) ([]model.Category, error) { } out := make([]model.Category, 0, len(arr)) for _, r := range arr { - out = append(out, model.Category{ID: int64Val(r["id"]), Name: strVal(r["name"]), CompleteName: strVal(r["completename"])}) + out = append(out, model.Category{ID: int64Val(r["id"]), Name: strVal(r["name"]), CompleteName: strVal(r["completename"]), KnowbaseCategoryID: firstRefID(r, "knowbase_category", "knowbasecategory")}) } return out, nil } +// DiscoverKnowledgeBasePath finds the read-only KnowbaseItem collection route +// from the OpenAPI document of the installed GLPI instance. This avoids +// hard-coding a path that may move between high-level API versions. +func (c *Client) DiscoverKnowledgeBasePath(ctx context.Context, configured string) (string, error) { + configured = strings.TrimSpace(configured) + if configured != "" && !strings.EqualFold(configured, "auto") { + if err := c.ValidateReadRoutes(ctx, []string{configured}); err != nil { + return "", err + } + return configured, nil + } + doc, err := c.FetchOpenAPI(ctx) + if err != nil { + return "", fmt.Errorf("fetch GLPI OpenAPI: %w", err) + } + paths, ok := doc["paths"].(map[string]any) + if !ok { + return "", errors.New("GLPI OpenAPI document has no paths map") + } + type candidate struct { + path string + score int + } + var candidates []candidate + for path, raw := range paths { + if strings.Contains(path, "{") { + continue + } + ops, ok := raw.(map[string]any) + if !ok || ops["get"] == nil { + continue + } + l := strings.ToLower(path) + score := 0 + if strings.Contains(l, "knowbaseitem") { + score += 100 + } + if strings.Contains(l, "knowledge") { + score += 40 + } + if strings.Contains(l, "knowbase") { + score += 40 + } + if strings.HasSuffix(l, "/knowbaseitem") { + score += 30 + } + if score > 0 { + candidates = append(candidates, candidate{path: path, score: score}) + } + } + if len(candidates) == 0 { + return "", errors.New("GLPI OpenAPI exposes no readable KnowbaseItem collection route; verify GLPI version and service-account knowledge-base rights") + } + sort.Slice(candidates, func(i, j int) bool { + if candidates[i].score == candidates[j].score { + return candidates[i].path < candidates[j].path + } + return candidates[i].score > candidates[j].score + }) + path := candidates[0].path + // The OpenAPI document may include /api.php/vX.Y in documented paths while + // c.do already prepends the configured API base. Keep only the route suffix. + if i := strings.Index(path, "/api.php/"); i >= 0 { + rest := path[i+len("/api.php/"):] + if slash := strings.Index(rest, "/"); slash >= 0 { + path = rest[slash:] + } + } + versionPrefix := "/" + strings.Trim(c.version, "/") + if strings.HasPrefix(path, versionPrefix+"/") { + path = strings.TrimPrefix(path, versionPrefix) + } + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + return path, nil +} + +// ListKnowledgeBaseItems reads only items visible to the authenticated GLPI +// service account. Visibility is therefore enforced by GLPI itself; an +// optional filter can further restrict the collection on a per-instance basis. +func (c *Client) ListKnowledgeBaseItems(ctx context.Context, path string, limit int, filter string) ([]model.GLPIKnowledgeItem, error) { + q := url.Values{"limit": {strconv.Itoa(limit)}, "sort": {"date_mod"}, "order": {"DESC"}} + if strings.TrimSpace(filter) != "" { + q.Set("filter", filter) + } + b, _, err := c.do(ctx, http.MethodGet, path, q, nil) + if err != nil { + return nil, err + } + arr, err := extractArray(b) + if err != nil { + return nil, err + } + out := make([]model.GLPIKnowledgeItem, 0, len(arr)) + for _, r := range arr { + id := int64Val(r["id"]) + if id <= 0 { + continue + } + if firstString(r, "answer", "content", "text", "description") == "" { + if detail, _, e := c.do(ctx, http.MethodGet, strings.TrimRight(path, "/")+"/"+strconv.FormatInt(id, 10), nil, nil); e == nil { + var full map[string]any + if json.Unmarshal(detail, &full) == nil { + for k, v := range full { + r[k] = v + } + } + } + } + title := firstString(r, "name", "title", "subject") + content := firstString(r, "answer", "content", "text", "description") + if title == "" || content == "" { + continue + } + out = append(out, model.GLPIKnowledgeItem{ + ID: id, + Title: title, + Content: content, + CategoryIDs: knowledgeCategoryIDs(r), + Language: firstString(r, "language", "locale"), + ModifiedAt: firstString(r, "date_mod", "modified_at", "date_creation"), + }) + } + return out, nil +} + +func knowledgeCategoryIDs(r map[string]any) []int64 { + seen := map[int64]struct{}{} + var out []int64 + var add func(any) + add = func(v any) { + switch x := v.(type) { + case []any: + for _, e := range x { + add(e) + } + case map[string]any: + if id := int64Val(x["id"]); id > 0 { + if _, ok := seen[id]; !ok { + seen[id] = struct{}{} + out = append(out, id) + } + return + } + for _, v2 := range x { + add(v2) + } + default: + if id := int64Val(x); id > 0 { + if _, ok := seen[id]; !ok { + seen[id] = struct{}{} + out = append(out, id) + } + } + } + } + for _, k := range []string{"knowbase_category", "knowbase_categories", "knowbaseitemcategory", "knowbaseitemcategories", "categories", "category"} { + if v, ok := r[k]; ok { + add(v) + } + } + sort.Slice(out, func(i, j int) bool { return out[i] < out[j] }) + return out +} + func extractArray(b []byte) ([]map[string]any, error) { var arr []map[string]any if json.Unmarshal(b, &arr) == nil { diff --git a/internal/glpi/client_test.go b/internal/glpi/client_test.go index 826ea3f..3fab93c 100644 --- a/internal/glpi/client_test.go +++ b/internal/glpi/client_test.go @@ -53,3 +53,39 @@ func TestValidateContractAcceptsVersionPrefixedOpenAPIPaths(t *testing.T) { t.Fatal(err) } } + +func TestDiscoverAndListKnowledgeBase(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api.php/token": + _ = json.NewEncoder(w).Encode(map[string]any{"access_token": "x", "expires_in": 3600}) + case "/api.php/doc.json": + _ = json.NewEncoder(w).Encode(map[string]any{"paths": map[string]any{ + "/v2.3/Knowledge/KnowbaseItem": map[string]any{"get": map[string]any{}}, + }}) + case "/api.php/v2.3/Knowledge/KnowbaseItem": + _ = json.NewEncoder(w).Encode([]map[string]any{{ + "id": 7, "name": "Konto gesperrt", "answer": "

Konto entsperren

", "date_mod": "2026-07-27 10:00:00", + "categories": []any{map[string]any{"id": 4}}, + }}) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + c := New(srv.URL, "v2.3", "cid", "sec", "u", "p", time.Second) + path, err := c.DiscoverKnowledgeBasePath(context.Background(), "auto") + if err != nil { + t.Fatal(err) + } + if path != "/Knowledge/KnowbaseItem" { + t.Fatalf("path=%q", path) + } + items, err := c.ListKnowledgeBaseItems(context.Background(), path, 50, "") + if err != nil { + t.Fatal(err) + } + if len(items) != 1 || items[0].ID != 7 || len(items[0].CategoryIDs) != 1 || items[0].CategoryIDs[0] != 4 { + t.Fatalf("unexpected items: %+v", items) + } +} diff --git a/internal/glpikb/sync.go b/internal/glpikb/sync.go new file mode 100644 index 0000000..8f1455b --- /dev/null +++ b/internal/glpikb/sync.go @@ -0,0 +1,284 @@ +package glpikb + +import ( + "context" + "encoding/json" + "fmt" + stdhtml "html" + "log/slog" + "os" + "path/filepath" + "regexp" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/example/glpi-ai-agent/internal/config" + "github.com/example/glpi-ai-agent/internal/metrics" + "github.com/example/glpi-ai-agent/internal/model" +) + +type Source interface { + DiscoverKnowledgeBasePath(context.Context, string) (string, error) + ListKnowledgeBaseItems(context.Context, string, int, string) ([]model.GLPIKnowledgeItem, error) + GetCategories(context.Context) ([]model.Category, error) +} + +type Store interface { + ReplaceExternalSource(context.Context, string, []model.KnowledgeDoc) error + Count() int +} + +type Syncer struct { + cfg config.Config + glpi Source + store Store + metrics *metrics.Metrics + cachePath string + + mu sync.RWMutex + resolvedPath string + lastSync time.Time + lastError string + count int +} + +type cacheFile struct { + SyncedAt time.Time `json:"synced_at"` + Path string `json:"path"` + Documents []model.KnowledgeDoc `json:"documents"` +} + +type Status struct { + Enabled bool `json:"enabled"` + Path string `json:"path,omitempty"` + LastSync time.Time `json:"last_sync,omitempty"` + LastError string `json:"last_error,omitempty"` + Documents int `json:"documents"` +} + +func New(cfg config.Config, g Source, store Store, m *metrics.Metrics) *Syncer { + return &Syncer{cfg: cfg, glpi: g, store: store, metrics: m, cachePath: filepath.Join(cfg.DataDir, "glpi-kb-cache.json")} +} + +func (s *Syncer) Status() Status { + s.mu.RLock() + defer s.mu.RUnlock() + return Status{Enabled: s.cfg.GLPIKBEnabled, Path: s.resolvedPath, LastSync: s.lastSync, LastError: s.lastError, Documents: s.count} +} + +func (s *Syncer) LoadCache(ctx context.Context) error { + b, err := os.ReadFile(s.cachePath) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return fmt.Errorf("read GLPI KB cache: %w", err) + } + var cf cacheFile + if err := json.Unmarshal(b, &cf); err != nil { + return fmt.Errorf("decode GLPI KB cache: %w", err) + } + if err := s.store.ReplaceExternalSource(ctx, s.cfg.GLPIKBSource, cf.Documents); err != nil { + return fmt.Errorf("load GLPI KB cache into knowledge store: %w", err) + } + s.mu.Lock() + s.resolvedPath = cf.Path + s.lastSync = cf.SyncedAt + s.count = len(cf.Documents) + s.lastError = "" + s.mu.Unlock() + s.metrics.SetGLPIKBStatus(true, len(cf.Documents), cf.SyncedAt, "") + s.metrics.SetKnowledgeDocs(s.store.Count()) + return nil +} + +func (s *Syncer) Sync(ctx context.Context) error { + path := s.currentPath() + if path == "" { + var err error + path, err = s.glpi.DiscoverKnowledgeBasePath(ctx, s.cfg.GLPIKBPath) + if err != nil { + s.fail(err) + return err + } + } + items, err := s.glpi.ListKnowledgeBaseItems(ctx, path, s.cfg.GLPIKBLimit, s.cfg.GLPIKBFilter) + if err != nil { + s.fail(err) + return fmt.Errorf("list GLPI knowledge base: %w", err) + } + cats, err := s.glpi.GetCategories(ctx) + if err != nil { + s.fail(err) + return fmt.Errorf("load GLPI ITIL categories for KB mapping: %w", err) + } + docs := s.normalize(items, cats) + if err := s.store.ReplaceExternalSource(ctx, s.cfg.GLPIKBSource, docs); err != nil { + s.fail(err) + return fmt.Errorf("replace GLPI knowledge source: %w", err) + } + now := time.Now() + cf := cacheFile{SyncedAt: now, Path: path, Documents: docs} + if err := writeAtomicJSON(s.cachePath, cf); err != nil { + s.fail(err) + return fmt.Errorf("persist GLPI KB cache: %w", err) + } + s.mu.Lock() + s.resolvedPath = path + s.lastSync = now + s.lastError = "" + s.count = len(docs) + s.mu.Unlock() + s.metrics.SetGLPIKBStatus(true, len(docs), now, "") + s.metrics.SetKnowledgeDocs(s.store.Count()) + slog.Info("GLPI knowledge base synchronized", "documents", len(docs), "path", path) + return nil +} + +func (s *Syncer) Start(ctx context.Context) { + if !s.cfg.GLPIKBEnabled { + return + } + go func() { + t := time.NewTicker(s.cfg.GLPIKBSyncInterval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + c, cancel := context.WithTimeout(ctx, maxDuration(s.cfg.GLPITimeout*3, 30*time.Second)) + if err := s.Sync(c); err != nil { + slog.Error("GLPI knowledge base sync failed", "error", err) + } + cancel() + } + } + }() +} + +func (s *Syncer) normalize(items []model.GLPIKnowledgeItem, cats []model.Category) []model.KnowledgeDoc { + kbToITIL := map[int64][]model.Category{} + for _, c := range cats { + if c.KnowbaseCategoryID > 0 { + kbToITIL[c.KnowbaseCategoryID] = append(kbToITIL[c.KnowbaseCategoryID], c) + } + } + autoCats := map[int64]struct{}{} + for _, id := range s.cfg.GLPIKBAutoReplyCategoryIDs { + autoCats[id] = struct{}{} + } + out := make([]model.KnowledgeDoc, 0, len(items)) + for _, item := range items { + text := cleanHTML(item.Content) + if strings.TrimSpace(text) == "" { + continue + } + itilSeen := map[int64]struct{}{} + var itilIDs []int64 + var keywords []string + for _, kbID := range item.CategoryIDs { + for _, c := range kbToITIL[kbID] { + if _, ok := itilSeen[c.ID]; !ok { + itilSeen[c.ID] = struct{}{} + itilIDs = append(itilIDs, c.ID) + } + if c.CompleteName != "" { + keywords = append(keywords, c.CompleteName) + } else if c.Name != "" { + keywords = append(keywords, c.Name) + } + } + } + sort.Slice(itilIDs, func(i, j int) bool { return itilIDs[i] < itilIDs[j] }) + auto := false + if s.cfg.GLPIKBAutoReply { + for _, id := range item.CategoryIDs { + if _, ok := autoCats[id]; ok { + auto = true + break + } + } + } + if auto && len(itilIDs) == 0 { + auto = false + } + language := strings.TrimSpace(item.Language) + if language == "" { + language = s.cfg.CommunicationLanguage + } + out = append(out, model.KnowledgeDoc{ + ID: "GLPI-KB-" + strconv.FormatInt(item.ID, 10), + Title: strings.TrimSpace(item.Title), Text: text, Answer: text, + AutoReply: auto, MinScore: 0, Categories: itilIDs, Keywords: uniqueStrings(keywords), + Source: s.cfg.GLPIKBSource, SourceURI: "glpi://KnowbaseItem/" + strconv.FormatInt(item.ID, 10), + SourceCategoryIDs: append([]int64(nil), item.CategoryIDs...), SourceModifiedAt: item.ModifiedAt, + Language: language, CommunicationStyle: s.cfg.CommunicationStyle, + }) + } + return out +} + +func (s *Syncer) currentPath() string { s.mu.RLock(); defer s.mu.RUnlock(); return s.resolvedPath } +func (s *Syncer) fail(err error) { + s.mu.Lock() + s.lastError = err.Error() + count := s.count + last := s.lastSync + s.mu.Unlock() + s.metrics.SetGLPIKBStatus(false, count, last, err.Error()) +} + +var tagRE = regexp.MustCompile(`(?s)<[^>]*>`) +var spaceRE = regexp.MustCompile(`[\t\r\n ]+`) + +func cleanHTML(v string) string { + v = strings.ReplaceAll(v, "
", "\n") + v = strings.ReplaceAll(v, "
", "\n") + v = strings.ReplaceAll(v, "
", "\n") + v = strings.ReplaceAll(v, "

", "\n") + v = strings.ReplaceAll(v, "", "\n") + v = tagRE.ReplaceAllString(v, " ") + v = stdhtml.UnescapeString(v) + return strings.TrimSpace(spaceRE.ReplaceAllString(v, " ")) +} +func uniqueStrings(in []string) []string { + seen := map[string]struct{}{} + out := []string{} + for _, v := range in { + v = strings.TrimSpace(v) + if v == "" { + continue + } + k := strings.ToLower(v) + if _, ok := seen[k]; ok { + continue + } + seen[k] = struct{}{} + out = append(out, v) + } + return out +} +func writeAtomicJSON(path string, v any) error { + b, err := json.MarshalIndent(v, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { + return err + } + tmp := path + ".tmp" + if err := os.WriteFile(tmp, b, 0o640); err != nil { + return err + } + return os.Rename(tmp, path) +} +func maxDuration(a, b time.Duration) time.Duration { + if a > b { + return a + } + return b +} diff --git a/internal/glpikb/sync_test.go b/internal/glpikb/sync_test.go new file mode 100644 index 0000000..1bee9c2 --- /dev/null +++ b/internal/glpikb/sync_test.go @@ -0,0 +1,52 @@ +package glpikb + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/example/glpi-ai-agent/internal/config" + "github.com/example/glpi-ai-agent/internal/metrics" + "github.com/example/glpi-ai-agent/internal/model" +) + +type fakeSource struct{} + +func (fakeSource) DiscoverKnowledgeBasePath(context.Context, string) (string, error) { + return "/Knowledge/KnowbaseItem", nil +} +func (fakeSource) ListKnowledgeBaseItems(context.Context, string, int, string) ([]model.GLPIKnowledgeItem, error) { + return []model.GLPIKnowledgeItem{{ID: 12, Title: "Benutzerkonto gesperrt", Content: "

Bitte entsperren.

", CategoryIDs: []int64{9}, ModifiedAt: "now"}}, nil +} +func (fakeSource) GetCategories(context.Context) ([]model.Category, error) { + return []model.Category{{ID: 2, Name: "Active Directory", KnowbaseCategoryID: 9}}, nil +} + +type fakeStore struct{ docs []model.KnowledgeDoc } + +func (f *fakeStore) ReplaceExternalSource(_ context.Context, _ string, d []model.KnowledgeDoc) error { + f.docs = append([]model.KnowledgeDoc(nil), d...) + return nil +} +func (f *fakeStore) Count() int { return len(f.docs) } + +func TestSyncMapsGLPIKBAndAutoReplyWhitelist(t *testing.T) { + cfg := config.Config{DataDir: t.TempDir(), GLPIKBEnabled: true, GLPIKBPath: "auto", GLPIKBLimit: 50, GLPIKBSyncInterval: time.Minute, GLPIKBSource: "glpi-kb", GLPIKBAutoReply: true, GLPIKBAutoReplyCategoryIDs: []int64{9}, CommunicationLanguage: "de-DE", CommunicationStyle: "formal", GLPITimeout: time.Second} + st := &fakeStore{} + m := metrics.New() + s := New(cfg, fakeSource{}, st, m) + if err := s.Sync(context.Background()); err != nil { + t.Fatal(err) + } + if len(st.docs) != 1 { + t.Fatalf("docs=%d", len(st.docs)) + } + d := st.docs[0] + if d.ID != "GLPI-KB-12" || !d.AutoReply || len(d.Categories) != 1 || d.Categories[0] != 2 || d.Text != "Bitte entsperren." { + t.Fatalf("unexpected doc: %+v", d) + } + if _, err := filepath.Abs(cfg.DataDir); err != nil { + t.Fatal(err) + } +} diff --git a/internal/knowledge/store.go b/internal/knowledge/store.go index e380c66..ffadf8e 100644 --- a/internal/knowledge/store.go +++ b/internal/knowledge/store.go @@ -27,6 +27,7 @@ type Store struct { docs []model.KnowledgeDoc files map[string]string managed map[string]bool + external map[string]string staticDocs map[string]model.KnowledgeDoc vectors map[string][]float64 embedder Embedder @@ -44,7 +45,7 @@ func Load(ctx context.Context, dir, dataDir string, embedder Embedder, rag bool, if err := os.MkdirAll(managedDir, 0o750); err != nil { return nil, fmt.Errorf("create managed knowledge directory: %w", err) } - s := &Store{dir: dir, managedDir: managedDir, vectors: map[string][]float64{}, files: map[string]string{}, managed: map[string]bool{}, staticDocs: map[string]model.KnowledgeDoc{}, embedder: embedder, rag: rag, cachePath: filepath.Join(dataDir, "embeddings.json"), allowedSources: map[string]struct{}{}} + s := &Store{dir: dir, managedDir: managedDir, vectors: map[string][]float64{}, files: map[string]string{}, managed: map[string]bool{}, external: map[string]string{}, staticDocs: map[string]model.KnowledgeDoc{}, embedder: embedder, rag: rag, cachePath: filepath.Join(dataDir, "embeddings.json"), allowedSources: map[string]struct{}{}} for _, source := range allowedSources { s.allowedSources[strings.ToLower(strings.TrimSpace(source))] = struct{}{} } @@ -195,7 +196,11 @@ func (s *Store) Upsert(ctx context.Context, d model.KnowledgeDoc) error { s.mu.RLock() _, exists := s.files[d.ID] isManaged := s.managed[d.ID] + externalSource := s.external[d.ID] s.mu.RUnlock() + if externalSource != "" { + return fmt.Errorf("externally synchronized knowledge entry %q from %q is read-only", d.ID, externalSource) + } if exists && !isManaged { return fmt.Errorf("static knowledge entry %q is read-only; use a new id for a managed entry", d.ID) } @@ -284,6 +289,169 @@ func (s *Store) Delete(id string) error { } func (s *Store) IsManaged(id string) bool { s.mu.RLock(); defer s.mu.RUnlock(); return s.managed[id] } +func (s *Store) Origin(id string) string { + if s == nil { + return "" + } + s.mu.RLock() + defer s.mu.RUnlock() + if s.managed[id] { + return "managed" + } + if src := s.external[id]; src != "" { + return src + } + if _, ok := s.staticDocs[id]; ok { + return "static" + } + return "" +} + +// ReplaceExternalSource atomically replaces all read-only documents imported +// from one connector source. Existing vectors are reused when the normalized +// document did not change, so periodic synchronization does not re-embed the +// whole GLPI knowledge base on every run. +func (s *Store) ReplaceExternalSource(ctx context.Context, source string, docs []model.KnowledgeDoc) error { + if s == nil { + return fmt.Errorf("knowledge store is not initialized") + } + source = strings.ToLower(strings.TrimSpace(source)) + if _, ok := s.allowedSources[source]; !ok { + return fmt.Errorf("source %q is not allowed", source) + } + + s.mu.RLock() + oldDocs := make(map[string]model.KnowledgeDoc, len(s.docs)) + oldVectors := make(map[string][]float64, len(s.vectors)) + for _, d := range s.docs { + oldDocs[d.ID] = d + } + for id, v := range s.vectors { + oldVectors[id] = append([]float64(nil), v...) + } + s.mu.RUnlock() + cached := cacheFile{Hashes: map[string]string{}, Vectors: map[string][]float64{}} + if b, err := os.ReadFile(s.cachePath); err == nil { + _ = json.Unmarshal(b, &cached) + } + + changed := make([]model.KnowledgeDoc, 0) + seen := map[string]struct{}{} + for i := range docs { + d := &docs[i] + d.ID = strings.TrimSpace(d.ID) + d.Title = strings.TrimSpace(d.Title) + d.Source = strings.ToLower(strings.TrimSpace(d.Source)) + if d.Source == "" { + d.Source = source + } + if d.Source != source { + return fmt.Errorf("external document %q has source %q, expected %q", d.ID, d.Source, source) + } + if d.ID == "" || d.Title == "" || !safeID(d.ID) { + return fmt.Errorf("invalid external knowledge document id/title") + } + if _, dup := seen[d.ID]; dup { + return fmt.Errorf("duplicate external knowledge id %q", d.ID) + } + seen[d.ID] = struct{}{} + h := hashDoc(*d) + old, ok := oldDocs[d.ID] + same := ok && hashDoc(old) == h && len(oldVectors[d.ID]) > 0 + if !same && cached.Hashes[d.ID] == h && len(cached.Vectors[d.ID]) > 0 { + oldVectors[d.ID] = append([]float64(nil), cached.Vectors[d.ID]...) + same = true + } + if !same { + changed = append(changed, *d) + } + } + newVectors := map[string][]float64{} + if s.rag && len(changed) > 0 { + if s.embedder == nil { + return fmt.Errorf("RAG is enabled but no embedding provider is configured") + } + texts := make([]string, len(changed)) + for i, d := range changed { + texts[i] = d.Title + "\n" + d.Text + "\n" + strings.Join(d.Keywords, " ") + } + vv, err := s.embedder.Embed(ctx, texts) + if err != nil { + return err + } + if len(vv) != len(changed) { + return fmt.Errorf("embedding provider returned %d vectors for %d documents", len(vv), len(changed)) + } + for i, d := range changed { + if len(vv[i]) == 0 { + return fmt.Errorf("embedding provider returned empty vector for %s", d.ID) + } + newVectors[d.ID] = vv[i] + } + } + + s.mu.Lock() + // Reject collisions with local/static documents. + for _, d := range docs { + if src := s.external[d.ID]; src == "" { + if _, exists := oldDocs[d.ID]; exists { + s.mu.Unlock() + return fmt.Errorf("external knowledge id %q collides with local knowledge", d.ID) + } + } else if src != source { + s.mu.Unlock() + return fmt.Errorf("external knowledge id %q belongs to source %q", d.ID, src) + } + } + rebuilt := make([]model.KnowledgeDoc, 0, len(s.docs)+len(docs)) + for _, d := range s.docs { + if s.external[d.ID] != source { + rebuilt = append(rebuilt, d) + } + } + for id, src := range s.external { + if src == source { + delete(s.external, id) + delete(s.vectors, id) + } + } + for _, d := range docs { + rebuilt = append(rebuilt, d) + s.external[d.ID] = source + if v := newVectors[d.ID]; len(v) > 0 { + s.vectors[d.ID] = v + } else if v := oldVectors[d.ID]; len(v) > 0 { + s.vectors[d.ID] = v + } + } + s.docs = rebuilt + s.mu.Unlock() + return s.persistVectorCache() +} + +func (s *Store) persistVectorCache() error { + if s == nil || !s.rag { + return nil + } + s.mu.RLock() + cf := cacheFile{Hashes: map[string]string{}, Vectors: map[string][]float64{}} + for _, d := range s.docs { + if v := s.vectors[d.ID]; len(v) > 0 { + cf.Hashes[d.ID] = hashDoc(d) + cf.Vectors[d.ID] = append([]float64(nil), v...) + } + } + s.mu.RUnlock() + b, err := json.MarshalIndent(cf, "", " ") + if err != nil { + return err + } + tmp := s.cachePath + ".tmp" + if err := os.WriteFile(tmp, b, 0o640); err != nil { + return err + } + return os.Rename(tmp, s.cachePath) +} func (s *Store) ManagedDir() string { if s == nil { return "" diff --git a/internal/knowledge/store_test.go b/internal/knowledge/store_test.go index 2649967..c88c6f3 100644 --- a/internal/knowledge/store_test.go +++ b/internal/knowledge/store_test.go @@ -113,3 +113,22 @@ func TestUpsertDelete(t *testing.T) { t.Fatalf("count=%d", s.Count()) } } + +func TestExternalKnowledgeIsReadOnly(t *testing.T) { + dir := t.TempDir() + data := t.TempDir() + s, err := Load(context.Background(), dir, data, nil, false, []string{"internal-kb", "glpi-kb"}) + if err != nil { + t.Fatal(err) + } + d := model.KnowledgeDoc{ID: "GLPI-KB-1", Title: "Extern", Text: "Wissen", Source: "glpi-kb", Language: "de-DE", CommunicationStyle: "formal"} + if err := s.ReplaceExternalSource(context.Background(), "glpi-kb", []model.KnowledgeDoc{d}); err != nil { + t.Fatal(err) + } + if s.Count() != 1 || s.Origin("GLPI-KB-1") != "glpi-kb" { + t.Fatalf("unexpected external store state") + } + if err := s.Upsert(context.Background(), d); err == nil { + t.Fatal("expected external document to be read-only") + } +} diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go index 2f17cc4..7e5f7fd 100644 --- a/internal/metrics/metrics.go +++ b/internal/metrics/metrics.go @@ -25,6 +25,10 @@ type Metrics struct { glpiOK bool ollamaOK bool knowledgeDocs int + glpiKBOK bool + glpiKBDocs int + glpiKBLastSync time.Time + glpiKBLastError string } func New() *Metrics { return &Metrics{Started: time.Now()} } @@ -43,6 +47,19 @@ func (m *Metrics) Health() (bool, bool) { } func (m *Metrics) SetKnowledgeDocs(n int) { m.mu.Lock(); m.knowledgeDocs = n; m.mu.Unlock() } func (m *Metrics) KnowledgeDocs() int { m.mu.RLock(); defer m.mu.RUnlock(); return m.knowledgeDocs } +func (m *Metrics) SetGLPIKBStatus(ok bool, docs int, lastSync time.Time, lastErr string) { + m.mu.Lock() + m.glpiKBOK = ok + m.glpiKBDocs = docs + m.glpiKBLastSync = lastSync + m.glpiKBLastError = lastErr + m.mu.Unlock() +} +func (m *Metrics) GLPIKBStatus() (bool, int, time.Time, string) { + m.mu.RLock() + defer m.mu.RUnlock() + return m.glpiKBOK, m.glpiKBDocs, m.glpiKBLastSync, m.glpiKBLastError +} func (m *Metrics) WritePrometheus(w io.Writer) { g, o := m.Health() @@ -63,4 +80,7 @@ func (m *Metrics) WritePrometheus(w io.Writer) { fmt.Fprintf(w, "# TYPE glpi_agent_glpi_up gauge\nglpi_agent_glpi_up %d\n", boolf(g)) fmt.Fprintf(w, "# TYPE glpi_agent_ollama_up gauge\nglpi_agent_ollama_up %d\n", boolf(o)) fmt.Fprintf(w, "# TYPE glpi_agent_knowledge_documents gauge\nglpi_agent_knowledge_documents %d\n", m.KnowledgeDocs()) + kbOK, kbDocs, _, _ := m.GLPIKBStatus() + fmt.Fprintf(w, "# TYPE glpi_agent_glpi_kb_up gauge\nglpi_agent_glpi_kb_up %d\n", boolf(kbOK)) + fmt.Fprintf(w, "# TYPE glpi_agent_glpi_kb_documents gauge\nglpi_agent_glpi_kb_documents %d\n", kbDocs) } diff --git a/internal/model/model.go b/internal/model/model.go index 9a799d5..fc96d24 100644 --- a/internal/model/model.go +++ b/internal/model/model.go @@ -28,11 +28,14 @@ type Followup struct { } type Category struct { - ID int64 `json:"id"` - Name string `json:"name"` - CompleteName string `json:"completename"` - Hints []string `json:"hints,omitempty"` - Examples []string `json:"confirmed_examples,omitempty"` + ID int64 `json:"id"` + Name string `json:"name"` + CompleteName string `json:"completename"` + // KnowbaseCategoryID is the GLPI knowledge-base category associated with + // this ITIL category, when the installed GLPI exposes that relation. + KnowbaseCategoryID int64 `json:"knowbase_category_id,omitempty"` + Hints []string `json:"hints,omitempty"` + Examples []string `json:"confirmed_examples,omitempty"` } type LearningExample struct { @@ -61,10 +64,23 @@ type KnowledgeDoc struct { Keywords []string `json:"keywords"` Source string `json:"source"` SourceURI string `json:"source_uri,omitempty"` + SourceCategoryIDs []int64 `json:"source_category_ids,omitempty"` + SourceModifiedAt string `json:"source_modified_at,omitempty"` Language string `json:"language"` CommunicationStyle string `json:"communication_style"` } +// GLPIKnowledgeItem is the normalized read-only representation returned by +// the GLPI connector before it is converted into a KnowledgeDoc. +type GLPIKnowledgeItem struct { + ID int64 `json:"id"` + Title string `json:"title"` + Content string `json:"content"` + CategoryIDs []int64 `json:"category_ids,omitempty"` + Language string `json:"language,omitempty"` + ModifiedAt string `json:"modified_at,omitempty"` +} + type KnowledgeHit struct { Doc KnowledgeDoc `json:"doc"` Score float64 `json:"score"` diff --git a/internal/web/server.go b/internal/web/server.go index b2f1da7..30112b9 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -32,6 +32,7 @@ type KnowledgeManager interface { Upsert(context.Context, model.KnowledgeDoc) error Delete(string) error IsManaged(string) bool + Origin(string) string } type FeedbackManager interface { Categories(context.Context) ([]model.Category, error) @@ -102,6 +103,7 @@ func (s *Server) dashboard(w http.ResponseWriter, r *http.Request) { } func (s *Server) status(w http.ResponseWriter, r *http.Request) { g, o := s.metrics.Health() + kbOK, kbDocs, kbLastSync, kbLastErr := s.metrics.GLPIKBStatus() respondJSON(w, map[string]any{ "uptime_seconds": int(time.Since(s.metrics.Started).Seconds()), "dry_run": s.cfg.DryRun, "auto_reply": s.cfg.AutoReply, "auto_category": s.cfg.AutoCategory, "processed": s.metrics.Processed.Load(), "skipped": s.metrics.Skipped.Load(), "errors": s.metrics.Errors.Load(), "category_changes": s.metrics.CategoryChanged.Load(), "replies": s.metrics.Replies.Load(), "queue_depth": s.q.Len(), @@ -111,6 +113,7 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request) { "context_enabled": s.cfg.ContextEnabled, "context_fetches": s.metrics.ContextFetches.Load(), "context_errors": s.metrics.ContextErrors.Load(), "change_calendar_enabled": s.cfg.ChangeCalendarEnabled, "major_incidents_enabled": s.cfg.MajorIncidentsEnabled, "user_device_context_enabled": s.cfg.UserDeviceContextEnabled, "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, }) } @@ -144,12 +147,13 @@ func (s *Server) categories(w http.ResponseWriter, r *http.Request) { func (s *Server) knowledgeList(w http.ResponseWriter, r *http.Request) { type view struct { model.KnowledgeDoc - Managed bool `json:"managed"` + Managed bool `json:"managed"` + Origin string `json:"origin"` } docs := s.knowledge.List() out := make([]view, 0, len(docs)) for _, d := range docs { - out = append(out, view{KnowledgeDoc: d, Managed: s.knowledge.IsManaged(d.ID)}) + out = append(out, view{KnowledgeDoc: d, Managed: s.knowledge.IsManaged(d.ID), Origin: s.knowledge.Origin(d.ID)}) } respondJSON(w, out) } diff --git a/internal/web/templates/dashboard.html b/internal/web/templates/dashboard.html index b2d76c2..622091c 100644 --- a/internal/web/templates/dashboard.html +++ b/internal/web/templates/dashboard.html @@ -92,9 +92,9 @@ function replyDecision(x){ return `
Lösungsvorschlag KI: ${esc(ai)}
Reply-Schwellwert ${esc(threshold)}${x.ai_knowledge_id?` · KB ${esc(x.ai_knowledge_id)}`:''}
${esc(label)} ${esc(detail)}
${knowledge}
`; } function renderRuns(r){runsData=r;document.querySelector('#runs').innerHTML=r.length?r.map(x=>{const aiReason=x.ai_reason||x.reason||'–';const policy=x.policy_reason||[x.category_decision,x.reply_decision].filter(Boolean).join('; ')||'–';return `${esc(new Date(x.finished_at).toLocaleString('de-DE'))}#${esc(x.ticket_id)} ${esc(x.ticket_name)}${esc(x.outcome)}${x.dry_run?'
Dry Run
':''}${categoryDecision(x)}${replyDecision(x)}C:${esc(x.context_changes||0)} I:${esc(x.context_incidents||0)} U:${esc(x.context_issues||0)} D:${esc(x.context_devices||0)}${(x.context_warnings||[]).length?' ⚠':''}
KI: ${esc(aiReason)}
Policy: ${esc(policy)}
${x.error?`
Fehler: ${esc(x.error)}
`:''}`}).join(''):'Noch keine Verarbeitung.'} -function renderKB(){document.querySelector('#kbRows').innerHTML=kbDocs.length?kbDocs.map(d=>`
${esc(d.id)}
${esc(d.title)}${esc(d.source)}
${d.managed?'Web-verwaltet':'statisch / read-only'}
${d.auto_reply?'ja':'nein'}${esc((d.categories||[]).join(', ')||'alle')}
${d.managed?``:'über Git/Datei verwalten'}
`).join(''):'Noch keine Knowledge-Einträge.'} +function renderKB(){document.querySelector('#kbRows').innerHTML=kbDocs.length?kbDocs.map(d=>`
${esc(d.id)}
${esc(d.title)}${esc(d.source)}
${d.managed?'Web-verwaltet':(d.origin&&d.origin!=='static'?`${d.origin} / read-only`:'statisch / read-only')}
${d.auto_reply?'ja':'nein'}${esc((d.categories||[]).join(', ')||'alle')} ${(d.source_category_ids||[]).length?`
GLPI-KB-Kategorien: ${esc(d.source_category_ids.join(', '))}
`:''}
${d.managed?``:'über Git/Datei verwalten'}
`).join(''):'Noch keine Knowledge-Einträge.'} function renderLearning(rows){document.querySelector('#learningRows').innerHTML=rows.length?rows.map(x=>`#${esc(x.ticket_id)} ${esc(x.subject)}
${esc((x.text||'').slice(0,180))}
${esc(x.category_name)} (#${esc(x.category_id)})
${x.correction?'Korrektur':'Bestätigung'}${x.ai_recommended_category_id?` · KI #${esc(x.ai_recommended_category_id)} ${esc(pct(x.ai_confidence))}`:''}
${esc(new Date(x.created_at).toLocaleString('de-DE'))}`).join(''):'Noch keine bestätigten Beispiele.'} -async function refresh(){try{const [s,r,c,k,l]=await Promise.all([api('/api/status'),api('/api/runs?limit=50'),api('/api/categories'),api('/api/knowledge'),api('/api/learning')]);categories=c;kbDocs=k;const srcSel=document.querySelector('#kbSource');const oldSource=srcSel.value;srcSel.innerHTML=(s.knowledge_allowed_sources||[]).map(x=>``).join('');if(oldSource&&[...srcSel.options].some(o=>o.value===oldSource))srcSel.value=oldSource;else if([...srcSel.options].some(o=>o.value==='internal-kb'))srcSel.value='internal-kb';const kbSel=document.querySelector('#kbCategories');const selected=new Set([...kbSel.selectedOptions].map(o=>Number(o.value)));kbSel.innerHTML=categories.map(x=>``).join('');[...kbSel.options].forEach(o=>o.selected=selected.has(Number(o.value)));const cards=[['GLPI',s.glpi_ok?'OK':'Fehler',s.glpi_ok],['Ollama',s.ollama_ok?'OK':'Fehler',s.ollama_ok],['Verarbeitet',s.processed,true],['Fehler',s.errors,s.errors===0],['Knowledge',s.knowledge_docs,true],['Lernbeispiele',s.learning_examples,true],['Kategorie-Schwelle',pct(s.category_confidence),true],['Reply-Schwelle',pct(s.reply_confidence),true],['Sprache',s.communication_language,true],['Stil',s.communication_style,true],['KB-Editor',s.knowledge_edit_enabled?'aktiv':'aus',s.knowledge_edit_enabled],['Uptime Kuma',s.uptime_kuma_enabled?'an':'aus',true]];document.querySelector('#cards').innerHTML=cards.map(c=>`
${esc(c[0])}
${esc(c[1])}
`).join('');document.querySelector('#kbForm').querySelectorAll('input,textarea,select,button').forEach(x=>x.disabled=!s.knowledge_edit_enabled);const kd=document.querySelector('#kbDisabled');if(!s.knowledge_edit_enabled){kd.textContent='KB-Bearbeitung ist deaktiviert. Setzen Sie KNOWLEDGE_WEB_EDIT_ENABLED=true (nur mit authentifiziertem Dashboard).';kd.className='msg show'}else{kd.className='msg'}renderRuns(r);renderKB();renderLearning(l)}catch(e){msg(e.message,'err')}} +async function refresh(){try{const [s,r,c,k,l]=await Promise.all([api('/api/status'),api('/api/runs?limit=50'),api('/api/categories'),api('/api/knowledge'),api('/api/learning')]);categories=c;kbDocs=k;const srcSel=document.querySelector('#kbSource');const oldSource=srcSel.value;srcSel.innerHTML=(s.knowledge_allowed_sources||[]).map(x=>``).join('');if(oldSource&&[...srcSel.options].some(o=>o.value===oldSource))srcSel.value=oldSource;else if([...srcSel.options].some(o=>o.value==='internal-kb'))srcSel.value='internal-kb';const kbSel=document.querySelector('#kbCategories');const selected=new Set([...kbSel.selectedOptions].map(o=>Number(o.value)));kbSel.innerHTML=categories.map(x=>``).join('');[...kbSel.options].forEach(o=>o.selected=selected.has(Number(o.value)));const cards=[['GLPI',s.glpi_ok?'OK':'Fehler',s.glpi_ok],['Ollama',s.ollama_ok?'OK':'Fehler',s.ollama_ok],['Verarbeitet',s.processed,true],['Fehler',s.errors,s.errors===0],['Knowledge',s.knowledge_docs,true],['Lernbeispiele',s.learning_examples,true],['Kategorie-Schwelle',pct(s.category_confidence),true],['Reply-Schwelle',pct(s.reply_confidence),true],['Sprache',s.communication_language,true],['Stil',s.communication_style,true],['KB-Editor',s.knowledge_edit_enabled?'aktiv':'aus',s.knowledge_edit_enabled],['GLPI-KB',s.glpi_kb_enabled?(s.glpi_kb_ok?`${s.glpi_kb_documents} synchronisiert`:'Fehler'):'aus',!s.glpi_kb_enabled||s.glpi_kb_ok],['Uptime Kuma',s.uptime_kuma_enabled?'an':'aus',true]];document.querySelector('#cards').innerHTML=cards.map(c=>`
${esc(c[0])}
${esc(c[1])}
`).join('');document.querySelector('#kbForm').querySelectorAll('input,textarea,select,button').forEach(x=>x.disabled=!s.knowledge_edit_enabled);const kd=document.querySelector('#kbDisabled');if(!s.knowledge_edit_enabled){kd.textContent='KB-Bearbeitung ist deaktiviert. Setzen Sie KNOWLEDGE_WEB_EDIT_ENABLED=true (nur mit authentifiziertem Dashboard).';kd.className='msg show'}else{kd.className='msg'}renderRuns(r);renderKB();renderLearning(l)}catch(e){msg(e.message,'err')}} function resetKBForm(){document.querySelector('#kbForm').reset();if([...document.querySelector('#kbSource').options].some(o=>o.value==='internal-kb'))document.querySelector('#kbSource').value='internal-kb';document.querySelector('#kbLanguage').value='de-DE';document.querySelector('#kbStyle').value='formal';document.querySelector('#kbScore').value='0.88';document.querySelector('#kbFormTitle').textContent='Knowledge-Eintrag anlegen'} function editKB(id){const d=kbDocs.find(x=>x.id===id);if(!d)return;document.querySelector('#kbFormTitle').textContent=`Knowledge-Eintrag bearbeiten: ${id}`;document.querySelector('#kbId').value=d.id||'';document.querySelector('#kbTitle').value=d.title||'';document.querySelector('#kbSource').value=d.source||'internal-kb';document.querySelector('#kbLanguage').value=d.language||'de-DE';document.querySelector('#kbStyle').value=d.communication_style||'formal';document.querySelector('#kbScore').value=d.min_score??0.88;[...document.querySelector('#kbCategories').options].forEach(o=>o.selected=(d.categories||[]).includes(Number(o.value)));document.querySelector('#kbKeywords').value=(d.keywords||[]).join(', ');document.querySelector('#kbUri').value=d.source_uri||'';document.querySelector('#kbText').value=d.text||'';document.querySelector('#kbAnswer').value=d.answer||'';document.querySelector('#kbAutoReply').checked=!!d.auto_reply;document.querySelector('#kbForm').scrollIntoView({behavior:'smooth'})} document.querySelector('#kbForm').addEventListener('submit',async e=>{e.preventDefault();const strs=v=>v.split(',').map(x=>x.trim()).filter(Boolean);const selectedCats=[...document.querySelector('#kbCategories').selectedOptions].map(o=>Number(o.value));const d={id:document.querySelector('#kbId').value.trim(),title:document.querySelector('#kbTitle').value.trim(),source:document.querySelector('#kbSource').value.trim(),language:document.querySelector('#kbLanguage').value.trim(),communication_style:document.querySelector('#kbStyle').value,text:document.querySelector('#kbText').value.trim(),answer:document.querySelector('#kbAnswer').value.trim(),auto_reply:document.querySelector('#kbAutoReply').checked,min_score:Number(document.querySelector('#kbScore').value||0),categories:selectedCats,keywords:strs(document.querySelector('#kbKeywords').value),source_uri:document.querySelector('#kbUri').value.trim()};try{await api('/api/knowledge',{method:'POST',body:JSON.stringify(d)});msg('Knowledge-Eintrag gespeichert.','good');resetKBForm();await refresh()}catch(e){msg(e.message,'err')}})