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)
|
||||
}
|
||||
|
||||
@@ -32,6 +32,13 @@ func Resolve(proposed []wiki.Page, inventory map[wiki.PageType][]wiki.Entry) []w
|
||||
|
||||
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)}
|
||||
@@ -97,3 +104,9 @@ func extractTitle(content string) string {
|
||||
}
|
||||
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"
|
||||
}
|
||||
|
||||
@@ -292,3 +292,83 @@ func TestRun_AnExistingSourceEntityPairDoesNotLockTheSourceOut(t *testing.T) {
|
||||
assert.Contains(t, read(t, dir, "wiki/homelab/sources/notes.md"), "fresh", "the source took the update")
|
||||
assert.Contains(t, read(t, dir, "wiki/entities/notes.md"), "stale entity", "the entity is untouched")
|
||||
}
|
||||
|
||||
// Pass 3 of #211: a note must never leave the inbox as "processed" when nothing
|
||||
// of it was written.
|
||||
func TestRun_WithAWingAnIngestThatWritesNoSourcePageIsAnError(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pages []RawPage
|
||||
prep func(t *testing.T, dir string)
|
||||
}{
|
||||
{"the model returned nothing", nil, nil},
|
||||
{"only pages of a type the layout has no place for", []RawPage{{Title: "Claim", Type: "claim", Content: "## x\n\ny\n"}}, nil},
|
||||
{"only an entity, and its slug is taken by a source", []RawPage{{Title: "S1", Type: "entity", Subtype: "tool", Content: "## Definition\n\nx\n"}},
|
||||
func(t *testing.T, dir string) {
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/web-clip/sources"), 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "wiki/web-clip/sources/s1.md"), []byte("---\ntitle: 'S1'\n---\nx"), 0o644))
|
||||
}},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if tt.prep != nil {
|
||||
tt.prep(t, dir)
|
||||
}
|
||||
res, err := Run(context.Background(), wingCfg("web-clip", tt.pages...), dir, "a note body", "other-note", false)
|
||||
require.Error(t, err, "nothing was ingested; the caller must not archive it as done")
|
||||
assert.Empty(t, res.Pages)
|
||||
assert.Contains(t, err.Error(), "no source page")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRun_WithAWingAnUnchangedSourceIsStillASkipNotAnError(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
_, err := Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "same body", "talk", false)
|
||||
require.NoError(t, err)
|
||||
res, err := Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "same body", "talk", false)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.Skipped)
|
||||
}
|
||||
|
||||
// Title resolution must not merge two different notes into one source page just
|
||||
// because the model titled both "Home": in a wing the source path is already
|
||||
// deterministic (the filename), so the title must not redirect it.
|
||||
func TestRun_WithAWingTwoNotesTheModelTitlesAlikeKeepTheirOwnSourcePages(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
home := func(body string) []RawPage {
|
||||
return []RawPage{{Title: "Home", Type: "source", Subtype: "article", Content: "## Summary\n\n" + body + "\n"}}
|
||||
}
|
||||
_, err := Run(context.Background(), wingCfg("web-clip", home("note A text")...), dir, "body A", "note-a", false)
|
||||
require.NoError(t, err)
|
||||
_, err = Run(context.Background(), wingCfg("web-clip", home("note B text")...), dir, "body B", "note-b", false)
|
||||
require.NoError(t, err)
|
||||
|
||||
assert.True(t, exists(dir, "wiki/web-clip/sources/note-a.md"))
|
||||
assert.True(t, exists(dir, "wiki/web-clip/sources/note-b.md"), "note B has its own page")
|
||||
assert.Contains(t, read(t, dir, "wiki/web-clip/sources/note-a.md"), "note A text")
|
||||
assert.Contains(t, read(t, dir, "wiki/web-clip/sources/note-b.md"), "note B text")
|
||||
|
||||
res, err := Run(context.Background(), wingCfg("web-clip", home("note A text")...), dir, "body A", "note-a", false)
|
||||
require.NoError(t, err)
|
||||
assert.True(t, res.Skipped, "note A's hash was not overwritten by note B")
|
||||
}
|
||||
|
||||
// Both a concept and an entity named "Foo" arrive and the CONCEPT already exists
|
||||
// on disk: the page that exists wins the tie, so its source reference is added.
|
||||
func TestRun_ABatchTieBetweenAConceptAndAnEntityPrefersThePageAlreadyOnDisk(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/concepts"), 0o755))
|
||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "wiki/concepts/foo.md"),
|
||||
[]byte("---\ntitle: 'Foo'\naliases:\n - 'Foo'\n---\n\n## Definition\n\nexisting concept\n\n## Sources\n"), 0o644))
|
||||
pages := []RawPage{
|
||||
{Title: "Talk", Type: "source", Subtype: "video", Content: "## Summary\n\nx\n\n## Concepts Introduced or Reinforced\n\n[[Foo]]\n"},
|
||||
{Title: "Foo", Type: "entity", Subtype: "tool", Content: "## Definition\n\nan entity\n"},
|
||||
{Title: "Foo", Type: "concept", Content: "## Definition\n\na concept\n\n## Sources\n"},
|
||||
}
|
||||
_, err := Run(context.Background(), wingCfg("web-clip", pages...), dir, "body", "talk", false)
|
||||
require.NoError(t, err)
|
||||
assert.False(t, exists(dir, "wiki/entities/foo.md"))
|
||||
assert.Contains(t, read(t, dir, "wiki/concepts/foo.md"), "talk", "the existing concept got the new source reference")
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user