Files
glpi-ai-agent/internal/knowledge/category_mapping.go
jbergner bca5c1335c
All checks were successful
release-tag / release-image (push) Successful in 1m39s
KB Kategorie-Mapping
2026-07-30 08:14:37 +02:00

333 lines
9.7 KiB
Go

package knowledge
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
// CategoryMappingEntry represents one external category label discovered in
// knowledge documents or configured in KNOWLEDGE_CATEGORY_MAP_FILE.
type CategoryMappingEntry struct {
Label string `json:"label"`
Normalized string `json:"normalized"`
UsageCount int `json:"usage_count"`
MappedIDs []int64 `json:"mapped_ids"`
Observed bool `json:"observed"`
Configured bool `json:"configured"`
}
// CategoryMappingState is the UI/API representation of the current external
// taxonomy -> GLPI ITIL category mapping.
type CategoryMappingState struct {
Configured bool `json:"configured"`
Path string `json:"path,omitempty"`
Mode string `json:"mode"`
Entries []CategoryMappingEntry `json:"entries"`
ObservedCount int `json:"observed_count"`
MappedObserved int `json:"mapped_observed"`
UnmappedObserved int `json:"unmapped_observed"`
OrphanMappings int `json:"orphan_mappings"`
LastAppliedAt time.Time `json:"last_applied_at,omitempty"`
}
// CategoryMappings returns all external category labels known from the active
// index plus entries that exist only in the configured mapping file.
func (s *Store) CategoryMappings() (CategoryMappingState, error) {
if s == nil {
return CategoryMappingState{}, fmt.Errorf("knowledge store is not initialized")
}
path := strings.TrimSpace(s.loadOptions.CategoryMapFile)
state := CategoryMappingState{Configured: path != "", Path: path, Mode: s.loadOptions.CategoryMode}
if path == "" {
return state, nil
}
configured, err := readCategoryMapDisplay(path)
if err != nil {
return state, err
}
type aggregate struct {
label string
count int
observed bool
configured bool
ids []int64
}
byKey := map[string]*aggregate{}
s.mu.RLock()
for _, d := range s.docs {
// KNOWLEDGE_CATEGORY_MAP_FILE applies to local JSON documents. Connector-
// backed documents (for example GLPI KB sync) already arrive normalized and
// must not be presented as editable mappings here.
if s.external[d.ID] != "" {
continue
}
seenInDoc := map[string]struct{}{}
for _, label := range d.ExternalCategories {
label = strings.TrimSpace(label)
if label == "" {
continue
}
key := normalizeCategoryLabel(label)
if _, dup := seenInDoc[key]; dup {
continue
}
seenInDoc[key] = struct{}{}
a := byKey[key]
if a == nil {
a = &aggregate{label: label}
byKey[key] = a
}
a.observed = true
a.count++
if len(a.ids) == 0 {
a.ids = append([]int64(nil), s.categoryMap[key]...)
}
}
}
s.mu.RUnlock()
for label, ids := range configured {
key := normalizeCategoryLabel(label)
a := byKey[key]
if a == nil {
a = &aggregate{label: label}
byKey[key] = a
}
// Preserve the spelling from the file for mappings that are not observed;
// for observed labels keep the spelling found in the KB corpus.
if !a.observed {
a.label = label
}
a.configured = true
a.ids = append([]int64(nil), ids...)
}
entries := make([]CategoryMappingEntry, 0, len(byKey))
for key, a := range byKey {
ids := uniqueInt64(a.ids)
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
entries = append(entries, CategoryMappingEntry{
Label: a.label, Normalized: key, UsageCount: a.count,
MappedIDs: ids, Observed: a.observed, Configured: a.configured,
})
if a.observed {
state.ObservedCount++
if len(ids) > 0 {
state.MappedObserved++
} else {
state.UnmappedObserved++
}
} else if a.configured {
state.OrphanMappings++
}
}
sort.Slice(entries, func(i, j int) bool {
if entries[i].Observed != entries[j].Observed {
return entries[i].Observed
}
if entries[i].UsageCount != entries[j].UsageCount {
return entries[i].UsageCount > entries[j].UsageCount
}
return strings.ToLower(entries[i].Label) < strings.ToLower(entries[j].Label)
})
state.Entries = entries
if info, err := os.Stat(path); err == nil {
state.LastAppliedAt = info.ModTime()
}
return state, nil
}
// SaveCategoryMappings atomically writes KNOWLEDGE_CATEGORY_MAP_FILE and then
// forces a metadata-only re-evaluation of all local KB files. Existing
// embeddings are reused because category mappings do not change embedding
// input text.
func (s *Store) SaveCategoryMappings(ctx context.Context, mappings map[string][]int64) error {
if s == nil {
return fmt.Errorf("knowledge store is not initialized")
}
s.categoryMapWriteMu.Lock()
defer s.categoryMapWriteMu.Unlock()
if strings.EqualFold(strings.TrimSpace(s.scoring.IndexMode), "readonly") {
return fmt.Errorf("category mappings cannot be changed while KNOWLEDGE_INDEX_MODE=readonly")
}
path := strings.TrimSpace(s.loadOptions.CategoryMapFile)
if path == "" {
return fmt.Errorf("KNOWLEDGE_CATEGORY_MAP_FILE is not configured")
}
clean := make(map[string][]int64, len(mappings))
display := make(map[string]string, len(mappings))
for label, ids := range mappings {
label = strings.TrimSpace(label)
if label == "" {
return fmt.Errorf("category mapping label must not be empty")
}
key := normalizeCategoryLabel(label)
ids = uniqueInt64(ids)
if len(ids) == 0 {
// An empty assignment means "remove mapping" and is intentionally not
// persisted. The external category remains visible as unmapped.
continue
}
for _, id := range ids {
if id <= 0 {
return fmt.Errorf("category mapping %q contains invalid GLPI id %d", label, id)
}
}
if prev, exists := clean[key]; exists {
clean[key] = uniqueInt64(append(prev, ids...))
} else {
clean[key] = append([]int64(nil), ids...)
display[key] = label
}
}
if strings.EqualFold(strings.TrimSpace(s.loadOptions.CategoryMode), "strict") {
s.mu.RLock()
missing := []string{}
for _, d := range s.docs {
if s.external[d.ID] != "" {
continue
}
for _, label := range d.ExternalCategories {
if len(clean[normalizeCategoryLabel(label)]) == 0 {
missing = appendUniqueString(missing, label)
}
}
}
s.mu.RUnlock()
if len(missing) > 0 {
sort.Strings(missing)
return fmt.Errorf("KNOWLEDGE_CATEGORY_MODE=strict requires mappings for all observed external categories; missing: %s", strings.Join(missing, ", "))
}
}
if err := os.MkdirAll(filepath.Dir(path), 0o750); err != nil {
return fmt.Errorf("create category mapping directory: %w", err)
}
if err := writeCategoryMapAtomic(path, clean, display); err != nil {
return err
}
// Coordinate with periodic delta scans: update the in-memory mapping and
// invalidate local manifest metadata under initMu, then let SyncLocal do the
// normal safe rebuild/reuse path.
s.initMu.Lock()
s.mu.Lock()
s.categoryMap = cloneCategoryMap(clean)
for key, rec := range s.manifest {
if strings.HasPrefix(key, "static/") || strings.HasPrefix(key, "managed/") {
rec.Size = -1
rec.ModTimeUnixNano = -1
rec.RawHash = ""
s.manifest[key] = rec
}
}
s.mu.Unlock()
s.initMu.Unlock()
if err := s.SyncLocal(ctx); err != nil {
return fmt.Errorf("category mapping saved but re-evaluation failed: %w", err)
}
return nil
}
func readCategoryMapDisplay(path string) (map[string][]int64, error) {
out := map[string][]int64{}
path = strings.TrimSpace(path)
if path == "" {
return out, nil
}
b, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return out, nil
}
return nil, fmt.Errorf("read knowledge category map %q: %w", path, err)
}
var root map[string]json.RawMessage
if err := json.Unmarshal(b, &root); err != nil {
return nil, fmt.Errorf("parse knowledge category map %q: %w", path, err)
}
if nested, ok := root["mappings"]; ok {
var m map[string]json.RawMessage
if err := json.Unmarshal(nested, &m); err != nil {
return nil, fmt.Errorf("parse mappings in %q: %w", path, err)
}
root = m
}
for label, rv := range root {
ids, err := parseMappingIDs(rv)
if err != nil {
return nil, fmt.Errorf("category mapping %q: %w", label, err)
}
if len(ids) == 0 {
return nil, fmt.Errorf("category mapping %q contains no positive GLPI ids", label)
}
out[strings.TrimSpace(label)] = uniqueInt64(ids)
}
return out, nil
}
func writeCategoryMapAtomic(path string, normalized map[string][]int64, display map[string]string) error {
keys := make([]string, 0, len(normalized))
for key := range normalized {
keys = append(keys, key)
}
sort.Slice(keys, func(i, j int) bool {
return strings.ToLower(display[keys[i]]) < strings.ToLower(display[keys[j]])
})
// Build deterministic, human-readable JSON. A single mapping is written as
// a number; multiple targets are written as an array. Both formats are
// already supported by the loader.
var b strings.Builder
b.WriteString("{\n")
for i, key := range keys {
labelJSON, _ := json.Marshal(display[key])
b.WriteString(" ")
b.Write(labelJSON)
b.WriteString(": ")
ids := append([]int64(nil), normalized[key]...)
sort.Slice(ids, func(i, j int) bool { return ids[i] < ids[j] })
if len(ids) == 1 {
fmt.Fprintf(&b, "%d", ids[0])
} else {
encoded, _ := json.Marshal(ids)
b.Write(encoded)
}
if i < len(keys)-1 {
b.WriteByte(',')
}
b.WriteByte('\n')
}
b.WriteString("}\n")
tmp := path + ".tmp"
if err := os.WriteFile(tmp, []byte(b.String()), 0o640); err != nil {
return fmt.Errorf("write category mapping temp file: %w", err)
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
return fmt.Errorf("replace category mapping file: %w", err)
}
return nil
}
func cloneCategoryMap(in map[string][]int64) map[string][]int64 {
out := make(map[string][]int64, len(in))
for key, ids := range in {
out[key] = append([]int64(nil), ids...)
}
return out
}