- a source whose slug is taken by anything but this wing's own page gets a wing-qualified, stable slug (talk-homelab) instead of failing the ingest - an extracted entity/concept whose slug belongs to a different page is skipped with a warning, so one page can no longer lock a source out of re-ingest - in a wing only source/concept/entity are valid page types; the model (and so the clip) can no longer choose wiki/<type>/ directories Mutation-checked; stable-slug choice pinned directly because title resolution masks it at the Run level. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
158 lines
4.9 KiB
Go
158 lines
4.9 KiB
Go
// ingestion/internal/pipeline/build.go
|
|
package pipeline
|
|
|
|
import (
|
|
"fmt"
|
|
"regexp"
|
|
"strings"
|
|
|
|
"github.com/mathiasbq/hyperguild/ingestion/internal/wiki"
|
|
)
|
|
|
|
// BuildPages converts RawPages from the LLM into wiki.Pages with computed slugs,
|
|
// paths, and YAML frontmatter. sourceSlug is the slug of the source being ingested
|
|
// (derived from the filename, not the LLM title). Pages whose title resolves to an
|
|
// empty slug are skipped and returned as warnings instead.
|
|
func BuildPages(rawPages []RawPage, sourceSlug, date string) ([]wiki.Page, []string) {
|
|
return BuildPagesIn("", rawPages, sourceSlug, date)
|
|
}
|
|
|
|
// BuildPagesIn is BuildPages for a wing: a source goes to wiki/<wing>/sources/
|
|
// with wing/hall frontmatter. Concepts and entities stay in the shared
|
|
// wiki/concepts/ and wiki/entities/, since their slug is the graph's global
|
|
// identity and they are cited from many wings. An empty wing is the retired
|
|
// layout for sources too.
|
|
func BuildPagesIn(wing string, rawPages []RawPage, sourceSlug, date string) ([]wiki.Page, []string) {
|
|
out := make([]wiki.Page, 0, len(rawPages))
|
|
var warnings []string
|
|
for _, rp := range rawPages {
|
|
// The type becomes a directory under wiki/; "../raw" wrote outside it
|
|
// (PR #147 review). Only a plain lower-case name is a page type.
|
|
if !validPageType.MatchString(rp.Type) || (wing != "" && !layoutType(rp.Type)) {
|
|
warnings = append(warnings, fmt.Sprintf("skipped page with invalid type %q", rp.Type))
|
|
continue
|
|
}
|
|
slug := computeSlug(rp, sourceSlug)
|
|
if slug == "" {
|
|
warnings = append(warnings, fmt.Sprintf("skipped page with empty title (type: %s)", rp.Type))
|
|
continue
|
|
}
|
|
out = append(out, buildPage(rp, sourceSlug, date, wing))
|
|
}
|
|
return out, warnings
|
|
}
|
|
|
|
// layoutType reports whether a wing-mode page type has a place in the layout.
|
|
// The type comes from the model, so from the clip being ingested: anything else
|
|
// would name a directory (wiki/<type>/) of the model's choosing.
|
|
func layoutType(t string) bool { return t == "source" || t == "concept" || t == "entity" }
|
|
|
|
var validPageType = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
|
|
|
|
func computeSlug(rp RawPage, sourceSlug string) string {
|
|
if rp.Type == "source" {
|
|
return sourceSlug
|
|
}
|
|
return wiki.Slug(rp.Title)
|
|
}
|
|
|
|
func buildPage(rp RawPage, sourceSlug, date, wing string) wiki.Page {
|
|
var slug, dir string
|
|
switch rp.Type {
|
|
case "source":
|
|
slug = sourceSlug
|
|
dir = "wiki/sources"
|
|
case "concept":
|
|
slug = wiki.Slug(rp.Title)
|
|
dir = "wiki/concepts"
|
|
case "entity":
|
|
slug = wiki.Slug(rp.Title)
|
|
dir = "wiki/entities"
|
|
default:
|
|
slug = wiki.Slug(rp.Title)
|
|
dir = "wiki/" + rp.Type
|
|
}
|
|
|
|
if wing != "" && rp.Type == "source" {
|
|
dir = "wiki/" + wing + "/sources"
|
|
}
|
|
path := dir + "/" + slug + ".md"
|
|
fm := buildFrontmatter(rp, date)
|
|
if wing != "" && rp.Type == "source" {
|
|
fm = addWingFrontmatter(fm, wing, "sources")
|
|
}
|
|
|
|
return wiki.Page{
|
|
Path: path,
|
|
Content: fm + "\n" + rp.Content,
|
|
}
|
|
}
|
|
|
|
func buildFrontmatter(rp RawPage, date string) string {
|
|
var sb strings.Builder
|
|
sb.WriteString("---\n")
|
|
fmt.Fprintf(&sb, "title: %s\n", yamlScalar(rp.Title))
|
|
|
|
switch rp.Type {
|
|
case "source":
|
|
subtype := rp.Subtype
|
|
if subtype == "" {
|
|
subtype = "article"
|
|
}
|
|
fmt.Fprintf(&sb, "type: %s\n", yamlScalar(subtype))
|
|
if rp.Domain != "" {
|
|
fmt.Fprintf(&sb, "domain: %s\n", yamlScalar(rp.Domain))
|
|
}
|
|
fmt.Fprintf(&sb, "date_ingested: %s\n", date)
|
|
fmt.Fprintf(&sb, "last_updated: %s\n", date)
|
|
if rp.SourceHash != "" {
|
|
fmt.Fprintf(&sb, "source_hash: %s\n", rp.SourceHash)
|
|
}
|
|
if rp.Source != "" {
|
|
fmt.Fprintf(&sb, "source: %s\n", yamlScalar(rp.Source))
|
|
}
|
|
if rp.Author != "" {
|
|
fmt.Fprintf(&sb, "author: %s\n", yamlScalar(rp.Author))
|
|
}
|
|
if rp.Published != "" {
|
|
fmt.Fprintf(&sb, "published: %s\n", yamlScalar(rp.Published))
|
|
}
|
|
case "concept":
|
|
if rp.Domain != "" {
|
|
fmt.Fprintf(&sb, "domain: %s\n", yamlScalar(rp.Domain))
|
|
}
|
|
fmt.Fprintf(&sb, "last_updated: %s\n", date)
|
|
case "entity":
|
|
if rp.Subtype != "" {
|
|
fmt.Fprintf(&sb, "type: %s\n", yamlScalar(rp.Subtype))
|
|
}
|
|
if rp.Domain != "" {
|
|
fmt.Fprintf(&sb, "domain: %s\n", yamlScalar(rp.Domain))
|
|
}
|
|
fmt.Fprintf(&sb, "last_updated: %s\n", date)
|
|
default:
|
|
if rp.Domain != "" {
|
|
fmt.Fprintf(&sb, "domain: %s\n", yamlScalar(rp.Domain))
|
|
}
|
|
fmt.Fprintf(&sb, "last_updated: %s\n", date)
|
|
}
|
|
|
|
fmt.Fprintf(&sb, "aliases:\n - %s\n", yamlScalar(rp.Title))
|
|
sb.WriteString("---\n")
|
|
return sb.String()
|
|
}
|
|
|
|
// addWingFrontmatter inserts wing/hall right after the opening fence of an
|
|
// already-built frontmatter block.
|
|
func addWingFrontmatter(fm, wing, hall string) string {
|
|
return strings.Replace(fm, "---\n", "---\nwing: "+wing+"\nhall: "+hall+"\n", 1)
|
|
}
|
|
|
|
func yamlScalar(s string) string {
|
|
// A raw newline or CR would end the scalar for every line-oriented
|
|
// frontmatter reader and start a new key (PR #147 review: a title of
|
|
// "x\norigin: static" forged provenance). Fold all line breaks to spaces.
|
|
s = strings.NewReplacer("\r\n", " ", "\r", " ", "\n", " ").Replace(s)
|
|
return "'" + strings.ReplaceAll(s, "'", "''") + "'"
|
|
}
|