285 lines
7.6 KiB
Go
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
|
|
}
|