This commit is contained in:
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user