#103 made ingest merge into the page already on disk instead of clobbering it, but the extraction itself stayed page-blind: the model wrote every page as if fresh, and Merge reconciled the two texts afterwards by heading. It could restate a claim but never refine it, and had no way to know what already existed. BuildPrompt now accepts the source page's current on-disk content (when one exists) and asks the model to revise it explicitly: add what's missing, strengthen what the source now supports, leave the rest alone and reuse its wording verbatim so unchanged claims are recognized as unchanged. Scoped to the source page specifically — its path (wiki/sources/<sourceSlug>.md) is deterministic before extraction runs, unlike a concept/entity page's path, which only exists once the model has produced a title. The deterministic merge in wiki.Merge is unchanged and stays the backstop: an on-disk section the model doesn't touch survives untouched regardless of what the prompt asks for, so a model that ignores the instruction still cannot destroy human content. Closes hyperguild#106 (first half; heading normalization follows). Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
394 lines
15 KiB
Go
394 lines
15 KiB
Go
// ingestion/internal/pipeline/pipeline_test.go
|
|
package pipeline
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/mathiasbq/hyperguild/ingestion/internal/llm"
|
|
)
|
|
|
|
func TestRun_WritesPages(t *testing.T) {
|
|
brainDir := t.TempDir()
|
|
for _, sub := range []string{"wiki/concepts", "wiki/entities", "wiki/sources"} {
|
|
require.NoError(t, os.MkdirAll(filepath.Join(brainDir, sub), 0o755))
|
|
}
|
|
|
|
llmResponse := mustJSON([]RawPage{
|
|
{
|
|
Title: "Test Article",
|
|
Type: "source",
|
|
Subtype: "article",
|
|
Domain: "software-engineering",
|
|
Content: "## Summary\n\nA test article.\n\n## Key Claims\n\n- It tests things.\n\n## Concepts Introduced or Reinforced\n\n[[Testing]]\n\n## Entities Mentioned\n\n## Open Questions Raised\n",
|
|
},
|
|
{
|
|
Title: "Testing",
|
|
Type: "concept",
|
|
Domain: "software-engineering",
|
|
Content: "## Definition\n\nThe practice of verifying software.\n\n## Why It Matters\n\nCatches bugs.\n\n## Related Concepts\n\n## Related Entities\n\n## Sources\n\n## Evolving Notes\n",
|
|
},
|
|
})
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json")
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"choices": []map[string]any{
|
|
{"message": map[string]any{"role": "assistant", "content": llmResponse}},
|
|
},
|
|
})
|
|
}))
|
|
defer srv.Close()
|
|
|
|
cfg := Config{
|
|
Complete: llm.New(srv.URL, "", "test-model", 30*time.Second).Complete,
|
|
ChunkSize: 0,
|
|
}
|
|
|
|
result, err := Run(context.Background(), cfg, brainDir, "An article about testing.", "test-article", false)
|
|
require.NoError(t, err)
|
|
assert.Len(t, result.Pages, 2)
|
|
|
|
_, err = os.Stat(filepath.Join(brainDir, "wiki", "sources", "test-article.md"))
|
|
require.NoError(t, err)
|
|
_, err = os.Stat(filepath.Join(brainDir, "wiki", "concepts", "testing.md"))
|
|
require.NoError(t, err)
|
|
_, err = os.Stat(filepath.Join(brainDir, "wiki", "index.md"))
|
|
require.NoError(t, err)
|
|
_, err = os.Stat(filepath.Join(brainDir, "log.md"))
|
|
require.NoError(t, err)
|
|
}
|
|
|
|
func TestRun_DryRunDoesNotWrite(t *testing.T) {
|
|
brainDir := t.TempDir()
|
|
for _, sub := range []string{"wiki/concepts", "wiki/entities", "wiki/sources"} {
|
|
require.NoError(t, os.MkdirAll(filepath.Join(brainDir, sub), 0o755))
|
|
}
|
|
|
|
llmResponse := mustJSON([]RawPage{{
|
|
Title: "Foo",
|
|
Type: "source",
|
|
Subtype: "article",
|
|
Content: "## Summary\n\nFoo.\n",
|
|
}})
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"choices": []map[string]any{{"message": map[string]any{"content": llmResponse}}},
|
|
})
|
|
}))
|
|
defer srv.Close()
|
|
|
|
cfg := Config{Complete: llm.New(srv.URL, "", "m", 30*time.Second).Complete}
|
|
result, err := Run(context.Background(), cfg, brainDir, "foo content", "foo", true)
|
|
require.NoError(t, err)
|
|
assert.Len(t, result.Pages, 1)
|
|
|
|
_, err = os.Stat(filepath.Join(brainDir, "wiki", "sources", "foo.md"))
|
|
assert.True(t, os.IsNotExist(err))
|
|
}
|
|
|
|
func TestRun_MergesDuplicatePaths(t *testing.T) {
|
|
brainDir := t.TempDir()
|
|
for _, sub := range []string{"wiki/concepts", "wiki/entities", "wiki/sources"} {
|
|
require.NoError(t, os.MkdirAll(filepath.Join(brainDir, sub), 0o755))
|
|
}
|
|
|
|
// LLM returns same title twice (simulates multi-chunk duplicate)
|
|
llmResponse := mustJSON([]RawPage{
|
|
{Title: "Foo", Type: "concept", Content: "## Definition\n\nFirst.\n\n## Related Concepts\n\n[[Bar]]\n"},
|
|
{Title: "Foo", Type: "concept", Content: "## Definition\n\nSecond.\n\n## Related Concepts\n\n[[Baz]]\n"},
|
|
})
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"choices": []map[string]any{{"message": map[string]any{"content": llmResponse}}},
|
|
})
|
|
}))
|
|
defer srv.Close()
|
|
|
|
cfg := Config{Complete: llm.New(srv.URL, "", "m", 30*time.Second).Complete}
|
|
result, err := Run(context.Background(), cfg, brainDir, "content", "foo", false)
|
|
require.NoError(t, err)
|
|
assert.Len(t, result.Pages, 1) // deduplicated
|
|
|
|
content, err := os.ReadFile(filepath.Join(brainDir, "wiki", "concepts", "foo.md"))
|
|
require.NoError(t, err)
|
|
// keep-first for Definition, union for Related Concepts
|
|
assert.Contains(t, string(content), "First.")
|
|
// Bar and Baz unknown in empty inventory → left as plain [[links]]
|
|
assert.Contains(t, string(content), "[[Bar]]")
|
|
assert.Contains(t, string(content), "[[Baz]]")
|
|
}
|
|
|
|
func TestRun_ThreadsSourceAuthorPublishedFromContentFrontmatter(t *testing.T) {
|
|
brainDir := t.TempDir()
|
|
for _, sub := range []string{"wiki/concepts", "wiki/entities", "wiki/sources"} {
|
|
require.NoError(t, os.MkdirAll(filepath.Join(brainDir, sub), 0o755))
|
|
}
|
|
|
|
llmResponse := mustJSON([]RawPage{{
|
|
Title: "Ornith",
|
|
Type: "source",
|
|
Subtype: "article",
|
|
Content: "## Summary\n\nAn agentic coding model.\n",
|
|
}})
|
|
|
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
|
_ = json.NewEncoder(w).Encode(map[string]any{
|
|
"choices": []map[string]any{{"message": map[string]any{"content": llmResponse}}},
|
|
})
|
|
}))
|
|
defer srv.Close()
|
|
|
|
cfg := Config{Complete: llm.New(srv.URL, "", "m", 30*time.Second).Complete}
|
|
rawContent := "---\nsource: https://example.com/ornith\nauthor: Jane Doe\npublished: 2026-07-20\n---\n\nAn agentic coding model that runs on your laptop.\n"
|
|
|
|
result, err := Run(context.Background(), cfg, brainDir, rawContent, "ornith", false)
|
|
require.NoError(t, err)
|
|
require.Len(t, result.Pages, 1)
|
|
|
|
content, err := os.ReadFile(filepath.Join(brainDir, "wiki", "sources", "ornith.md"))
|
|
require.NoError(t, err)
|
|
assert.Contains(t, string(content), "source: 'https://example.com/ornith'")
|
|
assert.Contains(t, string(content), "author: 'Jane Doe'")
|
|
assert.Contains(t, string(content), "published: '2026-07-20'")
|
|
}
|
|
|
|
func mustJSON(v any) string {
|
|
b, err := json.Marshal(v)
|
|
if err != nil {
|
|
panic(err)
|
|
}
|
|
return string(b)
|
|
}
|
|
|
|
// --- B1: compile-on-ingest — revise the existing page, never clobber it -----
|
|
|
|
func TestRunRaw_MergesIntoAnExistingPageInsteadOfOverwritingIt(t *testing.T) {
|
|
// Before this, buildAndWrite did an unconditional os.WriteFile: re-ingesting
|
|
// a source replaced its page wholesale, discarding anything accumulated
|
|
// since — including human edits. hyperguild#43 recorded the symptom as
|
|
// "re-ingest overwrites wholesale".
|
|
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
|
|
|
|
A book about shaping product work.
|
|
|
|
## My Take
|
|
|
|
Read this before the next planning cycle.
|
|
`), 0o644))
|
|
|
|
_, err := RunRaw(dir, "shape-up", []RawPage{{
|
|
Title: "Shape Up", Type: "source", Subtype: "book", Domain: "product-strategy",
|
|
Content: "## Summary\n\nA book about shaping product work.\n\n## Key Claims\n\n- Appetite beats estimation.\n",
|
|
}}, false)
|
|
require.NoError(t, err)
|
|
|
|
got, err := os.ReadFile(page)
|
|
require.NoError(t, err)
|
|
s := string(got)
|
|
|
|
assert.Contains(t, s, "## My Take", "a section the extraction never saw must survive the merge")
|
|
assert.Contains(t, s, "Read this before the next planning cycle.")
|
|
assert.Contains(t, s, "Appetite beats estimation.", "newly extracted material must still land")
|
|
}
|
|
|
|
func TestRunRaw_ReIngestingUnchangedContentIsByteIdentical(t *testing.T) {
|
|
// Compile-on-ingest runs on every ingest, so "nothing changed" must produce
|
|
// no diff at all. Otherwise every re-ingest dirties the tree, brain-sync
|
|
// commits it, and the git history fills with noise that hides real edits.
|
|
dir := t.TempDir()
|
|
raw := []RawPage{{
|
|
Title: "Shape Up", Type: "source", Subtype: "book", Domain: "product-strategy",
|
|
Content: "## Summary\n\nA book about shaping product work.\n\n## Open Questions\n\n- Does appetite scale?\n",
|
|
}}
|
|
|
|
_, err := RunRaw(dir, "shape-up", raw, false)
|
|
require.NoError(t, err)
|
|
first, err := os.ReadFile(filepath.Join(dir, "wiki", "sources", "shape-up.md"))
|
|
require.NoError(t, err)
|
|
|
|
_, err = RunRaw(dir, "shape-up", raw, false)
|
|
require.NoError(t, err)
|
|
second, err := os.ReadFile(filepath.Join(dir, "wiki", "sources", "shape-up.md"))
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, string(first), string(second),
|
|
"re-ingesting unchanged content must leave the page byte-identical")
|
|
}
|
|
|
|
// --- #105: source drift — an unchanged re-ingest must not re-extract --------
|
|
|
|
func TestRun_SkipsExtractionWhenTheSourceIsUnchanged(t *testing.T) {
|
|
// #103 made a re-ingest harmless (byte-identical merge). It is still not
|
|
// free: the full LLM extraction runs over every chunk to produce a result
|
|
// that merges to nothing. This is the cost half.
|
|
dir := t.TempDir()
|
|
var calls int
|
|
cfg := Config{Complete: func(_ context.Context, _, _ string) (string, error) {
|
|
calls++
|
|
return `[{"title":"Shape Up","type":"source","subtype":"book","domain":"product","content":"## Summary\n\nA book.\n"}]`, nil
|
|
}}
|
|
|
|
content := "---\nsource: https://example.com/shape-up\n---\n\nThe body of the article.\n"
|
|
|
|
first, err := Run(context.Background(), cfg, dir, content, "shape-up", false)
|
|
require.NoError(t, err)
|
|
require.Equal(t, 1, calls, "first ingest must extract")
|
|
assert.False(t, first.Skipped)
|
|
|
|
second, err := Run(context.Background(), cfg, dir, content, "shape-up", false)
|
|
require.NoError(t, err)
|
|
assert.Equal(t, 1, calls, "unchanged content must not be extracted again")
|
|
assert.True(t, second.Skipped, "the skip must be reported, not silent")
|
|
}
|
|
|
|
func TestRun_ReExtractsWhenTheSourceBodyChanged(t *testing.T) {
|
|
dir := t.TempDir()
|
|
var calls int
|
|
cfg := Config{Complete: func(_ context.Context, _, _ string) (string, error) {
|
|
calls++
|
|
return `[{"title":"Shape Up","type":"source","subtype":"book","domain":"product","content":"## Summary\n\nA book.\n"}]`, nil
|
|
}}
|
|
|
|
_, err := Run(context.Background(), cfg, dir, "---\nsource: x\n---\n\nversion one\n", "shape-up", false)
|
|
require.NoError(t, err)
|
|
_, err = Run(context.Background(), cfg, dir, "---\nsource: x\n---\n\nversion two — the source was edited\n", "shape-up", false)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, 2, calls, "changed content must be re-extracted")
|
|
}
|
|
|
|
// --- #106: page-aware extraction — the model sees the page it is revising --
|
|
|
|
func TestRun_ExistingSourcePageIsIncludedInTheExtractionPrompt(t *testing.T) {
|
|
// #103 made the merge asymmetric (on-disk wins); #106 is the other half —
|
|
// the extraction itself gets to see what's already there instead of
|
|
// producing a page blind and letting the merge reconcile it afterwards.
|
|
// This only proves the existing page reaches the prompt; the byte-identical
|
|
// guarantee for an echoed-back revision is a separate test below.
|
|
dir := t.TempDir()
|
|
for _, sub := range []string{"wiki/concepts", "wiki/entities", "wiki/sources"} {
|
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, sub), 0o755))
|
|
}
|
|
|
|
seeded := "---\ntitle: 'Shape Up'\n---\n\n## Summary\n\nA book about shaping product work.\n\n## Key Claims\n\n- Appetite beats estimation.\n"
|
|
page := filepath.Join(dir, "wiki", "sources", "shape-up.md")
|
|
require.NoError(t, os.WriteFile(page, []byte(seeded), 0o644))
|
|
|
|
var gotPrompt string
|
|
cfg := Config{Complete: func(_ context.Context, _, user string) (string, error) {
|
|
gotPrompt = user
|
|
return mustJSON([]RawPage{{
|
|
Title: "Shape Up", Type: "source", Subtype: "book",
|
|
Content: "## Summary\n\nA book about shaping product work.\n",
|
|
}}), nil
|
|
}}
|
|
|
|
// Deliberately does not mention "Appetite beats estimation." — that phrase
|
|
// can only reach the prompt via the existing on-disk page, not the source
|
|
// content, so its presence proves the page was threaded through.
|
|
content := "A short editorial update to the book's listing. No new claims here."
|
|
_, err := Run(context.Background(), cfg, dir, content, "shape-up", false)
|
|
require.NoError(t, err)
|
|
|
|
assert.Contains(t, gotPrompt, "Appetite beats estimation.",
|
|
"the existing page's content must be visible to the extraction prompt")
|
|
assert.Contains(t, gotPrompt, "revis", "the prompt must ask for a revision, not a fresh page")
|
|
}
|
|
|
|
func TestRun_NewSourceHasNoExistingPageInThePrompt(t *testing.T) {
|
|
// The other side of the same behavior: a source with no page on disk yet
|
|
// must not get an "existing page" section injected into its prompt at all.
|
|
dir := t.TempDir()
|
|
for _, sub := range []string{"wiki/concepts", "wiki/entities", "wiki/sources"} {
|
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, sub), 0o755))
|
|
}
|
|
|
|
var gotPrompt string
|
|
cfg := Config{Complete: func(_ context.Context, _, user string) (string, error) {
|
|
gotPrompt = user
|
|
return mustJSON([]RawPage{{
|
|
Title: "New Thing", Type: "source", Subtype: "article",
|
|
Content: "## Summary\n\nBrand new.\n",
|
|
}}), nil
|
|
}}
|
|
|
|
_, err := Run(context.Background(), cfg, dir, "Brand new content.", "new-thing", false)
|
|
require.NoError(t, err)
|
|
|
|
assert.NotContains(t, gotPrompt, "Current page",
|
|
"a source with no page on disk yet must not get a revision section in its prompt")
|
|
}
|
|
|
|
func TestRun_RevisionEchoingExistingPageStaysByteIdentical(t *testing.T) {
|
|
// The issue's explicit risk: a model given the current page will sometimes
|
|
// echo it back verbatim. That must not duplicate content or otherwise
|
|
// perturb the page — the deterministic merge is still the backstop.
|
|
dir := t.TempDir()
|
|
for _, sub := range []string{"wiki/concepts", "wiki/entities", "wiki/sources"} {
|
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, sub), 0o755))
|
|
}
|
|
|
|
seeded := "---\ntitle: 'Shape Up'\n---\n\n## Summary\n\nA book about shaping product work.\n\n## Key Claims\n\n- Appetite beats estimation.\n"
|
|
page := filepath.Join(dir, "wiki", "sources", "shape-up.md")
|
|
require.NoError(t, os.WriteFile(page, []byte(seeded), 0o644))
|
|
|
|
cfg := Config{Complete: func(_ context.Context, _, _ string) (string, error) {
|
|
return mustJSON([]RawPage{{
|
|
Title: "Shape Up", Type: "source", Subtype: "book",
|
|
Content: "## Summary\n\nA book about shaping product work.\n\n## Key Claims\n\n- Appetite beats estimation.\n",
|
|
}}), nil
|
|
}}
|
|
|
|
content := "A short editorial update to the book's listing. No new claims here."
|
|
result, err := Run(context.Background(), cfg, dir, content, "shape-up", false)
|
|
require.NoError(t, err)
|
|
require.False(t, result.Skipped)
|
|
|
|
got, err := os.ReadFile(page)
|
|
require.NoError(t, err)
|
|
|
|
want := "---\ntitle: 'Shape Up'\nlast_updated: " + time.Now().UTC().Format("2006-01-02") +
|
|
"\nsource_hash: " + hashBody(content) + "\n---\n\n" +
|
|
"## Summary\n\nA book about shaping product work.\n\n" +
|
|
"## Key Claims\n\n- Appetite beats estimation.\n"
|
|
assert.Equal(t, want, string(got),
|
|
"a revision pass that echoes the existing page back must not duplicate or alter its content")
|
|
}
|
|
|
|
func TestRun_FrontmatterOnlyChangeIsNotDrift(t *testing.T) {
|
|
// The hash covers the body, so re-fetching a page whose metadata moved
|
|
// (a re-crawl stamping a new date) is not treated as the source changing.
|
|
dir := t.TempDir()
|
|
var calls int
|
|
cfg := Config{Complete: func(_ context.Context, _, _ string) (string, error) {
|
|
calls++
|
|
return `[{"title":"Shape Up","type":"source","subtype":"book","domain":"product","content":"## Summary\n\nA book.\n"}]`, nil
|
|
}}
|
|
|
|
_, err := Run(context.Background(), cfg, dir, "---\nsource: x\nfetched: monday\n---\n\nsame body\n", "shape-up", false)
|
|
require.NoError(t, err)
|
|
_, err = Run(context.Background(), cfg, dir, "---\nsource: x\nfetched: tuesday\n---\n\nsame body\n", "shape-up", false)
|
|
require.NoError(t, err)
|
|
|
|
assert.Equal(t, 1, calls, "only the body counts as the source")
|
|
}
|