Files
glpi-ai-agent/internal/glpikb/sync.go
T
jbergner 523e597e7a
release-tag / release-image (push) Has been cancelled
RC-2
2026-07-28 20:15:43 +02:00

285 lines
7.6 KiB
Go

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, AnswerHTML: strings.TrimSpace(item.Content),
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
}