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 <noreply@anthropic.com>
This commit is contained in:
@@ -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"))
|
||||||
|
}
|
||||||
@@ -31,10 +31,12 @@ type Config struct {
|
|||||||
// memory (hyperguild#198). 0 means defaultMaxNoteBytes.
|
// memory (hyperguild#198). 0 means defaultMaxNoteBytes.
|
||||||
MaxNoteBytes int64
|
MaxNoteBytes int64
|
||||||
|
|
||||||
// Resolver, when set, decides each note's wing (hyperguild#205): explicit
|
// Resolver, when set, decides each note's wing (hyperguild#205, #208): a
|
||||||
// subfolder/frontmatter, else classifier + scorer. A note it cannot place
|
// deliberate subfolder/frontmatter wing, else the default wing. A note it
|
||||||
// is parked in raw/needs-review/ for a human. Nil keeps the retired layout.
|
// cannot place is parked in raw/needs-review/ for a human. Nil keeps the
|
||||||
Resolver *wing.Resolver
|
// 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
|
// defaultMaxNoteBytes bounds a single note: extraction and the archive copy both
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package watcher
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"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()
|
t.Helper()
|
||||||
brainDir := setupBrainDir(t)
|
brainDir := setupBrainDir(t)
|
||||||
return Config{
|
return Config{
|
||||||
@@ -329,3 +330,11 @@ func TestWithProposal_ANonFenceDashLineDoesNotCloseTheBlock(t *testing.T) {
|
|||||||
assert.Contains(t, strings.Join(lines[:closing], "\n"), "proposal_reason:")
|
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")
|
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)
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user