Kategorie-Problem bei Typ String bezüglich Daten aus dem KB-System
This commit is contained in:
+282
-16
@@ -10,6 +10,7 @@ import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode"
|
||||
@@ -32,6 +33,21 @@ type ScoringConfig struct {
|
||||
ChunkOverlap int
|
||||
MaxChunksPerDoc int
|
||||
MaxQueryChunks int
|
||||
CategoryMode string
|
||||
CategoryMapFile string
|
||||
IgnoreGlobs []string
|
||||
}
|
||||
|
||||
type LoadOptions struct {
|
||||
CategoryMode string
|
||||
CategoryMapFile string
|
||||
IgnoreGlobs []string
|
||||
}
|
||||
|
||||
type LoadStats struct {
|
||||
IgnoredFiles int `json:"ignored_files"`
|
||||
UnmappedCategoryFiles int `json:"unmapped_category_files"`
|
||||
UnmappedCategories []string `json:"unmapped_categories,omitempty"`
|
||||
}
|
||||
|
||||
type Store struct {
|
||||
@@ -51,6 +67,9 @@ type Store struct {
|
||||
cachePath string
|
||||
allowedSources map[string]struct{}
|
||||
scoring ScoringConfig
|
||||
loadOptions LoadOptions
|
||||
loadStats LoadStats
|
||||
categoryMap map[string][]int64
|
||||
}
|
||||
type cacheFile struct {
|
||||
Version int `json:"version,omitempty"`
|
||||
@@ -108,22 +127,34 @@ func Load(ctx context.Context, dir, dataDir string, embedder Embedder, rag bool,
|
||||
if len(scoring) > 0 {
|
||||
scoreCfg = normalizeScoring(scoring[0])
|
||||
}
|
||||
s := &Store{dir: dir, managedDir: managedDir, titleVectors: map[string][]float64{}, chunkVectors: map[string][][]float64{}, chunks: map[string][]string{}, files: map[string]string{}, managed: map[string]bool{}, external: map[string]string{}, staticDocs: map[string]model.KnowledgeDoc{}, embedder: embedder, rag: rag, cachePath: filepath.Join(dataDir, "embeddings.json"), allowedSources: map[string]struct{}{}, scoring: scoreCfg}
|
||||
for _, source := range allowedSources {
|
||||
s.allowedSources[strings.ToLower(strings.TrimSpace(source))] = struct{}{}
|
||||
loadOpts := LoadOptions{CategoryMode: scoreCfg.CategoryMode, CategoryMapFile: scoreCfg.CategoryMapFile, IgnoreGlobs: scoreCfg.IgnoreGlobs}
|
||||
if strings.TrimSpace(loadOpts.CategoryMode) == "" {
|
||||
loadOpts.CategoryMode = "unscoped"
|
||||
}
|
||||
static, staticFiles, err := readDocs(dir, s.allowedSources)
|
||||
categoryMap, err := loadCategoryMap(loadOpts.CategoryMapFile)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s := &Store{dir: dir, managedDir: managedDir, titleVectors: map[string][]float64{}, chunkVectors: map[string][][]float64{}, chunks: map[string][]string{}, files: map[string]string{}, managed: map[string]bool{}, external: map[string]string{}, staticDocs: map[string]model.KnowledgeDoc{}, embedder: embedder, rag: rag, cachePath: filepath.Join(dataDir, "embeddings.json"), allowedSources: map[string]struct{}{}, scoring: scoreCfg, loadOptions: loadOpts, categoryMap: categoryMap}
|
||||
for _, source := range allowedSources {
|
||||
s.allowedSources[strings.ToLower(strings.TrimSpace(source))] = struct{}{}
|
||||
}
|
||||
static, staticFiles, stats, err := readDocs(dir, s.allowedSources, loadOpts, categoryMap)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.loadStats = stats
|
||||
for i, d := range static {
|
||||
s.staticDocs[d.ID] = d
|
||||
s.files[d.ID] = staticFiles[i]
|
||||
}
|
||||
managed, managedFiles, err := readDocs(managedDir, s.allowedSources)
|
||||
managed, managedFiles, managedStats, err := readDocs(managedDir, s.allowedSources, LoadOptions{CategoryMode: "strict"}, categoryMap)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
s.loadStats.IgnoredFiles += managedStats.IgnoredFiles
|
||||
s.loadStats.UnmappedCategoryFiles += managedStats.UnmappedCategoryFiles
|
||||
s.loadStats.UnmappedCategories = mergeStrings(s.loadStats.UnmappedCategories, managedStats.UnmappedCategories)
|
||||
merged := map[string]model.KnowledgeDoc{}
|
||||
order := []string{}
|
||||
for _, d := range static {
|
||||
@@ -154,35 +185,48 @@ func Load(ctx context.Context, dir, dataDir string, embedder Embedder, rag bool,
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func readDocs(dir string, allowed map[string]struct{}) ([]model.KnowledgeDoc, []string, error) {
|
||||
func readDocs(dir string, allowed map[string]struct{}, opts LoadOptions, categoryMap map[string][]int64) ([]model.KnowledgeDoc, []string, LoadStats, error) {
|
||||
entries, err := os.ReadDir(dir)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("read knowledge directory %q: %w", dir, err)
|
||||
return nil, nil, LoadStats{}, fmt.Errorf("read knowledge directory %q: %w", dir, err)
|
||||
}
|
||||
var docs []model.KnowledgeDoc
|
||||
var files []string
|
||||
stats := LoadStats{}
|
||||
for _, e := range entries {
|
||||
if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".json") {
|
||||
continue
|
||||
}
|
||||
if matchesAnyGlob(e.Name(), opts.IgnoreGlobs) {
|
||||
stats.IgnoredFiles++
|
||||
continue
|
||||
}
|
||||
path := filepath.Join(dir, e.Name())
|
||||
b, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
return nil, nil, stats, err
|
||||
}
|
||||
var d model.KnowledgeDoc
|
||||
if err := json.Unmarshal(b, &d); err != nil {
|
||||
return nil, nil, fmt.Errorf("%s: %w", e.Name(), err)
|
||||
d, unmapped, skip, err := decodeKnowledgeDoc(b, opts.CategoryMode, categoryMap)
|
||||
if err != nil {
|
||||
return nil, nil, stats, fmt.Errorf("%s: %w", e.Name(), err)
|
||||
}
|
||||
if len(unmapped) > 0 {
|
||||
stats.UnmappedCategoryFiles++
|
||||
stats.UnmappedCategories = mergeStrings(stats.UnmappedCategories, unmapped)
|
||||
}
|
||||
if skip {
|
||||
stats.IgnoredFiles++
|
||||
continue
|
||||
}
|
||||
if d.ID == "" || d.Title == "" {
|
||||
return nil, nil, fmt.Errorf("%s: id/title required", e.Name())
|
||||
return nil, nil, stats, fmt.Errorf("%s: id/title required", e.Name())
|
||||
}
|
||||
if !safeID(d.ID) {
|
||||
return nil, nil, fmt.Errorf("%s: invalid id %q", e.Name(), d.ID)
|
||||
return nil, nil, stats, fmt.Errorf("%s: invalid id %q", e.Name(), d.ID)
|
||||
}
|
||||
d.Source = strings.ToLower(strings.TrimSpace(d.Source))
|
||||
if d.Source == "" {
|
||||
return nil, nil, fmt.Errorf("%s: source required", e.Name())
|
||||
return nil, nil, stats, fmt.Errorf("%s: source required", e.Name())
|
||||
}
|
||||
if _, ok := allowed[d.Source]; !ok {
|
||||
continue
|
||||
@@ -192,7 +236,229 @@ func readDocs(dir string, allowed map[string]struct{}) ([]model.KnowledgeDoc, []
|
||||
docs = append(docs, d)
|
||||
files = append(files, path)
|
||||
}
|
||||
return docs, files, nil
|
||||
return docs, files, stats, nil
|
||||
}
|
||||
|
||||
func decodeKnowledgeDoc(b []byte, mode string, categoryMap map[string][]int64) (model.KnowledgeDoc, []string, bool, error) {
|
||||
var raw map[string]json.RawMessage
|
||||
if err := json.Unmarshal(b, &raw); err != nil {
|
||||
return model.KnowledgeDoc{}, nil, false, err
|
||||
}
|
||||
catRaw := raw["categories"]
|
||||
raw["categories"] = json.RawMessage(`[]`)
|
||||
normalized, err := json.Marshal(raw)
|
||||
if err != nil {
|
||||
return model.KnowledgeDoc{}, nil, false, err
|
||||
}
|
||||
var d model.KnowledgeDoc
|
||||
if err := json.Unmarshal(normalized, &d); err != nil {
|
||||
return model.KnowledgeDoc{}, nil, false, err
|
||||
}
|
||||
ids, labels, unmapped, err := parseKnowledgeCategories(catRaw, categoryMap)
|
||||
if err != nil {
|
||||
return model.KnowledgeDoc{}, nil, false, err
|
||||
}
|
||||
d.Categories = ids
|
||||
d.ExternalCategories = labels
|
||||
d.UnmappedExternalCategories = append([]string(nil), unmapped...)
|
||||
if len(unmapped) == 0 {
|
||||
return d, nil, false, nil
|
||||
}
|
||||
switch strings.ToLower(strings.TrimSpace(mode)) {
|
||||
case "strict":
|
||||
return model.KnowledgeDoc{}, unmapped, false, fmt.Errorf("unmapped external categories: %s", strings.Join(unmapped, ", "))
|
||||
case "skip":
|
||||
return d, unmapped, true, nil
|
||||
default:
|
||||
// Unmapped external taxonomies remain searchable, but may not trigger an automatic reply.
|
||||
d.AutoReply = false
|
||||
return d, unmapped, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func parseKnowledgeCategories(raw json.RawMessage, categoryMap map[string][]int64) ([]int64, []string, []string, error) {
|
||||
if len(raw) == 0 || string(raw) == "null" {
|
||||
return nil, nil, nil, nil
|
||||
}
|
||||
var v any
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
items, ok := v.([]any)
|
||||
if !ok {
|
||||
items = []any{v}
|
||||
}
|
||||
ids := []int64{}
|
||||
labels := []string{}
|
||||
unmapped := []string{}
|
||||
for _, item := range items {
|
||||
itemIDs, label, err := parseCategoryItem(item, categoryMap)
|
||||
if err != nil {
|
||||
return nil, nil, nil, err
|
||||
}
|
||||
ids = append(ids, itemIDs...)
|
||||
if label != "" {
|
||||
labels = appendUniqueString(labels, label)
|
||||
if len(itemIDs) == 0 {
|
||||
unmapped = appendUniqueString(unmapped, label)
|
||||
}
|
||||
}
|
||||
}
|
||||
return uniqueInt64(ids), labels, unmapped, nil
|
||||
}
|
||||
|
||||
func parseCategoryItem(v any, categoryMap map[string][]int64) ([]int64, string, error) {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
return nil, "", nil
|
||||
case float64:
|
||||
if x <= 0 || math.Trunc(x) != x {
|
||||
return nil, "", fmt.Errorf("category id must be a positive integer")
|
||||
}
|
||||
return []int64{int64(x)}, "", nil
|
||||
case string:
|
||||
label := strings.TrimSpace(x)
|
||||
if label == "" {
|
||||
return nil, "", nil
|
||||
}
|
||||
if n, err := strconv.ParseInt(label, 10, 64); err == nil && n > 0 {
|
||||
return []int64{n}, "", nil
|
||||
}
|
||||
return append([]int64(nil), categoryMap[normalizeCategoryLabel(label)]...), label, nil
|
||||
case map[string]any:
|
||||
if id, ok := x["id"]; ok {
|
||||
ids, _, err := parseCategoryItem(id, categoryMap)
|
||||
if err == nil && len(ids) > 0 {
|
||||
return ids, "", nil
|
||||
}
|
||||
}
|
||||
for _, key := range []string{"name", "label", "title"} {
|
||||
if val, ok := x[key].(string); ok {
|
||||
return parseCategoryItem(val, categoryMap)
|
||||
}
|
||||
}
|
||||
return nil, "", fmt.Errorf("unsupported category object")
|
||||
default:
|
||||
return nil, "", fmt.Errorf("unsupported category value type %T", v)
|
||||
}
|
||||
}
|
||||
|
||||
func loadCategoryMap(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 {
|
||||
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[normalizeCategoryLabel(label)] = uniqueInt64(ids)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func parseMappingIDs(raw json.RawMessage) ([]int64, error) {
|
||||
var v any
|
||||
if err := json.Unmarshal(raw, &v); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
items, ok := v.([]any)
|
||||
if !ok {
|
||||
items = []any{v}
|
||||
}
|
||||
ids := []int64{}
|
||||
for _, item := range items {
|
||||
switch x := item.(type) {
|
||||
case float64:
|
||||
if x <= 0 || math.Trunc(x) != x {
|
||||
return nil, fmt.Errorf("id must be a positive integer")
|
||||
}
|
||||
ids = append(ids, int64(x))
|
||||
case string:
|
||||
n, err := strconv.ParseInt(strings.TrimSpace(x), 10, 64)
|
||||
if err != nil || n <= 0 {
|
||||
return nil, fmt.Errorf("%q is not a positive GLPI category id", x)
|
||||
}
|
||||
ids = append(ids, n)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported mapping value type %T", item)
|
||||
}
|
||||
}
|
||||
return ids, nil
|
||||
}
|
||||
|
||||
func matchesAnyGlob(name string, patterns []string) bool {
|
||||
for _, pattern := range patterns {
|
||||
if ok, _ := filepath.Match(pattern, name); ok {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func normalizeCategoryLabel(s string) string {
|
||||
return strings.ToLower(strings.Join(strings.Fields(s), " "))
|
||||
}
|
||||
func uniqueInt64(in []int64) []int64 {
|
||||
seen := map[int64]struct{}{}
|
||||
out := []int64{}
|
||||
for _, v := range in {
|
||||
if v <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := seen[v]; ok {
|
||||
continue
|
||||
}
|
||||
seen[v] = struct{}{}
|
||||
out = append(out, v)
|
||||
}
|
||||
return out
|
||||
}
|
||||
func appendUniqueString(in []string, s string) []string {
|
||||
for _, v := range in {
|
||||
if strings.EqualFold(v, s) {
|
||||
return in
|
||||
}
|
||||
}
|
||||
return append(in, s)
|
||||
}
|
||||
func mergeStrings(a, b []string) []string {
|
||||
out := append([]string(nil), a...)
|
||||
for _, s := range b {
|
||||
out = appendUniqueString(out, s)
|
||||
}
|
||||
sort.Strings(out)
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Store) LoadStats() LoadStats {
|
||||
if s == nil {
|
||||
return LoadStats{}
|
||||
}
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
out := s.loadStats
|
||||
out.UnmappedCategories = append([]string(nil), s.loadStats.UnmappedCategories...)
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Store) Count() int {
|
||||
@@ -1206,7 +1472,7 @@ func cosine(a, b []float64) float64 {
|
||||
}
|
||||
func lexical(text string, d model.KnowledgeDoc) float64 {
|
||||
q := tokens(text)
|
||||
hay := tokens(d.Title + " " + d.Text + " " + strings.Join(d.Keywords, " "))
|
||||
hay := tokens(d.Title + " " + d.Text + " " + strings.Join(d.Keywords, " ") + " " + strings.Join(d.ExternalCategories, " "))
|
||||
if len(q) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -310,3 +310,85 @@ func TestRegressionShortGermanLoginTicketGetsStrongLexicalEvidence(t *testing.T)
|
||||
t.Fatalf("anmelden/Benutzeranmeldung should match through a German support stem, got %f", sim)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalStringCategoryLoadsUnscoped(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
data := t.TempDir()
|
||||
body := `{"id":"KB-EXT-1","title":"Docker Test","text":"Docker Fehler","answer":"Pruefen","auto_reply":false,"min_score":0.7,"categories":["Docker","Security"],"keywords":["docker"],"source":"internal-kb","language":"de-DE","communication_style":"formal"}`
|
||||
if err := os.WriteFile(filepath.Join(dir, "ext.json"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := Load(context.Background(), dir, data, nil, false, []string{"internal-kb"}, ScoringConfig{CategoryMode: "unscoped"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
doc, ok := s.ByID("KB-EXT-1")
|
||||
if !ok {
|
||||
t.Fatal("document not loaded")
|
||||
}
|
||||
if len(doc.Categories) != 0 {
|
||||
t.Fatalf("expected no GLPI ids, got %v", doc.Categories)
|
||||
}
|
||||
if len(doc.ExternalCategories) != 2 {
|
||||
t.Fatalf("external categories=%v", doc.ExternalCategories)
|
||||
}
|
||||
stats := s.LoadStats()
|
||||
if stats.UnmappedCategoryFiles != 1 {
|
||||
t.Fatalf("stats=%+v", stats)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExternalStringCategoryMapsToGLPI(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
data := t.TempDir()
|
||||
mapPath := filepath.Join(data, "category-map.json")
|
||||
if err := os.WriteFile(mapPath, []byte(`{"Docker":[12,13],"Security":7}`), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
body := `{"id":"KB-EXT-2","title":"Docker Test","text":"Docker Fehler","answer":"Pruefen","auto_reply":false,"min_score":0.7,"categories":["Docker","Security"],"keywords":["docker"],"source":"internal-kb","language":"de-DE","communication_style":"formal"}`
|
||||
if err := os.WriteFile(filepath.Join(dir, "ext.json"), []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := Load(context.Background(), dir, data, nil, false, []string{"internal-kb"}, ScoringConfig{CategoryMode: "unscoped", CategoryMapFile: mapPath})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
doc, _ := s.ByID("KB-EXT-2")
|
||||
want := []int64{12, 13, 7}
|
||||
if len(doc.Categories) != len(want) {
|
||||
t.Fatalf("categories=%v", doc.Categories)
|
||||
}
|
||||
for _, id := range want {
|
||||
found := false
|
||||
for _, got := range doc.Categories {
|
||||
if got == id {
|
||||
found = true
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Fatalf("missing id %d in %v", id, doc.Categories)
|
||||
}
|
||||
}
|
||||
if s.LoadStats().UnmappedCategoryFiles != 0 {
|
||||
t.Fatalf("unexpected unmapped stats: %+v", s.LoadStats())
|
||||
}
|
||||
}
|
||||
|
||||
func TestKnowledgeIgnoreGlobs(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
data := t.TempDir()
|
||||
bad := `{"id":"KB-BAD","title":"Foreign","text":"x","answer":"x","auto_reply":false,"min_score":0.7,"categories":[{"unsupported":true}],"source":"internal-kb","language":"de-DE","communication_style":"formal"}`
|
||||
if err := os.WriteFile(filepath.Join(dir, "KB-SEC-ATTCK-AN-0001.json"), []byte(bad), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
s, err := Load(context.Background(), dir, data, nil, false, []string{"internal-kb"}, ScoringConfig{CategoryMode: "strict", IgnoreGlobs: []string{"KB-SEC-ATTCK-*.json"}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if s.Count() != 0 {
|
||||
t.Fatalf("count=%d", s.Count())
|
||||
}
|
||||
if s.LoadStats().IgnoredFiles != 1 {
|
||||
t.Fatalf("stats=%+v", s.LoadStats())
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user