- in a wing, an ingest that writes no source page is an error: the note goes to
raw/failed with the warnings in log.md instead of leaving the inbox as
'processed' (model returned [], only invalid types, only clashing entities)
- the wing's source path is deterministic, so Resolve no longer redirects a
source onto another note's page because the model gave both the same title
('Home'); that overwrote the hash and the text
- a batch tie between concept and entity prefers the page already on disk, so
its update is not lost; the wing's own source page keeps its slug even when a
foreign page shares it
- brain_pending lists an unreadable (over-cap) note with the reason instead of
dropping it; the client filter's 4 KiB match-span limit is documented
Each has a test that fails without it (mutation-checked).
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
113 lines
3.4 KiB
Go
113 lines
3.4 KiB
Go
// ingestion/internal/pipeline/resolve.go
|
|
package pipeline
|
|
|
|
import (
|
|
"path/filepath"
|
|
"strings"
|
|
|
|
"github.com/mathiasbq/hyperguild/ingestion/internal/wiki"
|
|
)
|
|
|
|
// Resolve remaps proposed pages to existing slugs when a fuzzy title match is found.
|
|
// It only matches within the same page type (entities→entities, concepts→concepts).
|
|
// Pages with no inventory match are returned unchanged.
|
|
func Resolve(proposed []wiki.Page, inventory map[wiki.PageType][]wiki.Entry) []wiki.Page {
|
|
type key struct {
|
|
pt wiki.PageType
|
|
normalized string
|
|
}
|
|
lookup := make(map[key]string) // key → canonical slug
|
|
for pt, entries := range inventory {
|
|
for _, e := range entries {
|
|
k := key{pt: pt, normalized: normalizeTitle(e.Title)}
|
|
lookup[k] = e.Slug
|
|
for _, alias := range e.Aliases {
|
|
ak := key{pt: pt, normalized: normalizeTitle(alias)}
|
|
if _, exists := lookup[ak]; !exists {
|
|
lookup[ak] = e.Slug
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
out := make([]wiki.Page, 0, len(proposed))
|
|
for _, page := range proposed {
|
|
if isWingSourcePath(page.Path) {
|
|
// In a wing the source's path is its filename-derived slug. Matching
|
|
// by TITLE would merge two different notes the model happened to
|
|
// title alike ("Home", "Untitled") into one page.
|
|
out = append(out, page)
|
|
continue
|
|
}
|
|
pt := pageTypeOf(page)
|
|
title := extractTitle(page.Content)
|
|
k := key{pt: pt, normalized: normalizeTitle(title)}
|
|
if canonicalSlug, ok := lookup[k]; ok {
|
|
dir := filepath.Dir(page.Path)
|
|
page.Path = dir + "/" + canonicalSlug + ".md"
|
|
}
|
|
out = append(out, page)
|
|
}
|
|
return out
|
|
}
|
|
|
|
// normalizeTitle lowercases, removes leading articles, collapses whitespace.
|
|
// "The Shape Up Method" → "shape up method"
|
|
func normalizeTitle(s string) string {
|
|
s = strings.ToLower(strings.TrimSpace(s))
|
|
for _, article := range []string{"the ", "a ", "an "} {
|
|
s = strings.TrimPrefix(s, article)
|
|
}
|
|
s = strings.ReplaceAll(s, "-", " ")
|
|
return strings.Join(strings.Fields(s), " ")
|
|
}
|
|
|
|
// pageTypeOf is the page's type: from its directory in the retired layout, or
|
|
// "sources" for wiki/<wing>/sources/<slug>.md. Concepts and entities are always
|
|
// in their shared directories.
|
|
func pageTypeOf(p wiki.Page) wiki.PageType {
|
|
parts := strings.Split(filepath.ToSlash(p.Path), "/")
|
|
if len(parts) == 4 && parts[0] == "wiki" && parts[2] == "sources" {
|
|
return wiki.PageTypeSource
|
|
}
|
|
return pageTypeFromPath(p.Path)
|
|
}
|
|
|
|
// pageTypeFromPath extracts the wiki.PageType from a path like "wiki/entities/foo.md".
|
|
func pageTypeFromPath(path string) wiki.PageType {
|
|
parts := strings.Split(filepath.ToSlash(path), "/")
|
|
if len(parts) >= 2 {
|
|
return wiki.PageType(parts[1])
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// extractTitle reads the title field from YAML frontmatter in content.
|
|
// Falls back to empty string if not found.
|
|
func extractTitle(content string) string {
|
|
lines := strings.SplitN(content, "\n", 30)
|
|
inFM := false
|
|
for _, line := range lines {
|
|
if strings.TrimSpace(line) == "---" {
|
|
if !inFM {
|
|
inFM = true
|
|
continue
|
|
}
|
|
break
|
|
}
|
|
if inFM {
|
|
key, val, ok := strings.Cut(line, ":")
|
|
if ok && strings.TrimSpace(key) == "title" {
|
|
return strings.Trim(strings.TrimSpace(val), `"'`)
|
|
}
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// isWingSourcePath reports whether path is wiki/<wing>/sources/<slug>.md.
|
|
func isWingSourcePath(path string) bool {
|
|
parts := strings.Split(filepath.ToSlash(path), "/")
|
|
return len(parts) == 4 && parts[0] == "wiki" && parts[2] == "sources"
|
|
}
|