Compile-on-ingest, first half. buildAndWrite did an unconditional os.WriteFile, so re-ingesting a source replaced its page wholesale and discarded everything accumulated since — including human edits. hyperguild#43 recorded the symptom as "re-ingest overwrites wholesale"; the framing that ingest "appends" understates it. Now the write reads what is on disk and merges the fresh extraction into it, asymmetrically in the existing page's favour: on-disk frontmatter and on-disk sections win, extraction-only sections are added, bullet lists union. An extraction can add to a note but never silently rewrite what a human put there, which is what makes it safe on every ingest rather than behind a review gate. Two supporting fixes in wiki.Merge, both required for this to be safe to run repeatedly: - append sections (Open Questions, Updates, Evolving Notes) concatenated wholesale, which is correct exactly once. Re-ingesting an unchanged source would grow them every run until the page was unreadable. Now only genuinely new lines are appended. Line-level rather than block-level, because the common case is a re-ingest carrying one new line alongside several it already has, and a block-level check would duplicate the old ones every time. - rebuildContent kept whatever blank lines a section was parsed with and added its own, so every merge pass grew the page by a blank line — invisible in a diff viewer, fatal to byte-stability. Section bodies are now normalised. Re-ingesting unchanged content is byte-identical, so brain-sync does not commit noise that hides real edits. The drift guard that would otherwise catch repeated ingests (hashing the source body, B3) does not exist yet, so idempotency holds here rather than upstream. Known limitation, deliberate: on-disk frontmatter wins, so last_updated keeps its first-ingest date on a revised page. The alternative is this function editing frontmatter it does not own, and no lint reads that field. Refs hyperguild#43.
236 lines
8.2 KiB
Go
236 lines
8.2 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")
|
|
}
|