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, "
", "\n") v = strings.ReplaceAll(v, "
", "\n") v = strings.ReplaceAll(v, "
", "\n") v = strings.ReplaceAll(v, "

", "\n") v = strings.ReplaceAll(v, "", "\n") v = tagRE.ReplaceAllString(v, " ") v = stdhtml.UnescapeString(v) return strings.TrimSpace(spaceRE.ReplaceAllString(v, " ")) } func uniqueStrings(in []string) []string { seen := map[string]struct{}{} out := []string{} for _, v := range in { v = strings.TrimSpace(v) if v == "" { continue } k := strings.ToLower(v) if _, ok := seen[k]; ok { continue } seen[k] = struct{}{} out = append(out, v) } return out } func writeAtomicJSON(path string, v any) error { b, err := json.MarshalIndent(v, "", " ") if err != nil { return err } if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil { return err } tmp := path + ".tmp" if err := os.WriteFile(tmp, b, 0o640); err != nil { return err } return os.Rename(tmp, path) } func maxDuration(a, b time.Duration) time.Duration { if a > b { return a } return b }