diff -ruN '--exclude=.git' /mnt/data/mega_work/originals/glpi-ai-knowledgebase/cmd/server/app.go /mnt/data/mega_work/glpi-neuroforge-mega/services/knowledge/cmd/server/app.go
--- /mnt/data/mega_work/originals/glpi-ai-knowledgebase/cmd/server/app.go 2026-08-04 19:15:15.000000000 +0000
+++ /mnt/data/mega_work/glpi-neuroforge-mega/services/knowledge/cmd/server/app.go 2026-08-25 18:21:01.399614480 +0000
@@ -2,6 +2,7 @@
import (
"context"
+ "crypto/subtle"
"encoding/json"
"errors"
"fmt"
@@ -15,6 +16,7 @@
"kb-editor/internal/aifallback"
"kb-editor/internal/brainactivity"
+ "kb-editor/internal/obsidian"
"kb-editor/internal/staging"
"kb-editor/internal/store"
)
@@ -64,10 +66,12 @@
mux.HandleFunc("GET /api/items", a.handleList)
mux.HandleFunc("GET /api/search", a.handleSearch)
mux.HandleFunc("GET /api/facets", a.handleFacets)
+ mux.HandleFunc("GET /api/export/obsidian", a.handleObsidianExport)
mux.HandleFunc("GET /api/items/{key}", a.handleGet)
mux.HandleFunc("POST /api/ai/fallback", a.handleAIFallback)
mux.HandleFunc("GET /api/staging", a.handleStagingList)
mux.HandleFunc("GET /api/staging/{key}", a.handleStagingGet)
+ mux.HandleFunc("POST /api/integrations/staging", a.handleIntegrationStaging)
if a.config.Writable {
mux.HandleFunc("PUT /api/items/{key}", a.handlePut)
@@ -144,6 +148,20 @@
writeJSON(w, http.StatusOK, result)
}
+func (a *app) handleObsidianExport(w http.ResponseWriter, r *http.Request) {
+ records := a.store.ExportDocuments()
+ docs := make([]obsidian.Document, 0, len(records))
+ for _, record := range records {
+ docs = append(docs, obsidian.Document{Data: record.Document, ModifiedAt: record.Summary.ModifiedAt})
+ }
+ w.Header().Set("Content-Type", "application/zip")
+ w.Header().Set("Content-Disposition", `attachment; filename="glpi-knowledge-obsidian.zip"`)
+ w.Header().Set("Cache-Control", "no-store")
+ if err := obsidian.WriteZIP(w, docs, time.Now().UTC()); err != nil {
+ return
+ }
+}
+
func (a *app) handleFacets(w http.ResponseWriter, r *http.Request) {
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
writeJSON(w, http.StatusOK, a.store.Facets(limit))
@@ -220,6 +238,76 @@
writeJSON(w, http.StatusCreated, result)
}
+type integrationStagingRequest struct {
+ Source string `json:"source"`
+ Query string `json:"query"`
+ Title string `json:"title"`
+ Text string `json:"text"`
+ Answer string `json:"answer"`
+ Categories []string `json:"categories"`
+ Keywords []string `json:"keywords"`
+ MinScore *float64 `json:"min_score,omitempty"`
+}
+
+func integrationBearerAuthorized(r *http.Request) (bool, bool) {
+ expected := strings.TrimSpace(os.Getenv("KB_INTEGRATION_TOKEN"))
+ if expected == "" {
+ return false, false
+ }
+ got := strings.TrimSpace(r.Header.Get("Authorization"))
+ const prefix = "Bearer "
+ if !strings.HasPrefix(got, prefix) {
+ return true, false
+ }
+ provided := strings.TrimSpace(strings.TrimPrefix(got, prefix))
+ return true, subtle.ConstantTimeCompare([]byte(provided), []byte(expected)) == 1
+}
+
+// handleIntegrationStaging is a one-way governance boundary: machine-generated
+// research may enter human review, but it cannot write production knowledge or
+// enable automatic replies.
+func (a *app) handleIntegrationStaging(w http.ResponseWriter, r *http.Request) {
+ enabled, authorized := integrationBearerAuthorized(r)
+ if !enabled {
+ writeError(w, http.StatusServiceUnavailable, "KB staging integration is disabled")
+ return
+ }
+ if !authorized {
+ writeError(w, http.StatusUnauthorized, "invalid integration token")
+ return
+ }
+ if a.staging == nil {
+ writeError(w, http.StatusServiceUnavailable, "staging is unavailable")
+ return
+ }
+ if !mustJSONContentType(w, r) {
+ return
+ }
+ var req integrationStagingRequest
+ if err := decodeJSON(r, &req); err != nil {
+ writeError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ req.Source = strings.TrimSpace(req.Source)
+ if req.Source == "" {
+ req.Source = "NeuroForge Research"
+ }
+ minScore := 0.85
+ if req.MinScore != nil {
+ minScore = *req.MinScore
+ }
+ result, err := a.staging.SaveFromSource(req.Query, req.Source, staging.Draft{
+ Title: req.Title, Text: req.Text, Answer: req.Answer, Categories: req.Categories, Keywords: req.Keywords,
+ }, false, minScore)
+ if err != nil {
+ writeError(w, http.StatusBadRequest, err.Error())
+ return
+ }
+ writeJSON(w, http.StatusCreated, map[string]any{
+ "ok": true, "staging": result, "governance": "human-review-required", "auto_reply": false,
+ })
+}
+
func (a *app) handleStagingList(w http.ResponseWriter, r *http.Request) {
if !a.config.Writable {
writeError(w, http.StatusForbidden, "Die Staging-Liste ist nur im Editor-Modus verfügbar")
diff -ruN '--exclude=.git' /mnt/data/mega_work/originals/glpi-ai-knowledgebase/cmd/server/app_test.go /mnt/data/mega_work/glpi-neuroforge-mega/services/knowledge/cmd/server/app_test.go
--- /mnt/data/mega_work/originals/glpi-ai-knowledgebase/cmd/server/app_test.go 2026-08-04 19:15:15.000000000 +0000
+++ /mnt/data/mega_work/glpi-neuroforge-mega/services/knowledge/cmd/server/app_test.go 2026-08-25 16:08:52.000000000 +0000
@@ -4,11 +4,13 @@
"bytes"
"encoding/json"
"errors"
+ "fmt"
"io/fs"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
+ "strings"
"testing"
"time"
@@ -333,3 +335,92 @@
t.Fatalf("unexpected bulk result=%+v prod=%d staging=%d", result, s.Count(), st.Count())
}
}
+
+func TestIntegrationDraftCanOnlyEnterStaging(t *testing.T) {
+ t.Setenv("KB_INTEGRATION_TOKEN", "integration-secret")
+ knowledge := t.TempDir()
+ s, err := store.New(knowledge)
+ if err != nil {
+ t.Fatal(err)
+ }
+ st, err := staging.New(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ web, err := fs.Sub(webFS, "web")
+ if err != nil {
+ t.Fatal(err)
+ }
+ h := newApp(s, web).withStaging(st).routes()
+
+ payload := `{"source":"NeuroForge Research","query":"VPN Fehler","title":"VPN Diagnose","text":"Symptom","answer":"Erst Gateway prüfen","categories":["VPN"],"keywords":["gateway"],"min_score":0.9}`
+ unauth := httptest.NewRequest(http.MethodPost, "/api/integrations/staging", bytes.NewBufferString(payload))
+ unauth.Header.Set("Content-Type", "application/json")
+ unauthRR := httptest.NewRecorder()
+ h.ServeHTTP(unauthRR, unauth)
+ if unauthRR.Code != http.StatusUnauthorized {
+ t.Fatalf("unauth status=%d body=%s", unauthRR.Code, unauthRR.Body.String())
+ }
+
+ req := httptest.NewRequest(http.MethodPost, "/api/integrations/staging", bytes.NewBufferString(payload))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer integration-secret")
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, req)
+ if rr.Code != http.StatusCreated {
+ t.Fatalf("status=%d body=%s", rr.Code, rr.Body.String())
+ }
+ if s.Count() != 0 {
+ t.Fatalf("integration proposal must not write production, count=%d", s.Count())
+ }
+ if st.Count() != 1 {
+ t.Fatalf("staging count=%d", st.Count())
+ }
+ items, err := st.List(staging.Query{Page: 1, PageSize: 10})
+ if err != nil || len(items.Items) != 1 {
+ t.Fatalf("staging list err=%v items=%+v", err, items.Items)
+ }
+ result, err := st.Get(items.Items[0].Key)
+ if err != nil {
+ t.Fatal(err)
+ }
+ if got, _ := result.Document["auto_reply"].(bool); got {
+ t.Fatal("machine-generated integration draft must never enable auto_reply")
+ }
+ if source := fmt.Sprint(result.Document["source"]); !strings.Contains(source, "NeuroForge Research") || !strings.Contains(source, "AI-Staging") {
+ t.Fatalf("unexpected proposal source %q", source)
+ }
+}
+
+func TestEditorBasicAuthDoesNotLeakCredentialsToIntegrationClient(t *testing.T) {
+ t.Setenv("BASIC_AUTH_USER", "editor")
+ t.Setenv("BASIC_AUTH_PASSWORD", "editor-secret")
+ t.Setenv("KB_INTEGRATION_TOKEN", "integration-secret")
+ s, err := store.New(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ st, err := staging.New(t.TempDir())
+ if err != nil {
+ t.Fatal(err)
+ }
+ web, _ := fs.Sub(webFS, "web")
+ h := optionalBasicAuth(newApp(s, web).withStaging(st).routes())
+
+ payload := `{"source":"NeuroForge Research","query":"x","title":"Draft","answer":"Review me"}`
+ req := httptest.NewRequest(http.MethodPost, "/api/integrations/staging", bytes.NewBufferString(payload))
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("Authorization", "Bearer integration-secret")
+ rr := httptest.NewRecorder()
+ h.ServeHTTP(rr, req)
+ if rr.Code != http.StatusCreated {
+ t.Fatalf("integration should not require editor credentials: status=%d body=%s", rr.Code, rr.Body.String())
+ }
+
+ items := httptest.NewRequest(http.MethodGet, "/api/items", nil)
+ itemsRR := httptest.NewRecorder()
+ h.ServeHTTP(itemsRR, items)
+ if itemsRR.Code != http.StatusUnauthorized {
+ t.Fatalf("editor API unexpectedly bypassed basic auth: %d", itemsRR.Code)
+ }
+}
diff -ruN '--exclude=.git' /mnt/data/mega_work/originals/glpi-ai-knowledgebase/cmd/server/main.go /mnt/data/mega_work/glpi-neuroforge-mega/services/knowledge/cmd/server/main.go
--- /mnt/data/mega_work/originals/glpi-ai-knowledgebase/cmd/server/main.go 2026-08-04 19:15:15.000000000 +0000
+++ /mnt/data/mega_work/glpi-neuroforge-mega/services/knowledge/cmd/server/main.go 2026-08-25 16:05:38.000000000 +0000
@@ -254,6 +254,10 @@
log.Fatal("BASIC_AUTH_USER and BASIC_AUTH_PASSWORD must either both be set or both be empty")
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if (r.Method == http.MethodGet && r.URL.Path == "/api/health") || (r.Method == http.MethodPost && r.URL.Path == "/api/integrations/staging") {
+ next.ServeHTTP(w, r)
+ return
+ }
u, p, ok := r.BasicAuth()
userOK := subtle.ConstantTimeCompare([]byte(u), []byte(user)) == 1
passOK := subtle.ConstantTimeCompare([]byte(p), []byte(pass)) == 1
diff -ruN '--exclude=.git' /mnt/data/mega_work/originals/glpi-ai-knowledgebase/cmd/server/viewer/index.html /mnt/data/mega_work/glpi-neuroforge-mega/services/knowledge/cmd/server/viewer/index.html
--- /mnt/data/mega_work/originals/glpi-ai-knowledgebase/cmd/server/viewer/index.html 2026-08-04 19:15:15.000000000 +0000
+++ /mnt/data/mega_work/glpi-neuroforge-mega/services/knowledge/cmd/server/viewer/index.html 2026-08-25 18:23:24.582176607 +0000
@@ -19,6 +19,7 @@
diff -ruN '--exclude=.git' /mnt/data/mega_work/originals/glpi-ai-knowledgebase/cmd/server/web/index.html /mnt/data/mega_work/glpi-neuroforge-mega/services/knowledge/cmd/server/web/index.html
--- /mnt/data/mega_work/originals/glpi-ai-knowledgebase/cmd/server/web/index.html 2026-08-04 19:15:15.000000000 +0000
+++ /mnt/data/mega_work/glpi-neuroforge-mega/services/knowledge/cmd/server/web/index.html 2026-08-25 18:23:14.809823522 +0000
@@ -18,6 +18,7 @@
diff -ruN '--exclude=.git' /mnt/data/mega_work/originals/glpi-ai-knowledgebase/go.mod /mnt/data/mega_work/glpi-neuroforge-mega/services/knowledge/go.mod
--- /mnt/data/mega_work/originals/glpi-ai-knowledgebase/go.mod 2026-08-04 19:15:15.000000000 +0000
+++ /mnt/data/mega_work/glpi-neuroforge-mega/services/knowledge/go.mod 2026-08-25 15:35:04.000000000 +0000
@@ -1,3 +1,3 @@
module kb-editor
-go 1.26
+go 1.23
diff -ruN '--exclude=.git' /mnt/data/mega_work/originals/glpi-ai-knowledgebase/internal/aifallback/ollama_test.go /mnt/data/mega_work/glpi-neuroforge-mega/services/knowledge/internal/aifallback/ollama_test.go
--- /mnt/data/mega_work/originals/glpi-ai-knowledgebase/internal/aifallback/ollama_test.go 2026-08-04 19:15:15.000000000 +0000
+++ /mnt/data/mega_work/glpi-neuroforge-mega/services/knowledge/internal/aifallback/ollama_test.go 2026-08-25 18:31:40.932092591 +0000
@@ -23,7 +23,7 @@
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(map[string]any{
"message": map[string]any{"content": `{"title":"Fehler 0x1234","text":"Symptom","answer":"1. Prüfen","categories":["Windows"],"keywords":["0x1234"]}`},
- "done": true,
+ "done": true,
})
}))
defer server.Close()
diff -ruN '--exclude=.git' /mnt/data/mega_work/originals/glpi-ai-knowledgebase/internal/obsidian/export.go /mnt/data/mega_work/glpi-neuroforge-mega/services/knowledge/internal/obsidian/export.go
--- /mnt/data/mega_work/originals/glpi-ai-knowledgebase/internal/obsidian/export.go 1970-01-01 00:00:00.000000000 +0000
+++ /mnt/data/mega_work/glpi-neuroforge-mega/services/knowledge/internal/obsidian/export.go 2026-08-25 18:22:51.044942675 +0000
@@ -0,0 +1,539 @@
+package obsidian
+
+import (
+ "archive/zip"
+ "bytes"
+ "encoding/json"
+ "io"
+ "path"
+ "sort"
+ "strconv"
+ "strings"
+ "time"
+ "unicode"
+)
+
+type Document struct {
+ Data map[string]any
+ ModifiedAt string
+}
+
+type relation struct {
+ ID string
+ Title string
+ ItemType string
+ URI string
+ Kind string
+}
+
+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"`
+}
+type manifest struct {
+ Format string `json:"format"`
+ Version int `json:"version"`
+ GeneratedAt time.Time `json:"generated_at"`
+ Documents int `json:"documents"`
+ Categories int `json:"categories"`
+ Relations int `json:"relations"`
+}
+
+// WriteZIP exports canonical JSON knowledge as a self-contained Obsidian vault.
+// Unknown JSON fields remain untouched in the source database; relation-like
+// fields are interpreted only for export and never mutate the canonical data.
+func WriteZIP(w io.Writer, docs []Document, now time.Time) error {
+ if now.IsZero() {
+ now = time.Now().UTC()
+ }
+ docs = append([]Document(nil), docs...)
+ sort.Slice(docs, func(i, j int) bool {
+ return strings.ToLower(text(docs[i].Data, "title")) < strings.ToLower(text(docs[j].Data, "title"))
+ })
+ pageByID := map[string]string{}
+ pageByTitle := map[string]string{}
+ for _, d := range docs {
+ id := text(d.Data, "id")
+ title := text(d.Data, "title")
+ p := "Wiki/Knowledge/" + pageFilename(title, id)
+ if id != "" {
+ pageByID[strings.ToLower(id)] = p
+ }
+ if title != "" {
+ pageByTitle[strings.ToLower(title)] = p
+ }
+ }
+
+ zw := zip.NewWriter(w)
+ if err := writeFile(zw, "Wiki/Schema.md", schemaPage()); err != nil {
+ return err
+ }
+ g := graph{}
+ categoryPages := map[string]string{}
+ categoryTitles := map[string]string{}
+ relationStubs := map[string]relation{}
+ var relationCount int
+ for _, d := range docs {
+ id := text(d.Data, "id")
+ title := text(d.Data, "title")
+ p := pageByID[strings.ToLower(id)]
+ if p == "" {
+ p = "Wiki/Knowledge/" + pageFilename(title, id)
+ }
+ g.Nodes = append(g.Nodes, graphNode{ID: id, Title: title, Type: "knowledge", Path: p})
+ content, edges, cats, stubs := articlePage(d, p, pageByID, pageByTitle, now)
+ g.Edges = append(g.Edges, edges...)
+ relationCount += len(edges)
+ for _, c := range cats {
+ key := strings.ToLower(c)
+ cp := "Wiki/Categories/" + pageFilename(c, "")
+ categoryPages[key] = cp
+ categoryTitles[key] = c
+ }
+ for k, v := range stubs {
+ relationStubs[k] = v
+ }
+ if err := writeFile(zw, p, content); err != nil {
+ return err
+ }
+ }
+ keys := make([]string, 0, len(categoryPages))
+ for k := range categoryPages {
+ keys = append(keys, k)
+ }
+ sort.Strings(keys)
+ for _, k := range keys {
+ p := categoryPages[k]
+ title := categoryTitles[k]
+ g.Nodes = append(g.Nodes, graphNode{ID: "category:" + k, Title: title, Type: "category", Path: p})
+ if err := writeFile(zw, p, categoryPage(title, now)); err != nil {
+ return err
+ }
+ }
+ stubKeys := make([]string, 0, len(relationStubs))
+ for k := range relationStubs {
+ stubKeys = append(stubKeys, k)
+ }
+ sort.Strings(stubKeys)
+ for _, k := range stubKeys {
+ r := relationStubs[k]
+ p := stubPath(r)
+ g.Nodes = append(g.Nodes, graphNode{ID: k, Title: r.Title, Type: "entity", Path: p})
+ if err := writeFile(zw, p, stubPage(r, now)); err != nil {
+ return err
+ }
+ }
+ if err := writeFile(zw, "Wiki/index.md", indexPage(docs, pageByID, now)); err != nil {
+ return err
+ }
+ gb, _ := json.MarshalIndent(g, "", " ")
+ if err := writeFile(zw, "Wiki/graph.json", string(gb)+"\n"); err != nil {
+ return err
+ }
+ mb, _ := json.MarshalIndent(manifest{Format: "glpi-neuroforge-obsidian", Version: 1, GeneratedAt: now.UTC(), Documents: len(docs), Categories: len(categoryPages), Relations: relationCount}, "", " ")
+ if err := writeFile(zw, "Wiki/.manifest.json", string(mb)+"\n"); err != nil {
+ return err
+ }
+ return zw.Close()
+}
+
+func articlePage(d Document, page string, byID, byTitle map[string]string, now time.Time) (string, []graphEdge, []string, map[string]relation) {
+ m := d.Data
+ id := text(m, "id")
+ title := text(m, "title")
+ cats := stringsList(m["categories"])
+ tags := stringsList(m["keywords"])
+ rels := extractRelations(m)
+ var b strings.Builder
+ b.WriteString("---\n")
+ front(&b, "type", "knowledge")
+ front(&b, "title", title)
+ front(&b, "id", id)
+ front(&b, "source", text(m, "source"))
+ front(&b, "source_uri", text(m, "source_uri"))
+ front(&b, "language", text(m, "language"))
+ front(&b, "communication_style", text(m, "communication_style"))
+ front(&b, "created", isoDate(d.ModifiedAt, now))
+ front(&b, "updated", isoDate(d.ModifiedAt, now))
+ frontBoolAny(&b, "auto_reply", m["auto_reply"])
+ frontNumberAny(&b, "min_score", m["min_score"])
+ frontList(&b, "tags", tags)
+ frontList(&b, "categories", cats)
+ var resolved []string
+ stubs := map[string]relation{}
+ for _, r := range rels {
+ target, _ := resolveRelation(r, byID, byTitle, stubs)
+ if target != "" {
+ resolved = append(resolved, "[["+trimMD(target)+"|"+r.Title+"]]")
+ }
+ }
+ for _, c := range cats {
+ resolved = append(resolved, "[[Wiki/Categories/"+trimMD(pageFilename(c, ""))+"|"+c+"]]")
+ }
+ frontList(&b, "related", resolved)
+ b.WriteString("---\n\n# " + title + "\n\n")
+ if v := strings.TrimSpace(text(m, "text")); v != "" {
+ b.WriteString("## Kontext / Problem\n\n" + v + "\n\n")
+ }
+ if v := strings.TrimSpace(text(m, "answer")); v != "" {
+ b.WriteString("## Lösung / Antwort\n\n" + v + "\n\n")
+ }
+ if len(cats) > 0 || len(rels) > 0 || text(m, "source_uri") != "" {
+ b.WriteString("## Verknüpfungen\n\n")
+ }
+ var edges []graphEdge
+ for _, c := range cats {
+ cp := "Wiki/Categories/" + pageFilename(c, "")
+ b.WriteString("- [[" + trimMD(cp) + "|" + c + "]] — Kategorie\n")
+ edges = append(edges, graphEdge{From: id, To: "category:" + strings.ToLower(c), Relation: "category"})
+ }
+ for _, r := range rels {
+ target, targetID := resolveRelation(r, byID, byTitle, stubs)
+ if target == "" {
+ continue
+ }
+ b.WriteString("- [[" + trimMD(target) + "|" + escapeLinkLabel(r.Title) + "]]")
+ if r.ItemType != "" {
+ b.WriteString(" — `" + r.ItemType + "`")
+ }
+ if r.URI != "" {
+ b.WriteString(" · `" + strings.ReplaceAll(r.URI, "`", "") + "`")
+ }
+ b.WriteByte('\n')
+ edges = append(edges, graphEdge{From: id, To: targetID, Relation: r.Kind})
+ }
+ if uri := text(m, "source_uri"); uri != "" {
+ b.WriteString("- Quelle: `" + strings.ReplaceAll(uri, "`", "") + "`\n")
+ }
+ _ = page
+ return b.String(), edges, cats, stubs
+}
+
+func extractRelations(m map[string]any) []relation {
+ keys := []string{"linked_items", "relations", "related", "related_articles", "references", "links", "connections", "associations", "glpi_relations"}
+ var out []relation
+ seen := map[string]struct{}{}
+ var add func(any, string)
+ add = func(v any, kind string) {
+ switch x := v.(type) {
+ case []any:
+ for _, e := range x {
+ add(e, kind)
+ }
+ case []string:
+ for _, e := range x {
+ add(e, kind)
+ }
+ case string:
+ x = strings.TrimSpace(x)
+ if x == "" {
+ return
+ }
+ r := relation{ID: x, Title: x, Kind: kind}
+ k := strings.ToLower(kind + "|" + x)
+ if _, ok := seen[k]; !ok {
+ seen[k] = struct{}{}
+ out = append(out, r)
+ }
+ case map[string]any:
+ id := firstText(x, "id", "items_id", "item_id", "target_id", "knowledge_id")
+ title := firstText(x, "title", "name", "label", "target_title")
+ itemType := firstText(x, "item_type", "itemtype", "type")
+ uri := firstText(x, "uri", "url", "source_uri", "href")
+ relKind := firstText(x, "relation", "kind")
+ if relKind == "" {
+ relKind = kind
+ }
+ if title == "" {
+ if itemType != "" && id != "" {
+ title = itemType + " #" + id
+ } else {
+ title = id
+ }
+ }
+ if id == "" {
+ id = title
+ }
+ if id == "" {
+ return
+ }
+ r := relation{ID: id, Title: title, ItemType: itemType, URI: uri, Kind: relKind}
+ k := strings.ToLower(relKind + "|" + itemType + "|" + id)
+ if _, ok := seen[k]; !ok {
+ seen[k] = struct{}{}
+ out = append(out, r)
+ }
+ }
+ }
+ for _, k := range keys {
+ if v, ok := m[k]; ok {
+ add(v, k)
+ }
+ }
+ return out
+}
+
+func resolveRelation(r relation, byID, byTitle map[string]string, stubs map[string]relation) (string, string) {
+ if p := byID[strings.ToLower(strings.TrimSpace(r.ID))]; p != "" {
+ return p, r.ID
+ }
+ if p := byTitle[strings.ToLower(strings.TrimSpace(r.Title))]; p != "" {
+ return p, r.ID
+ }
+ if strings.EqualFold(r.ItemType, "KnowbaseItem") {
+ if p := byID[strings.ToLower("GLPI-KB-"+r.ID)]; p != "" {
+ return p, "GLPI-KB-" + r.ID
+ }
+ }
+ key := "relation:" + strings.ToLower(strings.TrimSpace(r.ItemType)) + ":" + strings.ToLower(strings.TrimSpace(r.ID))
+ stubs[key] = r
+ return stubPath(r), key
+}
+func stubPath(r relation) string {
+ typ := slug(r.ItemType)
+ if typ == "" {
+ typ = "related"
+ }
+ return "Wiki/Relations/" + typ + "/" + pageFilename(r.Title, r.ID)
+}
+func stubPage(r relation, now time.Time) string {
+ var b strings.Builder
+ b.WriteString("---\n")
+ front(&b, "type", "entity")
+ front(&b, "entity_type", r.ItemType)
+ front(&b, "title", r.Title)
+ front(&b, "source", "relation")
+ front(&b, "source_uri", r.URI)
+ front(&b, "created", now.UTC().Format("2006-01-02"))
+ front(&b, "updated", now.UTC().Format("2006-01-02"))
+ b.WriteString("---\n\n# " + r.Title + "\n\nVerknüpftes Wissens- oder GLPI-Objekt.\n")
+ return b.String()
+}
+func categoryPage(title string, now time.Time) string {
+ var b strings.Builder
+ b.WriteString("---\n")
+ front(&b, "type", "entity")
+ front(&b, "entity_type", "category")
+ front(&b, "title", title)
+ front(&b, "created", now.UTC().Format("2006-01-02"))
+ front(&b, "updated", now.UTC().Format("2006-01-02"))
+ b.WriteString("---\n\n# " + title + "\n\nKategorie der GLPI/NeuroForge-Wissensbasis.\n")
+ return b.String()
+}
+func indexPage(docs []Document, 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")
+ for _, d := range docs {
+ id := text(d.Data, "id")
+ title := text(d.Data, "title")
+ p := pages[strings.ToLower(id)]
+ b.WriteString("- [[" + trimMD(p) + "|" + escapeLinkLabel(title) + "]] — `" + id + "`\n")
+ }
+ return b.String()
+}
+func schemaPage() string {
+ return `---
+type: meta
+title: GLPI NeuroForge Wiki Schema
+status: active
+---
+
+# Wiki Schema
+
+Obsidian-kompatibler Export nach llm-wiki-artigen Konventionen.
+
+- Metadaten: YAML-Frontmatter
+- Beziehungen: [[Wiki/Namespace/Page]]
+- Datumswerte: ISO-8601 (YYYY-MM-DD)
+- Knowledge-Seiten: type=knowledge
+- Kategorien/GLPI-Objekte: type=entity
+- Index: type=overview
+- graph.json: maschinenlesbare Knoten und Kanten
+
+Der Export ist read-only und enthält keine Zugangsdaten.
+`
+}
+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
+}
+func text(m map[string]any, k string) string {
+ if v, ok := m[k]; ok {
+ switch x := v.(type) {
+ case string:
+ return strings.TrimSpace(x)
+ case json.Number:
+ return x.String()
+ case float64:
+ return strconv.FormatFloat(x, 'f', -1, 64)
+ case int:
+ return strconv.Itoa(x)
+ case int64:
+ return strconv.FormatInt(x, 10)
+ }
+ }
+ return ""
+}
+func firstText(m map[string]any, keys ...string) string {
+ for _, k := range keys {
+ if v := text(m, k); v != "" {
+ return v
+ }
+ }
+ return ""
+}
+func stringsList(v any) []string {
+ var out []string
+ seen := map[string]struct{}{}
+ var add func(any)
+ add = func(x any) {
+ switch y := x.(type) {
+ case []any:
+ for _, e := range y {
+ add(e)
+ }
+ case []string:
+ for _, e := range y {
+ add(e)
+ }
+ case string:
+ y = strings.TrimSpace(y)
+ if y != "" {
+ k := strings.ToLower(y)
+ if _, ok := seen[k]; !ok {
+ seen[k] = struct{}{}
+ out = append(out, y)
+ }
+ }
+ case json.Number:
+ add(y.String())
+ case float64:
+ add(strconv.FormatFloat(y, 'f', -1, 64))
+ }
+ }
+ add(v)
+ sort.Strings(out)
+ return out
+}
+func front(b *strings.Builder, k, v string) {
+ if strings.TrimSpace(v) == "" {
+ return
+ }
+ raw, _ := json.Marshal(strings.TrimSpace(v))
+ b.WriteString(k + ": " + string(raw) + "\n")
+}
+func frontList(b *strings.Builder, k string, vs []string) {
+ if len(vs) == 0 {
+ return
+ }
+ b.WriteString(k + ":\n")
+ for _, v := range vs {
+ raw, _ := json.Marshal(v)
+ b.WriteString(" - " + string(raw) + "\n")
+ }
+}
+func frontBoolAny(b *strings.Builder, k string, v any) {
+ switch x := v.(type) {
+ case bool:
+ b.WriteString(k + ": " + strconv.FormatBool(x) + "\n")
+ case string:
+ if x != "" {
+ b.WriteString(k + ": " + strings.ToLower(x) + "\n")
+ }
+ }
+}
+func frontNumberAny(b *strings.Builder, k string, v any) {
+ switch x := v.(type) {
+ case json.Number:
+ b.WriteString(k + ": " + x.String() + "\n")
+ case float64:
+ b.WriteString(k + ": " + strconv.FormatFloat(x, 'f', -1, 64) + "\n")
+ case int:
+ b.WriteString(k + ": " + strconv.Itoa(x) + "\n")
+ case string:
+ if x != "" {
+ b.WriteString(k + ": " + x + "\n")
+ }
+ }
+}
+func pageFilename(title, id string) string {
+ s := slug(title)
+ if s == "" {
+ s = "artikel"
+ }
+ sid := slug(id)
+ if sid != "" && !strings.Contains(s, sid) {
+ s += "--" + sid
+ }
+ return s + ".md"
+}
+func slug(v string) string {
+ v = strings.ToLower(strings.TrimSpace(v))
+ var b strings.Builder
+ dash := false
+ for _, r := range v {
+ var repl string
+ switch r {
+ case 'ä':
+ repl = "ae"
+ case 'ö':
+ repl = "oe"
+ case 'ü':
+ repl = "ue"
+ case 'ß':
+ repl = "ss"
+ default:
+ if unicode.IsLetter(r) || unicode.IsDigit(r) {
+ b.WriteRune(r)
+ dash = false
+ continue
+ }
+ repl = "-"
+ }
+ for _, rr := range repl {
+ if rr == '-' {
+ if !dash && b.Len() > 0 {
+ b.WriteByte('-')
+ dash = true
+ }
+ } else {
+ b.WriteRune(rr)
+ dash = false
+ }
+ }
+ }
+ return strings.Trim(b.String(), "-")
+}
+func trimMD(v string) string { return strings.TrimSuffix(v, ".md") }
+func escapeLinkLabel(v string) string { return strings.ReplaceAll(v, "]", "\\]") }
+func isoDate(v string, fallback time.Time) string {
+ v = strings.TrimSpace(v)
+ for _, layout := range []string{time.RFC3339, "2006-01-02T15:04:05", "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' /mnt/data/mega_work/originals/glpi-ai-knowledgebase/internal/obsidian/export_test.go /mnt/data/mega_work/glpi-neuroforge-mega/services/knowledge/internal/obsidian/export_test.go
--- /mnt/data/mega_work/originals/glpi-ai-knowledgebase/internal/obsidian/export_test.go 1970-01-01 00:00:00.000000000 +0000
+++ /mnt/data/mega_work/glpi-neuroforge-mega/services/knowledge/internal/obsidian/export_test.go 2026-08-25 18:22:28.211087382 +0000
@@ -0,0 +1,47 @@
+package obsidian
+
+import (
+ "archive/zip"
+ "bytes"
+ "io"
+ "strings"
+ "testing"
+ "time"
+)
+
+func TestWriteZIPCreatesCategoryAndExplicitRelationGraph(t *testing.T) {
+ docs := []Document{
+ {Data: map[string]any{"id": "KB-1", "title": "VPN", "text": "Fehler", "answer": "Neu verbinden", "source": "internal-kb", "categories": []any{"Netzwerk > VPN"}, "keywords": []any{"vpn"}, "related_articles": []any{map[string]any{"id": "KB-2", "title": "Netzwerk"}}}, ModifiedAt: "2026-08-20"},
+ {Data: map[string]any{"id": "KB-2", "title": "Netzwerk", "text": "Netz", "answer": "Pruefen", "source": "internal-kb"}, ModifiedAt: "2026-08-20"},
+ }
+ 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, _ := f.Open()
+ b, _ := io.ReadAll(r)
+ r.Close()
+ files[f.Name] = string(b)
+ }
+ var vpn string
+ for n, b := range files {
+ if strings.Contains(n, "vpn--kb-1") {
+ vpn = b
+ }
+ }
+ if !strings.Contains(vpn, "[[Wiki/Categories/netzwerk-vpn|Netzwerk > VPN]]") {
+ t.Fatalf("category wikilink missing:\n%s", vpn)
+ }
+ if !strings.Contains(vpn, "[[Wiki/Knowledge/netzwerk--kb-2|Netzwerk]]") {
+ t.Fatalf("article relation missing:\n%s", vpn)
+ }
+ if !strings.Contains(files["Wiki/graph.json"], `"relation": "related_articles"`) {
+ t.Fatalf("graph relation missing: %s", files["Wiki/graph.json"])
+ }
+}
diff -ruN '--exclude=.git' /mnt/data/mega_work/originals/glpi-ai-knowledgebase/internal/staging/staging.go /mnt/data/mega_work/glpi-neuroforge-mega/services/knowledge/internal/staging/staging.go
--- /mnt/data/mega_work/originals/glpi-ai-knowledgebase/internal/staging/staging.go 2026-08-04 19:15:15.000000000 +0000
+++ /mnt/data/mega_work/glpi-neuroforge-mega/services/knowledge/internal/staging/staging.go 2026-08-25 16:05:05.000000000 +0000
@@ -105,6 +105,12 @@
}
func (s *Store) Save(query, model string, draft Draft, autoReply bool, minScore float64) (Result, error) {
+ return s.SaveFromSource(query, fmt.Sprintf("Ollama / %s", strings.TrimSpace(model)), draft, autoReply, minScore)
+}
+
+// SaveFromSource stores a proposal in the human-review staging area while
+// preserving the system that produced it. It never promotes into production.
+func (s *Store) SaveFromSource(query, source string, draft Draft, autoReply bool, minScore float64) (Result, error) {
draft.Title = clampString(draft.Title, 320)
draft.Text = clampString(draft.Text, 16000)
draft.Answer = clampString(draft.Answer, 32000)
@@ -116,6 +122,10 @@
if minScore < 0 || minScore > 1 {
minScore = 0.78
}
+ source = clampString(source, 240)
+ if source == "" {
+ source = "External Research"
+ }
now := time.Now().UTC()
sum := sha256.Sum256([]byte(strings.ToLower(strings.TrimSpace(query)) + "\x00" + now.Format(time.RFC3339Nano)))
@@ -136,7 +146,7 @@
"min_score": minScore,
"categories": categories,
"keywords": keywords,
- "source": fmt.Sprintf("Ollama / %s (AI-Staging)", strings.TrimSpace(model)),
+ "source": source + " (AI-Staging)",
"source_uri": "",
"language": "de-DE",
"communication_style": "formal",
diff -ruN '--exclude=.git' /mnt/data/mega_work/originals/glpi-ai-knowledgebase/internal/store/store.go /mnt/data/mega_work/glpi-neuroforge-mega/services/knowledge/internal/store/store.go
--- /mnt/data/mega_work/originals/glpi-ai-knowledgebase/internal/store/store.go 2026-08-04 19:15:15.000000000 +0000
+++ /mnt/data/mega_work/glpi-neuroforge-mega/services/knowledge/internal/store/store.go 2026-08-25 18:20:52.614767393 +0000
@@ -1170,3 +1170,22 @@
}
return out
}
+
+// ExportDocument is an immutable snapshot used by read-only exporters.
+type ExportDocument struct {
+ Document map[string]any `json:"document"`
+ Summary Summary `json:"summary"`
+}
+
+// ExportDocuments returns a consistent copy of the complete canonical
+// knowledge base without exposing mutable in-memory maps to exporters.
+func (s *Store) ExportDocuments() []ExportDocument {
+ s.mu.RLock()
+ defer s.mu.RUnlock()
+ out := make([]ExportDocument, 0, len(s.order))
+ for _, key := range s.order {
+ rec := s.records[key]
+ out = append(out, ExportDocument{Document: cloneMap(rec.Doc), Summary: summarize(rec)})
+ }
+ return out
+}