Update GLPI-Knowledge
release-tag / release-image (push) Successful in 1m35s

This commit is contained in:
2026-07-28 14:35:03 +02:00
parent 8ac1f97174
commit 4cfdac042d
18 changed files with 992 additions and 29 deletions
+77 -7
View File
@@ -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 {
+35
View File
@@ -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)
}
}
+168 -1
View File
@@ -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 {
+36
View File
@@ -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": "<p>Konto entsperren</p>", "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)
}
}
+284
View File
@@ -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, "<br>", "\n")
v = strings.ReplaceAll(v, "<br/>", "\n")
v = strings.ReplaceAll(v, "<br />", "\n")
v = strings.ReplaceAll(v, "</p>", "\n")
v = strings.ReplaceAll(v, "</li>", "\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
}
+52
View File
@@ -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: "<p>Bitte entsperren.</p>", 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)
}
}
+169 -1
View File
@@ -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 ""
+19
View File
@@ -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")
}
}
+20
View File
@@ -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)
}
+21 -5
View File
@@ -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"`
+6 -2
View File
@@ -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)
}
+2 -2
View File
@@ -92,9 +92,9 @@ function replyDecision(x){
return `<div class="decision"><div><strong>Lösungsvorschlag KI:</strong> ${esc(ai)}</div><div class="sub">Reply-Schwellwert ${esc(threshold)}${x.ai_knowledge_id?` · KB ${esc(x.ai_knowledge_id)}`:''}</div><div style="margin-top:6px"><span class="pill ${cls}">${esc(label)}</span> <span class="sub">${esc(detail)}</span></div>${knowledge}</div>`;
}
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 `<tr><td>${esc(new Date(x.finished_at).toLocaleString('de-DE'))}</td><td>#${esc(x.ticket_id)} ${esc(x.ticket_name)}</td><td><span class="pill">${esc(x.outcome)}</span>${x.dry_run?'<div class="sub">Dry Run</div>':''}</td><td>${categoryDecision(x)}</td><td>${replyDecision(x)}</td><td class="hide-md">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?' ⚠':''}</td><td class="hide-sm reason"><div><strong>KI:</strong> ${esc(aiReason)}</div><div class="policy"><strong>Policy:</strong> ${esc(policy)}</div>${x.error?`<div class="bad"><strong>Fehler:</strong> ${esc(x.error)}</div>`:''}</td></tr>`}).join(''):'<tr><td colspan="7">Noch keine Verarbeitung.</td></tr>'}
function renderKB(){document.querySelector('#kbRows').innerHTML=kbDocs.length?kbDocs.map(d=>`<tr><td><div class="kb-title">${esc(d.id)}</div>${esc(d.title)}</td><td>${esc(d.source)}<div class="sub">${d.managed?'Web-verwaltet':'statisch / read-only'}</div></td><td>${d.auto_reply?'<span class="pill pill-ok">ja</span>':'<span class="pill">nein</span>'}</td><td>${esc((d.categories||[]).join(', ')||'alle')}</td><td><div class="actions">${d.managed?`<button onclick="editKB('${esc(d.id)}')">Bearbeiten</button><button class="danger" onclick="deleteKB('${esc(d.id)}')">Löschen</button>`:'<span class="sub">über Git/Datei verwalten</span>'}</div></td></tr>`).join(''):'<tr><td colspan="5">Noch keine Knowledge-Einträge.</td></tr>'}
function renderKB(){document.querySelector('#kbRows').innerHTML=kbDocs.length?kbDocs.map(d=>`<tr><td><div class="kb-title">${esc(d.id)}</div>${esc(d.title)}</td><td>${esc(d.source)}<div class="sub">${d.managed?'Web-verwaltet':(d.origin&&d.origin!=='static'?`${d.origin} / read-only`:'statisch / read-only')}</div></td><td>${d.auto_reply?'<span class="pill pill-ok">ja</span>':'<span class="pill">nein</span>'}</td><td>${esc((d.categories||[]).join(', ')||'alle')} ${(d.source_category_ids||[]).length?`<div class="sub">GLPI-KB-Kategorien: ${esc(d.source_category_ids.join(', '))}</div>`:''}</td><td><div class="actions">${d.managed?`<button onclick="editKB('${esc(d.id)}')">Bearbeiten</button><button class="danger" onclick="deleteKB('${esc(d.id)}')">Löschen</button>`:'<span class="sub">über Git/Datei verwalten</span>'}</div></td></tr>`).join(''):'<tr><td colspan="5">Noch keine Knowledge-Einträge.</td></tr>'}
function renderLearning(rows){document.querySelector('#learningRows').innerHTML=rows.length?rows.map(x=>`<tr><td><strong>#${esc(x.ticket_id)} ${esc(x.subject)}</strong><div class="sub">${esc((x.text||'').slice(0,180))}</div></td><td>${esc(x.category_name)} (#${esc(x.category_id)})<div class="sub">${x.correction?'Korrektur':'Bestätigung'}${x.ai_recommended_category_id?` · KI #${esc(x.ai_recommended_category_id)} ${esc(pct(x.ai_confidence))}`:''}</div></td><td>${esc(new Date(x.created_at).toLocaleString('de-DE'))}</td><td><button class="danger" onclick="deleteLearning('${esc(x.id)}')">Löschen</button></td></tr>`).join(''):'<tr><td colspan="4">Noch keine bestätigten Beispiele.</td></tr>'}
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=>`<option value="${esc(x)}">${esc(x)}</option>`).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=>`<option value="${Number(x.id)}">${esc(x.completename||x.name)} (#${Number(x.id)})</option>`).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=>`<div class="card"><div class="k">${esc(c[0])}</div><div class="v ${c[2]?'ok':'bad'}">${esc(c[1])}</div></div>`).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=>`<option value="${esc(x)}">${esc(x)}</option>`).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=>`<option value="${Number(x.id)}">${esc(x.completename||x.name)} (#${Number(x.id)})</option>`).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=>`<div class="card"><div class="k">${esc(c[0])}</div><div class="v ${c[2]?'ok':'bad'}">${esc(c[1])}</div></div>`).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')}})