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
385 lines
11 KiB
Go
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")
|
|
}
|