Update GLPI-Knowledge
All checks were successful
release-tag / release-image (push) Successful in 1m35s
All checks were successful
release-tag / release-image (push) Successful in 1m35s
This commit is contained in:
19
.env.example
19
.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
|
||||
|
||||
29
README.md
29
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
|
||||
|
||||
@@ -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.
|
||||
|
||||
17
UPGRADE.md
17
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.
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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: .
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
internal/glpikb/sync.go
Normal file
284
internal/glpikb/sync.go
Normal 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
internal/glpikb/sync_test.go
Normal file
52
internal/glpikb/sync_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -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 ""
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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')}})
|
||||
|
||||
Reference in New Issue
Block a user