feat(pipeline): report when a re-ingest disagrees with the page on disk (#104)
The asymmetry that makes unattended ingest safe (#103) also discarded a real signal. When a fresh extraction produced a different version of a section already on disk, the on-disk version won and the model's version went in the bin without a word — so "the source is unchanged" and "the source changed its mind and we ignored it" were indistinguishable. wiki.MergeReport now returns the headings where the incoming version disagreed and lost, and the ingest run reports them as warnings, which AppendLog already writes into log.md. This is the simplified answer to contradiction flagging at ingest. The issue proposed calling brain-gardener's Judge from the ingest path, which needs either a shared Go module across two repos or an HTTP service that does not exist — and puts an LLM call on the write path, where a timeout must neither fail the ingest nor silently mark nothing as contested. None of that is necessary to get the signal. The merge already computes the disagreement; it was throwing it away. Surfacing it costs nothing, needs no model, no cue-word matcher, no cross-repo dependency, and is deterministic. Deliberately textual disagreement, not semantic contradiction. Deciding whether a disagreement is a genuine contradiction stays with the Judge-gated enrich pass that already runs hourly and is approval-gated by design (brain-gardener#175). Detection here, adjudication there. Merge is kept as a thin wrapper over MergeReport so existing callers are untouched. Refs #104, #103, brain-gardener#175, brain#21.
This commit is contained in:
@@ -104,6 +104,24 @@ Two fields are **generator-owned** and do get refreshed on a revise:
|
|||||||
`last_updated` and `source_hash`. Everything else on disk wins. The boundary is
|
`last_updated` and `source_hash`. Everything else on disk wins. The boundary is
|
||||||
an explicit allowlist, so a field nobody has thought about stays human-owned.
|
an explicit allowlist, so a field nobody has thought about stays human-owned.
|
||||||
|
|
||||||
|
### Disagreement is reported, not resolved
|
||||||
|
|
||||||
|
When the fresh extraction produces a *different version* of a section that
|
||||||
|
already exists, the on-disk version still wins — but the run now says so:
|
||||||
|
|
||||||
|
```
|
||||||
|
wiki/sources/shape-up.md: source now disagrees on section "Summary" — on-disk version kept
|
||||||
|
```
|
||||||
|
|
||||||
|
That line lands in `log.md` with the rest of the run's warnings. Without it, "the
|
||||||
|
source is unchanged" and "the source changed its mind and we ignored it" look
|
||||||
|
identical from the outside.
|
||||||
|
|
||||||
|
This is deliberately **textual** disagreement, not semantic contradiction: cheap,
|
||||||
|
deterministic, no model on the write path. Deciding whether a disagreement is a
|
||||||
|
genuine contradiction stays with the Judge-gated `brain-enrich` pass, which is
|
||||||
|
approval-gated by design. Detection here, adjudication there.
|
||||||
|
|
||||||
## Re-ingesting an unchanged source is skipped
|
## Re-ingesting an unchanged source is skipped
|
||||||
|
|
||||||
Ingest stores `source_hash` — sha256 of the ingested **body**, frontmatter
|
Ingest stores `source_hash` — sha256 of the ingested **body**, frontmatter
|
||||||
|
|||||||
@@ -117,10 +117,18 @@ func buildAndWrite(rawPages []RawPage, sourceSlug, date, brainDir, source string
|
|||||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
||||||
return Result{}, fmt.Errorf("mkdir for %s: %w", page.Path, err)
|
return Result{}, fmt.Errorf("mkdir for %s: %w", page.Path, err)
|
||||||
}
|
}
|
||||||
content, err := reviseOrCreate(dest, page)
|
content, conflicts, err := reviseOrCreate(dest, page)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Result{}, err
|
return Result{}, err
|
||||||
}
|
}
|
||||||
|
for _, heading := range conflicts {
|
||||||
|
// Reported, not resolved. The on-disk version stands; whether
|
||||||
|
// the disagreement is a genuine contradiction is the
|
||||||
|
// Judge-gated enrich pass's call, not the write path's.
|
||||||
|
warnings = append(warnings, fmt.Sprintf(
|
||||||
|
"%s: source now disagrees on section %q — on-disk version kept",
|
||||||
|
page.Path, heading))
|
||||||
|
}
|
||||||
if err := os.WriteFile(dest, []byte(content), 0o644); err != nil {
|
if err := os.WriteFile(dest, []byte(content), 0o644); err != nil {
|
||||||
return Result{}, fmt.Errorf("write %s: %w", page.Path, err)
|
return Result{}, fmt.Errorf("write %s: %w", page.Path, err)
|
||||||
}
|
}
|
||||||
@@ -198,15 +206,16 @@ func storedSourceHash(brainDir, sourceSlug string) string {
|
|||||||
// its first-ingest date on a revised page. Left deliberately — the alternative
|
// its first-ingest date on a revised page. Left deliberately — the alternative
|
||||||
// is this function editing frontmatter it does not own, and no lint reads that
|
// is this function editing frontmatter it does not own, and no lint reads that
|
||||||
// field today.
|
// field today.
|
||||||
func reviseOrCreate(dest string, page wiki.Page) (string, error) {
|
func reviseOrCreate(dest string, page wiki.Page) (string, []string, error) {
|
||||||
existing, err := os.ReadFile(dest)
|
existing, err := os.ReadFile(dest)
|
||||||
switch {
|
switch {
|
||||||
case os.IsNotExist(err):
|
case os.IsNotExist(err):
|
||||||
return page.Content, nil
|
return page.Content, nil, nil
|
||||||
case err != nil:
|
case err != nil:
|
||||||
return "", fmt.Errorf("read existing %s: %w", page.Path, err)
|
return "", nil, fmt.Errorf("read existing %s: %w", page.Path, err)
|
||||||
}
|
}
|
||||||
return wiki.Merge(wiki.Page{Path: page.Path, Content: string(existing)}, page).Content, nil
|
merged, conflicts := wiki.MergeReport(wiki.Page{Path: page.Path, Content: string(existing)}, page)
|
||||||
|
return merged.Content, conflicts, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// mergeAll deduplicates pages by path, merging content from later occurrences.
|
// mergeAll deduplicates pages by path, merging content from later occurrences.
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -293,3 +294,34 @@ func TestRun_FrontmatterOnlyChangeIsNotDrift(t *testing.T) {
|
|||||||
|
|
||||||
assert.Equal(t, 1, calls, "only the body counts as the source")
|
assert.Equal(t, 1, calls, "only the body counts as the source")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestRunRaw_ReportsWhenTheSourceDisagreesWithWhatIsOnDisk(t *testing.T) {
|
||||||
|
// The on-disk version still wins — that is what makes unattended ingest
|
||||||
|
// safe. But the run must say that the source now claims something else,
|
||||||
|
// or a changed source is indistinguishable from an unchanged one.
|
||||||
|
dir := t.TempDir()
|
||||||
|
page := filepath.Join(dir, "wiki", "sources", "shape-up.md")
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(page), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(page, []byte(`---
|
||||||
|
title: 'Shape Up'
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Appetite is fixed, scope is variable.
|
||||||
|
`), 0o644))
|
||||||
|
|
||||||
|
res, err := RunRaw(dir, "shape-up", []RawPage{{
|
||||||
|
Title: "Shape Up", Type: "source", Subtype: "book", Domain: "product",
|
||||||
|
Content: "## Summary\n\nAppetite is negotiable and scope is fixed.\n",
|
||||||
|
}}, false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(page)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(got), "Appetite is fixed", "on-disk version wins")
|
||||||
|
|
||||||
|
joined := strings.Join(res.Warnings, " ")
|
||||||
|
assert.Contains(t, joined, "Summary", "the disagreeing section must be named")
|
||||||
|
assert.Contains(t, joined, "shape-up", "and the page it happened on")
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,10 +32,33 @@ type section struct {
|
|||||||
// Frontmatter is taken from a. Sections are merged by strategy:
|
// Frontmatter is taken from a. Sections are merged by strategy:
|
||||||
// bullet sections union unique lines, append sections concatenate,
|
// bullet sections union unique lines, append sections concatenate,
|
||||||
// all others keep a's version. Sections in b not present in a are appended.
|
// all others keep a's version. Sections in b not present in a are appended.
|
||||||
|
// Merge combines two pages, discarding any disagreement silently.
|
||||||
|
// Prefer MergeReport where the caller can act on the disagreement.
|
||||||
func Merge(a, b Page) Page {
|
func Merge(a, b Page) Page {
|
||||||
|
page, _ := MergeReport(a, b)
|
||||||
|
return page
|
||||||
|
}
|
||||||
|
|
||||||
|
// MergeReport is Merge, plus the headings of sections where b carried a
|
||||||
|
// different version of a section a already had — and lost.
|
||||||
|
//
|
||||||
|
// That loss is the property which makes ingest safe to run unattended (#103):
|
||||||
|
// a regenerating writer can add to a note but never rewrite it. But the
|
||||||
|
// discarded text is also a signal, and until now it went in the bin: the source
|
||||||
|
// now says something different about a section we already hold. Nobody could
|
||||||
|
// tell "the source is unchanged" from "the source changed its mind and we
|
||||||
|
// ignored it".
|
||||||
|
//
|
||||||
|
// Deliberately NOT a semantic contradiction check. This reports textual
|
||||||
|
// disagreement, which is cheap, deterministic, and needs no model on the write
|
||||||
|
// path. Deciding whether a disagreement is a genuine contradiction stays with
|
||||||
|
// the Judge-gated enrich pass that already exists (brain-gardener#175), which
|
||||||
|
// is approval-gated by design — detection here, adjudication there.
|
||||||
|
func MergeReport(a, b Page) (Page, []string) {
|
||||||
fmA, secsA := parseSections(a.Content)
|
fmA, secsA := parseSections(a.Content)
|
||||||
fmB, secsB := parseSections(b.Content)
|
fmB, secsB := parseSections(b.Content)
|
||||||
|
|
||||||
|
var conflicts []string
|
||||||
idx := make(map[string]int, len(secsA))
|
idx := make(map[string]int, len(secsA))
|
||||||
for i, s := range secsA {
|
for i, s := range secsA {
|
||||||
idx[s.heading] = i
|
idx[s.heading] = i
|
||||||
@@ -54,10 +77,15 @@ func Merge(a, b Page) Page {
|
|||||||
secsA[i].content = mergeBullets(sA.content, sB.content)
|
secsA[i].content = mergeBullets(sA.content, sB.content)
|
||||||
case appendSections[sB.heading]:
|
case appendSections[sB.heading]:
|
||||||
secsA[i].content = appendNew(sA.content, sB.content)
|
secsA[i].content = appendNew(sA.content, sB.content)
|
||||||
|
default:
|
||||||
|
// a wins — but say that b disagreed, rather than dropping it mute.
|
||||||
|
if strings.TrimSpace(sA.content) != strings.TrimSpace(sB.content) {
|
||||||
|
conflicts = append(conflicts, sB.heading)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return Page{Path: a.Path, Content: rebuildContent(refreshOwned(fmA, fmB), secsA)}
|
return Page{Path: a.Path, Content: rebuildContent(refreshOwned(fmA, fmB), secsA)}, conflicts
|
||||||
}
|
}
|
||||||
|
|
||||||
// generatorOwned names the frontmatter fields the ingest pipeline may refresh
|
// generatorOwned names the frontmatter fields the ingest pipeline may refresh
|
||||||
|
|||||||
@@ -143,3 +143,56 @@ Rewritten by the model.
|
|||||||
assert.Contains(t, got, "Original.", "an existing section still wins over the model's rewrite")
|
assert.Contains(t, got, "Original.", "an existing section still wins over the model's rewrite")
|
||||||
assert.NotContains(t, got, "Renamed by the model")
|
assert.NotContains(t, got, "Renamed by the model")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMerge_ReportsSectionsWhereTheFreshVersionWasDiscarded(t *testing.T) {
|
||||||
|
// The asymmetry that makes ingest safe (on-disk wins) also throws away a
|
||||||
|
// real signal: the source now says something different about a section we
|
||||||
|
// already have. Today that disagreement is silently discarded, so nobody
|
||||||
|
// can tell "the source is unchanged" from "the source changed its mind and
|
||||||
|
// we ignored it".
|
||||||
|
existing := Page{Path: "wiki/sources/x.md", Content: `---
|
||||||
|
title: 'X'
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Appetite is fixed, scope is variable.
|
||||||
|
|
||||||
|
## Method
|
||||||
|
|
||||||
|
Six-week cycles.
|
||||||
|
`}
|
||||||
|
fresh := Page{Path: "wiki/sources/x.md", Content: `---
|
||||||
|
title: 'X'
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Appetite is negotiable and scope is fixed.
|
||||||
|
|
||||||
|
## Method
|
||||||
|
|
||||||
|
Six-week cycles.
|
||||||
|
`}
|
||||||
|
|
||||||
|
got, conflicts := MergeReport(existing, fresh)
|
||||||
|
|
||||||
|
assert.Contains(t, got.Content, "Appetite is fixed", "on-disk version still wins")
|
||||||
|
assert.NotContains(t, got.Content, "negotiable")
|
||||||
|
assert.Equal(t, []string{"Summary"}, conflicts,
|
||||||
|
"only the section that actually disagreed is reported")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMerge_ReportsNothingWhenTheFreshVersionAgrees(t *testing.T) {
|
||||||
|
page := Page{Path: "wiki/sources/x.md", Content: `---
|
||||||
|
title: 'X'
|
||||||
|
---
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Appetite is fixed, scope is variable.
|
||||||
|
`}
|
||||||
|
|
||||||
|
_, conflicts := MergeReport(page, page)
|
||||||
|
assert.Empty(t, conflicts, "identical content is not a disagreement")
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user