Compile-on-ingest, first half. buildAndWrite did an unconditional os.WriteFile, so re-ingesting a source replaced its page wholesale and discarded everything accumulated since — including human edits. hyperguild#43 recorded the symptom as "re-ingest overwrites wholesale"; the framing that ingest "appends" understates it. Now the write reads what is on disk and merges the fresh extraction into it, asymmetrically in the existing page's favour: on-disk frontmatter and on-disk sections win, extraction-only sections are added, bullet lists union. An extraction can add to a note but never silently rewrite what a human put there, which is what makes it safe on every ingest rather than behind a review gate. Two supporting fixes in wiki.Merge, both required for this to be safe to run repeatedly: - append sections (Open Questions, Updates, Evolving Notes) concatenated wholesale, which is correct exactly once. Re-ingesting an unchanged source would grow them every run until the page was unreadable. Now only genuinely new lines are appended. Line-level rather than block-level, because the common case is a re-ingest carrying one new line alongside several it already has, and a block-level check would duplicate the old ones every time. - rebuildContent kept whatever blank lines a section was parsed with and added its own, so every merge pass grew the page by a blank line — invisible in a diff viewer, fatal to byte-stability. Section bodies are now normalised. Re-ingesting unchanged content is byte-identical, so brain-sync does not commit noise that hides real edits. The drift guard that would otherwise catch repeated ingests (hashing the source body, B3) does not exist yet, so idempotency holds here rather than upstream. Known limitation, deliberate: on-disk frontmatter wins, so last_updated keeps its first-ingest date on a revised page. The alternative is this function editing frontmatter it does not own, and no lint reads that field. Refs hyperguild#43.
170 lines
4.9 KiB
Go
170 lines
4.9 KiB
Go
// ingestion/internal/wiki/merge.go
|
|
package wiki
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
)
|
|
|
|
var bulletSections = map[string]bool{
|
|
"Related Concepts": true,
|
|
"Related Entities": true,
|
|
"Sources": true,
|
|
"Key Claims": true,
|
|
"Entities Mentioned": true,
|
|
"Concepts Introduced or Reinforced": true,
|
|
"Chapters": true,
|
|
}
|
|
|
|
var appendSections = map[string]bool{
|
|
"Evolving Notes": true,
|
|
"Updates": true,
|
|
"Open Questions Raised": true,
|
|
"Open Questions": true,
|
|
}
|
|
|
|
type section struct {
|
|
heading string
|
|
content string
|
|
}
|
|
|
|
// Merge combines two Page values with the same path.
|
|
// Frontmatter is taken from a. Sections are merged by strategy:
|
|
// bullet sections union unique lines, append sections concatenate,
|
|
// all others keep a's version. Sections in b not present in a are appended.
|
|
func Merge(a, b Page) Page {
|
|
fmA, secsA := parseSections(a.Content)
|
|
_, secsB := parseSections(b.Content)
|
|
|
|
idx := make(map[string]int, len(secsA))
|
|
for i, s := range secsA {
|
|
idx[s.heading] = i
|
|
}
|
|
|
|
for _, sB := range secsB {
|
|
i, exists := idx[sB.heading]
|
|
if !exists {
|
|
idx[sB.heading] = len(secsA)
|
|
secsA = append(secsA, sB)
|
|
continue
|
|
}
|
|
sA := secsA[i]
|
|
switch {
|
|
case bulletSections[sB.heading]:
|
|
secsA[i].content = mergeBullets(sA.content, sB.content)
|
|
case appendSections[sB.heading]:
|
|
secsA[i].content = appendNew(sA.content, sB.content)
|
|
}
|
|
}
|
|
|
|
return Page{Path: a.Path, Content: rebuildContent(fmA, secsA)}
|
|
}
|
|
|
|
func parseSections(markdown string) (frontmatter string, sections []section) {
|
|
lines := strings.Split(markdown, "\n")
|
|
i := 0
|
|
|
|
if i < len(lines) && strings.TrimSpace(lines[i]) == "---" {
|
|
i++
|
|
var fmLines []string
|
|
for i < len(lines) {
|
|
if strings.TrimSpace(lines[i]) == "---" {
|
|
i++
|
|
break
|
|
}
|
|
fmLines = append(fmLines, lines[i])
|
|
i++
|
|
}
|
|
frontmatter = fmt.Sprintf("---\n%s\n---\n", strings.Join(fmLines, "\n"))
|
|
}
|
|
|
|
var cur *section
|
|
for ; i < len(lines); i++ {
|
|
line := lines[i]
|
|
if strings.HasPrefix(line, "## ") {
|
|
if cur != nil {
|
|
sections = append(sections, *cur)
|
|
}
|
|
cur = §ion{heading: strings.TrimPrefix(line, "## ")}
|
|
} else if cur != nil {
|
|
cur.content += line + "\n"
|
|
}
|
|
}
|
|
if cur != nil {
|
|
sections = append(sections, *cur)
|
|
}
|
|
return
|
|
}
|
|
|
|
// rebuildContent serialises sections back into a page.
|
|
//
|
|
// Each section body is normalised to exactly one trailing newline and no
|
|
// leading blank line. Without that, a section keeps whatever blank lines it was
|
|
// parsed with and this function adds its own, so every merge pass grows the page
|
|
// by a blank line — invisible in a diff viewer, fatal to byte-stability. Compile-on-ingest
|
|
// merges on every run, so "merging changes nothing when nothing changed" has to
|
|
// hold at the byte level, not just the content level.
|
|
func rebuildContent(frontmatter string, sections []section) string {
|
|
var sb strings.Builder
|
|
sb.WriteString(frontmatter)
|
|
for _, sec := range sections {
|
|
body := strings.Trim(sec.content, "\n")
|
|
fmt.Fprintf(&sb, "\n## %s\n\n%s\n", sec.heading, body)
|
|
}
|
|
return sb.String()
|
|
}
|
|
|
|
// appendNew concatenates the lines of b that a does not already carry.
|
|
//
|
|
// Append sections used to concatenate wholesale, which is correct exactly once.
|
|
// Compile-on-ingest merges a freshly extracted page into the one already on
|
|
// disk, so an unchanged source re-ingested would grow "Open Questions" and
|
|
// "Updates" on every run until the page is unreadable — and the drift guard
|
|
// that would otherwise catch it (hashing the source body) does not exist yet,
|
|
// so idempotency has to hold here rather than upstream.
|
|
//
|
|
// Line-level rather than block-level: the common case is a re-ingest carrying
|
|
// one genuinely new line alongside several it already has. A block-level
|
|
// "skip if identical" would duplicate the old lines every time that happened.
|
|
//
|
|
// The cost is that a line repeated verbatim on purpose collapses to one. For
|
|
// these sections — questions, updates, evolving notes — a repeat is noise
|
|
// rather than signal, and the failure is conservative: the page stops growing
|
|
// instead of growing wrongly.
|
|
func appendNew(a, b string) string {
|
|
seen := make(map[string]bool)
|
|
for _, line := range strings.Split(a, "\n") {
|
|
if t := strings.TrimSpace(line); t != "" {
|
|
seen[t] = true
|
|
}
|
|
}
|
|
|
|
var add []string
|
|
for _, line := range strings.Split(b, "\n") {
|
|
t := strings.TrimSpace(line)
|
|
if t == "" || seen[t] {
|
|
continue
|
|
}
|
|
seen[t] = true
|
|
add = append(add, line)
|
|
}
|
|
if len(add) == 0 {
|
|
return a // nothing new — leave the section byte-identical
|
|
}
|
|
return strings.TrimRight(a, "\n") + "\n\n" + strings.Join(add, "\n") + "\n"
|
|
}
|
|
|
|
func mergeBullets(a, b string) string {
|
|
seen := make(map[string]bool)
|
|
var lines []string
|
|
for _, line := range strings.Split(a+b, "\n") {
|
|
trimmed := strings.TrimSpace(line)
|
|
if trimmed == "" || seen[trimmed] {
|
|
continue
|
|
}
|
|
seen[trimmed] = true
|
|
lines = append(lines, line)
|
|
}
|
|
return strings.Join(lines, "\n") + "\n"
|
|
}
|