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 @@
Nur lesen Wissensbasis lädt … + ⇩ Obsidian Export
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 @@
Verbinde … + ⇩ Obsidian Export
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 +}