All checks were successful
release-tag / release-image (push) Successful in 1m39s
333 lines
9.7 KiB
Go
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
|
|
}
|