fix(pipeline,api,wing): never archive a note as processed when nothing was ingested; titles do not merge sources; ties prefer the page on disk (review of #211, pass 3)
- 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>
This commit is contained in:
@@ -113,7 +113,19 @@ func Run(ctx context.Context, cfg Config, brainDir, content, source string, dryR
|
||||
meta.BodyHash = bodyHash
|
||||
applySourceMeta(allRaw, meta)
|
||||
|
||||
return buildAndWrite(ctx, cfg.Wing, allRaw, sourceSlug, date, brainDir, source, inventory, allWarnings, dryRun)
|
||||
res, err := buildAndWrite(ctx, cfg.Wing, allRaw, sourceSlug, date, brainDir, source, inventory, allWarnings, dryRun)
|
||||
if err != nil || cfg.Wing == "" {
|
||||
return res, err
|
||||
}
|
||||
// In a wing every ingest must leave its source page: it is what ties the
|
||||
// note to the wiki (and carries the hash the drift check reads). Without one
|
||||
// nothing of the note exists, and the caller would archive it as processed.
|
||||
for _, p := range res.Pages {
|
||||
if isSourcePath(p) {
|
||||
return res, nil
|
||||
}
|
||||
}
|
||||
return res, fmt.Errorf("no source page was written for %q; nothing of the note was ingested (%s)", source, strings.Join(res.Warnings, "; "))
|
||||
}
|
||||
|
||||
// RunRaw runs the pipeline on pre-parsed RawPages, skipping the LLM extraction
|
||||
@@ -357,13 +369,19 @@ func resolveSourceSlug(brainDir, wing, base string) (string, error) {
|
||||
return "", fmt.Errorf("check source slug: %w", err)
|
||||
}
|
||||
own := "wiki/" + wing + "/sources/" + cand + ".md"
|
||||
free := true
|
||||
free, hasOwn := true, false
|
||||
for _, p := range existing {
|
||||
if p != own {
|
||||
if p == own {
|
||||
hasOwn = true
|
||||
} else {
|
||||
free = false
|
||||
break
|
||||
}
|
||||
}
|
||||
// The wing's own page is its identity: keep using it even if some other
|
||||
// page shares the slug (that other page is the one dropSlugClashes skips).
|
||||
if hasOwn {
|
||||
return cand, nil
|
||||
}
|
||||
if free {
|
||||
return cand, nil
|
||||
}
|
||||
@@ -399,12 +417,42 @@ func dropSlugClashes(brainDir string, pages []wiki.Page) ([]wiki.Page, []string,
|
||||
}
|
||||
slugOf := func(p wiki.Page) string { return strings.TrimSuffix(filepath.Base(p.Path), ".md") }
|
||||
|
||||
winner := map[string]wiki.Page{}
|
||||
// Group by slug; where several pages with different paths want one slug,
|
||||
// the source wins, then a page that already exists on disk (so its update
|
||||
// is not lost), then entity over concept.
|
||||
bySlug := 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
|
||||
bySlug[slugOf(p)] = append(bySlug[slugOf(p)], p)
|
||||
}
|
||||
winner := map[string]wiki.Page{}
|
||||
for slug, group := range bySlug {
|
||||
best := group[0]
|
||||
if distinctPaths(group) > 1 {
|
||||
existing, err := wiki.FindSlugs(brainDir, slug)
|
||||
if err != nil {
|
||||
return nil, nil, fmt.Errorf("check slug %q: %w", slug, err)
|
||||
}
|
||||
onDisk := func(p wiki.Page) int {
|
||||
for _, e := range existing {
|
||||
if e == p.Path {
|
||||
return 0
|
||||
}
|
||||
}
|
||||
return 1
|
||||
}
|
||||
for _, p := range group[1:] {
|
||||
if rank(p) != rank(best) && (rank(p) == 0 || rank(best) == 0) {
|
||||
if rank(p) < rank(best) {
|
||||
best = p
|
||||
}
|
||||
continue
|
||||
}
|
||||
if onDisk(p) < onDisk(best) || (onDisk(p) == onDisk(best) && rank(p) < rank(best)) {
|
||||
best = p
|
||||
}
|
||||
}
|
||||
}
|
||||
winner[slug] = best
|
||||
}
|
||||
|
||||
var kept []wiki.Page
|
||||
@@ -438,3 +486,11 @@ func dropSlugClashes(brainDir string, pages []wiki.Page) ([]wiki.Page, []string,
|
||||
}
|
||||
return kept, warnings, nil
|
||||
}
|
||||
|
||||
func distinctPaths(pages []wiki.Page) int {
|
||||
seen := map[string]bool{}
|
||||
for _, p := range pages {
|
||||
seen[p.Path] = true
|
||||
}
|
||||
return len(seen)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user