From 750dc93bdb6409687b5a62e7467ebda6c64abf79 Mon Sep 17 00:00:00 2001 From: Mathias Date: Tue, 20 Oct 2026 20:09:19 +0200 Subject: [PATCH] feat(watcher): Placer interface; acceptance tests for the sprint goal (#209) A clip dropped in raw/ with no other action lands in web-clip, its entity and concept are the shared pages, raw/ is empty and nothing waits for a human; client-marked text and a typo'd wing name park; a deliberate subfolder places. Real pipeline and file layout, model stubbed. Co-Authored-By: Claude Sonnet 5.5 --- ingestion/internal/watcher/acceptance_test.go | 116 ++++++++++++++++++ ingestion/internal/watcher/watcher.go | 10 +- .../internal/watcher/watcher_wing_test.go | 11 +- 3 files changed, 132 insertions(+), 5 deletions(-) create mode 100644 ingestion/internal/watcher/acceptance_test.go diff --git a/ingestion/internal/watcher/acceptance_test.go b/ingestion/internal/watcher/acceptance_test.go new file mode 100644 index 0000000..2cfa999 --- /dev/null +++ b/ingestion/internal/watcher/acceptance_test.go @@ -0,0 +1,116 @@ +package watcher + +import ( + "context" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + "sync/atomic" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mathiasbq/hyperguild/ingestion/internal/pipeline" + "github.com/mathiasbq/hyperguild/ingestion/internal/wing" +) + +// These tests are the sprint's goal (hyperguild#209) stated as behaviour, with +// the real pipeline and real file layout and only the model stubbed: a person +// drops a clip into raw/ and does nothing else. + +func acceptanceCfg(t *testing.T) (Config, string, *atomic.Int32) { + t.Helper() + brainDir := t.TempDir() + var calls atomic.Int32 + // Each call extracts a source with its own title, as real clips have: two + // sources with one title are deliberately merged into one page by the + // pipeline's title resolution. + pagesFor := func(n int32) []pipeline.RawPage { + return []pipeline.RawPage{ + {Title: fmt.Sprintf("Dark Factory Talk %d", n), Type: "source", Subtype: "video", Domain: "ai", + Content: "## Summary\n\nA talk.\n\n## Entities Mentioned\n\n[[Jev]]\n\n## Concepts Introduced or Reinforced\n\n[[Dark Factory]]\n"}, + {Title: "Jev", Type: "entity", Subtype: "tool", Content: "## Definition\n\nA decision model.\n\n## Sources\n"}, + {Title: "Dark Factory", Type: "concept", Content: "## Definition\n\nA fully automated build line.\n\n## Sources\n"}, + } + } + return Config{ + BrainDir: brainDir, + Resolver: wing.Router{BrainDir: brainDir, Default: "web-clip", ClientBlock: regexp.MustCompile(`(?i)acme-client`)}, + Pipeline: pipeline.Config{ + Schema: "# Schema", + Complete: func(context.Context, string, string) (string, error) { + return mustJSONPages(pagesFor(calls.Add(1))), nil + }, + }, + }, brainDir, &calls +} + +// topLevel lists the regular files directly in raw/: the inbox. +func topLevel(t *testing.T, brainDir string) []string { + t.Helper() + entries, err := os.ReadDir(filepath.Join(brainDir, "raw")) + require.NoError(t, err) + var out []string + for _, e := range entries { + if e.Type().IsRegular() { + out = append(out, e.Name()) + } + } + return out +} + +func TestAcceptance_ADroppedClipLandsInWebClipAndTheInboxIsEmpty(t *testing.T) { + cfg, brainDir, calls := acceptanceCfg(t) + put(t, brainDir, "dark-factory-talk.md", "Transcript of a talk about dark factories.") + + require.Empty(t, processDir(context.Background(), cfg, "2026-10-20")) + + assert.True(t, has(brainDir, "wiki/web-clip/sources/dark-factory-talk.md"), "the clip is a page in web-clip") + assert.True(t, has(brainDir, "wiki/entities/jev.md"), "its entity is in the shared layer") + assert.True(t, has(brainDir, "wiki/concepts/dark-factory.md"), "its concept is in the shared layer") + assert.Empty(t, topLevel(t, brainDir), "raw/ holds nothing unprocessed") + assert.False(t, has(brainDir, "raw/needs-review"), "and nothing waits for a human") + assert.Equal(t, int32(1), calls.Load(), "one extraction, no classification step") +} + +func TestAcceptance_TheSameEntityFromTwoClipsIsOnePage(t *testing.T) { + cfg, brainDir, _ := acceptanceCfg(t) + put(t, brainDir, "talk-one.md", "First talk.") + require.Empty(t, processDir(context.Background(), cfg, "2026-10-20")) + put(t, brainDir, "talk-two.md", "Second talk, different words.") + require.Empty(t, processDir(context.Background(), cfg, "2026-10-20")) + + files, err := filepath.Glob(filepath.Join(brainDir, "wiki", "entities", "*.md")) + require.NoError(t, err) + assert.Len(t, files, 1) + b, _ := os.ReadFile(filepath.Join(brainDir, "wiki/entities/jev.md")) + assert.True(t, strings.Contains(string(b), "talk-one") && strings.Contains(string(b), "talk-two"), "both sources are cited") +} + +func TestAcceptance_AClipThatLooksLikeClientMaterialIsParkedNotFiled(t *testing.T) { + cfg, brainDir, calls := acceptanceCfg(t) + put(t, brainDir, "kickoff.md", "Notes from the ACME-CLIENT kickoff call.") + + require.Empty(t, processDir(context.Background(), cfg, "2026-10-20")) + + assert.Zero(t, calls.Load(), "not even extracted") + assert.False(t, has(brainDir, "wiki/web-clip"), "never filed into a shared wing") + assert.True(t, has(brainDir, "raw/needs-review/kickoff.md"), "waits for the owner") + assert.Empty(t, topLevel(t, brainDir)) +} + +func TestAcceptance_ASubfolderNamingAnExistingWingPlacesThereAndATypoParks(t *testing.T) { + cfg, brainDir, _ := acceptanceCfg(t) + require.NoError(t, os.MkdirAll(filepath.Join(brainDir, "wiki", "homelab", "facts"), 0o755)) + put(t, brainDir, "homelab/ops-note.md", "A note about the cluster.") + put(t, brainDir, "hoemlab/typo-note.md", "A misfiled note.") + + require.Empty(t, processDir(context.Background(), cfg, "2026-10-20")) + + assert.True(t, has(brainDir, "wiki/homelab/sources/ops-note.md")) + assert.False(t, has(brainDir, "wiki/hoemlab"), "a typo never creates a wing") + assert.True(t, has(brainDir, "raw/needs-review/typo-note.md")) +} diff --git a/ingestion/internal/watcher/watcher.go b/ingestion/internal/watcher/watcher.go index 14c6280..61ea04f 100644 --- a/ingestion/internal/watcher/watcher.go +++ b/ingestion/internal/watcher/watcher.go @@ -31,10 +31,12 @@ type Config struct { // memory (hyperguild#198). 0 means defaultMaxNoteBytes. MaxNoteBytes int64 - // Resolver, when set, decides each note's wing (hyperguild#205): explicit - // subfolder/frontmatter, else classifier + scorer. A note it cannot place - // is parked in raw/needs-review/ for a human. Nil keeps the retired layout. - Resolver *wing.Resolver + // Resolver, when set, decides each note's wing (hyperguild#205, #208): a + // deliberate subfolder/frontmatter wing, else the default wing. A note it + // cannot place is parked in raw/needs-review/ for a human. Nil keeps the + // retired layout. Set it only to a non-nil value: a nil pointer inside the + // interface is not nil. + Resolver wing.Placer } // defaultMaxNoteBytes bounds a single note: extraction and the archive copy both diff --git a/ingestion/internal/watcher/watcher_wing_test.go b/ingestion/internal/watcher/watcher_wing_test.go index 9778ab9..8a17f4c 100644 --- a/ingestion/internal/watcher/watcher_wing_test.go +++ b/ingestion/internal/watcher/watcher_wing_test.go @@ -2,6 +2,7 @@ package watcher import ( "context" + "encoding/json" "errors" "os" "path/filepath" @@ -41,7 +42,7 @@ func wingResolver(c wing.Classifier, s wing.Scorer) *wing.Resolver { } } -func wingTestCfg(t *testing.T, r *wing.Resolver, llmCalls *atomic.Int32) (Config, string) { +func wingTestCfg(t *testing.T, r wing.Placer, llmCalls *atomic.Int32) (Config, string) { t.Helper() brainDir := setupBrainDir(t) return Config{ @@ -329,3 +330,11 @@ func TestWithProposal_ANonFenceDashLineDoesNotCloseTheBlock(t *testing.T) { assert.Contains(t, strings.Join(lines[:closing], "\n"), "proposal_reason:") assert.NotContains(t, strings.Join(lines[:closing], "\n"), "foo: bar", "the unfenced note is body, not part of the block") } + +func mustJSONPages(pages []pipeline.RawPage) string { + b, err := json.Marshal(pages) + if err != nil { + panic(err) + } + return string(b) +}