Files
2026-08-05 19:13:45 +02:00

442 lines
14 KiB
Go

package glpikb
import (
"context"
"crypto/sha256"
"encoding/hex"
"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
}
const cachePolicyVersion = 2
type cacheFile struct {
PolicyVersion int `json:"policy_version"`
ApprovalHash string `json:"approval_hash"`
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 {
if len(cfg.GLPIKBAutoReplyITILCategoryIDs) > 0 {
slog.Warn(
"GLPI_KB_AUTO_REPLY_ITIL_CATEGORY_IDS is deprecated and ignored",
"ids", cfg.GLPIKBAutoReplyITILCategoryIDs,
"hint", "approve categorized articles with GLPI_KB_AUTO_REPLY_CATEGORY_IDS or uncategorized articles with GLPI_KB_AUTO_REPLY_UNCATEGORIZED_ARTICLE_IDS",
)
}
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 cf.PolicyVersion != cachePolicyVersion {
slog.Warn(
"ignoring GLPI knowledge cache created with an older Auto-Reply policy",
"path", s.cachePath,
"cache_policy_version", cf.PolicyVersion,
"required_policy_version", cachePolicyVersion,
)
return nil
}
currentApprovalHash := approvalConfigHash(s.cfg)
if cf.ApprovalHash == "" || cf.ApprovalHash != currentApprovalHash {
slog.Warn(
"ignoring GLPI knowledge cache because Auto-Reply approval configuration changed",
"path", s.cachePath,
)
return nil
}
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 {
slog.Warn(
"GLPI ITIL categories unavailable for optional KB relevance mapping",
"error", err,
"impact", "KB synchronization and Auto-Reply approval continue using GLPI knowledge-base categories",
)
cats = nil
}
docs := s.normalize(items, cats)
autoReplyApproved, autoReplyBlocked := autoReplyCounts(docs)
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{PolicyVersion: cachePolicyVersion, ApprovalHash: approvalConfigHash(s.cfg), 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),
"auto_reply_approved", autoReplyApproved,
"auto_reply_blocked", autoReplyBlocked,
"path", path,
)
return nil
}
func approvalConfigHash(cfg config.Config) string {
kbIDs := append([]int64(nil), cfg.GLPIKBAutoReplyCategoryIDs...)
articleIDs := append([]int64(nil), cfg.GLPIKBAutoReplyUncategorizedArticleIDs...)
sort.Slice(kbIDs, func(i, j int) bool { return kbIDs[i] < kbIDs[j] })
sort.Slice(articleIDs, func(i, j int) bool { return articleIDs[i] < articleIDs[j] })
payload, _ := json.Marshal(struct {
Enabled bool `json:"enabled"`
Source string `json:"source"`
KBCategoryIDs []int64 `json:"kb_category_ids"`
AllowUncategorized bool `json:"allow_uncategorized"`
ArticleIDs []int64 `json:"article_ids"`
}{
Enabled: cfg.GLPIKBAutoReply,
Source: strings.ToLower(strings.TrimSpace(cfg.GLPIKBSource)),
KBCategoryIDs: kbIDs,
AllowUncategorized: cfg.GLPIKBAutoReplyAllowUncategorized,
ArticleIDs: articleIDs,
})
sum := sha256.Sum256(payload)
return hex.EncodeToString(sum[:])
}
func autoReplyCounts(docs []model.KnowledgeDoc) (approved, blocked int) {
for _, doc := range docs {
if doc.AutoReply {
approved++
} else {
blocked++
}
}
return approved, blocked
}
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{}
itilCategoryIDs := map[int64]struct{}{}
for _, c := range cats {
if c.ID > 0 {
itilCategoryIDs[c.ID] = struct{}{}
}
if c.KnowbaseCategoryID > 0 {
kbToITIL[c.KnowbaseCategoryID] = append(kbToITIL[c.KnowbaseCategoryID], c)
}
}
autoKBCats := map[int64]struct{}{}
for _, id := range s.cfg.GLPIKBAutoReplyCategoryIDs {
autoKBCats[id] = struct{}{}
}
autoUncategorizedArticles := map[int64]struct{}{}
for _, id := range s.cfg.GLPIKBAutoReplyUncategorizedArticleIDs {
autoUncategorizedArticles[id] = struct{}{}
}
warnLikelyITILIDs(s.cfg.GLPIKBAutoReply, s.cfg.GLPIKBAutoReplyCategoryIDs, items, itilCategoryIDs)
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, autoDecision, autoDetail := autoReplyApproval(
s.cfg.GLPIKBAutoReply,
s.cfg.GLPIKBAutoReplyAllowUncategorized,
item.ID,
item.CategoryIDs,
itilIDs,
autoKBCats,
autoUncategorizedArticles,
)
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, AutoReplyDecision: autoDecision, AutoReplyDetail: autoDetail,
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 autoReplyApproval(enabled, allowUncategorized bool, articleID int64, sourceCategoryIDs, itilCategoryIDs []int64, allowedSourceCategories, allowedUncategorizedArticles map[int64]struct{}) (bool, string, string) {
detail := fmt.Sprintf(
"GLPI-Artikel-ID: %d; GLPI-KB-Kategorien: %v; gemappte ITIL-Kategorien (nur fachliches Signal): %v; freigegebene GLPI-KB-Kategorien: %v; freigegebene kategorielose Artikel: %v",
articleID,
sourceCategoryIDs,
itilCategoryIDs,
sortedSetIDs(allowedSourceCategories),
sortedSetIDs(allowedUncategorizedArticles),
)
if !enabled {
return false, "glpi_kb_auto_reply_disabled", detail + "; GLPI_KB_AUTO_REPLY=false"
}
if len(sourceCategoryIDs) == 0 {
if !allowUncategorized {
return false, "glpi_kb_article_without_category", detail + "; der Artikel besitzt keine GLPI-KB-Kategorie und GLPI_KB_AUTO_REPLY_ALLOW_UNCATEGORIZED=false"
}
if _, ok := allowedUncategorizedArticles[articleID]; !ok {
return false, "glpi_kb_uncategorized_article_not_whitelisted", detail + fmt.Sprintf("; GLPI-KB-Artikel #%d ist nicht in GLPI_KB_AUTO_REPLY_UNCATEGORIZED_ARTICLE_IDS freigegeben", articleID)
}
return true, "glpi_kb_uncategorized_article_approved", detail + "; Freigabe über die konkrete GLPI-Artikel-ID; die fachliche Eignung für das Ticket wird separat geprüft"
}
if intersectsSet(sourceCategoryIDs, allowedSourceCategories) {
return true, "glpi_kb_auto_reply_approved", detail + "; Freigabe über eine GLPI-Knowledge-Base-Kategorie"
}
if len(allowedSourceCategories) == 0 {
return false, "glpi_kb_auto_reply_whitelist_empty", detail + "; keine GLPI-Knowledge-Base-Kategorie für Auto-Reply freigegeben"
}
return false, "glpi_kb_category_not_whitelisted", detail + "; keine Artikel-Knowledge-Base-Kategorie ist in GLPI_KB_AUTO_REPLY_CATEGORY_IDS freigegeben"
}
func intersectsSet(ids []int64, allowed map[int64]struct{}) bool {
for _, id := range ids {
if _, ok := allowed[id]; ok {
return true
}
}
return false
}
func sortedSetIDs(values map[int64]struct{}) []int64 {
out := make([]int64, 0, len(values))
for id := range values {
out = append(out, id)
}
sort.Slice(out, func(i, j int) bool { return out[i] < out[j] })
return out
}
func warnLikelyITILIDs(enabled bool, configured []int64, items []model.GLPIKnowledgeItem, itilCategoryIDs map[int64]struct{}) {
if !enabled || len(configured) == 0 {
return
}
sourceIDs := map[int64]struct{}{}
for _, item := range items {
for _, id := range item.CategoryIDs {
sourceIDs[id] = struct{}{}
}
}
var suspicious []int64
for _, id := range configured {
_, existsAsSource := sourceIDs[id]
_, existsAsITIL := itilCategoryIDs[id]
if !existsAsSource && existsAsITIL {
suspicious = append(suspicious, id)
}
}
if len(suspicious) > 0 {
sort.Slice(suspicious, func(i, j int) bool { return suspicious[i] < suspicious[j] })
slog.Warn(
"GLPI_KB_AUTO_REPLY_CATEGORY_IDS appears to contain ITIL/ticket category IDs",
"ids", suspicious,
"hint", "configure the actual GLPI knowledge-base category IDs; ITIL/ticket category IDs do not release articles for Auto-Reply",
)
}
}
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
}