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
953 lines
33 KiB
Diff
953 lines
33 KiB
Diff
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 @@
|
|
<div class="top-meta">
|
|
<span id="modeBadge" class="mode-badge">Nur lesen</span>
|
|
<span id="countBadge" class="count-badge">Wissensbasis lädt …</span>
|
|
+ <a class="mode-badge" href="/api/export/obsidian" title="Obsidian/llm-wiki-kompatiblen Vault exportieren">⇩ Obsidian Export</a>
|
|
</div>
|
|
</header>
|
|
|
|
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 @@
|
|
</div>
|
|
<div class="top-actions">
|
|
<span id="healthPill" class="pill muted">Verbinde …</span>
|
|
+ <a class="btn ghost" href="/api/export/obsidian" title="Obsidian/llm-wiki-kompatiblen Vault exportieren">⇩ Obsidian Export</a>
|
|
<button id="reloadBtn" class="btn ghost" title="Dateien neu einlesen">↻ Neu einlesen</button>
|
|
<button id="bulkBtn" class="btn primary" disabled>✦ Massenbearbeitung</button>
|
|
</div>
|
|
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
|
|
+}
|