feat(watcher): resolve each note's wing; park the unplaceable in raw/needs-review/ (#205 S3)
With Config.Resolver set, an explicit subfolder/frontmatter wing or an agreeing classifier+scorer routes the note into that wing; anything else is parked with its proposal in frontmatter and never extracted. needs-review/ is skipped by the walk like rejected/. Nil Resolver keeps the retired layout. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,146 @@
|
||||
package watcher
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"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"
|
||||
)
|
||||
|
||||
type stubClassifier struct {
|
||||
s wing.Suggestion
|
||||
err error
|
||||
calls atomic.Int32
|
||||
}
|
||||
|
||||
func (c *stubClassifier) Classify(context.Context, string, []string) (wing.Suggestion, error) {
|
||||
c.calls.Add(1)
|
||||
return c.s, c.err
|
||||
}
|
||||
|
||||
type stubScorer map[string]float64
|
||||
|
||||
func (s stubScorer) Score(string) map[string]float64 { return s }
|
||||
|
||||
func wingResolver(c wing.Classifier, s wing.Scorer) *wing.Resolver {
|
||||
return &wing.Resolver{
|
||||
Known: []string{"radar", "homelab", "client-x"},
|
||||
Eligible: func(w string) bool { return w != "client-x" },
|
||||
Classifier: c,
|
||||
Scorer: s,
|
||||
Threshold: 0.8,
|
||||
}
|
||||
}
|
||||
|
||||
func wingTestCfg(t *testing.T, r *wing.Resolver, llmCalls *atomic.Int32) (Config, string) {
|
||||
t.Helper()
|
||||
brainDir := setupBrainDir(t)
|
||||
return Config{
|
||||
BrainDir: brainDir,
|
||||
Resolver: r,
|
||||
Pipeline: pipeline.Config{
|
||||
Schema: "# Schema",
|
||||
Complete: func(ctx context.Context, s, u string) (string, error) {
|
||||
llmCalls.Add(1)
|
||||
return successComplete(pipeline.RawPage{Title: "Talk", Type: "source", Subtype: "video", Content: "## Summary\n\nx\n"})(ctx, s, u)
|
||||
},
|
||||
},
|
||||
}, brainDir
|
||||
}
|
||||
|
||||
func put(t *testing.T, brainDir, rel, body string) {
|
||||
t.Helper()
|
||||
full := filepath.Join(brainDir, "raw", rel)
|
||||
require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755))
|
||||
require.NoError(t, os.WriteFile(full, []byte(body), 0o644))
|
||||
}
|
||||
|
||||
func has(brainDir, rel string) bool {
|
||||
_, err := os.Stat(filepath.Join(brainDir, rel))
|
||||
return err == nil
|
||||
}
|
||||
|
||||
func TestProcessDir_ASubfolderNamesTheWing(t *testing.T) {
|
||||
var llmCalls atomic.Int32
|
||||
clf := &stubClassifier{}
|
||||
cfg, brainDir := wingTestCfg(t, wingResolver(clf, stubScorer{}), &llmCalls)
|
||||
put(t, brainDir, "radar/dark-factory.md", "A talk about factories.")
|
||||
|
||||
errs := processDir(context.Background(), cfg, "2026-10-20")
|
||||
|
||||
require.Empty(t, errs)
|
||||
assert.True(t, has(brainDir, "wiki/radar/sources/dark-factory.md"), "lands in the named wing")
|
||||
assert.False(t, has(brainDir, "raw/radar/dark-factory.md"), "inbox zero holds for subfolders too")
|
||||
assert.Zero(t, clf.calls.Load(), "an explicit wing costs no classification")
|
||||
}
|
||||
|
||||
func TestProcessDir_ANoteTheResolverCannotPlaceIsParkedNotIngested(t *testing.T) {
|
||||
var llmCalls atomic.Int32
|
||||
clf := &stubClassifier{s: wing.Suggestion{Wing: "radar", Confidence: 0.5, Reason: "maybe"}}
|
||||
cfg, brainDir := wingTestCfg(t, wingResolver(clf, stubScorer{"radar": 3}), &llmCalls)
|
||||
put(t, brainDir, "unsure.md", "Some note.")
|
||||
|
||||
errs := processDir(context.Background(), cfg, "2026-10-20")
|
||||
|
||||
require.Empty(t, errs)
|
||||
assert.Zero(t, llmCalls.Load(), "no extraction runs for an unplaced note")
|
||||
assert.False(t, has(brainDir, "raw/unsure.md"), "leaves the top of raw/")
|
||||
parked, err := os.ReadFile(filepath.Join(brainDir, "raw", "needs-review", "unsure.md"))
|
||||
require.NoError(t, err)
|
||||
assert.Contains(t, string(parked), "proposed_wing: radar")
|
||||
assert.Contains(t, string(parked), "below threshold")
|
||||
assert.Contains(t, string(parked), "Some note.", "the note body is preserved")
|
||||
}
|
||||
|
||||
func TestProcessDir_ANoteInAClientWingFolderIsParked(t *testing.T) {
|
||||
var llmCalls atomic.Int32
|
||||
cfg, brainDir := wingTestCfg(t, wingResolver(&stubClassifier{}, stubScorer{}), &llmCalls)
|
||||
put(t, brainDir, "client-x/secret.md", "Client material.")
|
||||
|
||||
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
|
||||
|
||||
assert.Zero(t, llmCalls.Load())
|
||||
assert.False(t, has(brainDir, "wiki/client-x"), "nothing is ever written into an ineligible wing")
|
||||
assert.True(t, has(brainDir, "raw/needs-review/secret.md"))
|
||||
}
|
||||
|
||||
func TestProcessDir_ClassifierFailureParksTheNote(t *testing.T) {
|
||||
var llmCalls atomic.Int32
|
||||
cfg, brainDir := wingTestCfg(t, wingResolver(&stubClassifier{err: errors.New("llm down")}, stubScorer{}), &llmCalls)
|
||||
put(t, brainDir, "n.md", "Note.")
|
||||
|
||||
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
|
||||
|
||||
assert.Zero(t, llmCalls.Load())
|
||||
assert.True(t, has(brainDir, "raw/needs-review/n.md"))
|
||||
}
|
||||
|
||||
func TestProcessDir_ParkedNotesAreNotReprocessed(t *testing.T) {
|
||||
var llmCalls atomic.Int32
|
||||
clf := &stubClassifier{s: wing.Suggestion{Wing: "radar", Confidence: 0.99}}
|
||||
cfg, brainDir := wingTestCfg(t, wingResolver(clf, stubScorer{"radar": 3}), &llmCalls)
|
||||
put(t, brainDir, "needs-review/waiting.md", "---\nproposed_wing: radar\n---\nA parked note.")
|
||||
|
||||
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
|
||||
|
||||
assert.Zero(t, clf.calls.Load(), "needs-review/ is a human queue, like rejected/")
|
||||
assert.True(t, has(brainDir, "raw/needs-review/waiting.md"))
|
||||
}
|
||||
|
||||
func TestProcessDir_WithoutAResolverTheRetiredLayoutIsUnchanged(t *testing.T) {
|
||||
var llmCalls atomic.Int32
|
||||
cfg, brainDir := wingTestCfg(t, nil, &llmCalls)
|
||||
put(t, brainDir, "plain.md", "Plain note.")
|
||||
|
||||
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
|
||||
|
||||
assert.True(t, has(brainDir, "wiki/sources/plain.md"))
|
||||
}
|
||||
Reference in New Issue
Block a user