Files
glpi-neuroforge-mega/patches/glpi-agent-mega.diff
jbergner a6bc71fb3a
Some checks failed
release-tag / Resolve release metadata (push) Successful in 30s
release-tag / Build knowledge (push) Failing after 4m51s
release-tag / Build control (push) Failing after 5m0s
release-tag / Build agent (push) Failing after 5m0s
release-tag / Build agent-data-init (push) Failing after 5m5s
release-tag / Build neuroforge-worker (push) Failing after 5m7s
release-tag / Build neuroforge (push) Failing after 5m9s
Init
2026-08-26 18:34:41 +02:00

1785 lines
68 KiB
Diff
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
diff -ruN '--exclude=.git' '--exclude=.env_local' '--exclude=agent' '--exclude=mega-control' /mnt/data/mega_work/originals/glpi-ai-agent/dist/README.md /mnt/data/mega_work/glpi-neuroforge-mega/services/agent/dist/README.md
--- /mnt/data/mega_work/originals/glpi-ai-agent/dist/README.md 2026-08-05 17:17:23.000000000 +0000
+++ /mnt/data/mega_work/glpi-neuroforge-mega/services/agent/dist/README.md 1970-01-01 00:00:00.000000000 +0000
@@ -1,7 +0,0 @@
-# Vorgebaute Programme
-
-- `glpi-ai-agent-linux-amd64`: Linux amd64, statisch gebaut (`CGO_ENABLED=0`)
-- `glpi-ai-agent-windows-amd64.exe`: Windows amd64, statisch gebaut (`CGO_ENABLED=0`)
-- `SHA256SUMS.txt`: SHA-256-Prüfsummen der beiden Programme
-
-Die Programme wurden aus dem gemeinsam ausgelieferten Quellstand mit `make dist` erstellt. Für andere Architekturen kann das Go-Projekt direkt neu gebaut werden.
diff -ruN '--exclude=.git' '--exclude=.env_local' '--exclude=agent' '--exclude=mega-control' /mnt/data/mega_work/originals/glpi-ai-agent/dist/SHA256SUMS.txt /mnt/data/mega_work/glpi-neuroforge-mega/services/agent/dist/SHA256SUMS.txt
--- /mnt/data/mega_work/originals/glpi-ai-agent/dist/SHA256SUMS.txt 2026-08-05 17:17:23.000000000 +0000
+++ /mnt/data/mega_work/glpi-neuroforge-mega/services/agent/dist/SHA256SUMS.txt 1970-01-01 00:00:00.000000000 +0000
@@ -1,2 +0,0 @@
-4b9859394e592706bc73da7754c8e7a3cf71459e8517c8d75bed0da2331fa98a dist/glpi-ai-agent-linux-amd64
-62c2f41fd05171a0d5750c97881f4c80196664016a5800ee69d7806ca059a698 dist/glpi-ai-agent-windows-amd64.exe
Binary files /mnt/data/mega_work/originals/glpi-ai-agent/dist/glpi-ai-agent-linux-amd64 and /mnt/data/mega_work/glpi-neuroforge-mega/services/agent/dist/glpi-ai-agent-linux-amd64 differ
Binary files /mnt/data/mega_work/originals/glpi-ai-agent/dist/glpi-ai-agent-windows-amd64.exe and /mnt/data/mega_work/glpi-neuroforge-mega/services/agent/dist/glpi-ai-agent-windows-amd64.exe differ
diff -ruN '--exclude=.git' '--exclude=.env_local' '--exclude=agent' '--exclude=mega-control' /mnt/data/mega_work/originals/glpi-ai-agent/go.mod /mnt/data/mega_work/glpi-neuroforge-mega/services/agent/go.mod
--- /mnt/data/mega_work/originals/glpi-ai-agent/go.mod 2026-08-05 17:17:23.000000000 +0000
+++ /mnt/data/mega_work/glpi-neuroforge-mega/services/agent/go.mod 2026-08-25 15:35:04.000000000 +0000
@@ -1,3 +1,3 @@
module github.com/example/glpi-ai-agent
-go 1.26
+go 1.23
diff -ruN '--exclude=.git' '--exclude=.env_local' '--exclude=agent' '--exclude=mega-control' /mnt/data/mega_work/originals/glpi-ai-agent/internal/config/config.go /mnt/data/mega_work/glpi-neuroforge-mega/services/agent/internal/config/config.go
--- /mnt/data/mega_work/originals/glpi-ai-agent/internal/config/config.go 2026-08-05 17:17:23.000000000 +0000
+++ /mnt/data/mega_work/glpi-neuroforge-mega/services/agent/internal/config/config.go 2026-08-25 16:02:10.000000000 +0000
@@ -85,6 +85,13 @@
KnowledgeIndexMode string
KnowledgeEmbedBatchSize int
KnowledgeIndexScanInterval time.Duration
+ KnowledgeVectorBackend string
+ NeuroForgeURL string
+ NeuroForgeAPIKey string
+ NeuroForgeNamespace string
+ NeuroForgeTimeout time.Duration
+ NeuroForgeSearchK int
+ NeuroForgeFailOpen bool
GLPIKBEnabled bool
GLPIKBPath string
GLPIKBFilter string
@@ -276,6 +283,13 @@
KnowledgeIndexMode: envNormalizedLower("KNOWLEDGE_INDEX_MODE", "incremental"),
KnowledgeEmbedBatchSize: envInt("KNOWLEDGE_EMBED_BATCH_SIZE", 64),
KnowledgeIndexScanInterval: envDuration("KNOWLEDGE_INDEX_SCAN_INTERVAL", 5*time.Minute),
+ KnowledgeVectorBackend: envNormalizedLower("KNOWLEDGE_VECTOR_BACKEND", "local"),
+ NeuroForgeURL: strings.TrimRight(env("NEUROFORGE_URL", "http://127.0.0.1:8090"), "/"),
+ NeuroForgeAPIKey: strings.TrimSpace(os.Getenv("NEUROFORGE_API_KEY")),
+ NeuroForgeNamespace: env("NEUROFORGE_NAMESPACE", "glpi-agent"),
+ NeuroForgeTimeout: envDuration("NEUROFORGE_TIMEOUT", 15*time.Second),
+ NeuroForgeSearchK: envInt("NEUROFORGE_SEARCH_K", 128),
+ NeuroForgeFailOpen: envBool("NEUROFORGE_FAIL_OPEN", true),
GLPIKBEnabled: envBool("GLPI_KB_ENABLED", false),
GLPIKBPath: env("GLPI_KB_PATH", "auto"),
GLPIKBFilter: strings.TrimSpace(os.Getenv("GLPI_KB_FILTER")),
@@ -609,6 +623,23 @@
if c.KnowledgeIndexScanInterval < 0 {
return errors.New("KNOWLEDGE_INDEX_SCAN_INTERVAL must be >= 0")
}
+ switch c.KnowledgeVectorBackend {
+ case "", "local", "dual", "neuroforge":
+ default:
+ return fmt.Errorf("KNOWLEDGE_VECTOR_BACKEND must be one of: local, dual, neuroforge (got %q)", c.KnowledgeVectorBackend)
+ }
+ if c.KnowledgeVectorBackend == "dual" || c.KnowledgeVectorBackend == "neuroforge" {
+ u, err := url.Parse(c.NeuroForgeURL)
+ if err != nil || u.Scheme == "" || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
+ return errors.New("NEUROFORGE_URL must be an absolute http(s) URL")
+ }
+ if c.NeuroForgeSearchK < 1 || c.NeuroForgeSearchK > 500 {
+ return errors.New("NEUROFORGE_SEARCH_K must be between 1 and 500")
+ }
+ if c.NeuroForgeTimeout <= 0 {
+ return errors.New("NEUROFORGE_TIMEOUT must be positive")
+ }
+ }
if c.LearningEnabled {
if c.LearningMaxExamples < 1 || c.LearningMaxExamples > 10000 {
return errors.New("LEARNING_MAX_EXAMPLES must be between 1 and 10000")
diff -ruN '--exclude=.git' '--exclude=.env_local' '--exclude=agent' '--exclude=mega-control' /mnt/data/mega_work/originals/glpi-ai-agent/internal/glpi/client.go /mnt/data/mega_work/glpi-neuroforge-mega/services/agent/internal/glpi/client.go
--- /mnt/data/mega_work/originals/glpi-ai-agent/internal/glpi/client.go 2026-08-05 17:17:23.000000000 +0000
+++ /mnt/data/mega_work/glpi-neuroforge-mega/services/agent/internal/glpi/client.go 2026-08-25 18:19:32.870426876 +0000
@@ -10,6 +10,7 @@
"io"
"net/http"
"net/url"
+ "regexp"
"sort"
"strconv"
"strings"
@@ -493,6 +494,147 @@
return out, nil
}
+// ListKnowledgeBaseLinkedItems reads GLPI's KnowbaseItem_Item relation using
+// the route exposed by the installed OpenAPI contract. GLPI models these
+// records as (knowbaseitems_id, itemtype, items_id). The implementation
+// supports both a global relation collection and nested per-article routes.
+func (c *Client) ListKnowledgeBaseLinkedItems(ctx context.Context, articleIDs []int64, limit int) (map[int64][]model.LinkedItem, error) {
+ doc, err := c.FetchOpenAPI(ctx)
+ if err != nil {
+ return nil, fmt.Errorf("fetch GLPI OpenAPI for knowledge links: %w", err)
+ }
+ paths, ok := doc["paths"].(map[string]any)
+ if !ok {
+ return nil, errors.New("GLPI OpenAPI document has no paths map")
+ }
+ type candidate struct {
+ path string
+ nested bool
+ score int
+ }
+ var candidates []candidate
+ for documented, raw := range paths {
+ ops, ok := raw.(map[string]any)
+ if !ok || ops["get"] == nil {
+ continue
+ }
+ l := strings.ToLower(documented)
+ compact := strings.NewReplacer("_", "", "-", "", "/", "").Replace(l)
+ if !strings.Contains(compact, "knowbaseitemitem") {
+ continue
+ }
+ nested := strings.Contains(documented, "{")
+ score := 100
+ if !nested {
+ score += 40
+ }
+ if strings.Contains(l, "knowledge") {
+ score += 10
+ }
+ candidates = append(candidates, candidate{path: documented, nested: nested, score: score})
+ }
+ if len(candidates) == 0 {
+ return nil, errors.New("GLPI OpenAPI exposes no readable KnowbaseItem_Item relation route")
+ }
+ sort.Slice(candidates, func(i, j int) bool {
+ if candidates[i].score == candidates[j].score {
+ return candidates[i].path < candidates[j].path
+ }
+ return candidates[i].score > candidates[j].score
+ })
+ chosen := candidates[0]
+ cleanPath := func(p string) string {
+ if i := strings.Index(p, "/api.php/"); i >= 0 {
+ rest := p[i+len("/api.php/"):]
+ if slash := strings.Index(rest, "/"); slash >= 0 {
+ p = rest[slash:]
+ }
+ }
+ versionPrefix := "/" + strings.Trim(c.version, "/")
+ if strings.HasPrefix(p, versionPrefix+"/") {
+ p = strings.TrimPrefix(p, versionPrefix)
+ }
+ if !strings.HasPrefix(p, "/") {
+ p = "/" + p
+ }
+ return p
+ }
+ chosen.path = cleanPath(chosen.path)
+ if limit <= 0 {
+ limit = 10000
+ }
+ result := map[int64][]model.LinkedItem{}
+ seen := map[string]struct{}{}
+ addRows := func(articleHint int64, body []byte) error {
+ arr, err := extractArray(body)
+ if err != nil {
+ return err
+ }
+ for _, r := range arr {
+ articleID := firstPositiveInt(r, "knowbaseitems_id", "knowbaseitem_id")
+ if articleID <= 0 {
+ articleID = articleHint
+ }
+ itemType := firstString(r, "itemtype", "item_type", "type")
+ itemID := firstPositiveInt(r, "items_id", "item_id")
+ name := firstString(r, "item_name", "name", "completename", "title")
+ if articleID <= 0 || itemID <= 0 || strings.TrimSpace(itemType) == "" {
+ continue
+ }
+ key := fmt.Sprintf("%d|%s|%d", articleID, strings.ToLower(itemType), itemID)
+ if _, ok := seen[key]; ok {
+ continue
+ }
+ seen[key] = struct{}{}
+ result[articleID] = append(result[articleID], model.LinkedItem{ItemType: itemType, ID: itemID, Name: name})
+ }
+ return nil
+ }
+
+ if !chosen.nested {
+ body, _, err := c.do(ctx, http.MethodGet, chosen.path, url.Values{"limit": {strconv.Itoa(limit)}}, nil)
+ if err != nil {
+ return nil, err
+ }
+ if err := addRows(0, body); err != nil {
+ return nil, err
+ }
+ } else {
+ placeholder := regexp.MustCompile(`\{[^/{}]+\}`)
+ if len(placeholder.FindAllString(chosen.path, -1)) != 1 {
+ return nil, fmt.Errorf("unsupported GLPI knowledge relation route %q", chosen.path)
+ }
+ for _, articleID := range articleIDs {
+ p := placeholder.ReplaceAllString(chosen.path, strconv.FormatInt(articleID, 10))
+ body, _, err := c.do(ctx, http.MethodGet, p, url.Values{"limit": {strconv.Itoa(limit)}}, nil)
+ if err != nil {
+ return nil, err
+ }
+ if err := addRows(articleID, body); err != nil {
+ return nil, err
+ }
+ }
+ }
+ for id := range result {
+ sort.Slice(result[id], func(i, j int) bool {
+ if result[id][i].ItemType == result[id][j].ItemType {
+ return result[id][i].ID < result[id][j].ID
+ }
+ return result[id][i].ItemType < result[id][j].ItemType
+ })
+ }
+ return result, nil
+}
+
+func firstPositiveInt(r map[string]any, keys ...string) int64 {
+ for _, key := range keys {
+ if id := int64Val(r[key]); id > 0 {
+ return id
+ }
+ }
+ return 0
+}
+
func knowledgeCategoryIDs(r map[string]any) []int64 {
seen := map[int64]struct{}{}
var out []int64
diff -ruN '--exclude=.git' '--exclude=.env_local' '--exclude=agent' '--exclude=mega-control' /mnt/data/mega_work/originals/glpi-ai-agent/internal/glpi/client_test.go /mnt/data/mega_work/glpi-neuroforge-mega/services/agent/internal/glpi/client_test.go
--- /mnt/data/mega_work/originals/glpi-ai-agent/internal/glpi/client_test.go 2026-08-05 17:17:23.000000000 +0000
+++ /mnt/data/mega_work/glpi-neuroforge-mega/services/agent/internal/glpi/client_test.go 2026-08-25 18:22:28.219087408 +0000
@@ -320,3 +320,32 @@
t.Fatalf("unexpected link request path=%q body=%#v", gotPath, body)
}
}
+
+func TestListKnowledgeBaseLinkedItemsFromOpenAPICollection(t *testing.T) {
+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ switch r.URL.Path {
+ case "/api.php/token":
+ _ = json.NewEncoder(w).Encode(map[string]any{"access_token": "x", "expires_in": 3600})
+ case "/api.php/doc.json":
+ _ = json.NewEncoder(w).Encode(map[string]any{"paths": map[string]any{
+ "/v2.3/Knowledge/KnowbaseItem_Item": map[string]any{"get": map[string]any{}},
+ }})
+ case "/api.php/v2.3/Knowledge/KnowbaseItem_Item":
+ _ = json.NewEncoder(w).Encode([]map[string]any{
+ {"knowbaseitems_id": 12, "itemtype": "Computer", "items_id": 42, "item_name": "NB-042"},
+ {"knowbaseitems_id": 12, "itemtype": "Ticket", "items_id": 99},
+ })
+ default:
+ http.NotFound(w, r)
+ }
+ }))
+ defer srv.Close()
+ c := New(srv.URL, "v2.3", "cid", "sec", "u", "p", time.Second)
+ links, err := c.ListKnowledgeBaseLinkedItems(context.Background(), []int64{12}, 100)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if len(links[12]) != 2 || links[12][0].ItemType != "Computer" || links[12][0].Name != "NB-042" {
+ t.Fatalf("unexpected links: %#v", links)
+ }
+}
diff -ruN '--exclude=.git' '--exclude=.env_local' '--exclude=agent' '--exclude=mega-control' /mnt/data/mega_work/originals/glpi-ai-agent/internal/glpikb/sync.go /mnt/data/mega_work/glpi-neuroforge-mega/services/agent/internal/glpikb/sync.go
--- /mnt/data/mega_work/originals/glpi-ai-agent/internal/glpikb/sync.go 2026-08-05 17:17:23.000000000 +0000
+++ /mnt/data/mega_work/glpi-neuroforge-mega/services/agent/internal/glpikb/sync.go 2026-08-25 18:19:43.370684417 +0000
@@ -28,6 +28,10 @@
GetCategories(context.Context) ([]model.Category, error)
}
+type LinkedItemSource interface {
+ ListKnowledgeBaseLinkedItems(context.Context, []int64, int) (map[int64][]model.LinkedItem, error)
+}
+
type Store interface {
ReplaceExternalSource(context.Context, string, []model.KnowledgeDoc) error
Count() int
@@ -140,6 +144,24 @@
s.fail(err)
return fmt.Errorf("list GLPI knowledge base: %w", err)
}
+ if linkedSource, ok := s.glpi.(LinkedItemSource); ok {
+ articleIDs := make([]int64, 0, len(items))
+ for _, item := range items {
+ articleIDs = append(articleIDs, item.ID)
+ }
+ linkLimit := s.cfg.GLPIKBLimit * 20
+ if linkLimit < 1000 {
+ linkLimit = 1000
+ }
+ links, linkErr := linkedSource.ListKnowledgeBaseLinkedItems(ctx, articleIDs, linkLimit)
+ if linkErr != nil {
+ slog.Warn("GLPI knowledge linked items unavailable", "error", linkErr, "impact", "articles still synchronize; Obsidian export will omit unavailable GLPI object relations")
+ } else {
+ for i := range items {
+ items[i].LinkedItems = append([]model.LinkedItem(nil), links[items[i].ID]...)
+ }
+ }
+ }
cats, err := s.glpi.GetCategories(ctx)
if err != nil {
slog.Warn(
@@ -298,6 +320,7 @@
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,
+ LinkedItems: append([]model.LinkedItem(nil), item.LinkedItems...),
})
}
return out
diff -ruN '--exclude=.git' '--exclude=.env_local' '--exclude=agent' '--exclude=mega-control' /mnt/data/mega_work/originals/glpi-ai-agent/internal/glpikb/sync_test.go /mnt/data/mega_work/glpi-neuroforge-mega/services/agent/internal/glpikb/sync_test.go
--- /mnt/data/mega_work/originals/glpi-ai-agent/internal/glpikb/sync_test.go 2026-08-05 17:17:23.000000000 +0000
+++ /mnt/data/mega_work/glpi-neuroforge-mega/services/agent/internal/glpikb/sync_test.go 2026-08-25 18:23:32.995261319 +0000
@@ -247,3 +247,25 @@
t.Fatalf("decision=%q", got)
}
}
+
+type linkedFakeSource struct{ fakeSource }
+
+func (linkedFakeSource) ListKnowledgeBaseLinkedItems(context.Context, []int64, int) (map[int64][]model.LinkedItem, error) {
+ return map[int64][]model.LinkedItem{12: {{ItemType: "Computer", ID: 42, Name: "NB-042"}}}, nil
+}
+
+func TestSyncPreservesGLPIKnowledgeLinkedItems(t *testing.T) {
+ cfg := config.Config{DataDir: t.TempDir(), GLPIKBEnabled: true, GLPIKBPath: "auto", GLPIKBLimit: 50, GLPIKBSyncInterval: time.Minute, GLPIKBSource: "glpi-kb", CommunicationLanguage: "de-DE", CommunicationStyle: "formal", GLPITimeout: time.Second}
+ st := &fakeStore{}
+ s := New(cfg, linkedFakeSource{}, st, metrics.New())
+ if err := s.Sync(context.Background()); err != nil {
+ t.Fatal(err)
+ }
+ if len(st.docs) != 1 || len(st.docs[0].LinkedItems) != 1 {
+ t.Fatalf("linked items lost during sync: %#v", st.docs)
+ }
+ got := st.docs[0].LinkedItems[0]
+ if got.ItemType != "Computer" || got.ID != 42 || got.Name != "NB-042" {
+ t.Fatalf("unexpected linked item: %#v", got)
+ }
+}
diff -ruN '--exclude=.git' '--exclude=.env_local' '--exclude=agent' '--exclude=mega-control' /mnt/data/mega_work/originals/glpi-ai-agent/internal/knowledge/neuroforge_backend.go /mnt/data/mega_work/glpi-neuroforge-mega/services/agent/internal/knowledge/neuroforge_backend.go
--- /mnt/data/mega_work/originals/glpi-ai-agent/internal/knowledge/neuroforge_backend.go 1970-01-01 00:00:00.000000000 +0000
+++ /mnt/data/mega_work/glpi-neuroforge-mega/services/agent/internal/knowledge/neuroforge_backend.go 2026-08-25 16:02:10.000000000 +0000
@@ -0,0 +1,371 @@
+package knowledge
+
+import (
+ "bytes"
+ "context"
+ "crypto/sha256"
+ "encoding/hex"
+ "encoding/json"
+ "errors"
+ "fmt"
+ "io"
+ "log/slog"
+ "net/http"
+ "net/url"
+ "strconv"
+ "strings"
+ "time"
+
+ "github.com/example/glpi-ai-agent/internal/model"
+)
+
+// SemanticBackend externalizes chunk-vector persistence/search while the
+// existing deterministic GLPI hybrid scorer remains authoritative.
+type SemanticBackend interface {
+ Name() string
+ UpsertDocument(context.Context, model.KnowledgeDoc, []string, [][]float64) error
+ DeleteDocument(context.Context, string) error
+ Search(context.Context, []float64, int) ([]SemanticHit, error)
+ Health(context.Context) error
+}
+
+type SemanticHit struct {
+ DocumentID string
+ ChunkIndex int
+ Text string
+ Similarity float64
+ Source string
+}
+
+type NeuroForgeBackendConfig struct {
+ BaseURL string
+ APIKey string
+ Namespace string
+ Timeout time.Duration
+}
+
+type NeuroForgeBackend struct {
+ baseURL string
+ apiKey string
+ namespace string
+ http *http.Client
+}
+
+func NewNeuroForgeBackend(cfg NeuroForgeBackendConfig) (*NeuroForgeBackend, error) {
+ raw := strings.TrimRight(strings.TrimSpace(cfg.BaseURL), "/")
+ if raw == "" {
+ return nil, errors.New("neuroforge base URL is required")
+ }
+ u, err := url.Parse(raw)
+ if err != nil || u.Scheme == "" || u.Host == "" || (u.Scheme != "http" && u.Scheme != "https") {
+ return nil, fmt.Errorf("invalid neuroforge URL %q", raw)
+ }
+ ns := strings.TrimSpace(cfg.Namespace)
+ if ns == "" {
+ ns = "glpi-agent"
+ }
+ if cfg.Timeout <= 0 {
+ cfg.Timeout = 15 * time.Second
+ }
+ return &NeuroForgeBackend{baseURL: raw, apiKey: strings.TrimSpace(cfg.APIKey), namespace: ns, http: &http.Client{Timeout: cfg.Timeout}}, nil
+}
+
+func (c *NeuroForgeBackend) Name() string { return "neuroforge" }
+
+func vector32(in []float64) []float32 {
+ out := make([]float32, len(in))
+ for i, v := range in {
+ out[i] = float32(v)
+ }
+ return out
+}
+
+func contentHash(text string, vec []float64) string {
+ h := sha256.New()
+ _, _ = h.Write([]byte(text))
+ _, _ = h.Write([]byte{0})
+ for _, v := range vec {
+ _, _ = h.Write([]byte(strconv.FormatFloat(v, 'g', 9, 64)))
+ _, _ = h.Write([]byte{0})
+ }
+ return hex.EncodeToString(h.Sum(nil))
+}
+
+func (c *NeuroForgeBackend) UpsertDocument(ctx context.Context, d model.KnowledgeDoc, chunks []string, vectors [][]float64) error {
+ if len(chunks) != len(vectors) {
+ return fmt.Errorf("chunk/vector mismatch for %s: %d != %d", d.ID, len(chunks), len(vectors))
+ }
+ type chunkReq struct {
+ Index int `json:"index"`
+ Text string `json:"text"`
+ Vector []float32 `json:"vector"`
+ ContentHash string `json:"content_hash"`
+ }
+ req := struct {
+ Namespace string `json:"namespace"`
+ DocumentID string `json:"document_id"`
+ Title string `json:"title,omitempty"`
+ SourceURI string `json:"source_uri,omitempty"`
+ Tags []string `json:"tags,omitempty"`
+ Confidence float64 `json:"confidence"`
+ Chunks []chunkReq `json:"chunks"`
+ }{Namespace: c.namespace, DocumentID: d.ID, Title: d.Title, SourceURI: d.SourceURI, Confidence: 1}
+ req.Tags = append(req.Tags, "source:"+d.Source)
+ for _, cat := range d.Categories {
+ req.Tags = append(req.Tags, "category:"+strconv.FormatInt(cat, 10))
+ }
+ req.Chunks = make([]chunkReq, 0, len(chunks))
+ for i := range chunks {
+ req.Chunks = append(req.Chunks, chunkReq{Index: i, Text: chunks[i], Vector: vector32(vectors[i]), ContentHash: contentHash(chunks[i], vectors[i])})
+ }
+ var out map[string]any
+ return c.doJSON(ctx, http.MethodPost, "/api/v1/integrations/knowledge/upsert", req, &out)
+}
+
+func (c *NeuroForgeBackend) DeleteDocument(ctx context.Context, id string) error {
+ path := "/api/v1/integrations/knowledge/" + url.PathEscape(c.namespace) + "/" + url.PathEscape(strings.TrimSpace(id))
+ var out map[string]any
+ return c.doJSON(ctx, http.MethodDelete, path, nil, &out)
+}
+
+func (c *NeuroForgeBackend) Search(ctx context.Context, vector []float64, k int) ([]SemanticHit, error) {
+ if k <= 0 {
+ k = 128
+ }
+ req := struct {
+ Namespace string `json:"namespace"`
+ Vector []float32 `json:"vector"`
+ K int `json:"k"`
+ }{Namespace: c.namespace, Vector: vector32(vector), K: k}
+ var raw []struct {
+ Memory struct {
+ ID string `json:"id"`
+ Text string `json:"text"`
+ Provenance struct {
+ Source string `json:"source"`
+ SourceMemoryID string `json:"source_memory_id"`
+ ChunkIndex int `json:"chunk_index"`
+ } `json:"provenance"`
+ } `json:"memory"`
+ Similarity float64 `json:"similarity"`
+ }
+ if err := c.doJSON(ctx, http.MethodPost, "/api/v1/integrations/knowledge/search", req, &raw); err != nil {
+ return nil, err
+ }
+ out := make([]SemanticHit, 0, len(raw))
+ for _, h := range raw {
+ if h.Memory.Provenance.SourceMemoryID == "" {
+ continue
+ }
+ out = append(out, SemanticHit{DocumentID: h.Memory.Provenance.SourceMemoryID, ChunkIndex: h.Memory.Provenance.ChunkIndex, Text: h.Memory.Text, Similarity: h.Similarity, Source: h.Memory.Provenance.Source})
+ }
+ return out, nil
+}
+
+func (c *NeuroForgeBackend) Health(ctx context.Context) error {
+ req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/readyz", nil)
+ if err != nil {
+ return err
+ }
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode/100 != 2 {
+ b, _ := io.ReadAll(io.LimitReader(resp.Body, 4096))
+ return fmt.Errorf("neuroforge readiness failed: %s: %s", resp.Status, strings.TrimSpace(string(b)))
+ }
+ return nil
+}
+
+func (c *NeuroForgeBackend) doJSON(ctx context.Context, method, path string, input, output any) error {
+ var body io.Reader
+ if input != nil {
+ b, err := json.Marshal(input)
+ if err != nil {
+ return err
+ }
+ body = bytes.NewReader(b)
+ }
+ req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body)
+ if err != nil {
+ return err
+ }
+ if input != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+ if c.apiKey != "" {
+ req.Header.Set("Authorization", "Bearer "+c.apiKey)
+ }
+ resp, err := c.http.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+ if resp.StatusCode/100 != 2 {
+ b, _ := io.ReadAll(io.LimitReader(resp.Body, 8192))
+ return fmt.Errorf("neuroforge %s %s failed: %s: %s", method, path, resp.Status, strings.TrimSpace(string(b)))
+ }
+ if output == nil {
+ return nil
+ }
+ return json.NewDecoder(resp.Body).Decode(output)
+}
+
+// SetSemanticBackend configures the migration mode. local keeps the original
+// snapshot-only behavior; dual mirrors vectors to the backend but preserves the
+// local cache; neuroforge makes NeuroForge authoritative for chunk-vector
+// persistence/search while keeping lexical/title fallback locally.
+func (s *Store) SetSemanticBackend(backend SemanticBackend, mode string, searchK int, failOpen bool) error {
+ if s == nil {
+ return errors.New("knowledge store is nil")
+ }
+ mode = strings.ToLower(strings.TrimSpace(mode))
+ if mode == "" {
+ mode = "local"
+ }
+ switch mode {
+ case "local":
+ backend = nil
+ case "dual", "neuroforge":
+ if backend == nil {
+ return fmt.Errorf("semantic backend %q requires a configured backend", mode)
+ }
+ default:
+ return fmt.Errorf("semantic backend mode must be local, dual or neuroforge")
+ }
+ if searchK <= 0 {
+ searchK = 128
+ }
+ s.mu.Lock()
+ s.semanticBackend = backend
+ s.semanticBackendMode = mode
+ s.semanticBackendSearchK = searchK
+ s.semanticBackendFailOpen = failOpen
+ s.mu.Unlock()
+ return nil
+}
+
+func (s *Store) semanticExternalized() bool {
+ return s != nil && s.semanticBackend != nil && s.semanticBackendMode == "neuroforge"
+}
+
+func (s *Store) syncSemanticDocument(ctx context.Context, d model.KnowledgeDoc, chunks []string, vectors [][]float64) error {
+ s.mu.RLock()
+ backend := s.semanticBackend
+ mode := s.semanticBackendMode
+ s.mu.RUnlock()
+ if backend == nil || mode == "local" || len(vectors) == 0 {
+ return nil
+ }
+ if err := backend.UpsertDocument(ctx, d, chunks, vectors); err != nil {
+ return fmt.Errorf("semantic backend sync %s: %w", d.ID, err)
+ }
+ return nil
+}
+
+func (s *Store) semanticSettings() (SemanticBackend, string, int, bool) {
+ if s == nil {
+ return nil, "local", 0, true
+ }
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ return s.semanticBackend, s.semanticBackendMode, s.semanticBackendSearchK, s.semanticBackendFailOpen
+}
+
+func (s *Store) handleSemanticSyncError(err error) error {
+ if err == nil {
+ return nil
+ }
+ _, mode, _, failOpen := s.semanticSettings()
+ if mode == "dual" || failOpen {
+ return nil
+ }
+ return err
+}
+
+func (s *Store) externalizeChunkVectors() {
+ if !s.semanticExternalized() {
+ return
+ }
+ s.mu.Lock()
+ s.chunkVectors = map[string][][]float64{}
+ s.mu.Unlock()
+}
+
+func (s *Store) syncSemanticDocuments(ctx context.Context, docs []model.KnowledgeDoc, embedded map[string]embeddedDoc) (bool, error) {
+ backend, mode, _, failOpen := s.semanticSettings()
+ if backend == nil || mode == "local" {
+ return true, nil
+ }
+ allOK := true
+ for _, d := range docs {
+ e, ok := embedded[d.ID]
+ if !ok || len(e.chunks) == 0 {
+ continue
+ }
+ chunks := chunkText(d.Text, s.scoring.ChunkWords, s.scoring.ChunkOverlap, s.scoring.MaxChunksPerDoc)
+ if err := s.syncSemanticDocument(ctx, d, chunks, e.chunks); err != nil {
+ allOK = false
+ slog.Warn("semantic backend sync failed; local vectors retained", "backend", backend.Name(), "document", d.ID, "mode", mode, "error", err)
+ if mode == "neuroforge" && !failOpen {
+ return false, err
+ }
+ // dual mode and fail-open neuroforge mode preserve the local vectors.
+ continue
+ }
+ }
+ return allOK, nil
+}
+
+func (s *Store) syncLoadedSemanticBackend(ctx context.Context) (bool, error) {
+ backend, mode, _, failOpen := s.semanticSettings()
+ if backend == nil || mode == "local" {
+ return true, nil
+ }
+ s.mu.RLock()
+ docs := append([]model.KnowledgeDoc(nil), s.docs...)
+ embedded := make(map[string]embeddedDoc, len(s.chunkVectors))
+ for _, d := range docs {
+ if vv := s.chunkVectors[d.ID]; len(vv) > 0 {
+ embedded[d.ID] = embeddedDoc{title: append([]float64(nil), s.titleVectors[d.ID]...), chunks: cloneChunkVectors(vv)}
+ }
+ }
+ s.mu.RUnlock()
+ if len(embedded) == 0 {
+ if err := backend.Health(ctx); err != nil {
+ if failOpen {
+ return false, nil
+ }
+ return false, fmt.Errorf("semantic backend readiness: %w", err)
+ }
+ return true, nil
+ }
+ ok, err := s.syncSemanticDocuments(ctx, docs, embedded)
+ if err != nil {
+ return false, err
+ }
+ if ok && mode == "neuroforge" {
+ s.externalizeChunkVectors()
+ if err := s.persistSnapshot(); err != nil {
+ return false, err
+ }
+ }
+ return ok, nil
+}
+
+func (s *Store) deleteSemanticDocument(ctx context.Context, id string) error {
+ backend, mode, _, failOpen := s.semanticSettings()
+ if backend == nil || mode == "local" {
+ return nil
+ }
+ if err := backend.DeleteDocument(ctx, id); err != nil {
+ if mode == "dual" || failOpen {
+ slog.Warn("semantic backend delete failed; continuing by policy", "backend", backend.Name(), "document", id, "mode", mode, "error", err)
+ return nil
+ }
+ return fmt.Errorf("semantic backend delete %s: %w", id, err)
+ }
+ return nil
+}
diff -ruN '--exclude=.git' '--exclude=.env_local' '--exclude=agent' '--exclude=mega-control' /mnt/data/mega_work/originals/glpi-ai-agent/internal/knowledge/persistent_index.go /mnt/data/mega_work/glpi-neuroforge-mega/services/agent/internal/knowledge/persistent_index.go
--- /mnt/data/mega_work/originals/glpi-ai-agent/internal/knowledge/persistent_index.go 2026-08-05 17:17:23.000000000 +0000
+++ /mnt/data/mega_work/glpi-neuroforge-mega/services/agent/internal/knowledge/persistent_index.go 2026-08-25 15:56:01.000000000 +0000
@@ -323,6 +323,7 @@
oldTitle := s.titleVectors
oldChunkVec := s.chunkVectors
oldChunks := s.chunks
+ _, semanticMode, _, _ := s.semanticSettings()
externalDocs := make([]model.KnowledgeDoc, 0)
externalMap := make(map[string]string, len(s.external))
for id, src := range s.external {
@@ -382,15 +383,18 @@
for _, d := range localDocs {
parts := chunkText(d.Text, s.scoring.ChunkWords, s.scoring.ChunkOverlap, s.scoring.MaxChunksPerDoc)
newChunks[d.ID] = parts
- if old, ok := oldDocs[d.ID]; ok && hashDoc(old, s.scoring) == hashDoc(d, s.scoring) && len(oldTitle[d.ID]) > 0 && len(oldChunkVec[d.ID]) == len(parts) {
+ if old, ok := oldDocs[d.ID]; ok && hashDoc(old, s.scoring) == hashDoc(d, s.scoring) && len(oldTitle[d.ID]) > 0 && (semanticMode == "neuroforge" || len(oldChunkVec[d.ID]) == len(parts)) {
newTitle[d.ID] = oldTitle[d.ID]
- newChunkVec[d.ID] = oldChunkVec[d.ID]
+ if semanticMode != "neuroforge" {
+ newChunkVec[d.ID] = oldChunkVec[d.ID]
+ }
reused++
} else if s.rag {
needEmbed = append(needEmbed, d)
}
}
+ semanticReady := true
if s.rag && len(needEmbed) > 0 {
if s.embedder == nil {
return fmt.Errorf("RAG is enabled but no embedding provider is configured")
@@ -404,11 +408,17 @@
if end > len(needEmbed) {
end = len(needEmbed)
}
- emb, err := s.embedDocuments(ctx, needEmbed[start:end])
+ batch := needEmbed[start:end]
+ emb, err := s.embedDocuments(ctx, batch)
if err != nil {
return err
}
- for _, d := range needEmbed[start:end] {
+ if ok, syncErr := s.syncSemanticDocuments(ctx, batch, emb); syncErr != nil {
+ return syncErr
+ } else if !ok {
+ semanticReady = false
+ }
+ for _, d := range batch {
newTitle[d.ID] = emb[d.ID].title
newChunkVec[d.ID] = emb[d.ID].chunks
}
@@ -425,6 +435,22 @@
newChunks[d.ID] = oldChunks[d.ID]
}
+ for id := range oldDocs {
+ if externalMap[id] != "" {
+ continue
+ }
+ if _, stillLocal := merged[id]; !stillLocal {
+ if err := s.deleteSemanticDocument(ctx, id); err != nil {
+ return err
+ }
+ }
+ }
+ if semanticMode == "neuroforge" && semanticReady {
+ for _, d := range localDocs {
+ delete(newChunkVec, d.ID)
+ }
+ }
+
files := map[string]string{}
managedMap := map[string]bool{}
for _, rec := range manifest {
diff -ruN '--exclude=.git' '--exclude=.env_local' '--exclude=agent' '--exclude=mega-control' /mnt/data/mega_work/originals/glpi-ai-agent/internal/knowledge/store.go /mnt/data/mega_work/glpi-neuroforge-mega/services/agent/internal/knowledge/store.go
--- /mnt/data/mega_work/originals/glpi-ai-agent/internal/knowledge/store.go 2026-08-05 17:17:23.000000000 +0000
+++ /mnt/data/mega_work/glpi-neuroforge-mega/services/agent/internal/knowledge/store.go 2026-08-25 16:02:10.000000000 +0000
@@ -82,31 +82,35 @@
}
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
+ 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
+ semanticBackend SemanticBackend
+ semanticBackendMode string
+ semanticBackendSearchK int
+ semanticBackendFailOpen bool
}
type cacheFile struct {
Version int `json:"version,omitempty"`
@@ -230,6 +234,9 @@
}
slog.Warn("persistent knowledge index unavailable; falling back to rebuild", "error", loadErr, "path", s.snapshotPath)
} else if loaded {
+ if _, err := s.syncLoadedSemanticBackend(ctx); err != nil {
+ return err
+ }
return nil
} else if mode == "readonly" {
return fmt.Errorf("KNOWLEDGE_INDEX_MODE=readonly requires a compatible persistent index at %s", s.snapshotPath)
@@ -831,6 +838,20 @@
s.chunkVectors[d.ID] = chunkVectors
}
s.mu.Unlock()
+ semanticOK := true
+ if s.rag && len(chunkVectors) > 0 {
+ if err := s.syncSemanticDocument(ctx, d, chunks, chunkVectors); err != nil {
+ semanticOK = false
+ if handled := s.handleSemanticSyncError(err); handled != nil {
+ return fmt.Errorf("knowledge saved locally but %w", handled)
+ }
+ }
+ }
+ if semanticOK && s.semanticExternalized() {
+ s.mu.Lock()
+ delete(s.chunkVectors, d.ID)
+ s.mu.Unlock()
+ }
return s.persistVectorCache()
}
@@ -855,6 +876,9 @@
if !isManaged {
return fmt.Errorf("static knowledge entry %q is read-only", id)
}
+ if err := s.deleteSemanticDocument(context.Background(), id); err != nil {
+ return err
+ }
if err := os.Remove(path); err != nil {
return err
}
@@ -922,9 +946,13 @@
oldDocs := make(map[string]model.KnowledgeDoc, len(s.docs))
oldTitle := cloneVectorMap(s.titleVectors)
oldChunks := cloneChunkVectorMap(s.chunkVectors)
+ oldExternal := make(map[string]string, len(s.external))
for _, d := range s.docs {
oldDocs[d.ID] = d
}
+ for id, src := range s.external {
+ oldExternal[id] = src
+ }
s.mu.RUnlock()
cached := loadCache(s.externalCachePath)
if len(cached.Hashes) == 0 {
@@ -976,6 +1004,18 @@
if err != nil {
return err
}
+ if _, err := s.syncSemanticDocuments(ctx, changed, newEmbedded); err != nil {
+ return err
+ }
+ }
+ for id, src := range oldExternal {
+ if src == source {
+ if _, stillPresent := seen[id]; !stillPresent {
+ if err := s.deleteSemanticDocument(ctx, id); err != nil {
+ return err
+ }
+ }
+ }
}
s.mu.Lock()
@@ -1114,6 +1154,38 @@
}
}
+ type remoteSemanticMatch struct {
+ score float64
+ chunk string
+ queryChunk string
+ }
+ remoteSemantic := map[string]remoteSemanticMatch{}
+ remoteAttempted, remoteFailed := false, false
+ backend, backendMode, backendSearchK, backendFailOpen := s.semanticSettings()
+ if ragEnabled && backend != nil && backendMode == "neuroforge" && len(queryVectors) > 0 {
+ remoteAttempted = true
+ for qi, qv := range queryVectors {
+ hits, err := backend.Search(ctx, qv, backendSearchK)
+ if err != nil {
+ remoteFailed = true
+ if !backendFailOpen {
+ return nil, fmt.Errorf("neuroforge semantic search: %w", err)
+ }
+ break
+ }
+ for _, h := range hits {
+ cur, ok := remoteSemantic[h.DocumentID]
+ if !ok || h.Similarity > cur.score {
+ qc := ""
+ if qi < len(queryChunks) {
+ qc = queryChunks[qi]
+ }
+ remoteSemantic[h.DocumentID] = remoteSemanticMatch{score: clamp01(h.Similarity), chunk: h.Text, queryChunk: qc}
+ }
+ }
+ }
+ }
+
s.mu.RLock()
defer s.mu.RUnlock()
if len(s.docs) == 0 {
@@ -1123,7 +1195,15 @@
for _, d := range s.docs {
semantic, bestChunk, bestQueryChunk := 0.0, "", ""
semanticAvailable := false
- if len(queryVectors) > 0 && len(s.chunkVectors[d.ID]) > 0 {
+ if remoteAttempted && !remoteFailed {
+ if rm, ok := remoteSemantic[d.ID]; ok {
+ semanticAvailable = true
+ semantic = rm.score
+ bestChunk = rm.chunk
+ bestQueryChunk = rm.queryChunk
+ }
+ }
+ if !semanticAvailable && len(queryVectors) > 0 && len(s.chunkVectors[d.ID]) > 0 {
semanticAvailable = true
for qi, qv := range queryVectors {
for di, dv := range s.chunkVectors[d.ID] {
@@ -1139,7 +1219,7 @@
}
}
}
- } else if strings.TrimSpace(d.Text) != "" {
+ } else if !semanticAvailable && (!remoteAttempted || remoteFailed) && strings.TrimSpace(d.Text) != "" {
semanticAvailable = true
docChunks := s.chunks[d.ID]
if len(docChunks) == 0 {
@@ -1274,6 +1354,7 @@
cf := loadCache(s.cachePath)
var need []model.KnowledgeDoc
cacheHits := 0
+ cachedEmbedded := map[string]embeddedDoc{}
for _, d := range s.docs {
bodyChunks := chunkText(d.Text, s.scoring.ChunkWords, s.scoring.ChunkOverlap, s.scoring.MaxChunksPerDoc)
s.mu.Lock()
@@ -1281,10 +1362,13 @@
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) {
+ title := append([]float64(nil), cf.TitleVectors[d.ID]...)
+ chunks := cloneChunkVectors(cf.ChunkVectors[d.ID])
s.mu.Lock()
- s.titleVectors[d.ID] = append([]float64(nil), cf.TitleVectors[d.ID]...)
- s.chunkVectors[d.ID] = cloneChunkVectors(cf.ChunkVectors[d.ID])
+ s.titleVectors[d.ID] = title
+ s.chunkVectors[d.ID] = chunks
s.mu.Unlock()
+ cachedEmbedded[d.ID] = embeddedDoc{title: title, chunks: chunks}
cacheHits++
} else {
need = append(need, d)
@@ -1296,8 +1380,14 @@
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()
+
+ semanticReady := true
+ if len(cachedEmbedded) > 0 {
+ if ok, err := s.syncSemanticDocuments(ctx, s.docs, cachedEmbedded); err != nil {
+ return err
+ } else if !ok {
+ semanticReady = false
+ }
}
// Batch by documents, not by the whole corpus. A corpus with tens of
@@ -1318,6 +1408,11 @@
if err != nil {
return err
}
+ if ok, err := s.syncSemanticDocuments(ctx, batch, embedded); err != nil {
+ return err
+ } else if !ok {
+ semanticReady = false
+ }
s.mu.Lock()
for _, d := range batch {
s.titleVectors[d.ID] = embedded[d.ID].title
@@ -1335,6 +1430,9 @@
slog.Info("knowledge embedding progress", "indexed_docs", indexed, "total_docs", len(s.docs), "cache_hits", cacheHits, "pending_embeddings", pending)
}
}
+ if semanticReady {
+ s.externalizeChunkVectors()
+ }
return s.persistVectorCache()
}
diff -ruN '--exclude=.git' '--exclude=.env_local' '--exclude=agent' '--exclude=mega-control' /mnt/data/mega_work/originals/glpi-ai-agent/internal/knowledge/store_test.go /mnt/data/mega_work/glpi-neuroforge-mega/services/agent/internal/knowledge/store_test.go
--- /mnt/data/mega_work/originals/glpi-ai-agent/internal/knowledge/store_test.go 2026-08-05 17:17:23.000000000 +0000
+++ /mnt/data/mega_work/glpi-neuroforge-mega/services/agent/internal/knowledge/store_test.go 2026-08-25 16:03:28.000000000 +0000
@@ -3,6 +3,7 @@
import (
"context"
"encoding/json"
+ "errors"
"fmt"
"os"
"path/filepath"
@@ -625,3 +626,93 @@
t.Fatalf("reply hits=%+v", reply)
}
}
+
+type semanticBackendStub struct {
+ hits []SemanticHit
+ searchErr error
+ searches int
+ upserts int
+ deletes int
+}
+
+func (b *semanticBackendStub) Name() string { return "stub" }
+func (b *semanticBackendStub) UpsertDocument(context.Context, model.KnowledgeDoc, []string, [][]float64) error {
+ b.upserts++
+ return nil
+}
+func (b *semanticBackendStub) DeleteDocument(context.Context, string) error {
+ b.deletes++
+ return nil
+}
+func (b *semanticBackendStub) Search(context.Context, []float64, int) ([]SemanticHit, error) {
+ b.searches++
+ if b.searchErr != nil {
+ return nil, b.searchErr
+ }
+ return append([]SemanticHit(nil), b.hits...), nil
+}
+func (b *semanticBackendStub) Health(context.Context) error { return nil }
+
+func TestNeuroForgeSemanticBackendIsEvidenceNotPolicy(t *testing.T) {
+ dir := t.TempDir()
+ data := t.TempDir()
+ doc := model.KnowledgeDoc{ID: "KB-REMOTE", Title: "VPN Zugang", Text: "VPN Gateway und Token prüfen", Source: "internal-kb", Language: "de-DE", CommunicationStyle: "formal"}
+ b, _ := json.Marshal(doc)
+ if err := os.WriteFile(filepath.Join(dir, "remote.json"), b, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ cfg := ScoringConfig{SemanticWeight: 1, ChunkWords: 80, ChunkOverlap: 20, MaxChunksPerDoc: 8, MaxQueryChunks: 4}
+ s, err := Load(context.Background(), dir, data, hashTestEmbedder{}, true, []string{"internal-kb"}, cfg)
+ if err != nil {
+ t.Fatal(err)
+ }
+ backend := &semanticBackendStub{hits: []SemanticHit{{DocumentID: doc.ID, ChunkIndex: 0, Text: "remote canonical chunk", Similarity: .83}}}
+ if err := s.SetSemanticBackend(backend, "neuroforge", 32, false); err != nil {
+ t.Fatal(err)
+ }
+ hits, err := s.Search(context.Background(), "VPN Zugang\nToken prüfen", 1)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if backend.searches == 0 || len(hits) != 1 {
+ t.Fatalf("backend searches=%d hits=%d", backend.searches, len(hits))
+ }
+ if hits[0].Doc.ID != doc.ID || hits[0].SemanticScore != .83 {
+ t.Fatalf("remote semantic evidence was not preserved: %+v", hits[0])
+ }
+ if hits[0].BestChunkExcerpt != "remote canonical chunk" {
+ t.Fatalf("unexpected semantic chunk: %q", hits[0].BestChunkExcerpt)
+ }
+}
+
+func TestNeuroForgeSearchFailureHonorsFailOpenPolicy(t *testing.T) {
+ dir := t.TempDir()
+ data := t.TempDir()
+ doc := model.KnowledgeDoc{ID: "KB-FALLBACK", Title: "Anmeldung", Text: "Anmeldung Passwort Benutzerkonto", Source: "internal-kb", Language: "de-DE", CommunicationStyle: "formal"}
+ b, _ := json.Marshal(doc)
+ if err := os.WriteFile(filepath.Join(dir, "fallback.json"), b, 0o644); err != nil {
+ t.Fatal(err)
+ }
+ s, err := Load(context.Background(), dir, data, hashTestEmbedder{}, true, []string{"internal-kb"}, ScoringConfig{SemanticWeight: 1, ChunkWords: 80, ChunkOverlap: 20, MaxChunksPerDoc: 8, MaxQueryChunks: 4})
+ if err != nil {
+ t.Fatal(err)
+ }
+ backend := &semanticBackendStub{searchErr: errors.New("backend unavailable")}
+ if err := s.SetSemanticBackend(backend, "neuroforge", 32, true); err != nil {
+ t.Fatal(err)
+ }
+ hits, err := s.Search(context.Background(), "Anmeldung Passwort", 1)
+ if err != nil {
+ t.Fatalf("fail-open search should use local evidence: %v", err)
+ }
+ if len(hits) != 1 || hits[0].SemanticScore <= 0 {
+ t.Fatalf("expected local semantic fallback, hits=%+v", hits)
+ }
+
+ if err := s.SetSemanticBackend(backend, "neuroforge", 32, false); err != nil {
+ t.Fatal(err)
+ }
+ if _, err := s.Search(context.Background(), "Anmeldung Passwort", 1); err == nil {
+ t.Fatal("fail-closed search must surface backend failure")
+ }
+}
diff -ruN '--exclude=.git' '--exclude=.env_local' '--exclude=agent' '--exclude=mega-control' /mnt/data/mega_work/originals/glpi-ai-agent/internal/model/model.go /mnt/data/mega_work/glpi-neuroforge-mega/services/agent/internal/model/model.go
--- /mnt/data/mega_work/originals/glpi-ai-agent/internal/model/model.go 2026-08-05 17:17:23.000000000 +0000
+++ /mnt/data/mega_work/glpi-neuroforge-mega/services/agent/internal/model/model.go 2026-08-25 18:19:03.554284889 +0000
@@ -77,30 +77,32 @@
// AutoReplyDecision explains why a synchronized source document is or is
// not eligible for automatic replies. Local JSON documents may leave this
// empty because their explicit auto_reply flag is already authoritative.
- AutoReplyDecision string `json:"auto_reply_decision,omitempty"`
- AutoReplyDetail string `json:"auto_reply_detail,omitempty"`
- MinScore float64 `json:"min_score"`
- Categories []int64 `json:"categories"`
- ExternalCategories []string `json:"external_categories,omitempty"`
- UnmappedExternalCategories []string `json:"unmapped_external_categories,omitempty"`
- Keywords []string `json:"keywords"`
- Source string `json:"source"`
- SourceURI string `json:"source_uri,omitempty"`
- SourceCategoryIDs []int64 `json:"source_category_ids,omitempty"`
- SourceModifiedAt string `json:"source_modified_at,omitempty"`
- Language string `json:"language"`
- CommunicationStyle string `json:"communication_style"`
+ AutoReplyDecision string `json:"auto_reply_decision,omitempty"`
+ AutoReplyDetail string `json:"auto_reply_detail,omitempty"`
+ MinScore float64 `json:"min_score"`
+ Categories []int64 `json:"categories"`
+ ExternalCategories []string `json:"external_categories,omitempty"`
+ UnmappedExternalCategories []string `json:"unmapped_external_categories,omitempty"`
+ Keywords []string `json:"keywords"`
+ Source string `json:"source"`
+ SourceURI string `json:"source_uri,omitempty"`
+ SourceCategoryIDs []int64 `json:"source_category_ids,omitempty"`
+ SourceModifiedAt string `json:"source_modified_at,omitempty"`
+ Language string `json:"language"`
+ CommunicationStyle string `json:"communication_style"`
+ LinkedItems []LinkedItem `json:"linked_items,omitempty"`
}
// GLPIKnowledgeItem is the normalized read-only representation returned by
// the GLPI connector before it is converted into a KnowledgeDoc.
type GLPIKnowledgeItem struct {
- ID int64 `json:"id"`
- Title string `json:"title"`
- Content string `json:"content"`
- CategoryIDs []int64 `json:"category_ids,omitempty"`
- Language string `json:"language,omitempty"`
- ModifiedAt string `json:"modified_at,omitempty"`
+ ID int64 `json:"id"`
+ Title string `json:"title"`
+ Content string `json:"content"`
+ CategoryIDs []int64 `json:"category_ids,omitempty"`
+ Language string `json:"language,omitempty"`
+ ModifiedAt string `json:"modified_at,omitempty"`
+ LinkedItems []LinkedItem `json:"linked_items,omitempty"`
}
type KnowledgeHit struct {
@@ -299,26 +301,26 @@
// KnowledgeCandidateAudit captures the top retrieval candidates used for a run.
// It intentionally stores only normalized, non-secret diagnostic information.
type KnowledgeCandidateAudit struct {
- ID string `json:"id"`
- Title string `json:"title"`
- Source string `json:"source"`
- Score float64 `json:"score"`
- SemanticScore float64 `json:"semantic_score,omitempty"`
- TitleScore float64 `json:"title_score,omitempty"`
- LexicalScore float64 `json:"lexical_score,omitempty"`
- KeywordScore float64 `json:"keyword_score,omitempty"`
- CategoryScore float64 `json:"category_score,omitempty"`
- RequiredScore float64 `json:"required_score,omitempty"`
- AutoReply bool `json:"auto_reply"`
- AutoReplyDecision string `json:"auto_reply_decision,omitempty"`
- AutoReplyDetail string `json:"auto_reply_detail,omitempty"`
- BestChunkExcerpt string `json:"best_chunk_excerpt,omitempty"`
- BestQueryExcerpt string `json:"best_query_excerpt,omitempty"`
- QueryChunkCount int `json:"query_chunk_count,omitempty"`
- DocumentChunkCount int `json:"document_chunk_count,omitempty"`
- SentToAI bool `json:"sent_to_ai,omitempty"`
- RetrievalRank int `json:"retrieval_rank,omitempty"`
- SelectionReason string `json:"selection_reason,omitempty"`
+ ID string `json:"id"`
+ Title string `json:"title"`
+ Source string `json:"source"`
+ Score float64 `json:"score"`
+ SemanticScore float64 `json:"semantic_score,omitempty"`
+ TitleScore float64 `json:"title_score,omitempty"`
+ LexicalScore float64 `json:"lexical_score,omitempty"`
+ KeywordScore float64 `json:"keyword_score,omitempty"`
+ CategoryScore float64 `json:"category_score,omitempty"`
+ RequiredScore float64 `json:"required_score,omitempty"`
+ AutoReply bool `json:"auto_reply"`
+ AutoReplyDecision string `json:"auto_reply_decision,omitempty"`
+ AutoReplyDetail string `json:"auto_reply_detail,omitempty"`
+ BestChunkExcerpt string `json:"best_chunk_excerpt,omitempty"`
+ BestQueryExcerpt string `json:"best_query_excerpt,omitempty"`
+ QueryChunkCount int `json:"query_chunk_count,omitempty"`
+ DocumentChunkCount int `json:"document_chunk_count,omitempty"`
+ SentToAI bool `json:"sent_to_ai,omitempty"`
+ RetrievalRank int `json:"retrieval_rank,omitempty"`
+ SelectionReason string `json:"selection_reason,omitempty"`
}
// ContextAuditItem is a compact snapshot of context that influenced a run.
diff -ruN '--exclude=.git' '--exclude=.env_local' '--exclude=agent' '--exclude=mega-control' /mnt/data/mega_work/originals/glpi-ai-agent/internal/obsidian/export.go /mnt/data/mega_work/glpi-neuroforge-mega/services/agent/internal/obsidian/export.go
--- /mnt/data/mega_work/originals/glpi-ai-agent/internal/obsidian/export.go 1970-01-01 00:00:00.000000000 +0000
+++ /mnt/data/mega_work/glpi-neuroforge-mega/services/agent/internal/obsidian/export.go 2026-08-25 18:18:55.754239323 +0000
@@ -0,0 +1,384 @@
+package obsidian
+
+import (
+ "archive/zip"
+ "bytes"
+ "encoding/json"
+ "fmt"
+ "io"
+ "path"
+ "regexp"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+ "unicode"
+
+ "github.com/example/glpi-ai-agent/internal/model"
+)
+
+type manifest struct {
+ Format string `json:"format"`
+ Version int `json:"version"`
+ GeneratedAt time.Time `json:"generated_at"`
+ Documents int `json:"documents"`
+ Relations int `json:"relations"`
+}
+
+type graphNode struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+ Type string `json:"type"`
+ Path string `json:"path"`
+}
+
+type graphEdge struct {
+ From string `json:"from"`
+ To string `json:"to"`
+ Relation string `json:"relation"`
+}
+
+type graph struct {
+ Nodes []graphNode `json:"nodes"`
+ Edges []graphEdge `json:"edges"`
+}
+
+// WriteZIP exports a snapshot of the live knowledge store as an Obsidian vault.
+// It is deliberately read-only: no source files are modified by an export.
+func WriteZIP(w io.Writer, docs []model.KnowledgeDoc, generatedAt time.Time) error {
+ if generatedAt.IsZero() {
+ generatedAt = time.Now().UTC()
+ }
+ docs = append([]model.KnowledgeDoc(nil), docs...)
+ sort.Slice(docs, func(i, j int) bool {
+ if strings.EqualFold(docs[i].Title, docs[j].Title) {
+ return docs[i].ID < docs[j].ID
+ }
+ return strings.ToLower(docs[i].Title) < strings.ToLower(docs[j].Title)
+ })
+
+ pageByID := make(map[string]string, len(docs))
+ for _, d := range docs {
+ pageByID[d.ID] = "Wiki/Knowledge/" + pageFilename(d.Title, d.ID)
+ }
+
+ zw := zip.NewWriter(w)
+ defer zw.Close()
+ if err := writeFile(zw, "Wiki/Schema.md", schemaPage()); err != nil {
+ return err
+ }
+
+ g := graph{}
+ relationCount := 0
+ stubPages := map[string]model.LinkedItem{}
+ for _, d := range docs {
+ p := pageByID[d.ID]
+ g.Nodes = append(g.Nodes, graphNode{ID: d.ID, Title: d.Title, Type: "knowledge", Path: p})
+ content, edges := articlePage(d, p, pageByID, generatedAt, stubPages)
+ relationCount += len(edges)
+ g.Edges = append(g.Edges, edges...)
+ if err := writeFile(zw, p, content); err != nil {
+ return err
+ }
+ }
+
+ stubKeys := make([]string, 0, len(stubPages))
+ for key := range stubPages {
+ stubKeys = append(stubKeys, key)
+ }
+ sort.Strings(stubKeys)
+ for _, key := range stubKeys {
+ item := stubPages[key]
+ p := glpiItemPath(item)
+ title := linkedTitle(item)
+ g.Nodes = append(g.Nodes, graphNode{ID: key, Title: title, Type: "entity", Path: p})
+ if err := writeFile(zw, p, glpiEntityPage(item, generatedAt)); err != nil {
+ return err
+ }
+ }
+
+ if err := writeFile(zw, "Wiki/index.md", indexPage(docs, pageByID, generatedAt)); err != nil {
+ return err
+ }
+ gb, err := json.MarshalIndent(g, "", " ")
+ if err != nil {
+ return err
+ }
+ if err := writeFile(zw, "Wiki/graph.json", string(gb)+"\n"); err != nil {
+ return err
+ }
+ mb, err := json.MarshalIndent(manifest{Format: "glpi-neuroforge-obsidian", Version: 1, GeneratedAt: generatedAt.UTC(), Documents: len(docs), Relations: relationCount}, "", " ")
+ if err != nil {
+ return err
+ }
+ return writeFile(zw, "Wiki/.manifest.json", string(mb)+"\n")
+}
+
+func articlePage(d model.KnowledgeDoc, page string, pageByID map[string]string, now time.Time, stubs map[string]model.LinkedItem) (string, []graphEdge) {
+ var b strings.Builder
+ date := isoDate(d.SourceModifiedAt, now)
+ b.WriteString("---\n")
+ front(&b, "type", "knowledge")
+ front(&b, "title", d.Title)
+ front(&b, "id", d.ID)
+ front(&b, "source", d.Source)
+ front(&b, "source_uri", d.SourceURI)
+ front(&b, "language", d.Language)
+ front(&b, "communication_style", d.CommunicationStyle)
+ front(&b, "created", date)
+ front(&b, "updated", date)
+ frontBool(&b, "auto_reply", d.AutoReply)
+ frontFloat(&b, "min_score", d.MinScore)
+ frontList(&b, "tags", d.Keywords)
+ frontIntList(&b, "categories", d.Categories)
+ frontIntList(&b, "glpi_kb_categories", d.SourceCategoryIDs)
+ frontList(&b, "external_categories", d.ExternalCategories)
+ if len(d.LinkedItems) > 0 {
+ b.WriteString("related:\n")
+ for _, item := range d.LinkedItems {
+ target := relationTarget(item, pageByID, stubs)
+ b.WriteString(" - ")
+ b.WriteString(yamlQuote("[[" + trimMD(target) + "|" + linkedTitle(item) + "]]"))
+ b.WriteByte('\n')
+ }
+ }
+ b.WriteString("---\n\n")
+ b.WriteString("# " + d.Title + "\n\n")
+ if strings.TrimSpace(d.Text) != "" {
+ b.WriteString("## Kontext / Problem\n\n" + strings.TrimSpace(d.Text) + "\n\n")
+ }
+ if strings.TrimSpace(d.Answer) != "" {
+ b.WriteString("## Lösung / Antwort\n\n" + strings.TrimSpace(d.Answer) + "\n\n")
+ }
+ if len(d.LinkedItems) > 0 || d.SourceURI != "" {
+ b.WriteString("## Verknüpfungen\n\n")
+ }
+ var edges []graphEdge
+ for _, item := range d.LinkedItems {
+ target := relationTarget(item, pageByID, stubs)
+ b.WriteString("- [[" + trimMD(target) + "|" + escapeLinkLabel(linkedTitle(item)) + "]] — `" + item.ItemType + " #" + strconv.FormatInt(item.ID, 10) + "`\n")
+ edges = append(edges, graphEdge{From: d.ID, To: relationID(item), Relation: "glpi-linked-item"})
+ }
+ if d.SourceURI != "" {
+ b.WriteString("- Quelle: `" + strings.ReplaceAll(d.SourceURI, "`", "") + "`\n")
+ }
+ if d.AutoReplyDecision != "" {
+ b.WriteString("\n## Governance\n\n")
+ b.WriteString("- Auto-Reply: **" + strconv.FormatBool(d.AutoReply) + "**\n")
+ b.WriteString("- Entscheidung: `" + strings.ReplaceAll(d.AutoReplyDecision, "`", "") + "`\n")
+ if d.AutoReplyDetail != "" {
+ b.WriteString("- Begründung: " + strings.TrimSpace(d.AutoReplyDetail) + "\n")
+ }
+ }
+ _ = page
+ return b.String(), edges
+}
+
+func relationTarget(item model.LinkedItem, pageByID map[string]string, stubs map[string]model.LinkedItem) string {
+ if strings.EqualFold(item.ItemType, "KnowbaseItem") {
+ if p, ok := pageByID["GLPI-KB-"+strconv.FormatInt(item.ID, 10)]; ok {
+ return p
+ }
+ }
+ key := relationID(item)
+ stubs[key] = item
+ return glpiItemPath(item)
+}
+
+func relationID(item model.LinkedItem) string {
+ return "GLPI-" + safePart(item.ItemType) + "-" + strconv.FormatInt(item.ID, 10)
+}
+
+func glpiItemPath(item model.LinkedItem) string {
+ return "Wiki/GLPI/" + safePart(item.ItemType) + "/" + pageFilename(linkedTitle(item), strconv.FormatInt(item.ID, 10))
+}
+
+func linkedTitle(item model.LinkedItem) string {
+ if strings.TrimSpace(item.Name) != "" {
+ return strings.TrimSpace(item.Name)
+ }
+ t := strings.TrimSpace(item.ItemType)
+ if t == "" {
+ t = "GLPI-Objekt"
+ }
+ return fmt.Sprintf("%s #%d", t, item.ID)
+}
+
+func glpiEntityPage(item model.LinkedItem, now time.Time) string {
+ var b strings.Builder
+ b.WriteString("---\n")
+ front(&b, "type", "entity")
+ front(&b, "entity_type", strings.ToLower(safePart(item.ItemType)))
+ front(&b, "title", linkedTitle(item))
+ front(&b, "source", "glpi")
+ front(&b, "source_uri", fmt.Sprintf("glpi://%s/%d", item.ItemType, item.ID))
+ front(&b, "created", now.UTC().Format("2006-01-02"))
+ front(&b, "updated", now.UTC().Format("2006-01-02"))
+ b.WriteString("---\n\n# " + linkedTitle(item) + "\n\n")
+ b.WriteString("Von GLPI mit einem oder mehreren Knowledge-Base-Artikeln verknüpft.\n")
+ return b.String()
+}
+
+func indexPage(docs []model.KnowledgeDoc, pages map[string]string, now time.Time) string {
+ var b strings.Builder
+ b.WriteString("---\n")
+ front(&b, "type", "overview")
+ front(&b, "title", "Knowledge Index")
+ front(&b, "created", now.UTC().Format("2006-01-02"))
+ front(&b, "updated", now.UTC().Format("2006-01-02"))
+ b.WriteString("---\n\n# Knowledge Index\n\n")
+ b.WriteString("Exportierte Artikel: **" + strconv.Itoa(len(docs)) + "**\n\n")
+ for _, d := range docs {
+ b.WriteString("- [[" + trimMD(pages[d.ID]) + "|" + escapeLinkLabel(d.Title) + "]] — `" + d.ID + "` · `" + d.Source + "`\n")
+ }
+ return b.String()
+}
+
+func schemaPage() string {
+ return `---
+type: meta
+title: GLPI NeuroForge Wiki Schema
+status: active
+---
+
+# Wiki Schema
+
+Dieser Export ist für Obsidian und llm-wiki-artige Workflows ausgelegt.
+
+## Seitentypen
+
+- ` + "`knowledge`" + ` — Knowledge-Base-Artikel.
+- ` + "`entity`" + ` — aus GLPI verknüpfte Objekte.
+- ` + "`overview`" + ` — Indexseiten.
+- ` + "`meta`" + ` — Schema- und Steuerseiten.
+
+## Konventionen
+
+- Metadaten stehen in YAML-Frontmatter.
+- Interne Beziehungen verwenden ` + "`[[Wiki/...]]`" + `.
+- Datumswerte verwenden ISO-8601 (` + "`YYYY-MM-DD`" + `).
+- ` + "`source_uri`" + ` bewahrt die Herkunft; Secrets werden nicht exportiert.
+- ` + "`graph.json`" + ` enthält dieselben expliziten Beziehungen maschinenlesbar.
+`
+}
+
+func writeFile(zw *zip.Writer, name, content string) error {
+ h := &zip.FileHeader{Name: path.Clean(name), Method: zip.Deflate}
+ h.SetMode(0o644)
+ f, err := zw.CreateHeader(h)
+ if err != nil {
+ return err
+ }
+ _, err = io.Copy(f, bytes.NewBufferString(content))
+ return err
+}
+
+var nonSlug = regexp.MustCompile(`[^a-z0-9]+`)
+
+func pageFilename(title, id string) string {
+ s := slug(title)
+ if s == "" {
+ s = "artikel"
+ }
+ i := slug(id)
+ if i != "" && !strings.Contains(s, i) {
+ s += "--" + i
+ }
+ return s + ".md"
+}
+
+func slug(v string) string {
+ v = strings.ToLower(strings.TrimSpace(v))
+ var b strings.Builder
+ for _, r := range v {
+ switch r {
+ case 'ä':
+ b.WriteString("ae")
+ case 'ö':
+ b.WriteString("oe")
+ case 'ü':
+ b.WriteString("ue")
+ case 'ß':
+ b.WriteString("ss")
+ default:
+ if unicode.IsLetter(r) || unicode.IsDigit(r) {
+ b.WriteRune(r)
+ } else {
+ b.WriteByte('-')
+ }
+ }
+ }
+ return strings.Trim(nonSlug.ReplaceAllString(b.String(), "-"), "-")
+}
+
+func safePart(v string) string {
+ s := slug(v)
+ if s == "" {
+ return "item"
+ }
+ return s
+}
+func trimMD(v string) string { return strings.TrimSuffix(v, ".md") }
+func escapeLinkLabel(v string) string { return strings.ReplaceAll(v, "]", "\\]") }
+func yamlQuote(v string) string {
+ b, _ := json.Marshal(v)
+ return string(b)
+}
+func front(b *strings.Builder, key, value string) {
+ if strings.TrimSpace(value) == "" {
+ return
+ }
+ b.WriteString(key + ": " + yamlQuote(strings.TrimSpace(value)) + "\n")
+}
+func frontBool(b *strings.Builder, key string, value bool) {
+ b.WriteString(key + ": " + strconv.FormatBool(value) + "\n")
+}
+func frontFloat(b *strings.Builder, key string, value float64) {
+ b.WriteString(key + ": " + strconv.FormatFloat(value, 'f', -1, 64) + "\n")
+}
+func frontList(b *strings.Builder, key string, values []string) {
+ values = uniqueStrings(values)
+ if len(values) == 0 {
+ return
+ }
+ b.WriteString(key + ":\n")
+ for _, v := range values {
+ b.WriteString(" - " + yamlQuote(v) + "\n")
+ }
+}
+func frontIntList(b *strings.Builder, key string, values []int64) {
+ if len(values) == 0 {
+ return
+ }
+ b.WriteString(key + ":\n")
+ for _, v := range values {
+ b.WriteString(" - " + strconv.FormatInt(v, 10) + "\n")
+ }
+}
+func uniqueStrings(in []string) []string {
+ seen := map[string]struct{}{}
+ out := make([]string, 0, len(in))
+ 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)
+ }
+ sort.Strings(out)
+ return out
+}
+func isoDate(v string, fallback time.Time) string {
+ v = strings.TrimSpace(v)
+ for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02"} {
+ if t, err := time.Parse(layout, v); err == nil {
+ return t.Format("2006-01-02")
+ }
+ }
+ return fallback.UTC().Format("2006-01-02")
+}
diff -ruN '--exclude=.git' '--exclude=.env_local' '--exclude=agent' '--exclude=mega-control' /mnt/data/mega_work/originals/glpi-ai-agent/internal/obsidian/export_test.go /mnt/data/mega_work/glpi-neuroforge-mega/services/agent/internal/obsidian/export_test.go
--- /mnt/data/mega_work/originals/glpi-ai-agent/internal/obsidian/export_test.go 1970-01-01 00:00:00.000000000 +0000
+++ /mnt/data/mega_work/glpi-neuroforge-mega/services/agent/internal/obsidian/export_test.go 2026-08-25 18:22:28.211087382 +0000
@@ -0,0 +1,69 @@
+package obsidian
+
+import (
+ "archive/zip"
+ "bytes"
+ "io"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/example/glpi-ai-agent/internal/model"
+)
+
+func TestWriteZIPPreservesGLPILinksAsWikilinks(t *testing.T) {
+ docs := []model.KnowledgeDoc{
+ {ID: "GLPI-KB-12", Title: "VPN Hilfe", Text: "VPN Fehler", Answer: "Neu verbinden", Source: "glpi-kb", SourceURI: "glpi://KnowbaseItem/12", SourceModifiedAt: "2026-08-20 10:00:00", LinkedItems: []model.LinkedItem{{ItemType: "Computer", ID: 42, Name: "NB-042"}, {ItemType: "KnowbaseItem", ID: 13, Name: "Netzwerk"}}},
+ {ID: "GLPI-KB-13", Title: "Netzwerk", Text: "Netzwerk", Answer: "Pruefen", Source: "glpi-kb"},
+ }
+ var buf bytes.Buffer
+ if err := WriteZIP(&buf, docs, time.Date(2026, 8, 25, 12, 0, 0, 0, time.UTC)); err != nil {
+ t.Fatal(err)
+ }
+ zr, err := zip.NewReader(bytes.NewReader(buf.Bytes()), int64(buf.Len()))
+ if err != nil {
+ t.Fatal(err)
+ }
+ files := map[string]string{}
+ for _, f := range zr.File {
+ r, err := f.Open()
+ if err != nil {
+ t.Fatal(err)
+ }
+ b, err := io.ReadAll(r)
+ r.Close()
+ if err != nil {
+ t.Fatal(err)
+ }
+ files[f.Name] = string(b)
+ }
+ var vpn string
+ for name, body := range files {
+ if strings.Contains(name, "vpn-hilfe") {
+ vpn = body
+ }
+ }
+ if vpn == "" {
+ t.Fatalf("VPN article missing: %v", keys(files))
+ }
+ if !strings.Contains(vpn, "type: \"knowledge\"") || !strings.Contains(vpn, "[[Wiki/GLPI/computer/") || !strings.Contains(vpn, "|NB-042]]") {
+ t.Fatalf("missing Obsidian metadata/relation:\n%s", vpn)
+ }
+ if !strings.Contains(vpn, "[[Wiki/Knowledge/netzwerk--glpi-kb-13|Netzwerk]]") {
+ t.Fatalf("linked KB article did not resolve to article page:\n%s", vpn)
+ }
+ if _, ok := files["Wiki/Schema.md"]; !ok {
+ t.Fatal("schema missing")
+ }
+ if _, ok := files["Wiki/graph.json"]; !ok {
+ t.Fatal("graph missing")
+ }
+}
+
+func keys(m map[string]string) []string {
+ out := make([]string, 0, len(m))
+ for k := range m {
+ out = append(out, k)
+ }
+ return out
+}
diff -ruN '--exclude=.git' '--exclude=.env_local' '--exclude=agent' '--exclude=mega-control' /mnt/data/mega_work/originals/glpi-ai-agent/internal/web/server.go /mnt/data/mega_work/glpi-neuroforge-mega/services/agent/internal/web/server.go
--- /mnt/data/mega_work/originals/glpi-ai-agent/internal/web/server.go 2026-08-05 17:17:23.000000000 +0000
+++ /mnt/data/mega_work/glpi-neuroforge-mega/services/agent/internal/web/server.go 2026-08-25 18:20:01.518776121 +0000
@@ -21,6 +21,7 @@
knowledgepkg "github.com/example/glpi-ai-agent/internal/knowledge"
"github.com/example/glpi-ai-agent/internal/metrics"
"github.com/example/glpi-ai-agent/internal/model"
+ "github.com/example/glpi-ai-agent/internal/obsidian"
"github.com/example/glpi-ai-agent/internal/queue"
"github.com/example/glpi-ai-agent/internal/state"
)
@@ -103,6 +104,7 @@
mux.Handle("GET /api/category-mappings", s.auth(http.HandlerFunc(s.categoryMappingsGet)))
mux.Handle("PUT /api/category-mappings", s.auth(s.mutation(http.HandlerFunc(s.categoryMappingsPut))))
mux.Handle("GET /api/knowledge", s.auth(http.HandlerFunc(s.knowledgeList)))
+ mux.Handle("GET /api/knowledge/export/obsidian", s.auth(http.HandlerFunc(s.knowledgeExportObsidian)))
mux.Handle("GET /api/knowledge/{id}", s.auth(http.HandlerFunc(s.knowledgeGet)))
mux.Handle("POST /api/knowledge", s.auth(s.mutation(http.HandlerFunc(s.knowledgeCreate))))
mux.Handle("PUT /api/knowledge/{id}", s.auth(s.mutation(http.HandlerFunc(s.knowledgeUpdate))))
@@ -477,6 +479,15 @@
}
respondJSON(w, out)
}
+func (s *Server) knowledgeExportObsidian(w http.ResponseWriter, r *http.Request) {
+ w.Header().Set("Content-Type", "application/zip")
+ w.Header().Set("Content-Disposition", `attachment; filename="glpi-neuroforge-knowledge-obsidian.zip"`)
+ w.Header().Set("Cache-Control", "no-store")
+ if err := obsidian.WriteZIP(w, s.knowledge.List(), time.Now().UTC()); err != nil {
+ slog.Error("Obsidian knowledge export failed", "error", err)
+ }
+}
+
func (s *Server) knowledgeGet(w http.ResponseWriter, r *http.Request) {
id := strings.TrimSpace(r.PathValue("id"))
d, ok := s.knowledge.ByID(id)
diff -ruN '--exclude=.git' '--exclude=.env_local' '--exclude=agent' '--exclude=mega-control' /mnt/data/mega_work/originals/glpi-ai-agent/internal/web/templates/dashboard.html /mnt/data/mega_work/glpi-neuroforge-mega/services/agent/internal/web/templates/dashboard.html
--- /mnt/data/mega_work/originals/glpi-ai-agent/internal/web/templates/dashboard.html 2026-08-05 17:17:23.000000000 +0000
+++ /mnt/data/mega_work/glpi-neuroforge-mega/services/agent/internal/web/templates/dashboard.html 2026-08-25 18:23:14.811739258 +0000
@@ -62,7 +62,7 @@
</section>
<section class="view" id="view-knowledge">
- <div style="display:flex;justify-content:space-between;gap:16px;align-items:flex-start;flex-wrap:wrap"><div><h2 class="section-title">Knowledge Base</h2><div class="section-sub">Interne Artikel verwalten und synchronisierte Quellen kontrollieren.</div></div><button class="btn primary" id="newKbBtn"> Neuer interner Artikel</button></div>
+ <div style="display:flex;justify-content:space-between;gap:16px;align-items:flex-start;flex-wrap:wrap"><div><h2 class="section-title">Knowledge Base</h2><div class="section-sub">Interne Artikel verwalten und synchronisierte Quellen kontrollieren.</div></div><div style="display:flex;gap:8px;flex-wrap:wrap"><a class="btn" href="/api/knowledge/export/obsidian" title="Live-Export inklusive synchronisierter GLPI-KB-Artikel und Verknüpfungen">⇩ Obsidian Export</a><button class="btn primary" id="newKbBtn"> Neuer interner Artikel</button></div></div>
<div class="grid stats" id="kbStats" style="grid-template-columns:repeat(4,minmax(150px,1fr))"></div>
<div class="filterbar"><div class="search"><input id="kbSearch" placeholder="ID, Titel, Inhalt oder Keyword suchen …"></div><select id="kbSourceFilter" style="max-width:210px"><option value="">Alle Quellen</option></select><select id="kbManageFilter" style="max-width:210px"><option value="">Alle Artikel</option><option value="managed">Web-verwaltet</option><option value="readonly">Read-only</option><option value="autoreply">Auto-Reply erlaubt</option></select></div>
<div id="kbGrid" class="kb-card-grid"></div>