fix(pipeline): slug clashes inside one batch; the source page is never the one skipped (review of #211, pass 2)
dropSlugClashes only compared against what was on disk, so a clip named like an entity the model extracted wrote both pages and neither could be updated again. In-batch winners: source over entity over concept. Against the brain, only entities/concepts are skipped; the source's slug was already chosen free or its own by resolveSourceSlug, so an existing bad pair no longer locks the source out. Mutation-checked. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -372,15 +372,53 @@ func resolveSourceSlug(brainDir, wing, base string) (string, error) {
|
||||
}
|
||||
|
||||
// dropSlugClashes removes, from the pages about to be written in a wing, any
|
||||
// page whose slug already belongs to a different page elsewhere in the brain.
|
||||
// The page's own path is not a clash (a shared entity merging into itself).
|
||||
// A skipped page is a warning, not a failure: the ingest as a whole is fine,
|
||||
// and one extracted page must not be able to lock a source out of re-ingest.
|
||||
// page whose slug belongs to a different page. A slug is the graph identity, so
|
||||
// two pages must not share one; and a page that shares one cannot be updated
|
||||
// again, because every later ingest sees the other as its clash.
|
||||
//
|
||||
// Two kinds of clash, both by slug, neither failing the ingest (one extracted
|
||||
// page must not be able to lock a source out of re-ingest):
|
||||
// - inside the batch (a clip named notes.md whose model output also has an
|
||||
// entity "Notes"): the source wins over an entity, an entity over a concept;
|
||||
// - against the brain: an entity or concept whose slug is another page's. The
|
||||
// SOURCE page is exempt: it is the note's own output, and its slug was
|
||||
// already chosen free (or its own) by resolveSourceSlug.
|
||||
//
|
||||
// A page's own path is never a clash (a shared entity merging into itself).
|
||||
func dropSlugClashes(brainDir string, pages []wiki.Page) ([]wiki.Page, []string, error) {
|
||||
rank := func(p wiki.Page) int {
|
||||
switch {
|
||||
case isSourcePath(p.Path):
|
||||
return 0
|
||||
case strings.HasPrefix(p.Path, "wiki/entities/"):
|
||||
return 1
|
||||
case strings.HasPrefix(p.Path, "wiki/concepts/"):
|
||||
return 2
|
||||
}
|
||||
return 3
|
||||
}
|
||||
slugOf := func(p wiki.Page) string { return strings.TrimSuffix(filepath.Base(p.Path), ".md") }
|
||||
|
||||
winner := map[string]wiki.Page{}
|
||||
for _, p := range pages {
|
||||
w, seen := winner[slugOf(p)]
|
||||
if !seen || (w.Path != p.Path && rank(p) < rank(w)) {
|
||||
winner[slugOf(p)] = p
|
||||
}
|
||||
}
|
||||
|
||||
var kept []wiki.Page
|
||||
var warnings []string
|
||||
for _, p := range pages {
|
||||
slug := strings.TrimSuffix(filepath.Base(p.Path), ".md")
|
||||
slug := slugOf(p)
|
||||
if w := winner[slug]; w.Path != p.Path {
|
||||
warnings = append(warnings, fmt.Sprintf("skipped %s: slug %q is also used by %s in this batch", p.Path, slug, w.Path))
|
||||
continue
|
||||
}
|
||||
if rank(p) == 0 {
|
||||
kept = append(kept, p)
|
||||
continue
|
||||
}
|
||||
existing, err := wiki.FindSlugs(brainDir, slug)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("check slug %q: %w", slug, err)
|
||||
|
||||
Reference in New Issue
Block a user