442 lines
14 KiB
Go
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
|
|
}
|