Files
jbergner 83b831526a
All checks were successful
release-tag / release-image (push) Successful in 1m36s
Patch
2026-08-05 19:17:23 +02:00

1834 lines
53 KiB
Go

package knowledge
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"log/slog"
"math"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
"unicode"
"github.com/example/glpi-ai-agent/internal/brainactivity"
"github.com/example/glpi-ai-agent/internal/model"
)
type Embedder interface {
Embed(context.Context, []string) ([][]float64, error)
}
type ScoringConfig struct {
SemanticWeight float64
TitleWeight float64
LexicalWeight float64
KeywordWeight float64
CategoryWeight float64
EmbeddingProfile string
EmbeddingIdentity string
ChunkWords int
ChunkOverlap int
MaxChunksPerDoc int
MaxQueryChunks int
IndexMode string
EmbedBatchSize int
IndexScanInterval time.Duration
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"`
}
// InitStatus exposes the asynchronous local knowledge startup state to the
// dashboard and readiness endpoint. The HTTP server can therefore be available
// while a large corpus is still being scanned or embedded.
type InitStatus struct {
State string `json:"state"`
Phase string `json:"phase"`
TotalFiles int `json:"total_files"`
ProcessedFiles int `json:"processed_files"`
LoadedDocs int `json:"loaded_docs"`
IndexedDocs int `json:"indexed_docs"`
CacheHits int `json:"cache_hits"`
PendingEmbeddings int `json:"pending_embeddings"`
StartedAt time.Time `json:"started_at,omitempty"`
FinishedAt time.Time `json:"finished_at,omitempty"`
LastError string `json:"last_error,omitempty"`
SnapshotLoaded bool `json:"snapshot_loaded"`
SnapshotPath string `json:"snapshot_path,omitempty"`
SnapshotSavedAt time.Time `json:"snapshot_saved_at,omitempty"`
LastScanAt time.Time `json:"last_scan_at,omitempty"`
LastScanError string `json:"last_scan_error,omitempty"`
ChangedFiles int `json:"changed_files"`
DeletedFiles int `json:"deleted_files"`
ReusedFiles int `json:"reused_files"`
}
type Store struct {
mu sync.RWMutex
initMu sync.Mutex
categoryMapWriteMu sync.Mutex
initStatus InitStatus
dir string
managedDir string
docs []model.KnowledgeDoc
files map[string]string
managed map[string]bool
external map[string]string
staticDocs map[string]model.KnowledgeDoc
titleVectors map[string][]float64
chunkVectors map[string][][]float64
chunks map[string][]string
embedder Embedder
rag bool
cachePath string
allowedSources map[string]struct{}
scoring ScoringConfig
loadOptions LoadOptions
loadStats LoadStats
categoryMap map[string][]int64
manifest map[string]fileRecord
snapshotPath string
externalCachePath string
}
type cacheFile struct {
Version int `json:"version,omitempty"`
Hashes map[string]string `json:"hashes"`
TitleVectors map[string][]float64 `json:"title_vectors,omitempty"`
ChunkVectors map[string][][]float64 `json:"chunk_vectors,omitempty"`
}
func DefaultScoringConfig() ScoringConfig {
return ScoringConfig{SemanticWeight: .45, TitleWeight: .20, LexicalWeight: .20, KeywordWeight: .075, CategoryWeight: .075, EmbeddingProfile: "plain", ChunkWords: 160, ChunkOverlap: 30, MaxChunksPerDoc: 24, MaxQueryChunks: 64, IndexMode: "incremental", EmbedBatchSize: 64, IndexScanInterval: 5 * time.Minute}
}
// ResolveEmbeddingProfile selects prompt formatting for the configured embedding model.
// EmbeddingGemma benefits from distinct retrieval-query and retrieval-document prompts.
func ResolveEmbeddingProfile(profile, model string) string {
p := strings.ToLower(strings.TrimSpace(profile))
if p == "" || p == "auto" {
if strings.Contains(strings.ToLower(model), "embeddinggemma") {
return "embeddinggemma"
}
return "plain"
}
return p
}
func normalizeScoring(c ScoringConfig) ScoringConfig {
d := DefaultScoringConfig()
if c.SemanticWeight < 0 || c.TitleWeight < 0 || c.LexicalWeight < 0 || c.KeywordWeight < 0 || c.CategoryWeight < 0 || c.SemanticWeight+c.TitleWeight+c.LexicalWeight+c.KeywordWeight+c.CategoryWeight <= 0 {
c.SemanticWeight, c.TitleWeight, c.LexicalWeight, c.KeywordWeight, c.CategoryWeight = d.SemanticWeight, d.TitleWeight, d.LexicalWeight, d.KeywordWeight, d.CategoryWeight
}
if c.EmbeddingProfile == "" {
c.EmbeddingProfile = d.EmbeddingProfile
}
if c.ChunkWords <= 0 {
c.ChunkWords = d.ChunkWords
}
if c.ChunkOverlap < 0 || c.ChunkOverlap >= c.ChunkWords {
c.ChunkOverlap = d.ChunkOverlap
}
if c.MaxChunksPerDoc <= 0 {
c.MaxChunksPerDoc = d.MaxChunksPerDoc
}
if c.MaxQueryChunks <= 0 {
c.MaxQueryChunks = d.MaxQueryChunks
}
if strings.TrimSpace(c.IndexMode) == "" {
c.IndexMode = d.IndexMode
}
c.IndexMode = strings.ToLower(strings.TrimSpace(c.IndexMode))
if c.EmbedBatchSize <= 0 {
c.EmbedBatchSize = d.EmbedBatchSize
}
if c.IndexScanInterval < 0 {
c.IndexScanInterval = d.IndexScanInterval
}
return c
}
func NewStore(dir, dataDir string, embedder Embedder, rag bool, allowedSources []string, scoring ...ScoringConfig) (*Store, error) {
managedDir := filepath.Join(dataDir, "knowledge-managed")
if err := os.MkdirAll(managedDir, 0o750); err != nil {
return nil, fmt.Errorf("create managed knowledge directory: %w", err)
}
scoreCfg := DefaultScoringConfig()
if len(scoring) > 0 {
scoreCfg = normalizeScoring(scoring[0])
}
loadOpts := LoadOptions{CategoryMode: scoreCfg.CategoryMode, CategoryMapFile: scoreCfg.CategoryMapFile, IgnoreGlobs: scoreCfg.IgnoreGlobs}
if strings.TrimSpace(loadOpts.CategoryMode) == "" {
loadOpts.CategoryMode = "unscoped"
}
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, manifest: map[string]fileRecord{},
snapshotPath: filepath.Join(dataDir, "knowledge-index", "snapshot.gob"), externalCachePath: filepath.Join(dataDir, "knowledge-index", "external-embeddings.json"),
initStatus: InitStatus{State: "waiting", Phase: "waiting"},
}
for _, source := range allowedSources {
s.allowedSources[strings.ToLower(strings.TrimSpace(source))] = struct{}{}
}
return s, nil
}
// Load keeps the synchronous API used by tests and small deployments. The main
// application uses NewStore + Initialize in a goroutine so the Web UI is
// reachable immediately even for very large knowledge directories.
func Load(ctx context.Context, dir, dataDir string, embedder Embedder, rag bool, allowedSources []string, scoring ...ScoringConfig) (*Store, error) {
s, err := NewStore(dir, dataDir, embedder, rag, allowedSources, scoring...)
if err != nil {
return nil, err
}
if err := s.Initialize(ctx); err != nil {
return s, err
}
return s, nil
}
// Initialize scans, validates and indexes the local knowledge corpus. It is
// safe to call from a background goroutine. Until it succeeds Ready() is false,
// so ticket processing can stay paused while the dashboard remains available.
func (s *Store) Initialize(ctx context.Context) (err error) {
if s == nil {
return fmt.Errorf("knowledge store is not initialized")
}
mode := strings.ToLower(strings.TrimSpace(s.scoring.IndexMode))
if mode == "" {
mode = "incremental"
}
if mode != "rebuild" {
loaded, loadErr := s.loadPersistentSnapshot()
if loadErr != nil {
if mode == "readonly" {
return loadErr
}
slog.Warn("persistent knowledge index unavailable; falling back to rebuild", "error", loadErr, "path", s.snapshotPath)
} else if loaded {
return nil
} else if mode == "readonly" {
return fmt.Errorf("KNOWLEDGE_INDEX_MODE=readonly requires a compatible persistent index at %s", s.snapshotPath)
}
}
return s.fullRebuild(ctx)
}
func (s *Store) fullRebuild(ctx context.Context) (err error) {
s.initMu.Lock()
defer s.initMu.Unlock()
s.setInitStatus(func(st *InitStatus) {
*st = InitStatus{State: "loading", Phase: "scanning", StartedAt: time.Now(), SnapshotPath: s.snapshotPath}
})
defer func() {
if err != nil {
s.setInitStatus(func(st *InitStatus) {
st.State = "error"
st.Phase = "error"
st.LastError = err.Error()
st.FinishedAt = time.Now()
})
}
}()
staticProcessed, staticTotal := 0, 0
static, staticFiles, stats, err := readDocs(s.dir, s.allowedSources, s.loadOptions, s.categoryMap, func(total, processed, loaded int) {
staticTotal, staticProcessed = total, processed
s.setInitStatus(func(st *InitStatus) {
st.TotalFiles = total
st.ProcessedFiles = processed
st.LoadedDocs = loaded
})
if processed > 0 && processed%1000 == 0 {
slog.Info("knowledge scan progress", "processed_files", processed, "total_files", total, "loaded_docs", loaded)
}
})
if err != nil {
return err
}
managedTotal := 0
managed, managedFiles, managedStats, err := readDocs(s.managedDir, s.allowedSources, LoadOptions{CategoryMode: "strict"}, s.categoryMap, func(total, processed, loaded int) {
managedTotal = total
s.setInitStatus(func(st *InitStatus) {
st.TotalFiles = staticTotal + total
st.ProcessedFiles = staticProcessed + processed
st.LoadedDocs = len(static) + loaded
})
})
if err != nil {
return err
}
stats.IgnoredFiles += managedStats.IgnoredFiles
stats.UnmappedCategoryFiles += managedStats.UnmappedCategoryFiles
stats.UnmappedCategories = mergeStrings(stats.UnmappedCategories, managedStats.UnmappedCategories)
staticManifest, err := buildManifestForDocs(static, staticFiles, "static", false)
if err != nil {
return fmt.Errorf("build static knowledge manifest: %w", err)
}
managedManifest, err := buildManifestForDocs(managed, managedFiles, "managed", true)
if err != nil {
return fmt.Errorf("build managed knowledge manifest: %w", err)
}
manifest := make(map[string]fileRecord, len(staticManifest)+len(managedManifest))
for k, v := range staticManifest {
manifest[k] = v
}
for k, v := range managedManifest {
manifest[k] = v
}
if err := augmentManifestAllFiles(s.dir, "static", false, s.loadOptions, manifest); err != nil {
return fmt.Errorf("complete static knowledge manifest: %w", err)
}
if err := augmentManifestAllFiles(s.managedDir, "managed", true, LoadOptions{CategoryMode: "strict"}, manifest); err != nil {
return fmt.Errorf("complete managed knowledge manifest: %w", err)
}
files := map[string]string{}
managedMap := map[string]bool{}
staticMap := map[string]model.KnowledgeDoc{}
merged := map[string]model.KnowledgeDoc{}
order := []string{}
for i, d := range static {
staticMap[d.ID] = d
files[d.ID] = staticFiles[i]
if _, ok := merged[d.ID]; !ok {
order = append(order, d.ID)
}
merged[d.ID] = d
}
for i, d := range managed {
if _, ok := merged[d.ID]; !ok {
order = append(order, d.ID)
}
merged[d.ID] = d
files[d.ID] = managedFiles[i]
managedMap[d.ID] = true
}
docs := make([]model.KnowledgeDoc, 0, len(order))
for _, id := range order {
docs = append(docs, merged[id])
}
s.mu.Lock()
s.docs = docs
s.files = files
s.managed = managedMap
s.external = map[string]string{}
s.staticDocs = staticMap
s.loadStats = stats
s.manifest = manifest
s.titleVectors = map[string][]float64{}
s.chunkVectors = map[string][][]float64{}
s.chunks = map[string][]string{}
s.mu.Unlock()
s.setInitStatus(func(st *InitStatus) {
st.Phase = "indexing"
st.TotalFiles = staticTotal + managedTotal
st.ProcessedFiles = st.TotalFiles
st.LoadedDocs = len(docs)
})
slog.Info("knowledge scan complete", "documents", len(docs), "ignored_files", stats.IgnoredFiles, "unmapped_category_files", stats.UnmappedCategoryFiles)
if s.rag && len(docs) > 0 {
if s.embedder == nil {
return fmt.Errorf("RAG is enabled but no embedding provider is configured")
}
if err := s.index(ctx); err != nil {
return err
}
}
if err := s.persistSnapshot(); err != nil {
return fmt.Errorf("persist knowledge snapshot: %w", err)
}
s.setInitStatus(func(st *InitStatus) {
st.State = "ready"
st.Phase = "ready"
st.IndexedDocs = len(docs)
st.PendingEmbeddings = 0
st.FinishedAt = time.Now()
st.LastError = ""
st.SnapshotLoaded = false
st.SnapshotPath = s.snapshotPath
st.SnapshotSavedAt = time.Now()
})
slog.Info("knowledge store ready", "documents", len(docs), "rag_enabled", s.rag, "persistent_index", s.snapshotPath)
return nil
}
func (s *Store) setInitStatus(fn func(*InitStatus)) {
s.mu.Lock()
fn(&s.initStatus)
s.mu.Unlock()
}
func (s *Store) InitStatus() InitStatus {
if s == nil {
return InitStatus{State: "error", Phase: "error", LastError: "knowledge store is nil"}
}
s.mu.RLock()
defer s.mu.RUnlock()
return s.initStatus
}
func (s *Store) Ready() bool { return s != nil && s.InitStatus().State == "ready" }
func readDocs(dir string, allowed map[string]struct{}, opts LoadOptions, categoryMap map[string][]int64, progress func(total, processed, loaded int)) ([]model.KnowledgeDoc, []string, LoadStats, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, nil, LoadStats{}, fmt.Errorf("read knowledge directory %q: %w", dir, err)
}
var docs []model.KnowledgeDoc
var files []string
stats := LoadStats{}
total := 0
for _, e := range entries {
if !e.IsDir() && strings.HasSuffix(strings.ToLower(e.Name()), ".json") {
total++
}
}
processed := 0
if progress != nil {
progress(total, 0, 0)
}
for _, e := range entries {
if e.IsDir() || !strings.HasSuffix(strings.ToLower(e.Name()), ".json") {
continue
}
processed++
if matchesAnyGlob(e.Name(), opts.IgnoreGlobs) {
stats.IgnoredFiles++
if progress != nil {
progress(total, processed, len(docs))
}
continue
}
path := filepath.Join(dir, e.Name())
b, err := os.ReadFile(path)
if err != nil {
return nil, nil, stats, 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++
if progress != nil {
progress(total, processed, len(docs))
}
continue
}
if d.ID == "" || d.Title == "" {
return nil, nil, stats, fmt.Errorf("%s: id/title required", e.Name())
}
if !safeID(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, stats, fmt.Errorf("%s: source required", e.Name())
}
if _, ok := allowed[d.Source]; !ok {
if progress != nil {
progress(total, processed, len(docs))
}
continue
}
d.Language = strings.TrimSpace(d.Language)
d.CommunicationStyle = strings.ToLower(strings.TrimSpace(d.CommunicationStyle))
docs = append(docs, d)
files = append(files, path)
if progress != nil {
progress(total, processed, len(docs))
}
}
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 {
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[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 {
if s == nil {
return 0
}
s.mu.RLock()
defer s.mu.RUnlock()
return len(s.docs)
}
func (s *Store) ByID(id string) (model.KnowledgeDoc, bool) {
if s == nil {
return model.KnowledgeDoc{}, false
}
s.mu.RLock()
defer s.mu.RUnlock()
for _, d := range s.docs {
if d.ID == id {
return d, true
}
}
return model.KnowledgeDoc{}, false
}
func (s *Store) List() []model.KnowledgeDoc {
if s == nil {
return nil
}
s.mu.RLock()
defer s.mu.RUnlock()
out := append([]model.KnowledgeDoc(nil), s.docs...)
sort.SliceStable(out, func(i, j int) bool { return strings.ToLower(out[i].Title) < strings.ToLower(out[j].Title) })
return out
}
func (s *Store) Upsert(ctx context.Context, d model.KnowledgeDoc) error {
if s == nil {
return fmt.Errorf("knowledge store is not initialized")
}
if !s.Ready() {
return fmt.Errorf("knowledge store is still initializing")
}
d.ID = strings.TrimSpace(d.ID)
d.Title = strings.TrimSpace(d.Title)
d.Text = strings.TrimSpace(d.Text)
d.Answer = strings.TrimSpace(d.Answer)
d.Source = strings.ToLower(strings.TrimSpace(d.Source))
d.Language = strings.TrimSpace(d.Language)
d.CommunicationStyle = strings.ToLower(strings.TrimSpace(d.CommunicationStyle))
if d.ID == "" || d.Title == "" {
return fmt.Errorf("id/title required")
}
if !safeID(d.ID) {
return fmt.Errorf("knowledge id may contain only letters, digits, dot, dash and underscore")
}
if d.Source == "" {
return fmt.Errorf("source required")
}
if _, ok := s.allowedSources[d.Source]; !ok {
return fmt.Errorf("source %q is not allowed", d.Source)
}
if d.Language == "" || d.CommunicationStyle == "" {
return fmt.Errorf("language and communication_style required")
}
if d.MinScore < 0 || d.MinScore > 1 {
return fmt.Errorf("min_score must be between 0 and 1")
}
s.mu.RLock()
_, exists := s.files[d.ID]
isManaged := s.managed[d.ID]
externalSource := s.external[d.ID]
s.mu.RUnlock()
if externalSource != "" {
return fmt.Errorf("externally synchronized knowledge entry %q from %q is read-only", d.ID, externalSource)
}
if exists && !isManaged {
return fmt.Errorf("static knowledge entry %q is read-only; use a new id for a managed entry", d.ID)
}
var titleVector []float64
var chunkVectors [][]float64
chunks := chunkText(d.Text, s.scoring.ChunkWords, s.scoring.ChunkOverlap, s.scoring.MaxChunksPerDoc)
if s.rag {
if s.embedder == nil {
return fmt.Errorf("RAG is enabled but no embedding provider is configured")
}
embedded, err := s.embedDocuments(ctx, []model.KnowledgeDoc{d})
if err != nil {
return err
}
titleVector = embedded[d.ID].title
chunkVectors = embedded[d.ID].chunks
}
path := filepath.Join(s.managedDir, d.ID+".json")
b, err := json.MarshalIndent(d, "", " ")
if err != nil {
return err
}
tmp := path + ".tmp"
if err := os.WriteFile(tmp, b, 0o640); err != nil {
return err
}
if err := os.Rename(tmp, path); err != nil {
_ = os.Remove(tmp)
return err
}
info, statErr := os.Stat(path)
if statErr != nil {
return statErr
}
rawSum := sha256.Sum256(b)
manifestKey := "managed/" + filepath.Base(path)
s.mu.Lock()
replaced := false
for i := range s.docs {
if s.docs[i].ID == d.ID {
s.docs[i] = d
replaced = true
break
}
}
if !replaced {
s.docs = append(s.docs, d)
}
s.files[d.ID] = path
s.managed[d.ID] = true
s.manifest[manifestKey] = fileRecord{Key: manifestKey, Path: path, ID: d.ID, Size: info.Size(), ModTimeUnixNano: info.ModTime().UnixNano(), RawHash: hex.EncodeToString(rawSum[:]), Managed: true, Included: true, Unmapped: append([]string(nil), d.UnmappedExternalCategories...)}
s.chunks[d.ID] = chunks
if s.rag {
s.titleVectors[d.ID] = titleVector
s.chunkVectors[d.ID] = chunkVectors
}
s.mu.Unlock()
return s.persistVectorCache()
}
func (s *Store) Delete(id string) error {
if s == nil {
return fmt.Errorf("knowledge store is not initialized")
}
if !s.Ready() {
return fmt.Errorf("knowledge store is still initializing")
}
id = strings.TrimSpace(id)
if !safeID(id) {
return fmt.Errorf("invalid knowledge id")
}
s.mu.RLock()
path := s.files[id]
isManaged := s.managed[id]
s.mu.RUnlock()
if path == "" {
return os.ErrNotExist
}
if !isManaged {
return fmt.Errorf("static knowledge entry %q is read-only", id)
}
if err := os.Remove(path); err != nil {
return err
}
s.mu.Lock()
out := s.docs[:0]
for _, d := range s.docs {
if d.ID != id {
out = append(out, d)
}
}
s.docs = append([]model.KnowledgeDoc(nil), out...)
delete(s.files, id)
delete(s.managed, id)
for key, rec := range s.manifest {
if rec.Managed && rec.ID == id {
delete(s.manifest, key)
}
}
delete(s.titleVectors, id)
delete(s.chunkVectors, id)
delete(s.chunks, id)
s.mu.Unlock()
return s.persistVectorCache()
}
func (s *Store) IsManaged(id string) bool {
if s == nil {
return false
}
s.mu.RLock()
defer s.mu.RUnlock()
return s.managed[id]
}
func (s *Store) Origin(id string) string {
if s == nil {
return ""
}
s.mu.RLock()
defer s.mu.RUnlock()
if s.managed[id] {
return "managed"
}
if src := s.external[id]; src != "" {
return src
}
if _, ok := s.staticDocs[id]; ok {
return "static"
}
return ""
}
// ReplaceExternalSource atomically replaces all read-only documents imported
// from one connector source. Embeddings are reused when the normalized article
// did not change. Long article bodies are indexed as overlapping chunks.
func (s *Store) ReplaceExternalSource(ctx context.Context, source string, docs []model.KnowledgeDoc) error {
if s == nil {
return fmt.Errorf("knowledge store is not initialized")
}
source = strings.ToLower(strings.TrimSpace(source))
if _, ok := s.allowedSources[source]; !ok {
return fmt.Errorf("source %q is not allowed", source)
}
s.mu.RLock()
oldDocs := make(map[string]model.KnowledgeDoc, len(s.docs))
oldTitle := cloneVectorMap(s.titleVectors)
oldChunks := cloneChunkVectorMap(s.chunkVectors)
for _, d := range s.docs {
oldDocs[d.ID] = d
}
s.mu.RUnlock()
cached := loadCache(s.externalCachePath)
if len(cached.Hashes) == 0 {
cached = loadCache(s.cachePath) // one-time migration from the legacy combined cache
}
changed := make([]model.KnowledgeDoc, 0)
seen := map[string]struct{}{}
for i := range docs {
d := &docs[i]
d.ID = strings.TrimSpace(d.ID)
d.Title = strings.TrimSpace(d.Title)
d.Source = strings.ToLower(strings.TrimSpace(d.Source))
if d.Source == "" {
d.Source = source
}
if d.Source != source {
return fmt.Errorf("external document %q has source %q, expected %q", d.ID, d.Source, source)
}
if d.ID == "" || d.Title == "" || !safeID(d.ID) {
return fmt.Errorf("invalid external knowledge document id/title")
}
if _, dup := seen[d.ID]; dup {
return fmt.Errorf("duplicate external knowledge id %q", d.ID)
}
seen[d.ID] = struct{}{}
h := hashDoc(*d, s.scoring)
bodyChunks := chunkText(d.Text, s.scoring.ChunkWords, s.scoring.ChunkOverlap, s.scoring.MaxChunksPerDoc)
same := false
if old, ok := oldDocs[d.ID]; ok && hashDoc(old, s.scoring) == h && len(oldTitle[d.ID]) > 0 && len(oldChunks[d.ID]) == len(bodyChunks) {
same = true
} else if cached.Hashes[d.ID] == h && len(cached.TitleVectors[d.ID]) > 0 && len(cached.ChunkVectors[d.ID]) == len(bodyChunks) {
oldTitle[d.ID] = append([]float64(nil), cached.TitleVectors[d.ID]...)
oldChunks[d.ID] = cloneChunkVectors(cached.ChunkVectors[d.ID])
same = true
}
if !same {
changed = append(changed, *d)
}
}
newEmbedded := map[string]embeddedDoc{}
if s.rag && len(changed) > 0 {
if s.embedder == nil {
return fmt.Errorf("RAG is enabled but no embedding provider is configured")
}
var err error
newEmbedded, err = s.embedDocuments(ctx, changed)
if err != nil {
return err
}
}
s.mu.Lock()
for _, d := range docs {
if src := s.external[d.ID]; src == "" {
if _, exists := oldDocs[d.ID]; exists {
s.mu.Unlock()
return fmt.Errorf("external knowledge id %q collides with local knowledge", d.ID)
}
} else if src != source {
s.mu.Unlock()
return fmt.Errorf("external knowledge id %q belongs to source %q", d.ID, src)
}
}
rebuilt := make([]model.KnowledgeDoc, 0, len(s.docs)+len(docs))
for _, d := range s.docs {
if s.external[d.ID] != source {
rebuilt = append(rebuilt, d)
}
}
for id, src := range s.external {
if src == source {
delete(s.external, id)
delete(s.titleVectors, id)
delete(s.chunkVectors, id)
delete(s.chunks, id)
}
}
for _, d := range docs {
rebuilt = append(rebuilt, d)
s.external[d.ID] = source
s.chunks[d.ID] = chunkText(d.Text, s.scoring.ChunkWords, s.scoring.ChunkOverlap, s.scoring.MaxChunksPerDoc)
if e, ok := newEmbedded[d.ID]; ok {
s.titleVectors[d.ID] = e.title
s.chunkVectors[d.ID] = e.chunks
} else {
s.titleVectors[d.ID] = oldTitle[d.ID]
s.chunkVectors[d.ID] = oldChunks[d.ID]
}
}
s.docs = rebuilt
s.mu.Unlock()
return s.persistExternalVectorCache(source)
}
func (s *Store) persistVectorCache() error {
return s.persistSnapshot()
}
// FindMetadata searches knowledge metadata without copying the complete corpus.
// It is intended for interactive UI lookups on very large knowledge stores.
func (s *Store) FindMetadata(query string, limit int) []model.KnowledgeDoc {
if s == nil {
return nil
}
q := strings.ToLower(strings.TrimSpace(query))
if limit <= 0 {
limit = 30
}
s.mu.RLock()
defer s.mu.RUnlock()
out := make([]model.KnowledgeDoc, 0, limit)
for _, d := range s.docs {
hay := strings.ToLower(d.ID + " " + d.Title + " " + strings.Join(d.Keywords, " ") + " " + strings.Join(d.ExternalCategories, " "))
if q != "" && !strings.Contains(hay, q) {
continue
}
out = append(out, d)
if len(out) >= limit {
break
}
}
return out
}
func (s *Store) ManagedDir() string {
if s == nil {
return ""
}
return s.managedDir
}
func safeID(v string) bool {
if v == "" {
return false
}
for _, r := range v {
if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.') {
return false
}
}
return !strings.Contains(v, "..")
}
// Search calculates a transparent hybrid relevance score. Embedding similarity
// is only one component; titles, explicit keywords and category/learning hints
// are scored separately. Missing metadata does not lower a document's score:
// the weights of available components are normalized dynamically.
func (s *Store) Search(ctx context.Context, text string, topK int, categorySets ...[]model.Category) ([]model.KnowledgeHit, error) {
startedAt := time.Now()
if s == nil {
return nil, fmt.Errorf("knowledge store is not initialized")
}
s.mu.RLock()
scoreCfg := s.scoring
ragEnabled := s.rag
embedder := s.embedder
s.mu.RUnlock()
var cats []model.Category
if len(categorySets) > 0 {
cats = categorySets[0]
}
queryTitle, queryBody := splitQueryText(text)
queryChunks := chunkText(queryBody, scoreCfg.ChunkWords, scoreCfg.ChunkOverlap, scoreCfg.MaxQueryChunks)
if len(queryChunks) == 0 {
queryChunks = chunkText(text, scoreCfg.ChunkWords, scoreCfg.ChunkOverlap, scoreCfg.MaxQueryChunks)
}
var queryVectors [][]float64
var queryTitleVector []float64
if ragEnabled && embedder != nil {
if len(queryChunks) > 0 {
q, err := s.embedTexts(ctx, formatQueryEmbeddings(queryChunks, scoreCfg.EmbeddingProfile), scoreCfg.EmbedBatchSize)
if err != nil {
return nil, err
}
queryVectors = q
}
if strings.TrimSpace(queryTitle) != "" {
tq, err := embedder.Embed(ctx, formatQueryEmbeddings([]string{queryTitle}, scoreCfg.EmbeddingProfile))
if err != nil {
return nil, err
}
if len(tq) > 0 {
queryTitleVector = tq[0]
}
}
}
s.mu.RLock()
defer s.mu.RUnlock()
if len(s.docs) == 0 {
return nil, nil
}
hits := make([]model.KnowledgeHit, 0, len(s.docs))
for _, d := range s.docs {
semantic, bestChunk, bestQueryChunk := 0.0, "", ""
semanticAvailable := false
if len(queryVectors) > 0 && len(s.chunkVectors[d.ID]) > 0 {
semanticAvailable = true
for qi, qv := range queryVectors {
for di, dv := range s.chunkVectors[d.ID] {
score := clamp01(cosine(qv, dv))
if score > semantic || bestChunk == "" {
semantic = score
if di < len(s.chunks[d.ID]) {
bestChunk = s.chunks[d.ID][di]
}
if qi < len(queryChunks) {
bestQueryChunk = queryChunks[qi]
}
}
}
}
} else if strings.TrimSpace(d.Text) != "" {
semanticAvailable = true
docChunks := s.chunks[d.ID]
if len(docChunks) == 0 {
docChunks = []string{d.Text}
}
for _, qc := range queryChunks {
for _, dc := range docChunks {
score := tokenF1(qc, dc)
if score > semantic || bestChunk == "" {
semantic, bestChunk, bestQueryChunk = score, dc, qc
}
}
}
}
title := 0.0
titleAvailable := strings.TrimSpace(d.Title) != ""
if titleAvailable {
titleQuery := queryTitle
if strings.TrimSpace(titleQuery) == "" {
titleQuery = text
}
title = titleSimilarity(titleQuery, d.Title)
if len(queryTitleVector) > 0 && len(s.titleVectors[d.ID]) > 0 {
title = math.Max(title, clamp01(cosine(queryTitleVector, s.titleVectors[d.ID])))
}
}
lexicalScore := lexicalSimilarity(text, d)
keyword, keywordAvailable := keywordSimilarity(text, d.Keywords)
category, categoryAvailable := categorySimilarity(text, d.Categories, cats)
// Keywords and category profiles are positive evidence signals. Metadata that
// exists but has no lexical overlap must not drag an otherwise strong
// semantic/title match toward zero.
keywordAvailable = keywordAvailable && keyword > 0
categoryAvailable = categoryAvailable && category > 0
total := weightedScore(scoreCfg,
scorePart{semantic, scoreCfg.SemanticWeight, semanticAvailable},
scorePart{title, scoreCfg.TitleWeight, titleAvailable},
scorePart{lexicalScore, scoreCfg.LexicalWeight, lexicalScore > 0},
scorePart{keyword, scoreCfg.KeywordWeight, keywordAvailable},
scorePart{category, scoreCfg.CategoryWeight, categoryAvailable},
)
hits = append(hits, model.KnowledgeHit{Doc: d, Score: total, SemanticScore: semantic, TitleScore: title, LexicalScore: lexicalScore, KeywordScore: keyword, CategoryScore: category, BestChunkExcerpt: excerpt(bestChunk, 280), BestQueryExcerpt: excerpt(bestQueryChunk, 280), QueryChunkCount: len(queryChunks), DocumentChunkCount: len(s.chunks[d.ID])})
}
sort.SliceStable(hits, func(i, j int) bool {
if hits[i].Score == hits[j].Score {
return hits[i].TitleScore > hits[j].TitleScore
}
return hits[i].Score > hits[j].Score
})
if topK > 0 && len(hits) > topK {
hits = hits[:topK]
}
activityHits := make([]brainactivity.Hit, 0, len(hits))
for _, hit := range hits {
activityHits = append(activityHits, brainactivity.Hit{ID: hit.Doc.ID, Score: hit.Score})
}
brainactivity.EmitSearch("agent", text, activityHits, time.Since(startedAt))
return hits, nil
}
// FilterHitsBySources keeps the existing relevance order while applying a
// purpose-specific source allowlist. This lets one shared index serve normal
// retrieval and category-only knowledge without exposing category-only entries
// as reply candidates.
func FilterHitsBySources(hits []model.KnowledgeHit, sources []string, topK int) []model.KnowledgeHit {
allowed := make(map[string]struct{}, len(sources))
for _, source := range sources {
normalized := strings.ToLower(strings.TrimSpace(source))
if normalized != "" {
allowed[normalized] = struct{}{}
}
}
if len(allowed) == 0 || len(hits) == 0 {
return nil
}
out := make([]model.KnowledgeHit, 0, len(hits))
for _, hit := range hits {
if _, ok := allowed[strings.ToLower(strings.TrimSpace(hit.Doc.Source))]; !ok {
continue
}
out = append(out, hit)
if topK > 0 && len(out) >= topK {
break
}
}
return out
}
// RerankForCategory applies a deterministic post-classification boost when a
// knowledge document is explicitly mapped to the category selected by the
// classifier. This happens after the model decision, so the dashboard and
// policy can distinguish retrieval evidence from category alignment.
func (s *Store) RerankForCategory(hits []model.KnowledgeHit, categoryID int64) []model.KnowledgeHit {
if s == nil || len(hits) == 0 || categoryID == 0 {
return hits
}
s.mu.RLock()
cfg := s.scoring
s.mu.RUnlock()
out := append([]model.KnowledgeHit(nil), hits...)
for i := range out {
match := false
for _, id := range out[i].Doc.Categories {
if id == categoryID {
match = true
break
}
}
if match {
out[i].CategoryScore = 1
}
out[i].Score = weightedScore(cfg,
scorePart{out[i].SemanticScore, cfg.SemanticWeight, out[i].SemanticScore > 0},
scorePart{out[i].TitleScore, cfg.TitleWeight, out[i].TitleScore > 0},
scorePart{out[i].LexicalScore, cfg.LexicalWeight, out[i].LexicalScore > 0},
scorePart{out[i].KeywordScore, cfg.KeywordWeight, out[i].KeywordScore > 0},
scorePart{out[i].CategoryScore, cfg.CategoryWeight, out[i].CategoryScore > 0},
)
}
sort.SliceStable(out, func(i, j int) bool {
if out[i].Score == out[j].Score {
return out[i].SemanticScore > out[j].SemanticScore
}
return out[i].Score > out[j].Score
})
return out
}
func (s *Store) index(ctx context.Context) error {
_ = os.MkdirAll(filepath.Dir(s.cachePath), 0o750)
cf := loadCache(s.cachePath)
var need []model.KnowledgeDoc
cacheHits := 0
for _, d := range s.docs {
bodyChunks := chunkText(d.Text, s.scoring.ChunkWords, s.scoring.ChunkOverlap, s.scoring.MaxChunksPerDoc)
s.mu.Lock()
s.chunks[d.ID] = bodyChunks
s.mu.Unlock()
h := hashDoc(d, s.scoring)
if cf.Hashes[d.ID] == h && len(cf.TitleVectors[d.ID]) > 0 && len(cf.ChunkVectors[d.ID]) == len(bodyChunks) {
s.mu.Lock()
s.titleVectors[d.ID] = append([]float64(nil), cf.TitleVectors[d.ID]...)
s.chunkVectors[d.ID] = cloneChunkVectors(cf.ChunkVectors[d.ID])
s.mu.Unlock()
cacheHits++
} else {
need = append(need, d)
}
}
s.setInitStatus(func(st *InitStatus) {
st.CacheHits = cacheHits
st.IndexedDocs = cacheHits
st.PendingEmbeddings = len(need)
})
slog.Info("knowledge index prepared", "documents", len(s.docs), "cache_hits", cacheHits, "documents_to_embed", len(need))
if len(need) == 0 {
return s.persistVectorCache()
}
// Batch by documents, not by the whole corpus. A corpus with tens of
// thousands of files can otherwise allocate hundreds of thousands of
// embedding input strings before the first request is sent to Ollama.
const docsPerBatch = 20
embeddedDocs := 0
for start := 0; start < len(need); start += docsPerBatch {
if err := ctx.Err(); err != nil {
return err
}
end := start + docsPerBatch
if end > len(need) {
end = len(need)
}
batch := need[start:end]
embedded, err := s.embedDocuments(ctx, batch)
if err != nil {
return err
}
s.mu.Lock()
for _, d := range batch {
s.titleVectors[d.ID] = embedded[d.ID].title
s.chunkVectors[d.ID] = embedded[d.ID].chunks
}
s.mu.Unlock()
embeddedDocs += len(batch)
indexed := cacheHits + embeddedDocs
pending := len(need) - embeddedDocs
s.setInitStatus(func(st *InitStatus) {
st.IndexedDocs = indexed
st.PendingEmbeddings = pending
})
if embeddedDocs%500 == 0 || embeddedDocs == len(need) {
slog.Info("knowledge embedding progress", "indexed_docs", indexed, "total_docs", len(s.docs), "cache_hits", cacheHits, "pending_embeddings", pending)
}
}
return s.persistVectorCache()
}
type embeddedDoc struct {
title []float64
chunks [][]float64
}
func (s *Store) embedDocuments(ctx context.Context, docs []model.KnowledgeDoc) (map[string]embeddedDoc, error) {
out := make(map[string]embeddedDoc, len(docs))
type ref struct {
id string
title bool
chunk int
}
var texts []string
var refs []ref
for _, d := range docs {
texts = append(texts, formatDocumentEmbedding(d.Title, d.Title, s.scoring.EmbeddingProfile))
refs = append(refs, ref{id: d.ID, title: true})
parts := chunkText(d.Text, s.scoring.ChunkWords, s.scoring.ChunkOverlap, s.scoring.MaxChunksPerDoc)
for i, part := range parts {
texts = append(texts, formatDocumentEmbedding(d.Title, part, s.scoring.EmbeddingProfile))
refs = append(refs, ref{id: d.ID, chunk: i})
}
}
vectors, err := s.embedTexts(ctx, texts, s.scoring.EmbedBatchSize)
if err != nil {
return nil, err
}
if len(vectors) != len(refs) {
return nil, fmt.Errorf("embedding provider returned %d vectors for %d inputs", len(vectors), len(refs))
}
for i, r := range refs {
if len(vectors[i]) == 0 {
return nil, fmt.Errorf("embedding provider returned empty vector for %s", r.id)
}
e := out[r.id]
if r.title {
e.title = vectors[i]
} else {
for len(e.chunks) <= r.chunk {
e.chunks = append(e.chunks, nil)
}
e.chunks[r.chunk] = vectors[i]
}
out[r.id] = e
}
return out, nil
}
func (s *Store) embedTexts(ctx context.Context, texts []string, batch int) ([][]float64, error) {
if len(texts) == 0 {
return nil, nil
}
if batch <= 0 {
batch = 64
}
out := make([][]float64, 0, len(texts))
for start := 0; start < len(texts); start += batch {
end := start + batch
if end > len(texts) {
end = len(texts)
}
vv, err := s.embedder.Embed(ctx, texts[start:end])
if err != nil {
return nil, err
}
if len(vv) != end-start {
return nil, fmt.Errorf("embedding provider returned %d vectors for %d inputs", len(vv), end-start)
}
out = append(out, vv...)
}
return out, nil
}
func loadCache(path string) cacheFile {
cf := cacheFile{Version: 3, Hashes: map[string]string{}, TitleVectors: map[string][]float64{}, ChunkVectors: map[string][][]float64{}}
if b, err := os.ReadFile(path); err == nil {
_ = json.Unmarshal(b, &cf)
if cf.Version != 3 {
cf = cacheFile{Version: 3, Hashes: map[string]string{}, TitleVectors: map[string][]float64{}, ChunkVectors: map[string][][]float64{}}
}
}
if cf.Hashes == nil {
cf.Hashes = map[string]string{}
}
if cf.TitleVectors == nil {
cf.TitleVectors = map[string][]float64{}
}
if cf.ChunkVectors == nil {
cf.ChunkVectors = map[string][][]float64{}
}
return cf
}
func formatQueryEmbeddings(texts []string, profile string) []string {
out := make([]string, len(texts))
for i, text := range texts {
if profile == "embeddinggemma" {
out[i] = "task: search result | query: " + strings.TrimSpace(text)
} else {
out[i] = text
}
}
return out
}
func formatDocumentEmbedding(title, text, profile string) string {
if profile == "embeddinggemma" {
t := strings.TrimSpace(title)
if t == "" {
t = "none"
}
return "title: " + t + " | text: " + strings.TrimSpace(text)
}
return text
}
func splitQueryText(text string) (title, body string) {
text = strings.TrimSpace(text)
if text == "" {
return "", ""
}
if i := strings.IndexByte(text, '\n'); i >= 0 {
title = strings.TrimSpace(text[:i])
body = strings.TrimSpace(text[i+1:])
return title, body
}
return text, text
}
func chunkText(text string, words, overlap, maxChunks int) []string {
parts := strings.Fields(strings.TrimSpace(text))
if len(parts) == 0 {
return nil
}
if words <= 0 {
words = 160
}
if overlap < 0 || overlap >= words {
overlap = 0
}
if maxChunks <= 0 {
maxChunks = 24
}
step := words - overlap
out := make([]string, 0, minInt(maxChunks, (len(parts)+step-1)/step))
for start := 0; start < len(parts) && len(out) < maxChunks; start += step {
end := start + words
if end > len(parts) {
end = len(parts)
}
out = append(out, strings.Join(parts[start:end], " "))
if end == len(parts) {
break
}
}
return out
}
type scorePart struct {
value, weight float64
available bool
}
func weightedScore(_ ScoringConfig, parts ...scorePart) float64 {
var sum, weights float64
for _, p := range parts {
if !p.available || p.weight <= 0 {
continue
}
sum += clamp01(p.value) * p.weight
weights += p.weight
}
if weights == 0 {
return 0
}
return clamp01(sum / weights)
}
func titleSimilarity(query, title string) float64 {
q := strings.TrimSpace(query)
t := strings.TrimSpace(title)
if q == "" || t == "" {
return 0
}
qn := normalizeText(q)
tn := normalizeText(t)
if qn == tn || strings.Contains(tn, qn) || strings.Contains(qn, tn) {
return 1
}
// Title relevance is intentionally asymmetric: if the short ticket subject
// is fully represented by one of several concepts in a longer KB title, that
// is a strong title match rather than a low symmetric F1 score.
return math.Max(tokenCoverage(q, t), tokenF1(q, t))
}
func lexicalSimilarity(query string, d model.KnowledgeDoc) float64 {
best := math.Max(tokenF1(query, d.Title+" "+d.Text), tokenCoverage(query, d.Title+" "+d.Text))
if t, _ := splitQueryText(query); strings.TrimSpace(t) != "" {
best = math.Max(best, tokenCoverage(t, d.Title))
}
for _, kw := range d.Keywords {
best = math.Max(best, phraseCoverage(kw, query))
}
return clamp01(best)
}
func keywordSimilarity(query string, keywords []string) (float64, bool) {
if len(keywords) == 0 {
return 0, false
}
best := 0.0
for _, kw := range keywords {
kw = strings.TrimSpace(kw)
if kw == "" {
continue
}
best = math.Max(best, phraseCoverage(kw, query))
}
return clamp01(best), true
}
func categorySimilarity(query string, ids []int64, categories []model.Category) (float64, bool) {
if len(ids) == 0 || len(categories) == 0 {
return 0, false
}
wanted := make(map[int64]struct{}, len(ids))
for _, id := range ids {
wanted[id] = struct{}{}
}
best, found := 0.0, false
for _, c := range categories {
if _, ok := wanted[c.ID]; !ok {
continue
}
found = true
for _, part := range append([]string{c.Name, c.CompleteName}, append(c.Hints, c.Examples...)...) {
if strings.TrimSpace(part) == "" {
continue
}
best = math.Max(best, phraseCoverage(part, query))
}
}
return clamp01(best), found
}
// phraseCoverage asks "how much of this concept phrase occurs in the query?".
// It is better suited to support terminology than symmetric F1 because a long
// user ticket may contain lots of harmless extra words.
func phraseCoverage(phrase, query string) float64 {
pn := normalizeText(phrase)
qn := normalizeText(query)
if pn == "" || qn == "" {
return 0
}
if strings.Contains(qn, pn) {
return 1
}
return tokenCoverage(phrase, query)
}
func tokenCoverage(needle, haystack string) float64 {
a := tokenList(needle)
b := tokenList(haystack)
if len(a) == 0 || len(b) == 0 {
return 0
}
var sum float64
for _, x := range a {
best := 0.0
for _, y := range b {
best = math.Max(best, tokenSimilarity(x, y))
}
sum += best
}
return clamp01(sum / float64(len(a)))
}
func tokenSimilarity(a, b string) float64 {
if a == b {
return 1
}
if len(a) < 4 || len(b) < 4 {
return 0
}
// Helpdesk-German contains many compounds and inflections (anmelden,
// Anmeldung, Benutzeranmeldung, Nutzerkonto, Benutzerkonto). Exact-token
// overlap is therefore too brittle. First reward strong substring matches,
// then compare a deliberately small set of German support stems.
short, long := a, b
if len(short) > len(long) {
short, long = long, short
}
if len(short) >= 5 && strings.Contains(long, short) {
ratio := float64(len(short)) / float64(len(long))
return clamp01(.75 + .25*ratio)
}
sa, sb := supportStem(a), supportStem(b)
if sa == sb && len(sa) >= 5 {
return .95
}
stemShort, stemLong := sa, sb
if len(stemShort) > len(stemLong) {
stemShort, stemLong = stemLong, stemShort
}
if len(stemShort) >= 5 && strings.Contains(stemLong, stemShort) {
return .90
}
common := 0
limit := len(a)
if len(b) < limit {
limit = len(b)
}
for common < limit && a[common] == b[common] {
common++
}
minLen := len(a)
if len(b) < minLen {
minLen = len(b)
}
maxLen := len(a)
if len(b) > maxLen {
maxLen = len(b)
}
if common >= 5 && float64(common)/float64(minLen) >= .70 {
return clamp01(float64(common) / float64(maxLen))
}
return 0
}
func supportStem(s string) string {
s = strings.ToLower(strings.TrimSpace(s))
// Long, semantically common German suffixes first. This is intentionally
// conservative and is not meant to be a full linguistic stemmer.
for _, suffix := range []string{"ungen", "ern", "ung", "ieren", "ischen", "ische", "isch", "enden", "ende", "en", "er", "es", "e", "n", "s"} {
if strings.HasSuffix(s, suffix) && len(s)-len(suffix) >= 5 {
s = strings.TrimSuffix(s, suffix)
break
}
}
return s
}
func normalizeText(s string) string {
return strings.Join(tokenList(s), " ")
}
func tokenList(s string) []string {
parts := strings.FieldsFunc(strings.ToLower(s), func(r rune) bool { return !unicode.IsLetter(r) && !unicode.IsDigit(r) })
out := make([]string, 0, len(parts))
for _, p := range parts {
if len([]rune(p)) < 3 || isStopword(p) {
continue
}
out = append(out, p)
}
return out
}
func isStopword(s string) bool {
switch s {
case "der", "die", "das", "den", "dem", "des", "ein", "eine", "einer", "einem", "einen", "und", "oder", "aber", "mit", "ohne", "für", "fuer", "von", "vom", "zum", "zur", "ist", "sind", "war", "wird", "werden", "ich", "wir", "sie", "seit", "heute", "gestern", "bitte", "hilfe", "vielen", "dank", "nicht", "mehr", "kann", "mich", "mir", "mein", "meine", "meinen", "meinem":
return true
default:
return false
}
}
func tokenF1(a, b string) float64 {
aTok, bTok := tokens(a), tokens(b)
if len(aTok) == 0 || len(bTok) == 0 {
return 0
}
common := 0
for t := range aTok {
if _, ok := bTok[t]; ok {
common++
}
}
if common == 0 {
return 0
}
precision := float64(common) / float64(len(aTok))
recall := float64(common) / float64(len(bTok))
return 2 * precision * recall / (precision + recall)
}
func excerpt(s string, max int) string {
s = strings.Join(strings.Fields(s), " ")
if len([]rune(s)) <= max {
return s
}
r := []rune(s)
return string(r[:max]) + "…"
}
func clamp01(v float64) float64 {
if v < 0 {
return 0
}
if v > 1 {
return 1
}
return v
}
func cloneVectorMap(in map[string][]float64) map[string][]float64 {
out := make(map[string][]float64, len(in))
for k, v := range in {
out[k] = append([]float64(nil), v...)
}
return out
}
func cloneChunkVectorMap(in map[string][][]float64) map[string][][]float64 {
out := make(map[string][][]float64, len(in))
for k, v := range in {
out[k] = cloneChunkVectors(v)
}
return out
}
func cloneChunkVectors(in [][]float64) [][]float64 {
out := make([][]float64, len(in))
for i, v := range in {
out[i] = append([]float64(nil), v...)
}
return out
}
func cloneStringSliceMap(in map[string][]string) map[string][]string {
out := make(map[string][]string, len(in))
for k, v := range in {
out[k] = append([]string(nil), v...)
}
return out
}
func minInt(a, b int) int {
if a < b {
return a
}
return b
}
func hashDoc(d model.KnowledgeDoc, cfg ScoringConfig) string {
// Only fields that change the actual embedding input belong in this fingerprint.
// Category mappings affect deterministic ranking/policy, not the text sent to
// the embedding model, so remapping categories must not force re-embedding.
// Formatting-only changes to AnswerHTML must not force re-embedding.
b, _ := json.Marshal(struct {
ID string `json:"id"`
Title string `json:"title"`
Text string `json:"text"`
Keywords []string `json:"keywords"`
Profile string `json:"profile"`
EmbeddingIdentity string `json:"embedding_identity"`
ChunkWords int `json:"chunk_words"`
ChunkOverlap int `json:"chunk_overlap"`
MaxChunks int `json:"max_chunks"`
}{d.ID, d.Title, d.Text, d.Keywords, cfg.EmbeddingProfile, cfg.EmbeddingIdentity, cfg.ChunkWords, cfg.ChunkOverlap, cfg.MaxChunksPerDoc})
h := sha256.Sum256(b)
return hex.EncodeToString(h[:])
}
func cosine(a, b []float64) float64 {
if len(a) == 0 || len(a) != len(b) {
return 0
}
var dot, aa, bb float64
for i := range a {
dot += a[i] * b[i]
aa += a[i] * a[i]
bb += b[i] * b[i]
}
if aa == 0 || bb == 0 {
return 0
}
return dot / (math.Sqrt(aa) * math.Sqrt(bb))
}
func lexical(text string, d model.KnowledgeDoc) float64 {
q := tokens(text)
hay := tokens(d.Title + " " + d.Text + " " + strings.Join(d.Keywords, " ") + " " + strings.Join(d.ExternalCategories, " "))
if len(q) == 0 {
return 0
}
hits := 0
for t := range q {
if _, ok := hay[t]; ok {
hits++
}
}
return float64(hits) / float64(len(q))
}
func tokens(s string) map[string]struct{} {
m := map[string]struct{}{}
for _, p := range tokenList(s) {
m[p] = struct{}{}
}
return m
}