feat(pipeline): feed the current source page into the extraction prompt (#106) (#132)
CI / Lint / Test / Vet (push) Successful in 39s
CI / Mirror to GitHub (push) Successful in 4s

Co-authored-by: Mathias <mthbqv@gmail.com>
This commit was merged in pull request #132.
This commit is contained in:
2026-09-23 05:45:47 +00:00
committed by mathias
parent f324f04944
commit 9a37881441
5 changed files with 295 additions and 15 deletions
+62 -7
View File
@@ -64,23 +64,50 @@ func MergeReport(a, b Page) (Page, []string) {
fmB, _, secsB := parseSections(b.Content)
var conflicts []string
idx := make(map[string]int, len(secsA))
// Sections in secsA sharing a normalized heading (pre-existing corpus
// drift — "## Notes" and "## NOTES" both already on disk, the exact
// pattern this normalization exists to catch going forward, found in
// review of PR #130/#132) are matched to b's occurrences of that same
// heading POSITIONALLY: b's 1st occurrence of the key pairs with a's
// 1st, b's 2nd with a's 2nd, and so on. A single "last write wins" (or
// even "first write wins") map from key to one index would instead
// route every one of b's occurrences to the SAME on-disk section —
// which either freezes every duplicate but one (never receiving
// updates again) or, worse, compares mismatched pairs and reports a
// disagreement between two sections that were never supposed to be
// compared, false-positiving on the plain self-merge case a revision
// pass produces when the model echoes a page back unchanged (found in
// review of PR #132).
byKey := make(map[string][]int, len(secsA))
for i, s := range secsA {
idx[s.heading] = i
key := normalizeHeading(s.heading)
byKey[key] = append(byKey[key], i)
}
cursor := make(map[string]int, len(byKey))
for _, sB := range secsB {
i, exists := idx[sB.heading]
if !exists {
idx[sB.heading] = len(secsA)
key := normalizeHeading(sB.heading)
indices := byKey[key]
pos := cursor[key]
cursor[key] = pos + 1
if pos >= len(indices) {
// b has more occurrences of this heading than a does (including
// the case where a has none at all) — the extra is new content,
// appended as its own section and registered so a THIRD
// occurrence of the same heading in b pairs with this one
// rather than appending yet another duplicate.
byKey[key] = append(byKey[key], len(secsA))
secsA = append(secsA, sB)
continue
}
i := indices[pos]
sA := secsA[i]
switch {
case bulletSections[sB.heading]:
case bulletSectionsNorm[key]:
secsA[i].content = mergeBullets(sA.content, sB.content)
case appendSections[sB.heading]:
case appendSectionsNorm[key]:
secsA[i].content = appendNew(sA.content, sB.content)
default:
// a wins — but say that b disagreed, rather than dropping it mute.
@@ -93,6 +120,34 @@ func MergeReport(a, b Page) (Page, []string) {
return Page{Path: a.Path, Content: rebuildContent(refreshOwned(fmA, fmB), preambleA, secsA)}, conflicts
}
// normalizeHeading returns the key Merge uses to decide "is this the same
// section" — trimmed and case-folded, so "Key Claims" and "Key claims" match
// (hyperguild#106: heading casing drift must not fork a section). It is used
// only for that comparison; the on-disk section's original heading text
// (secsA[i].heading, untouched here) is what actually gets written out, so a
// human's heading casing is never silently rewritten by a merge.
func normalizeHeading(h string) string {
return strings.ToLower(strings.TrimSpace(h))
}
// bulletSectionsNorm and appendSectionsNorm are bulletSections/appendSections
// keyed by normalizeHeading instead of exact text, so a section's merge
// strategy (union bullets, append) is chosen by the same normalized identity
// Merge uses to match it — otherwise a heading that matched an on-disk
// section (case-insensitively) could still fail the exact-string strategy
// lookup and silently fall through to "keep a's version," dropping b's
// content instead of unioning or appending it.
var bulletSectionsNorm = normalizeSet(bulletSections)
var appendSectionsNorm = normalizeSet(appendSections)
func normalizeSet(names map[string]bool) map[string]bool {
out := make(map[string]bool, len(names))
for k := range names {
out[normalizeHeading(k)] = true
}
return out
}
// generatorOwned names the frontmatter fields the ingest pipeline may refresh
// on a page it is revising. Everything else on disk wins.
//