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

385 lines
11 KiB
Go

package obsidian
import (
"archive/zip"
"bytes"
"encoding/json"
"fmt"
"io"
"path"
"regexp"
"sort"
"strconv"
"strings"
"time"
"unicode"
"github.com/example/glpi-ai-agent/internal/model"
)
type manifest struct {
Format string `json:"format"`
Version int `json:"version"`
GeneratedAt time.Time `json:"generated_at"`
Documents int `json:"documents"`
Relations int `json:"relations"`
}
type graphNode struct {
ID string `json:"id"`
Title string `json:"title"`
Type string `json:"type"`
Path string `json:"path"`
}
type graphEdge struct {
From string `json:"from"`
To string `json:"to"`
Relation string `json:"relation"`
}
type graph struct {
Nodes []graphNode `json:"nodes"`
Edges []graphEdge `json:"edges"`
}
// WriteZIP exports a snapshot of the live knowledge store as an Obsidian vault.
// It is deliberately read-only: no source files are modified by an export.
func WriteZIP(w io.Writer, docs []model.KnowledgeDoc, generatedAt time.Time) error {
if generatedAt.IsZero() {
generatedAt = time.Now().UTC()
}
docs = append([]model.KnowledgeDoc(nil), docs...)
sort.Slice(docs, func(i, j int) bool {
if strings.EqualFold(docs[i].Title, docs[j].Title) {
return docs[i].ID < docs[j].ID
}
return strings.ToLower(docs[i].Title) < strings.ToLower(docs[j].Title)
})
pageByID := make(map[string]string, len(docs))
for _, d := range docs {
pageByID[d.ID] = "Wiki/Knowledge/" + pageFilename(d.Title, d.ID)
}
zw := zip.NewWriter(w)
defer zw.Close()
if err := writeFile(zw, "Wiki/Schema.md", schemaPage()); err != nil {
return err
}
g := graph{}
relationCount := 0
stubPages := map[string]model.LinkedItem{}
for _, d := range docs {
p := pageByID[d.ID]
g.Nodes = append(g.Nodes, graphNode{ID: d.ID, Title: d.Title, Type: "knowledge", Path: p})
content, edges := articlePage(d, p, pageByID, generatedAt, stubPages)
relationCount += len(edges)
g.Edges = append(g.Edges, edges...)
if err := writeFile(zw, p, content); err != nil {
return err
}
}
stubKeys := make([]string, 0, len(stubPages))
for key := range stubPages {
stubKeys = append(stubKeys, key)
}
sort.Strings(stubKeys)
for _, key := range stubKeys {
item := stubPages[key]
p := glpiItemPath(item)
title := linkedTitle(item)
g.Nodes = append(g.Nodes, graphNode{ID: key, Title: title, Type: "entity", Path: p})
if err := writeFile(zw, p, glpiEntityPage(item, generatedAt)); err != nil {
return err
}
}
if err := writeFile(zw, "Wiki/index.md", indexPage(docs, pageByID, generatedAt)); err != nil {
return err
}
gb, err := json.MarshalIndent(g, "", " ")
if err != nil {
return err
}
if err := writeFile(zw, "Wiki/graph.json", string(gb)+"\n"); err != nil {
return err
}
mb, err := json.MarshalIndent(manifest{Format: "glpi-neuroforge-obsidian", Version: 1, GeneratedAt: generatedAt.UTC(), Documents: len(docs), Relations: relationCount}, "", " ")
if err != nil {
return err
}
return writeFile(zw, "Wiki/.manifest.json", string(mb)+"\n")
}
func articlePage(d model.KnowledgeDoc, page string, pageByID map[string]string, now time.Time, stubs map[string]model.LinkedItem) (string, []graphEdge) {
var b strings.Builder
date := isoDate(d.SourceModifiedAt, now)
b.WriteString("---\n")
front(&b, "type", "knowledge")
front(&b, "title", d.Title)
front(&b, "id", d.ID)
front(&b, "source", d.Source)
front(&b, "source_uri", d.SourceURI)
front(&b, "language", d.Language)
front(&b, "communication_style", d.CommunicationStyle)
front(&b, "created", date)
front(&b, "updated", date)
frontBool(&b, "auto_reply", d.AutoReply)
frontFloat(&b, "min_score", d.MinScore)
frontList(&b, "tags", d.Keywords)
frontIntList(&b, "categories", d.Categories)
frontIntList(&b, "glpi_kb_categories", d.SourceCategoryIDs)
frontList(&b, "external_categories", d.ExternalCategories)
if len(d.LinkedItems) > 0 {
b.WriteString("related:\n")
for _, item := range d.LinkedItems {
target := relationTarget(item, pageByID, stubs)
b.WriteString(" - ")
b.WriteString(yamlQuote("[[" + trimMD(target) + "|" + linkedTitle(item) + "]]"))
b.WriteByte('\n')
}
}
b.WriteString("---\n\n")
b.WriteString("# " + d.Title + "\n\n")
if strings.TrimSpace(d.Text) != "" {
b.WriteString("## Kontext / Problem\n\n" + strings.TrimSpace(d.Text) + "\n\n")
}
if strings.TrimSpace(d.Answer) != "" {
b.WriteString("## Lösung / Antwort\n\n" + strings.TrimSpace(d.Answer) + "\n\n")
}
if len(d.LinkedItems) > 0 || d.SourceURI != "" {
b.WriteString("## Verknüpfungen\n\n")
}
var edges []graphEdge
for _, item := range d.LinkedItems {
target := relationTarget(item, pageByID, stubs)
b.WriteString("- [[" + trimMD(target) + "|" + escapeLinkLabel(linkedTitle(item)) + "]] — `" + item.ItemType + " #" + strconv.FormatInt(item.ID, 10) + "`\n")
edges = append(edges, graphEdge{From: d.ID, To: relationID(item), Relation: "glpi-linked-item"})
}
if d.SourceURI != "" {
b.WriteString("- Quelle: `" + strings.ReplaceAll(d.SourceURI, "`", "") + "`\n")
}
if d.AutoReplyDecision != "" {
b.WriteString("\n## Governance\n\n")
b.WriteString("- Auto-Reply: **" + strconv.FormatBool(d.AutoReply) + "**\n")
b.WriteString("- Entscheidung: `" + strings.ReplaceAll(d.AutoReplyDecision, "`", "") + "`\n")
if d.AutoReplyDetail != "" {
b.WriteString("- Begründung: " + strings.TrimSpace(d.AutoReplyDetail) + "\n")
}
}
_ = page
return b.String(), edges
}
func relationTarget(item model.LinkedItem, pageByID map[string]string, stubs map[string]model.LinkedItem) string {
if strings.EqualFold(item.ItemType, "KnowbaseItem") {
if p, ok := pageByID["GLPI-KB-"+strconv.FormatInt(item.ID, 10)]; ok {
return p
}
}
key := relationID(item)
stubs[key] = item
return glpiItemPath(item)
}
func relationID(item model.LinkedItem) string {
return "GLPI-" + safePart(item.ItemType) + "-" + strconv.FormatInt(item.ID, 10)
}
func glpiItemPath(item model.LinkedItem) string {
return "Wiki/GLPI/" + safePart(item.ItemType) + "/" + pageFilename(linkedTitle(item), strconv.FormatInt(item.ID, 10))
}
func linkedTitle(item model.LinkedItem) string {
if strings.TrimSpace(item.Name) != "" {
return strings.TrimSpace(item.Name)
}
t := strings.TrimSpace(item.ItemType)
if t == "" {
t = "GLPI-Objekt"
}
return fmt.Sprintf("%s #%d", t, item.ID)
}
func glpiEntityPage(item model.LinkedItem, now time.Time) string {
var b strings.Builder
b.WriteString("---\n")
front(&b, "type", "entity")
front(&b, "entity_type", strings.ToLower(safePart(item.ItemType)))
front(&b, "title", linkedTitle(item))
front(&b, "source", "glpi")
front(&b, "source_uri", fmt.Sprintf("glpi://%s/%d", item.ItemType, item.ID))
front(&b, "created", now.UTC().Format("2006-01-02"))
front(&b, "updated", now.UTC().Format("2006-01-02"))
b.WriteString("---\n\n# " + linkedTitle(item) + "\n\n")
b.WriteString("Von GLPI mit einem oder mehreren Knowledge-Base-Artikeln verknüpft.\n")
return b.String()
}
func indexPage(docs []model.KnowledgeDoc, pages map[string]string, now time.Time) string {
var b strings.Builder
b.WriteString("---\n")
front(&b, "type", "overview")
front(&b, "title", "Knowledge Index")
front(&b, "created", now.UTC().Format("2006-01-02"))
front(&b, "updated", now.UTC().Format("2006-01-02"))
b.WriteString("---\n\n# Knowledge Index\n\n")
b.WriteString("Exportierte Artikel: **" + strconv.Itoa(len(docs)) + "**\n\n")
for _, d := range docs {
b.WriteString("- [[" + trimMD(pages[d.ID]) + "|" + escapeLinkLabel(d.Title) + "]] — `" + d.ID + "` · `" + d.Source + "`\n")
}
return b.String()
}
func schemaPage() string {
return `---
type: meta
title: GLPI NeuroForge Wiki Schema
status: active
---
# Wiki Schema
Dieser Export ist für Obsidian und llm-wiki-artige Workflows ausgelegt.
## Seitentypen
- ` + "`knowledge`" + ` — Knowledge-Base-Artikel.
- ` + "`entity`" + ` — aus GLPI verknüpfte Objekte.
- ` + "`overview`" + ` — Indexseiten.
- ` + "`meta`" + ` — Schema- und Steuerseiten.
## Konventionen
- Metadaten stehen in YAML-Frontmatter.
- Interne Beziehungen verwenden ` + "`[[Wiki/...]]`" + `.
- Datumswerte verwenden ISO-8601 (` + "`YYYY-MM-DD`" + `).
- ` + "`source_uri`" + ` bewahrt die Herkunft; Secrets werden nicht exportiert.
- ` + "`graph.json`" + ` enthält dieselben expliziten Beziehungen maschinenlesbar.
`
}
func writeFile(zw *zip.Writer, name, content string) error {
h := &zip.FileHeader{Name: path.Clean(name), Method: zip.Deflate}
h.SetMode(0o644)
f, err := zw.CreateHeader(h)
if err != nil {
return err
}
_, err = io.Copy(f, bytes.NewBufferString(content))
return err
}
var nonSlug = regexp.MustCompile(`[^a-z0-9]+`)
func pageFilename(title, id string) string {
s := slug(title)
if s == "" {
s = "artikel"
}
i := slug(id)
if i != "" && !strings.Contains(s, i) {
s += "--" + i
}
return s + ".md"
}
func slug(v string) string {
v = strings.ToLower(strings.TrimSpace(v))
var b strings.Builder
for _, r := range v {
switch r {
case 'ä':
b.WriteString("ae")
case 'ö':
b.WriteString("oe")
case 'ü':
b.WriteString("ue")
case 'ß':
b.WriteString("ss")
default:
if unicode.IsLetter(r) || unicode.IsDigit(r) {
b.WriteRune(r)
} else {
b.WriteByte('-')
}
}
}
return strings.Trim(nonSlug.ReplaceAllString(b.String(), "-"), "-")
}
func safePart(v string) string {
s := slug(v)
if s == "" {
return "item"
}
return s
}
func trimMD(v string) string { return strings.TrimSuffix(v, ".md") }
func escapeLinkLabel(v string) string { return strings.ReplaceAll(v, "]", "\\]") }
func yamlQuote(v string) string {
b, _ := json.Marshal(v)
return string(b)
}
func front(b *strings.Builder, key, value string) {
if strings.TrimSpace(value) == "" {
return
}
b.WriteString(key + ": " + yamlQuote(strings.TrimSpace(value)) + "\n")
}
func frontBool(b *strings.Builder, key string, value bool) {
b.WriteString(key + ": " + strconv.FormatBool(value) + "\n")
}
func frontFloat(b *strings.Builder, key string, value float64) {
b.WriteString(key + ": " + strconv.FormatFloat(value, 'f', -1, 64) + "\n")
}
func frontList(b *strings.Builder, key string, values []string) {
values = uniqueStrings(values)
if len(values) == 0 {
return
}
b.WriteString(key + ":\n")
for _, v := range values {
b.WriteString(" - " + yamlQuote(v) + "\n")
}
}
func frontIntList(b *strings.Builder, key string, values []int64) {
if len(values) == 0 {
return
}
b.WriteString(key + ":\n")
for _, v := range values {
b.WriteString(" - " + strconv.FormatInt(v, 10) + "\n")
}
}
func uniqueStrings(in []string) []string {
seen := map[string]struct{}{}
out := make([]string, 0, len(in))
for _, v := range in {
v = strings.TrimSpace(v)
if v == "" {
continue
}
k := strings.ToLower(v)
if _, ok := seen[k]; ok {
continue
}
seen[k] = struct{}{}
out = append(out, v)
}
sort.Strings(out)
return out
}
func isoDate(v string, fallback time.Time) string {
v = strings.TrimSpace(v)
for _, layout := range []string{time.RFC3339, "2006-01-02 15:04:05", "2006-01-02"} {
if t, err := time.Parse(layout, v); err == nil {
return t.Format("2006-01-02")
}
}
return fallback.UTC().Format("2006-01-02")
}