diff --git a/ingestion/cmd/server/main.go b/ingestion/cmd/server/main.go index 5b2fbb8..a08322c 100644 --- a/ingestion/cmd/server/main.go +++ b/ingestion/cmd/server/main.go @@ -36,6 +36,7 @@ import ( "github.com/mathiasbq/hyperguild/ingestion/internal/vectorstore" "github.com/mathiasbq/hyperguild/ingestion/internal/watcher" "github.com/mathiasbq/hyperguild/ingestion/internal/webhook" + "github.com/mathiasbq/hyperguild/ingestion/internal/wing" "k8s.io/client-go/kubernetes" "k8s.io/client-go/rest" "k8s.io/client-go/tools/clientcmd" @@ -144,6 +145,17 @@ func envInt(key string, fallback int) int { return fallback } +// envFloat reads a confidence-style value in [0,1]. Anything unparseable or +// outside the range falls back: a threshold of 0 would auto-place everything. +func envFloat(key string, fallback float64) float64 { + if v := os.Getenv(key); v != "" { + if f, err := strconv.ParseFloat(v, 64); err == nil && f >= 0 && f <= 1 { + return f + } + } + return fallback +} + // buildNotifier returns the ntfy notifier, or a nil interface when // BRAIN_NTFY_URL is unset. A nil *interface* (not a typed-nil) is required so // DegradingSink/Reconcile and the governance observer skip it cleanly. @@ -389,10 +401,26 @@ func main() { } if watchInterval > 0 { + // INGEST_WING_RESOLVE=1 routes raw/ notes into wiki// by + // subfolder, frontmatter or classifier+scorer, and parks the rest in + // raw/needs-review/ (hyperguild#205). Off = the retired layout. Set + // explicitly, a failure to build the resolver is fatal: silently + // falling back would write into the layout this flag exists to leave. + var wingResolver *wing.Resolver + if os.Getenv("INGEST_WING_RESOLVE") == "1" { + var rerr error + wingResolver, rerr = wing.NewConfiguredResolver(brainDir, llmClient.Complete, envFloat("INGEST_WING_THRESHOLD", 0.9)) + if rerr != nil { + logger.Error("build wing resolver", "err", rerr) + os.Exit(1) + } + logger.Info("wing resolver enabled", "wings", len(wingResolver.Known), "threshold", wingResolver.Threshold) + } watcher.Start(ctx, watcher.Config{ BrainDir: brainDir, Interval: time.Duration(watchInterval) * time.Second, Pipeline: pipelineCfg, + Resolver: wingResolver, }) } diff --git a/ingestion/cmd/server/main_test.go b/ingestion/cmd/server/main_test.go index 4d1236d..8f6f125 100644 --- a/ingestion/cmd/server/main_test.go +++ b/ingestion/cmd/server/main_test.go @@ -76,3 +76,23 @@ func TestAlertingWarnings(t *testing.T) { }) } } + +func TestEnvFloat(t *testing.T) { + tests := []struct { + name string + set string + want float64 + }{ + {"unset falls back", "", 0.9}, + {"valid value", "0.75", 0.75}, + {"garbage falls back rather than becoming 0", "high", 0.9}, + {"out of range is rejected, not clamped silently into auto-placing everything", "-1", 0.9}, + {"above one is rejected", "1.5", 0.9}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Setenv("TEST_ENV_FLOAT", tt.set) + assert.InDelta(t, tt.want, envFloat("TEST_ENV_FLOAT", 0.9), 1e-9) + }) + } +} diff --git a/ingestion/cmd/wingbacktest/main.go b/ingestion/cmd/wingbacktest/main.go new file mode 100644 index 0000000..cab4638 --- /dev/null +++ b/ingestion/cmd/wingbacktest/main.go @@ -0,0 +1,147 @@ +// Command wingbacktest measures how well the wing resolver would have placed +// pages whose wing a human already decided (hyperguild#205). +// +// It reads pages from the wings given in -wings, hides the wing, asks the +// local classifier and the BM25 scorer, and reports coverage/precision per +// confidence threshold. Page text is sent to -llm-url, which MUST be a local +// model (it is not checked here); sample only wings you would allow. +package main + +import ( + "context" + "flag" + "fmt" + "io/fs" + "log/slog" + "math/rand" + "os" + "path/filepath" + "sort" + "strings" + "time" + + "github.com/mathiasbq/hyperguild/ingestion/internal/llm" + "github.com/mathiasbq/hyperguild/ingestion/internal/wing" +) + +func main() { + brain := flag.String("brain", "/var/lib/supervisor/brain", "brain directory (read only)") + wingsFlag := flag.String("wings", "", "comma-separated wings to sample pages FROM (must be wings you would allow)") + perWing := flag.Int("per-wing", 8, "pages sampled per wing") + llmURL := flag.String("llm-url", "http://10.0.1.20:31234/v1", "OpenAI-compatible LOCAL endpoint") + model := flag.String("model", "qwen35-9b-fast", "model name") + seed := flag.Int64("seed", 1, "sampling seed") + flag.Parse() + + if err := run(*brain, strings.Split(*wingsFlag, ","), *perWing, *llmURL, *model, *seed); err != nil { + slog.Error("wingbacktest", "err", err) + os.Exit(1) + } +} + +func run(brain string, sampleWings []string, perWing int, llmURL, model string, seed int64) error { + known, err := wing.KnownWings(brain) + if err != nil { + return err + } + client := llm.New(llmURL, "", model, 3*time.Minute) + classifier := wing.NewLLMClassifier(client.Complete) + rng := rand.New(rand.NewSource(seed)) + + var samples []wing.Sample + perWingSamples := map[string][]wing.Sample{} + for _, w := range sampleWings { + w = strings.TrimSpace(w) + if w == "" { + continue + } + pages, err := listPages(brain, w) + if err != nil { + return err + } + rng.Shuffle(len(pages), func(i, j int) { pages[i], pages[j] = pages[j], pages[i] }) + if len(pages) > perWing { + pages = pages[:perWing] + } + for _, rel := range pages { + text, err := readBody(brain, rel) + if err != nil || len(text) < 200 { + continue + } + sugg, cerr := classifier.Classify(context.Background(), text, known) + if cerr != nil { + slog.Warn("classify failed", "page", rel, "err", cerr) + } + top, _ := wing.TopWing(wing.NewBM25Scorer(brain, known, rel).Score(text)) + s := wing.Sample{Truth: w, Suggested: strings.ToLower(sugg.Wing), Confidence: sugg.Confidence, ScorerTop: top} + slog.Info("sample", "page", rel, "truth", w, "classifier", s.Suggested, "conf", s.Confidence, "scorer", top, "reason", sugg.Reason) + samples = append(samples, s) + perWingSamples[w] = append(perWingSamples[w], s) + } + } + report(samples, perWingSamples) + return nil +} + +func report(samples []wing.Sample, per map[string][]wing.Sample) { + fmt.Printf("# wing backtest\n\nn=%d pages. Classifier+scorer must agree; threshold applies to classifier confidence.\n\n", len(samples)) + fmt.Println("| threshold | auto | coverage | precision |") + fmt.Println("|---|---|---|---|") + for _, t := range []float64{0.5, 0.6, 0.7, 0.8, 0.9, 0.95} { + r := wing.Evaluate(samples, t) + fmt.Printf("| %.2f | %d/%d | %.0f%% | %.0f%% |\n", t, r.Auto, r.N, 100*r.Coverage, 100*r.Precision) + } + fmt.Println("\n## per wing (threshold 0.80)\n\n| wing | n | auto | precision |\n|---|---|---|---|") + names := make([]string, 0, len(per)) + for w := range per { + names = append(names, w) + } + sort.Strings(names) + for _, w := range names { + r := wing.Evaluate(per[w], 0.8) + fmt.Printf("| %s | %d | %d | %.0f%% |\n", w, r.N, r.Auto, 100*r.Precision) + } + fmt.Println("\n## wrong auto-placements at 0.80") + fmt.Println() + for _, s := range samples { + if s.Suggested != "" && s.Confidence >= 0.8 && s.ScorerTop == s.Suggested && s.Suggested != s.Truth { + fmt.Printf("- truth=%s -> both said %s (conf %.2f)\n", s.Truth, s.Suggested, s.Confidence) + } + } +} + +func listPages(brain, w string) ([]string, error) { + var out []string + root := filepath.Join(brain, "wiki", w) + err := filepath.WalkDir(root, func(p string, d fs.DirEntry, err error) error { + if err != nil || d.IsDir() || !d.Type().IsRegular() || !strings.HasSuffix(p, ".md") || d.Name() == "_index.md" { + return err + } + rel, rerr := filepath.Rel(brain, p) + if rerr != nil { + return rerr + } + out = append(out, filepath.ToSlash(rel)) + return nil + }) + if err != nil { + return nil, fmt.Errorf("walk %s: %w", w, err) + } + return out, nil +} + +// readBody returns the page text without YAML frontmatter, so the wing label +// (and anything else in frontmatter) cannot leak into the classification. +func readBody(brain, rel string) (string, error) { + b, err := os.ReadFile(filepath.Join(brain, filepath.FromSlash(rel))) + if err != nil { + return "", fmt.Errorf("read %s: %w", rel, err) + } + s := string(b) + if strings.HasPrefix(s, "---\n") { + if end := strings.Index(s[4:], "\n---"); end >= 0 { + s = s[4+end+4:] + } + } + return strings.TrimSpace(s), nil +} diff --git a/ingestion/cmd/wingmigrate/main.go b/ingestion/cmd/wingmigrate/main.go new file mode 100644 index 0000000..eb0707d --- /dev/null +++ b/ingestion/cmd/wingmigrate/main.go @@ -0,0 +1,92 @@ +// Command wingmigrate moves pages out of the retired wiki/{sources,concepts, +// entities}/ layout into wiki// (hyperguild#205 S5). +// +// Default is a dry run that prints the plan. -apply performs the Auto items. +// Page text goes to -llm-url, which MUST be a local model. +package main + +import ( + "context" + "flag" + "fmt" + "log/slog" + "os" + "strings" + "time" + + "github.com/mathiasbq/hyperguild/ingestion/internal/llm" + "github.com/mathiasbq/hyperguild/ingestion/internal/migrate" + "github.com/mathiasbq/hyperguild/ingestion/internal/wing" +) + +func main() { + brain := flag.String("brain", "/var/lib/supervisor/brain", "brain directory") + llmURL := flag.String("llm-url", "http://10.0.1.20:31234/v1", "OpenAI-compatible LOCAL endpoint") + model := flag.String("model", "qwen35-9b-fast", "model name") + threshold := flag.Float64("threshold", 0.9, "classifier confidence threshold") + apply := flag.Bool("apply", false, "perform the Auto moves (default: dry run)") + what := flag.String("what-if-eligible", "", "dry run only: comma-separated wings to treat as eligible, to see what tagging them would allow") + flag.Parse() + + if *apply && *what != "" { + slog.Error("-what-if-eligible is a dry-run aid and cannot be combined with -apply: eligibility is a sovereignty decision made in classification.yaml") + os.Exit(2) + } + if err := run(*brain, *llmURL, *model, *threshold, *apply, *what); err != nil { + slog.Error("wingmigrate", "err", err) + os.Exit(1) + } +} + +func run(brain, llmURL, model string, threshold float64, apply bool, whatIf string) error { + client := llm.New(llmURL, "", model, 3*time.Minute) + base, err := wing.NewConfiguredResolver(brain, client.Complete, threshold) + if err != nil { + return err + } + if whatIf != "" { + allowed := map[string]bool{} + for _, w := range strings.Split(whatIf, ",") { + allowed[strings.TrimSpace(w)] = true + } + base.Eligible = func(w string) bool { return allowed[w] } + } + rf := func(exclude string) *wing.Resolver { + r := *base + r.Scorer = wing.NewBM25Scorer(brain, base.Known, exclude) + return &r + } + items, err := migrate.BuildPlan(context.Background(), brain, rf) + if err != nil { + return err + } + report(items) + if !apply { + fmt.Println("\n_dry run: nothing was changed_") + return nil + } + n, err := migrate.Apply(brain, items) + fmt.Printf("\napplied %d moves\n", n) + return err +} + +func report(items []migrate.Item) { + auto, exc := 0, 0 + for _, it := range items { + if it.Status == migrate.Auto { + auto++ + } else { + exc++ + } + } + fmt.Printf("# wing migration plan\n\n%d pages: %d auto, %d exceptions\n\n", len(items), auto, exc) + fmt.Println("| status | kind | from | to / proposal | how | why |") + fmt.Println("|---|---|---|---|---|---|") + for _, it := range items { + dest := it.To + if dest == "" { + dest = it.Wing + } + fmt.Printf("| %s | %s | %s | %s | %s | %s |\n", it.Status, it.Kind, it.From, dest, it.Method, strings.ReplaceAll(it.Reason, "|", "/")) + } +} diff --git a/ingestion/internal/api/pending_review_test.go b/ingestion/internal/api/pending_review_test.go new file mode 100644 index 0000000..0283c24 --- /dev/null +++ b/ingestion/internal/api/pending_review_test.go @@ -0,0 +1,51 @@ +package api + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func writeParked(t *testing.T, brainDir, name, content string) { + t.Helper() + dir := filepath.Join(brainDir, "raw", "needs-review") + require.NoError(t, os.MkdirAll(dir, 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(content), 0o644)) +} + +func TestListPending_ShowsParkedNotesWithTheirProposal(t *testing.T) { + dir := t.TempDir() + writeRaw(t, dir, "fresh.md", "---\ncreated_at: 2026-10-20T08:00:00Z\n---\nA note not yet processed.\n") + writeParked(t, dir, "unsure.md", "---\nproposed_wing: radar\nproposal_confidence: 0.55\nproposal_reason: \"below threshold\"\ncreated_at: 2026-10-19T08:00:00Z\n---\nBody of the unsure note.\n") + + got, err := ListPending(dir) + require.NoError(t, err) + require.Len(t, got, 2) + + assert.Equal(t, "unsure.md", got[0].Filename, "oldest first across both queues") + assert.Equal(t, "needs-review", got[0].Queue) + assert.Equal(t, "radar", got[0].ProposedWing) + assert.Equal(t, "below threshold", got[0].ProposalReason) + assert.InDelta(t, 0.55, got[0].ProposalConfidence, 1e-9) + assert.Contains(t, got[0].Excerpt, "Body of the unsure note") + assert.NotContains(t, got[0].Excerpt, "proposed_wing", "excerpt is body, not frontmatter") + + assert.Equal(t, "fresh.md", got[1].Filename) + assert.Empty(t, got[1].Queue, "a top-level note has no queue label") + assert.Empty(t, got[1].ProposedWing) +} + +func TestListPending_ParkedSymlinkIsNotListed(t *testing.T) { + dir := t.TempDir() + outside := filepath.Join(t.TempDir(), "secret.md") + require.NoError(t, os.WriteFile(outside, []byte("outside"), 0o644)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw", "needs-review"), 0o755)) + require.NoError(t, os.Symlink(outside, filepath.Join(dir, "raw", "needs-review", "link.md"))) + + got, err := ListPending(dir) + require.NoError(t, err) + assert.Empty(t, got, "same symlink rule as the top level (#153)") +} diff --git a/ingestion/internal/api/promote.go b/ingestion/internal/api/promote.go index cfeae31..756641a 100644 --- a/ingestion/internal/api/promote.go +++ b/ingestion/internal/api/promote.go @@ -8,6 +8,7 @@ import ( "path/filepath" "regexp" "sort" + "strconv" "strings" "time" @@ -20,6 +21,15 @@ type PendingNote struct { CreatedAt string `json:"created_at"` SizeBytes int64 `json:"size_bytes"` Excerpt string `json:"excerpt"` + + // Set only for a note parked in raw/needs-review/ by the wing resolver + // (hyperguild#205): it was not placed automatically, and the proposal says + // what the resolver would have done and why it did not. Approve by moving + // the note into raw// and the watcher ingests it there. + Queue string `json:"queue,omitempty"` + ProposedWing string `json:"proposed_wing,omitempty"` + ProposalReason string `json:"proposal_reason,omitempty"` + ProposalConfidence float64 `json:"proposal_confidence,omitempty"` } // datePrefix matches a leading YYYY-MM-DD- on a raw filename, stripped when @@ -38,11 +48,32 @@ func ListPending(brainDir string) ([]PendingNote, error) { return nil, fmt.Errorf("read raw dir: %w", err) } defer func() { _ = root.Close() }() + + out, err := listNotes(root, "") + if err != nil { + return nil, err + } + // raw/needs-review/ is read through its own root, so a symlinked directory + // or note there is refused exactly like at the top level (#153). + if sub, serr := root.OpenRoot("needs-review"); serr == nil { + defer func() { _ = sub.Close() }() + parked, perr := listNotes(sub, "needs-review") + if perr != nil { + return nil, perr + } + out = append(out, parked...) + } + sort.SliceStable(out, func(i, j int) bool { return out[i].CreatedAt < out[j].CreatedAt }) + return out, nil +} + +// listNotes lists the regular .md notes directly under root. queue labels the +// notes ("" for the top of raw/) and enables reading a wing proposal. +func listNotes(root *os.Root, queue string) ([]PendingNote, error) { entries, err := fs.ReadDir(root.FS(), ".") if err != nil { return nil, fmt.Errorf("read raw dir: %w", err) } - out := make([]PendingNote, 0, len(entries)) for _, e := range entries { if !e.Type().IsRegular() || !strings.HasSuffix(e.Name(), ".md") || strings.HasPrefix(e.Name(), "tunnel-candidates-") { @@ -57,14 +88,20 @@ func ListPending(brainDir string) ([]PendingNote, error) { if created == "" { created = info.ModTime().UTC().Format(time.RFC3339) } - out = append(out, PendingNote{ + n := PendingNote{ Filename: e.Name(), CreatedAt: created, SizeBytes: info.Size(), Excerpt: excerpt(body, 200), - }) + Queue: queue, + } + if queue != "" { + n.ProposedWing = fm.get("proposed_wing") + n.ProposalReason = unquoteScalar(fm.get("proposal_reason")) + n.ProposalConfidence, _ = strconv.ParseFloat(fm.get("proposal_confidence"), 64) + } + out = append(out, n) } - sort.SliceStable(out, func(i, j int) bool { return out[i].CreatedAt < out[j].CreatedAt }) return out, nil } @@ -239,3 +276,13 @@ func readRawSource(brainDir, base string) ([]byte, error) { } return raw, nil } + +// unquoteScalar undoes the Go-style double quoting the watcher writes around a +// free-text value (strconv.Quote keeps it on one line); a bare value is +// returned as is. +func unquoteScalar(v string) string { + if u, err := strconv.Unquote(v); err == nil { + return u + } + return v +} diff --git a/ingestion/internal/migrate/migrate.go b/ingestion/internal/migrate/migrate.go new file mode 100644 index 0000000..d92001b --- /dev/null +++ b/ingestion/internal/migrate/migrate.go @@ -0,0 +1,309 @@ +// Package migrate moves pages out of the retired wiki/{sources,concepts, +// entities}/ layout into wiki//{sources,facts}/ (hyperguild#205). +// +// It is a two-step tool on purpose. BuildPlan only reads and decides; Apply +// performs exactly the items the plan marked Auto and nothing else. Sources are +// placed by the wing Resolver. Concepts and entities have no wing of their own: +// they inherit the strict-majority wing of the sources that cite them, which is +// deterministic and needs no model. Whatever cannot be placed safely is an +// Exception, left where it is for a human. +package migrate + +import ( + "context" + "errors" + "fmt" + "io/fs" + "path" + "regexp" + "sort" + "strings" + + "github.com/mathiasbq/hyperguild/ingestion/internal/brain" + "github.com/mathiasbq/hyperguild/ingestion/internal/brainfs" + "github.com/mathiasbq/hyperguild/ingestion/internal/wing" +) + +// Status is whether the plan moves a page by itself. +type Status string + +const ( + Auto Status = "auto" + Exception Status = "exception" +) + +// Item is one planned move. +type Item struct { + From string // brain-relative, e.g. wiki/entities/jev.md + To string // brain-relative; empty for an Exception without a proposal + Slug string + Kind string // source | concept | entity + Wing string // decided wing, or the proposal for an Exception + Status Status + Method string // resolver | inherited | junk | blocked + Confidence float64 + Reason string +} + +// ResolverFor returns the Resolver to use for one page. It takes the page's +// own path so a scorer can exclude the page from its evidence. +type ResolverFor func(excludePath string) *wing.Resolver + +var ( + linkSlugRE = regexp.MustCompile(`\[\[([^|\]]+)`) + legacy = []struct{ dir, kind string }{ + {"wiki/sources", "source"}, + {"wiki/concepts", "concept"}, + {"wiki/entities", "entity"}, + } +) + +// BuildPlan reads the legacy layout and decides, without writing anything. +func BuildPlan(ctx context.Context, brainDir string, rf ResolverFor) ([]Item, error) { + bfs, err := brainfs.Open(brainDir) + if err != nil { + return nil, fmt.Errorf("open brain: %w", err) + } + defer func() { _ = bfs.Close() }() + + taken, err := slugsInWings(bfs) + if err != nil { + return nil, err + } + + var items []Item + decided := map[string]Item{} // source slug -> its decision + for _, l := range legacy { + names, err := bfs.ReadDirRegular(l.dir) + if errors.Is(err, fs.ErrNotExist) { + continue + } + if err != nil { + return nil, fmt.Errorf("read %s: %w", l.dir, err) + } + for _, name := range names { + if !strings.HasSuffix(name, ".md") || name == "_index.md" { + continue + } + rel := l.dir + "/" + name + slug := strings.TrimSuffix(name, ".md") + it := Item{From: rel, Slug: slug, Kind: l.kind} + if strings.HasPrefix(name, "tunnel-candidates-") { + it.Status, it.Method, it.Reason = Exception, "junk", "auto-tunnel review log ingested as a source; not a source" + items = append(items, it) + continue + } + content, err := bfs.ReadRegular(rel) + if err != nil { + it.Status, it.Method, it.Reason = Exception, "blocked", fmt.Sprintf("unreadable: %v", err) + items = append(items, it) + continue + } + if l.kind == "source" { + d := rf(rel).Resolve(ctx, wing.Note{Text: string(content), FrontmatterWing: frontmatterValue(string(content), "wing")}) + it.Method, it.Wing, it.Confidence, it.Reason = "resolver", d.Wing, d.Confidence, d.Reason + if d.Source == wing.Exception { + it.Status = Exception + } else { + it.Status, it.To = Auto, "wiki/"+d.Wing+"/sources/"+name + } + decided[slug] = it + } + items = append(items, it) + } + } + + // Second pass: concepts and entities inherit from their sources. + for i, it := range items { + if it.Kind == "source" || it.Method == "junk" || it.Method == "blocked" { + continue + } + content, err := bfs.ReadRegular(it.From) + if err != nil { + continue + } + it.Method = "inherited" + w, reason := inheritedWing(string(content), decided) + if w == "" { + it.Status, it.Reason = Exception, reason + } else { + it.Status, it.Wing, it.To = Auto, w, "wiki/"+w+"/facts/"+it.Slug+".md" + it.Reason = fmt.Sprintf("strict majority of its sources are in %q", w) + } + items[i] = it + } + + blockCollisions(bfs, items, taken) + sort.SliceStable(items, func(i, j int) bool { return items[i].From < items[j].From }) + return items, nil +} + +// inheritedWing returns the wing held by a strict majority of the legacy +// sources cited in the page's "## Sources" section, or "" with the reason. +func inheritedWing(content string, decided map[string]Item) (string, string) { + section := sourcesSection(content) + if section == "" { + return "", "page has no Sources section to inherit a wing from" + } + total := 0 + votes := map[string]int{} + for _, m := range linkSlugRE.FindAllStringSubmatch(section, -1) { + d, ok := decided[strings.TrimSpace(m[1])] + if !ok { + continue // not a legacy source (already migrated, or not a source) + } + total++ + if d.Status == Auto { + votes[d.Wing]++ + } + } + if total == 0 { + return "", "none of its cited sources are in the legacy layout" + } + for w, n := range votes { + if n*2 > total { + return w, "" + } + } + return "", fmt.Sprintf("its %d cited sources disagree on a wing or were not placed", total) +} + +// blockCollisions turns an Auto item into an Exception when its destination +// exists, its slug is already used in some wing (graph identity is the slug +// alone), or two planned items would land on the same path. +func blockCollisions(bfs *brainfs.Brain, items []Item, taken map[string]string) { + planned := map[string]int{} + for _, it := range items { + if it.Status == Auto { + planned[it.To]++ + } + } + for i, it := range items { + if it.Status != Auto { + continue + } + switch { + case planned[it.To] > 1: + items[i].Status, items[i].Reason = Exception, fmt.Sprintf("another legacy page would land on %s", it.To) + case exists(bfs, it.To): + items[i].Status, items[i].Reason = Exception, fmt.Sprintf("%s already exists", it.To) + default: + if other, ok := taken[it.Slug]; ok { + items[i].Status, items[i].Reason = Exception, fmt.Sprintf("slug %q is already used by %s", it.Slug, other) + } + } + } +} + +func exists(bfs *brainfs.Brain, rel string) bool { + _, err := bfs.Lstat(rel) + return err == nil +} + +// slugsInWings maps every slug under wiki/// to its path. +func slugsInWings(bfs *brainfs.Brain) (map[string]string, error) { + out := map[string]string{} + wings, err := bfs.ReadDirDirs("wiki") + if errors.Is(err, fs.ErrNotExist) { + return out, nil + } + if err != nil { + return nil, fmt.Errorf("list wiki: %w", err) + } + for _, w := range wings { + if w == "sources" || w == "concepts" || w == "entities" { + continue + } + halls, err := bfs.ReadDirDirs("wiki/" + w) + if err != nil { + continue + } + for _, h := range halls { + names, err := bfs.ReadDirRegular("wiki/" + w + "/" + h) + if err != nil { + continue + } + for _, n := range names { + if strings.HasSuffix(n, ".md") && n != "_index.md" { + out[strings.TrimSuffix(n, ".md")] = "wiki/" + w + "/" + h + "/" + n + } + } + } + } + return out, nil +} + +// Apply performs the Auto items, one at a time, and stops at the first +// failure. Each move writes the destination with O_EXCL, then removes the +// source, so a failure never loses a page. Exceptions are not touched. +func Apply(brainDir string, items []Item) (int, error) { + bfs, err := brainfs.Open(brainDir) + if err != nil { + return 0, fmt.Errorf("open brain: %w", err) + } + defer func() { _ = bfs.Close() }() + + n := 0 + touched := map[string]bool{} + for _, it := range items { + if it.Status != Auto { + continue + } + content, err := bfs.ReadRegular(it.From) + if err != nil { + return n, fmt.Errorf("read %s: %w", it.From, err) + } + hall := path.Base(path.Dir(it.To)) + if err := bfs.CreateRegular(it.To, []byte(stamp(string(content), it.Wing, hall, it.Kind))); err != nil { + return n, fmt.Errorf("write %s: %w", it.To, err) + } + if err := bfs.Remove(it.From); err != nil { + return n, fmt.Errorf("moved to %s but removing %s failed: %w", it.To, it.From, err) + } + touched[it.Wing] = true + n++ + } + for w := range touched { + _ = brain.BuildWingIndex(brainDir, w) // best-effort upkeep; the pages are already moved + } + return n, nil +} + +// stamp inserts wing/hall (and kind for facts) after the opening fence, leaving +// every other byte of the page alone. A page without frontmatter gets a block. +func stamp(content, w, hall, kind string) string { + extra := "wing: " + w + "\nhall: " + hall + "\n" + if hall == "facts" { + extra += "kind: " + kind + "\n" + } + if strings.HasPrefix(content, "---\n") { + return "---\n" + extra + content[4:] + } + return "---\n" + extra + "---\n" + content +} + +// sourcesSection returns the text under a "## Sources" heading up to the next +// "## " heading. +func sourcesSection(content string) string { + _, rest, ok := strings.Cut(content, "\n## Sources") + if !ok { + return "" + } + if i := strings.Index(rest, "\n## "); i >= 0 { + rest = rest[:i] + } + return rest +} + +func frontmatterValue(content, key string) string { + if !strings.HasPrefix(content, "---\n") { + return "" + } + block, _, _ := strings.Cut(content[4:], "\n---") + for _, line := range strings.Split(block, "\n") { + if k, v, ok := strings.Cut(line, ":"); ok && strings.TrimSpace(k) == key { + return strings.Trim(strings.TrimSpace(v), `"'`) + } + } + return "" +} diff --git a/ingestion/internal/migrate/migrate_test.go b/ingestion/internal/migrate/migrate_test.go new file mode 100644 index 0000000..6dbc1d8 --- /dev/null +++ b/ingestion/internal/migrate/migrate_test.go @@ -0,0 +1,243 @@ +package migrate_test + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mathiasbq/hyperguild/ingestion/internal/migrate" + "github.com/mathiasbq/hyperguild/ingestion/internal/wing" +) + +// keywordClassifier picks a wing by a marker word in the text, and the scorer +// agrees by the same marker, so a page "belongs" to the wing whose marker it holds. +type keywordClassifier struct{} + +func (keywordClassifier) Classify(_ context.Context, text string, _ []string) (wing.Suggestion, error) { + for _, w := range []string{"alpha", "beta"} { + if strings.Contains(text, strings.ToUpper(w)) { + return wing.Suggestion{Wing: w, Confidence: 0.95, Reason: "marker"}, nil + } + } + return wing.Suggestion{}, nil +} + +type keywordScorer struct{} + +func (keywordScorer) Score(text string) map[string]float64 { + out := map[string]float64{} + for _, w := range []string{"alpha", "beta"} { + if strings.Contains(text, strings.ToUpper(w)) { + out[w] = 1 + } + } + return out +} + +func resolverFor(string) *wing.Resolver { + return &wing.Resolver{ + Known: []string{"alpha", "beta", "client"}, + Eligible: func(w string) bool { return w != "client" }, + Classifier: keywordClassifier{}, + Scorer: keywordScorer{}, + Threshold: 0.8, + } +} + +func page(t *testing.T, dir, rel, body string) { + t.Helper() + full := filepath.Join(dir, rel) + require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755)) + require.NoError(t, os.WriteFile(full, []byte(body), 0o644)) +} + +func src(title, marker string) string { + return "---\ntitle: " + title + "\ntype: video\n---\n\n## Summary\n\n" + marker + " content.\n" +} + +func entity(title string, sources ...string) string { + var b strings.Builder + b.WriteString("---\ntitle: " + title + "\ntype: tool\n---\n\n## Definition\n\nA thing.\n\n## Sources\n\n") + for _, s := range sources { + b.WriteString("- [[" + s + "|" + s + "]]\n") + } + return b.String() +} + +func byFrom(items []migrate.Item) map[string]migrate.Item { + m := map[string]migrate.Item{} + for _, it := range items { + m[it.From] = it + } + return m +} + +func TestPlan_AResolvedSourceGoesToItsWingsSourcesHall(t *testing.T) { + dir := t.TempDir() + page(t, dir, "wiki/sources/talk-a.md", src("Talk A", "ALPHA")) + + items, err := migrate.BuildPlan(context.Background(), dir, resolverFor) + require.NoError(t, err) + it := byFrom(items)["wiki/sources/talk-a.md"] + assert.Equal(t, migrate.Auto, it.Status) + assert.Equal(t, "wiki/alpha/sources/talk-a.md", it.To) +} + +func TestPlan_AutoTunnelLogsAreJunkNotSources(t *testing.T) { + dir := t.TempDir() + page(t, dir, "wiki/sources/tunnel-candidates-2026-06-14.md", src("t", "ALPHA")) + + items, err := migrate.BuildPlan(context.Background(), dir, resolverFor) + require.NoError(t, err) + it := byFrom(items)["wiki/sources/tunnel-candidates-2026-06-14.md"] + assert.Equal(t, migrate.Exception, it.Status, "never auto-moved, whatever the classifier says") + assert.Equal(t, "junk", it.Method) +} + +func TestPlan_AnEntityFollowsTheMajorityWingOfItsSources(t *testing.T) { + dir := t.TempDir() + page(t, dir, "wiki/sources/a1.md", src("A1", "ALPHA")) + page(t, dir, "wiki/sources/a2.md", src("A2", "ALPHA")) + page(t, dir, "wiki/sources/b1.md", src("B1", "BETA")) + page(t, dir, "wiki/entities/jev.md", entity("Jev", "a1", "a2", "b1")) + page(t, dir, "wiki/concepts/factory.md", "---\ntitle: Factory\n---\n\n## Definition\n\nx\n\n## Sources\n\n- [[a1|A1]]\n") + + items, err := migrate.BuildPlan(context.Background(), dir, resolverFor) + require.NoError(t, err) + m := byFrom(items) + assert.Equal(t, "wiki/alpha/facts/jev.md", m["wiki/entities/jev.md"].To) + assert.Equal(t, "entity", m["wiki/entities/jev.md"].Kind) + assert.Equal(t, "wiki/alpha/facts/factory.md", m["wiki/concepts/factory.md"].To) + assert.Equal(t, "concept", m["wiki/concepts/factory.md"].Kind) +} + +func TestPlan_ATieBetweenWingsIsAnException(t *testing.T) { + dir := t.TempDir() + page(t, dir, "wiki/sources/a1.md", src("A1", "ALPHA")) + page(t, dir, "wiki/sources/b1.md", src("B1", "BETA")) + page(t, dir, "wiki/entities/jev.md", entity("Jev", "a1", "b1")) + + items, err := migrate.BuildPlan(context.Background(), dir, resolverFor) + require.NoError(t, err) + it := byFrom(items)["wiki/entities/jev.md"] + assert.Equal(t, migrate.Exception, it.Status) + assert.Contains(t, it.Reason, "disagree") +} + +func TestPlan_AnEntityWhoseSourcesWereNotPlacedIsAnException(t *testing.T) { + dir := t.TempDir() + page(t, dir, "wiki/sources/mystery.md", src("Mystery", "nothing recognisable")) + page(t, dir, "wiki/entities/orphan.md", entity("Orphan", "mystery")) + page(t, dir, "wiki/entities/nosources.md", "---\ntitle: No Sources\n---\n\n## Definition\n\nx\n") + + items, err := migrate.BuildPlan(context.Background(), dir, resolverFor) + require.NoError(t, err) + m := byFrom(items) + assert.Equal(t, migrate.Exception, m["wiki/entities/orphan.md"].Status) + assert.Equal(t, migrate.Exception, m["wiki/entities/nosources.md"].Status) +} + +func TestPlan_ADestinationThatAlreadyExistsIsAnException(t *testing.T) { + dir := t.TempDir() + page(t, dir, "wiki/sources/talk-a.md", src("Talk A", "ALPHA")) + page(t, dir, "wiki/alpha/sources/talk-a.md", "someone else's page") + + items, err := migrate.BuildPlan(context.Background(), dir, resolverFor) + require.NoError(t, err) + it := byFrom(items)["wiki/sources/talk-a.md"] + assert.Equal(t, migrate.Exception, it.Status) + assert.Contains(t, it.Reason, "already exists", "the occupied destination is named as the reason, not a generic slug clash") +} + +func TestPlan_ASlugAlreadyUsedInAnotherWingIsAnException(t *testing.T) { + // Graph identity is the slug alone; two pages with one slug would merge. + dir := t.TempDir() + page(t, dir, "wiki/sources/talk-a.md", src("Talk A", "ALPHA")) + page(t, dir, "wiki/beta/facts/talk-a.md", "a beta page with the same slug") + + items, err := migrate.BuildPlan(context.Background(), dir, resolverFor) + require.NoError(t, err) + it := byFrom(items)["wiki/sources/talk-a.md"] + assert.Equal(t, migrate.Exception, it.Status) + assert.Contains(t, it.Reason, "slug") +} + +func TestApply_MovesOnlyAutoItemsAndStampsFrontmatterWithoutTouchingTheBody(t *testing.T) { + dir := t.TempDir() + body := src("Talk A", "ALPHA") + "\nSee [[jev|Jev]] and [[unknown-slug]].\n" + page(t, dir, "wiki/sources/talk-a.md", body) + page(t, dir, "wiki/sources/tunnel-candidates-2026-06-14.md", src("t", "ALPHA")) + page(t, dir, "wiki/entities/jev.md", entity("Jev", "talk-a")) + + items, err := migrate.BuildPlan(context.Background(), dir, resolverFor) + require.NoError(t, err) + n, err := migrate.Apply(dir, items) + require.NoError(t, err) + assert.Equal(t, 2, n) + + moved, err := os.ReadFile(filepath.Join(dir, "wiki/alpha/sources/talk-a.md")) + require.NoError(t, err) + assert.Contains(t, string(moved), "wing: alpha") + assert.Contains(t, string(moved), "hall: sources") + assert.Contains(t, string(moved), "See [[jev|Jev]] and [[unknown-slug]].", "links and body are byte-preserved") + + ent, err := os.ReadFile(filepath.Join(dir, "wiki/alpha/facts/jev.md")) + require.NoError(t, err) + assert.Contains(t, string(ent), "kind: entity") + + _, err = os.Stat(filepath.Join(dir, "wiki/sources/talk-a.md")) + assert.ErrorIs(t, err, os.ErrNotExist, "the original is removed after the copy is written") + _, err = os.Stat(filepath.Join(dir, "wiki/sources/tunnel-candidates-2026-06-14.md")) + assert.NoError(t, err, "an exception is left exactly where it is") +} + +func TestApply_NeverOverwritesAndKeepsTheSourceWhenTheDestinationAppearsLate(t *testing.T) { + dir := t.TempDir() + page(t, dir, "wiki/sources/talk-a.md", src("Talk A", "ALPHA")) + items, err := migrate.BuildPlan(context.Background(), dir, resolverFor) + require.NoError(t, err) + page(t, dir, "wiki/alpha/sources/talk-a.md", "appeared after the plan") + + n, err := migrate.Apply(dir, items) + require.Error(t, err) + assert.Zero(t, n) + got, _ := os.ReadFile(filepath.Join(dir, "wiki/alpha/sources/talk-a.md")) + assert.Equal(t, "appeared after the plan", string(got)) + _, statErr := os.Stat(filepath.Join(dir, "wiki/sources/talk-a.md")) + assert.NoError(t, statErr, "the source survives a failed write") +} + +func TestPlan_IsEmptyOnceEverythingIsMigrated(t *testing.T) { + dir := t.TempDir() + page(t, dir, "wiki/sources/talk-a.md", src("Talk A", "ALPHA")) + items, err := migrate.BuildPlan(context.Background(), dir, resolverFor) + require.NoError(t, err) + _, err = migrate.Apply(dir, items) + require.NoError(t, err) + + again, err := migrate.BuildPlan(context.Background(), dir, resolverFor) + require.NoError(t, err) + assert.Empty(t, again) +} + +// An exception that blocked on a collision still carries a destination; Apply +// must treat Status, not the presence of a path, as the decision. +func TestApply_LeavesACollisionExceptionAloneEvenThoughItHasADestination(t *testing.T) { + dir := t.TempDir() + page(t, dir, "wiki/sources/talk-a.md", src("Talk A", "ALPHA")) + page(t, dir, "wiki/alpha/sources/talk-a.md", "occupied") + items, err := migrate.BuildPlan(context.Background(), dir, resolverFor) + require.NoError(t, err) + require.Equal(t, migrate.Exception, byFrom(items)["wiki/sources/talk-a.md"].Status) + + n, err := migrate.Apply(dir, items) + require.NoError(t, err) + assert.Zero(t, n) + _, statErr := os.Stat(filepath.Join(dir, "wiki/sources/talk-a.md")) + assert.NoError(t, statErr) +} diff --git a/ingestion/internal/pipeline/build.go b/ingestion/internal/pipeline/build.go index ef09d82..c45fdac 100644 --- a/ingestion/internal/pipeline/build.go +++ b/ingestion/internal/pipeline/build.go @@ -14,6 +14,13 @@ import ( // (derived from the filename, not the LLM title). Pages whose title resolves to an // empty slug are skipped and returned as warnings instead. func BuildPages(rawPages []RawPage, sourceSlug, date string) ([]wiki.Page, []string) { + return BuildPagesIn("", rawPages, sourceSlug, date) +} + +// BuildPagesIn is BuildPages for a wing: sources go to wiki//sources/, +// concepts and entities to wiki//facts/ with `kind:` recording which. +// An empty wing is the retired wiki/{sources,concepts,entities}/ layout. +func BuildPagesIn(wing string, rawPages []RawPage, sourceSlug, date string) ([]wiki.Page, []string) { out := make([]wiki.Page, 0, len(rawPages)) var warnings []string for _, rp := range rawPages { @@ -28,7 +35,7 @@ func BuildPages(rawPages []RawPage, sourceSlug, date string) ([]wiki.Page, []str warnings = append(warnings, fmt.Sprintf("skipped page with empty title (type: %s)", rp.Type)) continue } - out = append(out, buildPage(rp, sourceSlug, date)) + out = append(out, buildPage(rp, sourceSlug, date, wing)) } return out, warnings } @@ -42,7 +49,7 @@ func computeSlug(rp RawPage, sourceSlug string) string { return wiki.Slug(rp.Title) } -func buildPage(rp RawPage, sourceSlug, date string) wiki.Page { +func buildPage(rp RawPage, sourceSlug, date, wing string) wiki.Page { var slug, dir string switch rp.Type { case "source": @@ -59,8 +66,21 @@ func buildPage(rp RawPage, sourceSlug, date string) wiki.Page { dir = "wiki/" + rp.Type } + hall := "" + if wing != "" { + switch rp.Type { + case "source": + hall = "sources" + default: + hall = "facts" + } + dir = "wiki/" + wing + "/" + hall + } path := dir + "/" + slug + ".md" fm := buildFrontmatter(rp, date) + if wing != "" { + fm = addWingFrontmatter(fm, wing, hall, rp.Type) + } return wiki.Page{ Path: path, @@ -122,6 +142,16 @@ func buildFrontmatter(rp RawPage, date string) string { return sb.String() } +// addWingFrontmatter inserts wing/hall (and kind for facts pages) right after +// the opening fence of an already-built frontmatter block. +func addWingFrontmatter(fm, wing, hall, rpType string) string { + extra := "wing: " + wing + "\nhall: " + hall + "\n" + if hall == "facts" { + extra += "kind: " + rpType + "\n" + } + return strings.Replace(fm, "---\n", "---\n"+extra, 1) +} + func yamlScalar(s string) string { // A raw newline or CR would end the scalar for every line-oriented // frontmatter reader and start a new key (PR #147 review: a title of diff --git a/ingestion/internal/pipeline/pipeline.go b/ingestion/internal/pipeline/pipeline.go index c368d7f..7c39691 100644 --- a/ingestion/internal/pipeline/pipeline.go +++ b/ingestion/internal/pipeline/pipeline.go @@ -7,6 +7,7 @@ import ( "encoding/hex" "errors" "fmt" + "github.com/mathiasbq/hyperguild/ingestion/internal/brain" "github.com/mathiasbq/hyperguild/ingestion/internal/brainfs" "io/fs" "os" @@ -26,6 +27,11 @@ type Config struct { Complete CompleteFunc ChunkSize int // 0 = no chunking Schema string // overrides brain/schema.md when set (useful in tests) + + // Wing, when set, writes into wiki//{sources,facts}/ and scopes the + // inventory (title resolution, back-references) to that wing alone. Empty + // keeps the retired wiki/{sources,concepts,entities}/ layout. + Wing string } // Result is the outcome of a pipeline run. @@ -42,7 +48,16 @@ type Result struct { // Run ingests content and writes structured wiki pages to brainDir/wiki/. // In dry-run mode, pages are returned but not written to disk. func Run(ctx context.Context, cfg Config, brainDir, content, source string, dryRun bool) (Result, error) { - inventory, err := wiki.LoadInventory(brainDir) + if cfg.Wing != "" && brain.Sanitise(cfg.Wing) != cfg.Wing { + return Result{}, fmt.Errorf("invalid wing %q: must be a plain lower-case wing name", cfg.Wing) + } + var inventory map[wiki.PageType][]wiki.Entry + var err error + if cfg.Wing != "" { + inventory, err = wiki.LoadWingInventory(brainDir, cfg.Wing) + } else { + inventory, err = wiki.LoadInventory(brainDir) + } if err != nil { return Result{}, fmt.Errorf("load inventory: %w", err) } @@ -60,7 +75,7 @@ func Run(ctx context.Context, cfg Config, brainDir, content, source string, dryR // The merge is already idempotent (#103), so this is cost and signal, not // correctness — which is why it is safe for it to be an early return. bodyHash := hashBody(content) - if !dryRun && bodyHash != "" && bodyHash == storedSourceHash(brainDir, sourceSlug) { + if !dryRun && bodyHash != "" && bodyHash == storedSourceHash(brainDir, cfg.Wing, sourceSlug) { return Result{Skipped: true, Warnings: []string{ fmt.Sprintf("source %q unchanged since last ingest (hash %s) — extraction skipped", source, bodyHash[:12]), }}, nil @@ -71,7 +86,7 @@ func Run(ctx context.Context, cfg Config, brainDir, content, source string, dryR // only exists once the model has produced a title. So page-aware // extraction (#106) is scoped to the source page: it's the one page every // chunk's prompt can be given in advance. - existingPage := readSourcePage(brainDir, sourceSlug) + existingPage := readSourcePage(brainDir, cfg.Wing, sourceSlug) chunks := Chunk(content, cfg.ChunkSize) @@ -93,7 +108,7 @@ func Run(ctx context.Context, cfg Config, brainDir, content, source string, dryR meta.BodyHash = bodyHash applySourceMeta(allRaw, meta) - return buildAndWrite(ctx, allRaw, sourceSlug, date, brainDir, source, inventory, allWarnings, dryRun) + return buildAndWrite(ctx, cfg.Wing, allRaw, sourceSlug, date, brainDir, source, inventory, allWarnings, dryRun) } // RunRaw runs the pipeline on pre-parsed RawPages, skipping the LLM extraction @@ -112,12 +127,12 @@ func RunRaw(ctx context.Context, brainDir, source string, rawPages []RawPage, dr sourceSlug := wiki.Slug(source) date := time.Now().UTC().Format("2006-01-02") - return buildAndWrite(ctx, rawPages, sourceSlug, date, brainDir, source, inventory, nil, dryRun) + return buildAndWrite(ctx, "", rawPages, sourceSlug, date, brainDir, source, inventory, nil, dryRun) } // buildAndWrite runs BuildPages through write for both Run and RunRaw. -func buildAndWrite(ctx context.Context, rawPages []RawPage, sourceSlug, date, brainDir, source string, inventory map[wiki.PageType][]wiki.Entry, warnings []string, dryRun bool) (Result, error) { - pages, buildWarnings := BuildPages(rawPages, sourceSlug, date) +func buildAndWrite(ctx context.Context, wing string, rawPages []RawPage, sourceSlug, date, brainDir, source string, inventory map[wiki.PageType][]wiki.Entry, warnings []string, dryRun bool) (Result, error) { + pages, buildWarnings := BuildPagesIn(wing, rawPages, sourceSlug, date) warnings = append(warnings, buildWarnings...) resolved := Resolve(pages, inventory) canonicalized, linkWarnings := CanonicalizeLinks(resolved, inventory) @@ -158,7 +173,11 @@ func buildAndWrite(ctx context.Context, rawPages []RawPage, sourceSlug, date, br } if !dryRun { - if err := wiki.RebuildIndex(brainDir, date); err != nil { + if wing != "" { + if err := brain.BuildWingIndex(brainDir, wing); err != nil { + warnings = append(warnings, fmt.Sprintf("rebuild wing index: %v", err)) + } + } else if err := wiki.RebuildIndex(brainDir, date); err != nil { warnings = append(warnings, fmt.Sprintf("rebuild index: %v", err)) } if err := wiki.AppendLog(brainDir, source, written, warnings, date); err != nil { @@ -193,8 +212,12 @@ func hashBody(content string) string { // wiki/sources/.md, or "" when it doesn't exist yet. // It reads through the brain root, regular files only (hyperguild#157): the // content goes into the LLM prompt. -func readSourcePage(brainDir, sourceSlug string) string { - raw, err := readBrainFile(brainDir, "wiki/sources/"+sourceSlug+".md") +func readSourcePage(brainDir, wing, sourceSlug string) string { + rel := "wiki/sources/" + sourceSlug + ".md" + if wing != "" { + rel = "wiki/" + wing + "/sources/" + sourceSlug + ".md" + } + raw, err := readBrainFile(brainDir, rel) if err != nil { return "" } @@ -218,8 +241,8 @@ func readBrainFile(brainDir, rel string) ([]byte, error) { // Absence always means "extract". That is the fail-safe direction: a missed // skip costs one extraction, while a wrong skip loses an update silently — and // silence is the failure mode this repo keeps paying for. -func storedSourceHash(brainDir, sourceSlug string) string { - for _, line := range strings.Split(readSourcePage(brainDir, sourceSlug), "\n") { +func storedSourceHash(brainDir, wing, sourceSlug string) string { + for _, line := range strings.Split(readSourcePage(brainDir, wing, sourceSlug), "\n") { if key, value, found := strings.Cut(line, ":"); found && strings.TrimSpace(key) == "source_hash" { return strings.TrimSpace(value) } diff --git a/ingestion/internal/pipeline/refs.go b/ingestion/internal/pipeline/refs.go index ba6c08a..ed91c4c 100644 --- a/ingestion/internal/pipeline/refs.go +++ b/ingestion/internal/pipeline/refs.go @@ -23,7 +23,7 @@ func injectSourceRefs(pages []wiki.Page, inventory map[wiki.PageType][]wiki.Entr var sourceContent string for _, p := range pages { - if strings.HasPrefix(p.Path, "wiki/sources/") && + if isSourcePath(p.Path) && strings.TrimSuffix(filepath.Base(p.Path), ".md") == sourceSlug { sourceContent = p.Content break @@ -35,7 +35,7 @@ func injectSourceRefs(pages []wiki.Page, inventory map[wiki.PageType][]wiki.Entr bySlug := make(map[string]int, len(pages)) for i, p := range pages { - if !strings.HasPrefix(p.Path, "wiki/sources/") { + if !isSourcePath(p.Path) { bySlug[strings.TrimSuffix(filepath.Base(p.Path), ".md")] = i } } @@ -52,12 +52,13 @@ func injectSourceRefs(pages []wiki.Page, inventory map[wiki.PageType][]wiki.Entr if !ok { continue } - b, err := readBrainFile(brainDir, "wiki/"+string(pt)+"/"+slug+".md") + path := entryPath(slug, pt, inventory) + b, err := readBrainFile(brainDir, path) if err != nil { continue } page := wiki.Page{ - Path: "wiki/" + string(pt) + "/" + slug + ".md", + Path: path, Content: string(b), } pages = append(pages, addSourceRef(page, sourceRef)) @@ -88,7 +89,7 @@ func extractWikilinks(content string) map[string]bool { // findSourcePage returns the slug and title of the first wiki/sources/ page in pages. func findSourcePage(pages []wiki.Page) (slug, title string, found bool) { for _, p := range pages { - if strings.HasPrefix(p.Path, "wiki/sources/") { + if isSourcePath(p.Path) { slug = strings.TrimSuffix(filepath.Base(p.Path), ".md") title = extractTitle(p.Content) if title == "" { @@ -111,3 +112,27 @@ func findInInventory(slug string, inventory map[wiki.PageType][]wiki.Entry) (wik } return "", false } + +// isSourcePath reports whether path is a source page in either layout: +// wiki/sources/.md or wiki//sources/.md. +func isSourcePath(path string) bool { + parts := strings.Split(filepath.ToSlash(path), "/") + switch { + case len(parts) == 3 && parts[0] == "wiki" && parts[1] == "sources": + return true + case len(parts) == 4 && parts[0] == "wiki" && parts[2] == "sources": + return true + } + return false +} + +// entryPath is the brain-relative path of an inventory page: the recorded Path +// for a wing-layout entry, else the retired wiki//.md. +func entryPath(slug string, pt wiki.PageType, inventory map[wiki.PageType][]wiki.Entry) string { + for _, e := range inventory[pt] { + if e.Slug == slug && e.Path != "" { + return e.Path + } + } + return "wiki/" + string(pt) + "/" + slug + ".md" +} diff --git a/ingestion/internal/pipeline/resolve.go b/ingestion/internal/pipeline/resolve.go index df08249..e098ca1 100644 --- a/ingestion/internal/pipeline/resolve.go +++ b/ingestion/internal/pipeline/resolve.go @@ -32,7 +32,7 @@ func Resolve(proposed []wiki.Page, inventory map[wiki.PageType][]wiki.Entry) []w out := make([]wiki.Page, 0, len(proposed)) for _, page := range proposed { - pt := pageTypeFromPath(page.Path) + pt := pageTypeOf(page) title := extractTitle(page.Content) k := key{pt: pt, normalized: normalizeTitle(title)} if canonicalSlug, ok := lookup[k]; ok { @@ -55,6 +55,22 @@ func normalizeTitle(s string) string { return strings.Join(strings.Fields(s), " ") } +// pageTypeOf is the page's type: from its directory in the retired layout, or +// from `kind:` for a wing-layout facts page (concepts and entities share facts/). +func pageTypeOf(p wiki.Page) wiki.PageType { + parts := strings.Split(filepath.ToSlash(p.Path), "/") + if len(parts) >= 4 && parts[0] == "wiki" { + switch parts[2] { + case "sources": + return wiki.PageTypeSource + case "facts": + pt, _ := wiki.KindFromContent(p.Content) + return pt + } + } + return pageTypeFromPath(p.Path) +} + // pageTypeFromPath extracts the wiki.PageType from a path like "wiki/entities/foo.md". func pageTypeFromPath(path string) wiki.PageType { parts := strings.Split(filepath.ToSlash(path), "/") diff --git a/ingestion/internal/pipeline/root_test.go b/ingestion/internal/pipeline/root_test.go index b9f7b6c..1e985a8 100644 --- a/ingestion/internal/pipeline/root_test.go +++ b/ingestion/internal/pipeline/root_test.go @@ -35,17 +35,17 @@ func TestReadSourcePageAndSchemaRefuseSymlinksAndSurviveSwapRaces(t *testing.T) inBrain := brainfstest.WriteSentinelAt(t, filepath.Join(brain, "raw", "s.md")) require.NoError(t, os.WriteFile(filepath.Join(brain, "wiki", "sources", "ok.md"), []byte("zqinside source"), 0o644)) require.NoError(t, os.WriteFile(filepath.Join(brain, "schema.md"), []byte("zqinside schema"), 0o644)) - assert.Equal(t, "zqinside source", readSourcePage(brain, "ok"), "positive control") + assert.Equal(t, "zqinside source", readSourcePage(brain, "", "ok"), "positive control") assert.Equal(t, "zqinside schema", loadSchema(brain), "positive control") require.NoError(t, os.Symlink(brainfstest.Outside(t), filepath.Join(brain, "wiki", "sources", "out.md"))) require.NoError(t, os.Symlink(filepath.Join("..", "..", "raw", "s.md"), filepath.Join(brain, "wiki", "sources", "in.md"))) - assert.NotContains(t, readSourcePage(brain, "out"), brainfstest.Sentinel) - assert.NotContains(t, readSourcePage(brain, "in"), brainfstest.Sentinel) - assert.NotContains(t, storedSourceHash(brain, "in"), brainfstest.Sentinel) + assert.NotContains(t, readSourcePage(brain, "", "out"), brainfstest.Sentinel) + assert.NotContains(t, readSourcePage(brain, "", "in"), brainfstest.Sentinel) + assert.NotContains(t, storedSourceHash(brain, "", "in"), brainfstest.Sentinel) src := filepath.Join(brain, "wiki", "sources", "r.md") - read := func() string { return readSourcePage(brain, "r") } + read := func() string { return readSourcePage(brain, "", "r") } brainfstest.AssertNoLeak(t, src, "zqinside", 1500*time.Millisecond, read) brainfstest.AssertNoLeakFrom(t, src, "zqinside", inBrain, 1500*time.Millisecond, read) brainfstest.AssertNoHang(t, src, "zqinside", 1500*time.Millisecond, 6*time.Second, func() { _ = read() }) diff --git a/ingestion/internal/pipeline/wing_layout_test.go b/ingestion/internal/pipeline/wing_layout_test.go new file mode 100644 index 0000000..0da280b --- /dev/null +++ b/ingestion/internal/pipeline/wing_layout_test.go @@ -0,0 +1,138 @@ +package pipeline + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func wingCfg(wing string, pages ...RawPage) Config { + return Config{ + Wing: wing, + Schema: "# Schema", + Complete: func(context.Context, string, string) (string, error) { + return mustJSON(pages), nil + }, + } +} + +func sourceAndEntity() []RawPage { + return []RawPage{ + {Title: "Dark Factory Talk", Type: "source", Subtype: "video", Domain: "ai", + Content: "## Summary\n\nA talk.\n\n## Entities Mentioned\n\n[[Jev]]\n"}, + {Title: "Jev", Type: "entity", Subtype: "tool", Domain: "ai", + Content: "## Definition\n\nA decision model.\n\n## Sources\n"}, + } +} + +func exists(dir, rel string) bool { + _, err := os.Stat(filepath.Join(dir, rel)) + return err == nil +} + +func read(t *testing.T, dir, rel string) string { + t.Helper() + b, err := os.ReadFile(filepath.Join(dir, rel)) + require.NoError(t, err) + return string(b) +} + +func TestRun_WithAWingWritesIntoThatWingsHalls(t *testing.T) { + dir := t.TempDir() + _, err := Run(context.Background(), wingCfg("radar", sourceAndEntity()...), dir, "body of the talk", "dark-factory-talk", false) + require.NoError(t, err) + + src := read(t, dir, "wiki/radar/sources/dark-factory-talk.md") + assert.Contains(t, src, "wing: radar") + assert.Contains(t, src, "hall: sources") + + ent := read(t, dir, "wiki/radar/facts/jev.md") + assert.Contains(t, ent, "wing: radar") + assert.Contains(t, ent, "hall: facts") + assert.Contains(t, ent, "kind: entity") +} + +func TestRun_WithAWingDoesNotTouchTheRetiredLayout(t *testing.T) { + dir := t.TempDir() + _, err := Run(context.Background(), wingCfg("radar", sourceAndEntity()...), dir, "body of the talk", "dark-factory-talk", false) + require.NoError(t, err) + for _, retired := range []string{"wiki/sources", "wiki/concepts", "wiki/entities"} { + assert.False(t, exists(dir, retired), "%s is the retired layout and must not be written", retired) + } +} + +func TestRun_WithAWingTheSourceLinksBackFromTheEntity(t *testing.T) { + dir := t.TempDir() + _, err := Run(context.Background(), wingCfg("radar", sourceAndEntity()...), dir, "body of the talk", "dark-factory-talk", false) + require.NoError(t, err) + assert.Contains(t, read(t, dir, "wiki/radar/facts/jev.md"), "[[dark-factory-talk|Dark Factory Talk]]") +} + +func TestRun_WithAWingASecondSourceReusesTheSameEntityPage(t *testing.T) { + dir := t.TempDir() + _, err := Run(context.Background(), wingCfg("radar", sourceAndEntity()...), dir, "first body", "talk-one", false) + require.NoError(t, err) + + again := []RawPage{ + {Title: "Second Talk", Type: "source", Subtype: "video", Content: "## Summary\n\nMore.\n\n## Entities Mentioned\n\n[[The Jev]]\n"}, + {Title: "The Jev", Type: "entity", Subtype: "tool", Content: "## Definition\n\nStill a decision model.\n\n## Sources\n"}, + } + _, err = Run(context.Background(), wingCfg("radar", again...), dir, "second body", "talk-two", false) + require.NoError(t, err) + + files, err := filepath.Glob(filepath.Join(dir, "wiki/radar/facts/*.md")) + require.NoError(t, err) + assert.Len(t, files, 1, "an entity matched by title (leading article ignored) is merged, not duplicated") + assert.Contains(t, read(t, dir, "wiki/radar/facts/jev.md"), "talk-two") +} + +func TestRun_WithAWingNeverMergesIntoAnotherWingsPage(t *testing.T) { + dir := t.TempDir() + other := "---\ntitle: Jev\nwing: client-x\nhall: facts\nkind: entity\n---\n\n## Definition\n\nPrivate to another wing.\n" + require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/client-x/facts"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "wiki/client-x/facts/jev.md"), []byte(other), 0o644)) + + _, err := Run(context.Background(), wingCfg("radar", sourceAndEntity()...), dir, "body", "dark-factory-talk", false) + require.NoError(t, err) + + assert.Equal(t, other, read(t, dir, "wiki/client-x/facts/jev.md"), "another wing's page is never read into or rewritten") + assert.True(t, exists(dir, "wiki/radar/facts/jev.md")) +} + +func TestRun_WithAWingRejectsANameThatIsNotAPlainWing(t *testing.T) { + for _, bad := range []string{"../escape", "a/b", "Radar", "has space", "."} { + t.Run(bad, func(t *testing.T) { + dir := t.TempDir() + _, err := Run(context.Background(), wingCfg(bad, sourceAndEntity()...), dir, "body", "talk", false) + require.Error(t, err) + assert.Len(t, listAll(t, dir), 1, "nothing is written for a rejected wing (only the temp dir itself exists)") + }) + } +} + +func listAll(t *testing.T, dir string) []string { + t.Helper() + var out []string + _ = filepath.WalkDir(dir, func(p string, _ os.DirEntry, _ error) error { out = append(out, p); return nil }) + return out +} + +// The dangerous path is the back-reference: a source that links [[Jev]] without +// proposing a Jev page makes the pipeline go and edit an existing Jev page. That +// lookup must be confined to the target wing. +func TestRun_WithAWingASourceLinkingToAnotherWingsEntityDoesNotEditIt(t *testing.T) { + dir := t.TempDir() + other := "---\ntitle: Jev\nwing: client-x\nhall: facts\nkind: entity\n---\n\n## Definition\n\nPrivate to another wing.\n\n## Sources\n" + require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/client-x/facts"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "wiki/client-x/facts/jev.md"), []byte(other), 0o644)) + + onlySource := sourceAndEntity()[:1] + _, err := Run(context.Background(), wingCfg("radar", onlySource...), dir, "body", "dark-factory-talk", false) + require.NoError(t, err) + + assert.Equal(t, other, read(t, dir, "wiki/client-x/facts/jev.md"), "no back-reference may be written into another wing") +} diff --git a/ingestion/internal/watcher/park.go b/ingestion/internal/watcher/park.go new file mode 100644 index 0000000..e0b1afb --- /dev/null +++ b/ingestion/internal/watcher/park.go @@ -0,0 +1,44 @@ +package watcher + +import ( + "errors" + "fmt" + "io/fs" + "path/filepath" + "strings" + + "github.com/mathiasbq/hyperguild/ingestion/internal/brainfs" +) + +// Kept out of watcher.go on purpose: that file is covered by the raw-reader +// guard (internal/extract/callsites_test.go), which forbids any .Open selector +// there. This function reads nothing from raw/; it creates a file under the +// brain root, which refuses symlinked directories and never follows a final +// symlink. + +// createParked writes data to raw/needs-review/ through the brain +// root, never replacing a parked note: on a name clash it tries -2, -3, ... +// A needs-review that is a symlink, or any non-directory, is refused. +func createParked(brainDir, filename string, data []byte) error { + bfs, err := brainfs.Open(brainDir) + if err != nil { + return err + } + defer func() { _ = bfs.Close() }() + ext := filepath.Ext(filename) + stem := strings.TrimSuffix(filename, ext) + for n := 1; n < 10000; n++ { + name := filename + if n > 1 { + name = fmt.Sprintf("%s-%d%s", stem, n, ext) + } + err := bfs.CreateRegular("raw/needs-review/"+name, data) + if err == nil { + return nil + } + if !errors.Is(err, fs.ErrExist) { + return err + } + } + return fmt.Errorf("no free name for %q in raw/needs-review", filename) +} diff --git a/ingestion/internal/watcher/watcher.go b/ingestion/internal/watcher/watcher.go index 2a951a3..2a3f67f 100644 --- a/ingestion/internal/watcher/watcher.go +++ b/ingestion/internal/watcher/watcher.go @@ -13,8 +13,10 @@ import ( "time" "unicode" + "github.com/mathiasbq/hyperguild/ingestion/internal/brain" "github.com/mathiasbq/hyperguild/ingestion/internal/extract" "github.com/mathiasbq/hyperguild/ingestion/internal/pipeline" + "github.com/mathiasbq/hyperguild/ingestion/internal/wing" ) // Config holds watcher configuration. @@ -27,6 +29,11 @@ type Config struct { // from raw/ nor marked (e.g. a read-only raw/), so later polls skip them // instead of ingesting them again. In memory only: a restart retries once. stuck *sync.Map + + // 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 } // Start launches the watcher in a background goroutine. @@ -94,7 +101,7 @@ func processDir(ctx context.Context, cfg Config, date string) []error { // path that treats raw/ as "awaiting processing" — this is that path. if d.IsDir() { name := d.Name() - if name == "processed" || name == "failed" || name == "rejected" { + if name == "processed" || name == "failed" || name == "rejected" || name == "needs-review" { return filepath.SkipDir } return nil @@ -165,7 +172,16 @@ func processFile(ctx context.Context, cfg Config, root *os.Root, rawDir, path, d return fmt.Errorf("extract text: %w", err) } - _, runErr := pipeline.Run(ctx, cfg.Pipeline, cfg.BrainDir, content, source, false) + pcfg := cfg.Pipeline + if cfg.Resolver != nil { + d := cfg.Resolver.Resolve(ctx, wing.Note{Dir: topDir(rel), FrontmatterWing: frontmatterWing(content), Text: content}) + if d.Source == wing.Exception { + return parkForReview(cfg, root, rel, path, filename, content, d, date, before) + } + pcfg.Wing = d.Wing + slog.Info("watcher: wing resolved", "file", filename, "wing", d.Wing, "how", d.Source, "reason", d.Reason) + } + _, runErr := pipeline.Run(ctx, pcfg, cfg.BrainDir, content, source, false) if runErr != nil { // Copy to failed/ and leave a .failed marker so we don't retry. failedDir := filepath.Join(cfg.BrainDir, "raw", "failed") @@ -308,3 +324,116 @@ func copyFromRoot(root *os.Root, rel, dst string) error { } return out.Close() } + +// topDir is the first directory of a raw/-relative path, "" for a top-level file. +func topDir(rel string) string { + if i := strings.IndexByte(filepath.ToSlash(rel), '/'); i > 0 { + return filepath.ToSlash(rel)[:i] + } + return "" +} + +// frontmatterWing reads `wing:` from a note's leading frontmatter, "" if none. +func frontmatterWing(content string) string { + content = strings.ReplaceAll(content, "\r\n", "\n") + if !strings.HasPrefix(content, "---\n") { + return "" + } + block, _, _ := strings.Cut(content[4:], "\n---") + for _, line := range strings.Split(block, "\n") { + if key, val, ok := strings.Cut(line, ":"); ok && strings.TrimSpace(key) == "wing" { + return strings.Trim(strings.TrimSpace(val), `"'`) + } + } + return "" +} + +// parkForReview moves a note the resolver could not place into +// raw/needs-review/, recording the proposal in its frontmatter so a human can +// approve it by moving it into raw//. Nothing is extracted or written to +// the wiki. +// +// The parked copy is created through the brain root (real directories only, +// O_EXCL, never overwriting) and is the only copy once the original is retired, +// so the original is removed only after that write succeeds and only if it has +// not changed since it was read: a newer version delivered while the resolver +// ran stays in raw/ for the next poll. +func parkForReview(cfg Config, root *os.Root, rel, path, filename, content string, d wing.Decision, date string, before os.FileInfo) error { + var data []byte + if strings.ToLower(filepath.Ext(filename)) == ".pdf" { + // Binary: park the bytes untouched; the proposal is in log.md only. + in, _, err := extract.OpenRegular(root, rel) + if err != nil { + return fmt.Errorf("park pdf: %w", err) + } + data, err = io.ReadAll(in) + _ = in.Close() + if err != nil { + return fmt.Errorf("park pdf: %w", err) + } + } else { + data = []byte(withProposal(content, d)) + } + if err := createParked(cfg.BrainDir, filename, data); err != nil { + return fmt.Errorf("park note: %w", err) + } + if err := appendWatcherLog(cfg.BrainDir, filename, fmt.Errorf("needs review (proposed wing %q): %s", d.Wing, d.Reason), date); err != nil { + slog.Error("watcher: failed to write log entry", "error", err) + } + slog.Info("watcher: parked for review", "file", filename, "proposed_wing", d.Wing, "reason", d.Reason) + if changedSince(root, rel, before) { + slog.Warn("watcher: note changed while it was being resolved; leaving the newer version in raw/", "file", filename) + return nil + } + retire(cfg, root, rel, path, ".processed", date) + return nil +} + +// proposalKeys are the frontmatter keys the resolver owns in a parked note. +var proposalKeys = map[string]bool{"proposed_wing": true, "proposal_confidence": true, "proposal_reason": true} + +// withProposal adds the resolver's proposal to a note's frontmatter, creating +// the block when the note has none. Every value is on one line: the wing only +// if it is a plain wing name (else it appears in the reason alone), strings +// Go-quoted. Any proposal keys the note already carried are dropped first, so a +// note cannot pre-empt the resolver's (frontmatter reads are first-wins). +// Line endings are normalised to LF so a CRLF note keeps one valid block. +func withProposal(content string, d wing.Decision) string { + content = strings.ReplaceAll(content, "\r\n", "\n") + reason := d.Reason + var kv strings.Builder + if d.Wing != "" && brain.Sanitise(d.Wing) == d.Wing { + fmt.Fprintf(&kv, "proposed_wing: %s\n", d.Wing) + } else if d.Wing != "" { + reason = fmt.Sprintf("proposed wing %q is not a plain wing name; %s", d.Wing, reason) + } + fmt.Fprintf(&kv, "proposal_confidence: %.2f\nproposal_reason: %q\n", d.Confidence, reason) + + if !strings.HasPrefix(content, "---\n") { + return "---\n" + kv.String() + "---\n" + content + } + block, rest, found := cutFence(content[4:]) + if !found { + return "---\n" + kv.String() + "---\n" + content + } + var kept []string + for _, line := range strings.Split(block, "\n") { + if key, _, ok := strings.Cut(line, ":"); ok && proposalKeys[strings.TrimSpace(key)] && !strings.HasPrefix(line, " ") { + continue + } + kept = append(kept, line) + } + return "---\n" + kv.String() + strings.Join(kept, "\n") + "\n---" + rest +} + +// cutFence splits s at the first closing fence: a line that is exactly "---". +// "----" or "---x" is not a fence, matching how the frontmatter readers parse. +func cutFence(s string) (block, rest string, found bool) { + if i := strings.Index(s, "\n---\n"); i >= 0 { + return s[:i], s[i+4:], true + } + if strings.HasSuffix(s, "\n---") { + return s[:len(s)-4], "", true + } + return "", "", false +} diff --git a/ingestion/internal/watcher/watcher_wing_test.go b/ingestion/internal/watcher/watcher_wing_test.go new file mode 100644 index 0000000..9778ab9 --- /dev/null +++ b/ingestion/internal/watcher/watcher_wing_test.go @@ -0,0 +1,331 @@ +package watcher + +import ( + "context" + "errors" + "os" + "path/filepath" + "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" +) + +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")) +} + +// The reason comes from an LLM and ends up in frontmatter: a newline in it must +// not be able to start a new key (PR #147: a title once forged `origin:`). +func TestProcessDir_AProposalReasonCannotForgeFrontmatterKeys(t *testing.T) { + var llmCalls atomic.Int32 + // A classifier error is the path by which model text reaches the reason. + clf := &stubClassifier{err: errors.New("bad reply\norigin: trusted\nwing: homelab")} + cfg, brainDir := wingTestCfg(t, wingResolver(clf, stubScorer{}), &llmCalls) + put(t, brainDir, "forge.md", "Note.") + + require.Empty(t, processDir(context.Background(), cfg, "2026-10-20")) + + parked, err := os.ReadFile(filepath.Join(brainDir, "raw", "needs-review", "forge.md")) + require.NoError(t, err) + assert.NotContains(t, string(parked), "\norigin: trusted", "no injected key at line start") + assert.NotContains(t, string(parked), "\nwing: homelab") +} + +// Syncthing can bring a removed note back (an offline peer, a restored backup). +// The source-hash drift check must keep that from costing a second extraction. +func TestProcessDir_ANoteThatReappearsUnchangedIsNotExtractedAgain(t *testing.T) { + var llmCalls atomic.Int32 + cfg, brainDir := wingTestCfg(t, wingResolver(&stubClassifier{}, stubScorer{}), &llmCalls) + + put(t, brainDir, "radar/talk.md", "The same talk, byte for byte.") + require.Empty(t, processDir(context.Background(), cfg, "2026-10-20")) + require.Equal(t, int32(1), llmCalls.Load(), "positive control: the first drop is extracted") + require.False(t, has(brainDir, "raw/radar/talk.md")) + + put(t, brainDir, "radar/talk.md", "The same talk, byte for byte.") // the peer sends it back + require.Empty(t, processDir(context.Background(), cfg, "2026-10-21")) + + assert.Equal(t, int32(1), llmCalls.Load(), "an unchanged source is not extracted twice") + assert.False(t, has(brainDir, "raw/radar/talk.md"), "and inbox zero still holds") +} + +func TestProcessDir_ANoteThatReappearsChangedIsExtractedAgain(t *testing.T) { + var llmCalls atomic.Int32 + cfg, brainDir := wingTestCfg(t, wingResolver(&stubClassifier{}, stubScorer{}), &llmCalls) + + put(t, brainDir, "radar/talk.md", "Version one.") + require.Empty(t, processDir(context.Background(), cfg, "2026-10-20")) + put(t, brainDir, "radar/talk.md", "Version two, edited.") + require.Empty(t, processDir(context.Background(), cfg, "2026-10-21")) + + assert.Equal(t, int32(2), llmCalls.Load(), "a real edit must still be picked up") +} + +// The parked copy is the only copy once the original is retired, so two notes +// that share a name must both survive. +func TestProcessDir_SameNamedNotesAreAllParkedNoneOverwritten(t *testing.T) { + var llmCalls atomic.Int32 + cfg, brainDir := wingTestCfg(t, wingResolver(&stubClassifier{}, stubScorer{}), &llmCalls) + for i, body := range []string{"ONE", "TWO", "THREE", "FOUR"} { + put(t, brainDir, filepath.Join("u"+string(rune('1'+i)), "x.md"), body) + } + // Unknown subfolders: each is an exception, all named x.md. + require.Empty(t, processDir(context.Background(), cfg, "2026-10-20")) + + files, err := filepath.Glob(filepath.Join(brainDir, "raw", "needs-review", "*.md")) + require.NoError(t, err) + var all string + for _, f := range files { + b, _ := os.ReadFile(f) + all += string(b) + } + for _, body := range []string{"ONE", "TWO", "THREE", "FOUR"} { + assert.Contains(t, all, body, "note %s must not have been overwritten", body) + } + assert.Len(t, files, 4) +} + +// proposed_wing comes from a model reply or a folder name. It must never be +// able to start another key, and the note's own proposal keys must not outrank +// the resolver's (frontmatter.get is first-wins). +func TestProcessDir_AHostileWingCannotForgeTheProposal(t *testing.T) { + var llmCalls atomic.Int32 + hostile := "radar\nproposal_confidence: 0.99\nproposal_reason: \"approved by operator\"" + clf := &stubClassifier{s: wing.Suggestion{Wing: hostile, Confidence: 1}} + cfg, brainDir := wingTestCfg(t, wingResolver(clf, stubScorer{"radar": 3}), &llmCalls) + put(t, brainDir, "n.md", "Note.") + + require.Empty(t, processDir(context.Background(), cfg, "2026-10-20")) + + b, err := os.ReadFile(filepath.Join(brainDir, "raw", "needs-review", "n.md")) + require.NoError(t, err) + assert.NotContains(t, string(b), "\nproposal_confidence: 0.99") + assert.NotContains(t, string(b), "approved by operator\"\n") + assert.Equal(t, 1, linesStartingWith(string(b), "proposal_confidence:"), "exactly one confidence key at line start") +} + +func TestProcessDir_ANotesOwnProposalKeysDoNotOutrankTheResolvers(t *testing.T) { + var llmCalls atomic.Int32 + cfg, brainDir := wingTestCfg(t, wingResolver(&stubClassifier{}, stubScorer{}), &llmCalls) + put(t, brainDir, "n.md", "---\nproposed_wing: homelab\nproposal_confidence: 1.00\nproposal_reason: \"self-approved\"\n---\nBody.") + + require.Empty(t, processDir(context.Background(), cfg, "2026-10-20")) + + b, _ := os.ReadFile(filepath.Join(brainDir, "raw", "needs-review", "n.md")) + assert.NotContains(t, string(b), "self-approved") + assert.Equal(t, 1, linesStartingWith(string(b), "proposal_reason:")) +} + +func TestProcessDir_ACRLFNoteWithAWingKeyIsStillRead(t *testing.T) { + var llmCalls atomic.Int32 + clf := &stubClassifier{} + cfg, brainDir := wingTestCfg(t, wingResolver(clf, stubScorer{}), &llmCalls) + put(t, brainDir, "n.md", "---\r\nwing: radar\r\n---\r\nBody.\r\n") + + require.Empty(t, processDir(context.Background(), cfg, "2026-10-20")) + + assert.True(t, has(brainDir, "wiki/radar/sources/n.md"), "the declared wing is honoured") + assert.Zero(t, clf.calls.Load()) +} + +// linesStartingWith counts frontmatter-style keys: a key quoted inside another +// value is not at the start of a line and is not a key. +func linesStartingWith(s, prefix string) int { + n := 0 + for _, l := range strings.Split(s, "\n") { + if strings.HasPrefix(l, prefix) { + n++ + } + } + return n +} + +// Resolving costs an LLM call. If Syncthing delivers a newer version of the note +// meanwhile, deleting the original loses that version: the parked copy is stale. +func TestProcessDir_ANoteEditedWhileTheResolverRunsIsNotDeleted(t *testing.T) { + var llmCalls atomic.Int32 + var notePath string + clf := &editingClassifier{} + cfg, brainDir := wingTestCfg(t, wingResolver(clf, stubScorer{}), &llmCalls) + notePath = filepath.Join(brainDir, "raw", "n.md") + clf.edit = func() { require.NoError(t, os.WriteFile(notePath, []byte("NEWER and longer version"), 0o644)) } + put(t, brainDir, "n.md", "ORIGINAL") + + require.Empty(t, processDir(context.Background(), cfg, "2026-10-20")) + + got, err := os.ReadFile(notePath) + require.NoError(t, err, "the edited note must still be in raw/") + assert.Equal(t, "NEWER and longer version", string(got)) +} + +type editingClassifier struct{ edit func() } + +func (c *editingClassifier) Classify(context.Context, string, []string) (wing.Suggestion, error) { + c.edit() + return wing.Suggestion{}, nil +} + +// A planted symlink at raw/needs-review must not redirect a parked note out of +// the brain, nor lose it from the review queue. +func TestProcessDir_AParkedNoteIsNeverWrittenThroughASymlinkedNeedsReview(t *testing.T) { + var llmCalls atomic.Int32 + cfg, brainDir := wingTestCfg(t, wingResolver(&stubClassifier{}, stubScorer{}), &llmCalls) + outside := t.TempDir() + require.NoError(t, os.Symlink(outside, filepath.Join(brainDir, "raw", "needs-review"))) + put(t, brainDir, "n.md", "Note.") + + errs := processDir(context.Background(), cfg, "2026-10-20") + + assert.NotEmpty(t, errs, "parking is refused, loudly") + entries, _ := os.ReadDir(outside) + assert.Empty(t, entries, "nothing was written through the symlink") + assert.True(t, has(brainDir, "raw/n.md"), "the note stays where it was") +} + +func TestWithProposal_ANonFenceDashLineDoesNotCloseTheBlock(t *testing.T) { + out := withProposal("---\nfoo: bar\n----\nBody", wing.Decision{Wing: "radar", Confidence: 0.4, Reason: "r"}) + lines := strings.Split(out, "\n") + require.Equal(t, "---", lines[0]) + closing := -1 + for i := 1; i < len(lines); i++ { + if lines[i] == "---" { + closing = i + break + } + } + require.Positive(t, closing, "the proposal block is closed by a real fence") + 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") +} diff --git a/ingestion/internal/wiki/inventory.go b/ingestion/internal/wiki/inventory.go index 00b67cd..c9e3d4e 100644 --- a/ingestion/internal/wiki/inventory.go +++ b/ingestion/internal/wiki/inventory.go @@ -96,3 +96,79 @@ func readFrontmatter(bfs *brainfs.Brain, rel, fallbackSlug string) (title string } return } + +// LoadWingInventory returns the pages of ONE wing, grouped by type. Sources +// come from wiki//sources/; concepts and entities live together in +// wiki//facts/ and are told apart by their `kind:` frontmatter. A facts +// page without a kind (a hand-written note) is left out, so extraction can +// never merge into something it did not create. Other wings are never read. +func LoadWingInventory(brainDir, wing string) (map[PageType][]Entry, error) { + result := map[PageType][]Entry{PageTypeConcept: {}, PageTypeEntity: {}, PageTypeSource: {}} + bfs, err := brainfs.Open(brainDir) + if err != nil { + return nil, fmt.Errorf("open brain: %w", err) + } + defer func() { _ = bfs.Close() }() + for _, hall := range []string{"sources", "facts"} { + dir := "wiki/" + wing + "/" + hall + names, err := bfs.ReadDirRegular(dir) + if errors.Is(err, fs.ErrNotExist) { + continue + } + if err != nil { + return nil, fmt.Errorf("read dir %s: %w", dir, err) + } + for _, name := range names { + if !strings.HasSuffix(name, ".md") { + continue + } + rel := dir + "/" + name + slug := strings.TrimSuffix(name, ".md") + pt := PageTypeSource + if hall == "facts" { + var ok bool + if pt, ok = kindOf(bfs, rel); !ok { + continue + } + } + title, aliases := readFrontmatter(bfs, rel, slug) + result[pt] = append(result[pt], Entry{Slug: slug, Title: title, Aliases: aliases, Type: pt, Path: rel}) + } + } + return result, nil +} + +// kindOf reads `kind:` from a facts page's frontmatter. +func kindOf(bfs *brainfs.Brain, rel string) (PageType, bool) { + content, err := bfs.ReadRegular(rel) + if err != nil { + return "", false + } + return KindFromContent(string(content)) +} + +// KindFromContent maps frontmatter `kind: concept|entity` to a PageType. +func KindFromContent(content string) (PageType, bool) { + inFM := false + for _, line := range strings.Split(content, "\n") { + if strings.TrimSpace(line) == "---" { + if inFM { + break + } + inFM = true + continue + } + if !inFM { + continue + } + if key, val, ok := strings.Cut(line, ":"); ok && strings.TrimSpace(key) == "kind" { + switch strings.Trim(strings.TrimSpace(val), `"'`) { + case "concept": + return PageTypeConcept, true + case "entity": + return PageTypeEntity, true + } + } + } + return "", false +} diff --git a/ingestion/internal/wiki/types.go b/ingestion/internal/wiki/types.go index 62e74aa..ed8a6c9 100644 --- a/ingestion/internal/wiki/types.go +++ b/ingestion/internal/wiki/types.go @@ -22,4 +22,5 @@ type Entry struct { Title string Aliases []string Type PageType + Path string // brain-relative path; set for wing-layout entries } diff --git a/ingestion/internal/wing/backtest.go b/ingestion/internal/wing/backtest.go new file mode 100644 index 0000000..f334251 --- /dev/null +++ b/ingestion/internal/wing/backtest.go @@ -0,0 +1,43 @@ +package wing + +// Sample is one backtest observation: the human-decided wing of an existing +// page, and what the two independent opinions said about it with the wing +// label removed. +type Sample struct { + Truth string + Suggested string // classifier's wing, "" if it abstained or failed + Confidence float64 + ScorerTop string // scorer's single best wing, "" if no clear opinion +} + +// Result is the policy outcome at one threshold. +type Result struct { + N int + Auto int // placed without a human + Correct int // of Auto, matching Truth + Coverage float64 // Auto / N + Precision float64 // Correct / Auto; 0 when Auto == 0 +} + +// Evaluate applies the Resolver's classified-path rule (confident, and the +// scorer agrees) to recorded samples. Eligibility is not modelled: callers +// backtest only on wings they would allow. +func Evaluate(samples []Sample, threshold float64) Result { + r := Result{N: len(samples)} + for _, s := range samples { + if s.Suggested == "" || s.Confidence < threshold || s.ScorerTop != s.Suggested { + continue + } + r.Auto++ + if s.Suggested == s.Truth { + r.Correct++ + } + } + if r.N > 0 { + r.Coverage = float64(r.Auto) / float64(r.N) + } + if r.Auto > 0 { + r.Precision = float64(r.Correct) / float64(r.Auto) + } + return r +} diff --git a/ingestion/internal/wing/backtest_test.go b/ingestion/internal/wing/backtest_test.go new file mode 100644 index 0000000..88ebc90 --- /dev/null +++ b/ingestion/internal/wing/backtest_test.go @@ -0,0 +1,38 @@ +package wing_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/mathiasbq/hyperguild/ingestion/internal/wing" +) + +func TestEvaluate_PolicyAtAThreshold(t *testing.T) { + samples := []wing.Sample{ + {Truth: "a", Suggested: "a", Confidence: 0.9, ScorerTop: "a"}, // auto, correct + {Truth: "a", Suggested: "b", Confidence: 0.9, ScorerTop: "b"}, // auto, WRONG (both models agree on a wrong wing) + {Truth: "a", Suggested: "a", Confidence: 0.5, ScorerTop: "a"}, // below threshold -> exception + {Truth: "a", Suggested: "a", Confidence: 0.95, ScorerTop: "b"}, // scorer disagrees -> exception + {Truth: "a", Suggested: "", Confidence: 0.99, ScorerTop: "a"}, // abstain -> exception + } + r := wing.Evaluate(samples, 0.8) + assert.Equal(t, 5, r.N) + assert.Equal(t, 2, r.Auto) + assert.Equal(t, 1, r.Correct) + assert.InDelta(t, 0.4, r.Coverage, 1e-9) + assert.InDelta(t, 0.5, r.Precision, 1e-9) +} + +func TestEvaluate_NothingAutoHasZeroPrecisionNotNaN(t *testing.T) { + r := wing.Evaluate([]wing.Sample{{Truth: "a", Suggested: "a", Confidence: 0.1, ScorerTop: "a"}}, 0.8) + assert.Zero(t, r.Auto) + assert.Zero(t, r.Precision) + assert.Zero(t, r.Coverage) +} + +func TestEvaluate_EmptyInput(t *testing.T) { + r := wing.Evaluate(nil, 0.8) + assert.Zero(t, r.N) + assert.Zero(t, r.Coverage) +} diff --git a/ingestion/internal/wing/bm25scorer.go b/ingestion/internal/wing/bm25scorer.go new file mode 100644 index 0000000..fedd93b --- /dev/null +++ b/ingestion/internal/wing/bm25scorer.go @@ -0,0 +1,85 @@ +package wing + +import ( + "sort" + "strings" + "unicode" + + "github.com/mathiasbq/hyperguild/ingestion/internal/search" +) + +const ( + maxQueryTerms = 12 + minTermLen = 5 +) + +// BM25Scorer scores a note against each wing by running the brain's own BM25 +// search scoped to that wing. It is deliberately a different mechanism from +// the LLM, so agreement between the two is evidence and not an echo. +type BM25Scorer struct { + brainDir string + wings []string + exclude string +} + +// NewBM25Scorer scores against wings under brainDir. exclude is a brain- +// relative path ignored in results (the backtest sample itself); "" for none. +func NewBM25Scorer(brainDir string, wings []string, exclude string) *BM25Scorer { + return &BM25Scorer{brainDir: brainDir, wings: wings, exclude: exclude} +} + +// Score implements Scorer. A wing whose search fails scores 0 rather than +// failing the note: the Resolver treats "no clear opinion" as an Exception. +func (b *BM25Scorer) Score(text string) map[string]float64 { + query := strings.Join(topTerms(text), " ") + out := map[string]float64{} + if query == "" { + return out + } + for _, w := range b.wings { + hits, err := search.Query(b.brainDir, search.QueryOptions{Query: query, Wing: w, Limit: 2}) + if err != nil { + continue + } + for _, h := range hits { + if b.exclude != "" && h.Path == b.exclude { + continue + } + // Hits come best-first: a wing is scored by its single best + // page, so a big wing cannot win on volume alone. + out[w] = float64(h.Score) + break + } + } + return out +} + +// topTerms picks the most frequent longer words, ties broken alphabetically +// so the query is deterministic. +func topTerms(text string) []string { + if len(text) > maxNoteChars { + text = text[:maxNoteChars] + } + counts := map[string]int{} + for _, f := range strings.FieldsFunc(strings.ToLower(text), func(r rune) bool { + return !unicode.IsLetter(r) && !unicode.IsDigit(r) + }) { + if len([]rune(f)) >= minTermLen { + counts[f]++ + } + } + terms := make([]string, 0, len(counts)) + for t := range counts { + terms = append(terms, t) + } + sort.Slice(terms, func(i, j int) bool { + if counts[terms[i]] != counts[terms[j]] { + return counts[terms[i]] > counts[terms[j]] + } + return terms[i] < terms[j] + }) + if len(terms) > maxQueryTerms { + terms = terms[:maxQueryTerms] + } + return terms +} diff --git a/ingestion/internal/wing/bm25scorer_test.go b/ingestion/internal/wing/bm25scorer_test.go new file mode 100644 index 0000000..b6c8901 --- /dev/null +++ b/ingestion/internal/wing/bm25scorer_test.go @@ -0,0 +1,61 @@ +package wing_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mathiasbq/hyperguild/ingestion/internal/wing" +) + +func writePage(t *testing.T, dir, rel, body string) { + t.Helper() + full := filepath.Join(dir, rel) + require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755)) + require.NoError(t, os.WriteFile(full, []byte(body), 0o644)) +} + +func scorerBrain(t *testing.T) string { + dir := t.TempDir() + writePage(t, dir, "wiki/infra/facts/k3s.md", "---\ntitle: K3s cluster\n---\nkubernetes cluster nodes scheduling pods deployment\n") + writePage(t, dir, "wiki/finance/facts/invoice.md", "---\ntitle: Invoices\n---\ninvoice payment bookkeeping ledger reconciliation\n") + return dir +} + +func TestBM25Scorer_RanksTheWingWhosePagesMatch(t *testing.T) { + s := wing.NewBM25Scorer(scorerBrain(t), []string{"infra", "finance"}, "") + got := s.Score("notes about kubernetes cluster scheduling and pods") + assert.Greater(t, got["infra"], got["finance"]) + assert.Greater(t, got["infra"], 0.0) +} + +func TestBM25Scorer_ExcludesThePageBeingClassified(t *testing.T) { + // A page must not vouch for its own wing when it is the backtest sample. + s := wing.NewBM25Scorer(scorerBrain(t), []string{"infra", "finance"}, "wiki/infra/facts/k3s.md") + got := s.Score("kubernetes cluster nodes scheduling pods deployment") + assert.Zero(t, got["infra"], "the excluded page is the only infra match") +} + +func TestBM25Scorer_NoUsableTermsScoresNothing(t *testing.T) { + s := wing.NewBM25Scorer(scorerBrain(t), []string{"infra", "finance"}, "") + assert.Empty(t, s.Score("a an to of")) +} + +// A big wing must not win just by having more pages that brush the query: a +// wing is scored by its single best page, not by the sum of its top hits. +func TestBM25Scorer_ABigWingDoesNotWinOnVolume(t *testing.T) { + dir := t.TempDir() + writePage(t, dir, "wiki/small/facts/target.md", "---\ntitle: Target\n---\nkubernetes cluster scheduling pods deployment nodes\n") + for i := 0; i < 8; i++ { + writePage(t, dir, filepath.Join("wiki/big/facts", "p"+string(rune('a'+i))+".md"), + "---\ntitle: Filler\n---\nkubernetes cluster scheduling mentioned in passing among bookkeeping ledger entries\n") + } + s := wing.NewBM25Scorer(dir, []string{"small", "big"}, "") + got := s.Score("kubernetes cluster scheduling pods deployment nodes") + top, ok := wing.TopWing(got) + assert.True(t, ok) + assert.Equal(t, "small", top) +} diff --git a/ingestion/internal/wing/configured.go b/ingestion/internal/wing/configured.go new file mode 100644 index 0000000..c27236c --- /dev/null +++ b/ingestion/internal/wing/configured.go @@ -0,0 +1,32 @@ +package wing + +import ( + "fmt" + + "github.com/mathiasbq/hyperguild/ingestion/internal/classification" +) + +// NewConfiguredResolver builds the production Resolver for a brain: Known from +// the wiki, Eligible from classification.yaml (a wing is auto-assignable only +// when it classifies below Confidential, so an untagged wing never is), the +// LLM classifier over complete, and BM25 over the known wings as the scorer. +// +// Known is read once. A wing created later is unknown to this Resolver until +// the process restarts, which fails safe: notes aimed at it are parked. +func NewConfiguredResolver(brainDir string, complete CompleteFunc, threshold float64) (*Resolver, error) { + known, err := KnownWings(brainDir) + if err != nil { + return nil, err + } + cls, err := classification.Load(brainDir) + if err != nil { + return nil, fmt.Errorf("load classification: %w", err) + } + return &Resolver{ + Known: known, + Eligible: func(w string) bool { return cls.Wing(w) < classification.Confidential }, + Classifier: NewLLMClassifier(complete), + Scorer: NewBM25Scorer(brainDir, known, ""), + Threshold: threshold, + }, nil +} diff --git a/ingestion/internal/wing/configured_test.go b/ingestion/internal/wing/configured_test.go new file mode 100644 index 0000000..16173ef --- /dev/null +++ b/ingestion/internal/wing/configured_test.go @@ -0,0 +1,47 @@ +package wing_test + +import ( + "context" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mathiasbq/hyperguild/ingestion/internal/wing" +) + +func configuredBrain(t *testing.T, classification string) string { + dir := t.TempDir() + writePage(t, dir, "wiki/homelab/facts/a.md", "x") + writePage(t, dir, "wiki/radar/facts/b.md", "x") + writePage(t, dir, "wiki/client-x/facts/c.md", "x") + if classification != "" { + writePage(t, dir, "classification.yaml", classification) + } + return dir +} + +func TestNewConfiguredResolver_EligibilityComesFromClassification(t *testing.T) { + dir := configuredBrain(t, "wings:\n radar: internal\n") + r, err := wing.NewConfiguredResolver(dir, func(context.Context, string, string) (string, error) { return "", nil }, 0.9) + require.NoError(t, err) + + assert.ElementsMatch(t, []string{"client-x", "homelab", "radar"}, r.Known) + assert.True(t, r.Eligible("radar"), "tagged internal") + assert.True(t, r.Eligible("homelab"), "internal by built-in default") + assert.False(t, r.Eligible("client-x"), "an untagged wing fails safe to confidential") + assert.InDelta(t, 0.9, r.Threshold, 1e-9) +} + +func TestNewConfiguredResolver_AMalformedClassificationFileIsAnError(t *testing.T) { + dir := configuredBrain(t, "wings: [not, a, map") + _, err := wing.NewConfiguredResolver(dir, nil, 0.9) + assert.Error(t, err, "a classification source that cannot be trusted must not degrade to 'everything eligible'") +} + +func TestNewConfiguredResolver_AConfidentialTagIsNeverEligible(t *testing.T) { + dir := configuredBrain(t, "wings:\n radar: confidential\n") + r, err := wing.NewConfiguredResolver(dir, nil, 0.9) + require.NoError(t, err) + assert.False(t, r.Eligible("radar")) +} diff --git a/ingestion/internal/wing/known.go b/ingestion/internal/wing/known.go new file mode 100644 index 0000000..60ee7b7 --- /dev/null +++ b/ingestion/internal/wing/known.go @@ -0,0 +1,46 @@ +package wing + +import ( + "fmt" + "os" + "path/filepath" + "sort" + + "github.com/mathiasbq/hyperguild/ingestion/internal/brain" +) + +// legacyDirs are the retired wiki/{concepts,entities,sources}/ layout, not wings. +var legacyDirs = map[string]bool{"concepts": true, "entities": true, "sources": true} + +// KnownWings lists wiki// directories that contain at least one hall +// subdirectory, sorted. The retired layout is not a wing. A missing wiki/ is +// an error: an unreadable brain must not look like "no wings". +func KnownWings(brainDir string) ([]string, error) { + entries, err := os.ReadDir(filepath.Join(brainDir, "wiki")) + if err != nil { + return nil, fmt.Errorf("list wings: %w", err) + } + var out []string + for _, e := range entries { + if !e.IsDir() || legacyDirs[e.Name()] || !plainWingName(e.Name()) { + continue + } + subs, err := os.ReadDir(filepath.Join(brainDir, "wiki", e.Name())) + if err != nil { + continue + } + for _, s := range subs { + if s.IsDir() { + out = append(out, e.Name()) + break + } + } + } + sort.Strings(out) + return out, nil +} + +// plainWingName reports whether name is already in the canonical lower-case form +// the pipeline writes. Anything else would make the pipeline create a different +// wing than the one matched. +func plainWingName(name string) bool { return brain.Sanitise(name) == name } diff --git a/ingestion/internal/wing/known_test.go b/ingestion/internal/wing/known_test.go new file mode 100644 index 0000000..2e09a0c --- /dev/null +++ b/ingestion/internal/wing/known_test.go @@ -0,0 +1,42 @@ +package wing_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mathiasbq/hyperguild/ingestion/internal/wing" +) + +func TestKnownWings_ListsWingsWithHallsAndSkipsTheRetiredLayout(t *testing.T) { + dir := t.TempDir() + writePage(t, dir, "wiki/homelab/facts/a.md", "x") + writePage(t, dir, "wiki/tapir/decisions/b.md", "x") + writePage(t, dir, "wiki/concepts/c.md", "x") // retired layout, a file directly under a legacy dir + writePage(t, dir, "wiki/sources/d.md", "x") // retired layout + writePage(t, dir, "wiki/entities/e.md", "x") // retired layout + writePage(t, dir, "wiki/loose-note.md", "x") // a file, not a wing + writePage(t, dir, "wiki/emptywing/_index.md", "x") // a dir with no hall subdirectory + got, err := wing.KnownWings(dir) + require.NoError(t, err) + assert.Equal(t, []string{"homelab", "tapir"}, got) +} + +func TestKnownWings_NoWikiDirIsAnError(t *testing.T) { + _, err := wing.KnownWings(t.TempDir()) + assert.Error(t, err, "a brain without wiki/ must not look like a brain with no wings") +} + +// Known matches case-insensitively and the pipeline writes the lower-case name, +// so a wiki directory that is not already plain lower-case would make the +// pipeline create a different, new wing. It is not a candidate. +func TestKnownWings_SkipsADirectoryThatIsNotAPlainWingName(t *testing.T) { + dir := t.TempDir() + writePage(t, dir, "wiki/Radar/facts/a.md", "x") + writePage(t, dir, "wiki/has space/facts/a.md", "x") + writePage(t, dir, "wiki/ok/facts/a.md", "x") + got, err := wing.KnownWings(dir) + require.NoError(t, err) + assert.Equal(t, []string{"ok"}, got) +} diff --git a/ingestion/internal/wing/llmclassifier.go b/ingestion/internal/wing/llmclassifier.go new file mode 100644 index 0000000..995d2a2 --- /dev/null +++ b/ingestion/internal/wing/llmclassifier.go @@ -0,0 +1,99 @@ +package wing + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "strings" +) + +// CompleteFunc is a chat completion, matching llm.Client.Complete. It must be +// backed by a local model: note content is sent to it. +type CompleteFunc func(ctx context.Context, system, user string) (string, error) + +// maxNoteChars bounds what is sent for classification; the start of a note +// carries its subject, and a whole transcript is cost with no benefit. +const maxNoteChars = 6000 + +const classifySystem = `You file notes into a personal knowledge base. Pick the single wing the note belongs to, from the candidate list only. +Answer with ONLY a JSON object: {"wing": "", "confidence": <0..1>, "reason": ""}. +Use "none" when no candidate clearly fits. Do not invent wings.` + +// LLMClassifier asks a model to pick a wing. +type LLMClassifier struct{ complete CompleteFunc } + +// NewLLMClassifier wraps a chat completion as a Classifier. +func NewLLMClassifier(c CompleteFunc) *LLMClassifier { return &LLMClassifier{complete: c} } + +type classifyReply struct { + Wing string `json:"wing"` + Confidence float64 `json:"confidence"` + Reason string `json:"reason"` +} + +// Classify implements Classifier. Any reply it cannot parse is an error, which +// the Resolver turns into an Exception: an unreadable answer is not a guess. +func (l *LLMClassifier) Classify(ctx context.Context, text string, candidates []string) (Suggestion, error) { + if len(text) > maxNoteChars { + text = text[:maxNoteChars] + } + user := "Candidate wings: " + strings.Join(candidates, ", ") + "\n\nNote:\n" + text + out, err := l.complete(ctx, classifySystem, user) + if err != nil { + return Suggestion{}, fmt.Errorf("classify: %w", err) + } + raw, ok := firstJSONObject(out) + if !ok { + return Suggestion{}, errors.New("classify: reply contains no JSON object") + } + var r classifyReply + if err := json.Unmarshal([]byte(raw), &r); err != nil { + return Suggestion{}, fmt.Errorf("classify: parse reply: %w", err) + } + w := strings.TrimSpace(r.Wing) + if strings.EqualFold(w, "none") { + w = "" + } + return Suggestion{Wing: w, Confidence: clamp01(r.Confidence), Reason: r.Reason}, nil +} + +// firstJSONObject returns the first balanced {...} in s, ignoring braces +// inside JSON strings, so prose or code fences around the reply are tolerated. +func firstJSONObject(s string) (string, bool) { + start := strings.IndexByte(s, '{') + if start < 0 { + return "", false + } + depth, inStr, esc := 0, false, false + for i := start; i < len(s); i++ { + c := s[i] + switch { + case esc: + esc = false + case inStr && c == '\\': + esc = true + case c == '"': + inStr = !inStr + case inStr: + case c == '{': + depth++ + case c == '}': + depth-- + if depth == 0 { + return s[start : i+1], true + } + } + } + return "", false +} + +func clamp01(f float64) float64 { + switch { + case f < 0: + return 0 + case f > 1: + return 1 + } + return f +} diff --git a/ingestion/internal/wing/llmclassifier_test.go b/ingestion/internal/wing/llmclassifier_test.go new file mode 100644 index 0000000..4f4a73b --- /dev/null +++ b/ingestion/internal/wing/llmclassifier_test.go @@ -0,0 +1,67 @@ +package wing_test + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mathiasbq/hyperguild/ingestion/internal/wing" +) + +func completes(reply string, err error) wing.CompleteFunc { + return func(_ context.Context, _, _ string) (string, error) { return reply, err } +} + +func TestLLMClassifier_ParsesTheReply(t *testing.T) { + tests := []struct { + name string + reply string + want wing.Suggestion + wantErr bool + }{ + {"bare json", `{"wing":"tapir","confidence":0.9,"reason":"video summariser"}`, wing.Suggestion{Wing: "tapir", Confidence: 0.9, Reason: "video summariser"}, false}, + {"json inside prose and fences", "Sure!\n```json\n{\"wing\":\"homelab\",\"confidence\":0.7,\"reason\":\"k3s\"}\n```\nDone.", wing.Suggestion{Wing: "homelab", Confidence: 0.7, Reason: "k3s"}, false}, + {"none means abstain", `{"wing":"none","confidence":0.2,"reason":"generic"}`, wing.Suggestion{Wing: "", Confidence: 0.2, Reason: "generic"}, false}, + {"confidence above 1 is clamped", `{"wing":"tapir","confidence":7,"reason":"x"}`, wing.Suggestion{Wing: "tapir", Confidence: 1, Reason: "x"}, false}, + {"negative confidence is clamped", `{"wing":"tapir","confidence":-1,"reason":"x"}`, wing.Suggestion{Wing: "tapir", Confidence: 0, Reason: "x"}, false}, + {"empty reply (a reasoning model that ran out of tokens)", ``, wing.Suggestion{}, true}, + {"no json at all", `I think it is tapir`, wing.Suggestion{}, true}, + {"malformed json", `{"wing":"tapir","confidence":`, wing.Suggestion{}, true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := wing.NewLLMClassifier(completes(tt.reply, nil)) + got, err := c.Classify(context.Background(), "note", []string{"tapir", "homelab"}) + if tt.wantErr { + require.Error(t, err) + return + } + require.NoError(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestLLMClassifier_PropagatesTheModelError(t *testing.T) { + boom := errors.New("model down") + _, err := wing.NewLLMClassifier(completes("", boom)).Classify(context.Background(), "n", []string{"tapir"}) + assert.ErrorIs(t, err, boom) +} + +func TestLLMClassifier_PromptListsCandidatesAndBoundsTheNote(t *testing.T) { + var gotUser string + c := wing.NewLLMClassifier(func(_ context.Context, _, user string) (string, error) { + gotUser = user + return `{"wing":"tapir","confidence":0.9,"reason":"x"}`, nil + }) + long := strings.Repeat("word ", 5000) + _, err := c.Classify(context.Background(), long, []string{"tapir", "homelab"}) + require.NoError(t, err) + assert.Contains(t, gotUser, "tapir") + assert.Contains(t, gotUser, "homelab") + assert.Less(t, len(gotUser), 12000, "a whole transcript must not be sent as the classification input") +} diff --git a/ingestion/internal/wing/resolve.go b/ingestion/internal/wing/resolve.go new file mode 100644 index 0000000..646ed19 --- /dev/null +++ b/ingestion/internal/wing/resolve.go @@ -0,0 +1,176 @@ +// Package wing decides which brain wing an incoming raw note belongs to +// without the human typing it (hyperguild#205). +// +// The contract is conservative: a note is placed automatically only when the +// decision is explicit, or when two independent opinions (an LLM and a +// deterministic scorer) agree with enough confidence on a wing that is both +// known and eligible. Everything else is an Exception for a human to approve. +// A wing is never created here, and a wing the caller does not mark eligible +// (client wings, anything not deliberately tagged) is never auto-assigned. +package wing + +import ( + "context" + "fmt" + "strings" +) + +// Source says how a Decision was reached. +type Source string + +const ( + Explicit Source = "explicit" // subfolder or frontmatter named the wing + Classified Source = "classified" // classifier + scorer agreed, confident + Exception Source = "exception" // needs a human +) + +// Note is what the resolver sees of an incoming raw note. +type Note struct { + Dir string // first subfolder under raw/, "" when dropped at the top + FrontmatterWing string // `wing:` from the note's frontmatter, "" if absent + Text string +} + +// Suggestion is a Classifier's answer. An empty Wing means "none fits". +type Suggestion struct { + Wing string + Confidence float64 // 0..1 + Reason string +} + +// Classifier proposes a wing from the candidates. Implementations must run on +// a local model; the resolver cannot enforce that. +type Classifier interface { + Classify(ctx context.Context, text string, candidates []string) (Suggestion, error) +} + +// Scorer is an independent, deterministic opinion (e.g. BM25 against each +// wing's index). Higher is better; the key is the wing name. +type Scorer interface { + Score(text string) map[string]float64 +} + +// Decision is the outcome. For an Exception, Wing holds the proposal when +// there was one (so a reviewer can approve it) and is empty otherwise. +type Decision struct { + Wing string + Source Source + Confidence float64 + Reason string +} + +// Resolver holds the policy. Known is every existing wing; Eligible reports +// whether a known wing may be assigned automatically. +type Resolver struct { + Known []string + Eligible func(wing string) bool + Classifier Classifier + Scorer Scorer + Threshold float64 +} + +// Resolve never returns an error: anything it cannot decide safely is an +// Exception with the reason spelled out. +func (r Resolver) Resolve(ctx context.Context, n Note) Decision { + dir := norm(n.Dir) + fm := norm(n.FrontmatterWing) + switch { + case dir != "" && fm != "" && dir != fm: + return exception("", 0, fmt.Sprintf("subfolder %q and frontmatter wing %q disagree", dir, fm)) + case dir != "": + return r.explicit(dir, "subfolder") + case fm != "": + return r.explicit(fm, "frontmatter") + } + return r.classify(ctx, n) +} + +func (r Resolver) explicit(w, via string) Decision { + if why := r.refuse(w); why != "" { + return exception(w, 0, fmt.Sprintf("%s names %q: %s", via, w, why)) + } + return Decision{Wing: w, Source: Explicit, Confidence: 1, Reason: "named by " + via} +} + +func (r Resolver) classify(ctx context.Context, n Note) Decision { + if r.Classifier == nil { + return exception("", 0, "no classifier configured") + } + s, err := r.Classifier.Classify(ctx, n.Text, r.Known) + if err != nil { + return exception("", 0, fmt.Sprintf("classifier failed: %v", err)) + } + w := norm(s.Wing) + if w == "" { + return exception("", s.Confidence, "classifier found no fitting wing") + } + if why := r.refuse(w); why != "" { + return exception(w, s.Confidence, fmt.Sprintf("classifier proposed %q: %s", w, why)) + } + if s.Confidence < r.Threshold { + return exception(w, s.Confidence, fmt.Sprintf("confidence %.2f below threshold %.2f", s.Confidence, r.Threshold)) + } + top, ok := r.scorerTop(n.Text) + if !ok { + return exception(w, s.Confidence, "scorer has no clear opinion to cross-check against") + } + if top != w { + return exception(w, s.Confidence, fmt.Sprintf("classifier says %q, scorer says %q", w, top)) + } + return Decision{Wing: w, Source: Classified, Confidence: s.Confidence, + Reason: fmt.Sprintf("classifier and scorer agree (%.2f): %s", s.Confidence, s.Reason)} +} + +// refuse returns why a wing may not be auto-assigned, or "" if it may. +func (r Resolver) refuse(w string) string { + if !r.known(w) { + return "not an existing wing (new wings are never created automatically)" + } + if r.Eligible == nil || !r.Eligible(w) { + return "not eligible for automatic assignment" + } + return "" +} + +func (r Resolver) known(w string) bool { + for _, k := range r.Known { + if norm(k) == w { + return true + } + } + return false +} + +// scorerTop returns the single best-scoring wing; a missing scorer, no scores, +// or a tie at the top is "no clear opinion". +func (r Resolver) scorerTop(text string) (string, bool) { + if r.Scorer == nil { + return "", false + } + return TopWing(r.Scorer.Score(text)) +} + +// TopWing returns the unique highest-scoring wing in scores, or false when +// there is none or the top is tied. Shared by the Resolver and the backtest so +// both apply the same notion of "the scorer has an opinion". +func TopWing(scores map[string]float64) (string, bool) { + best, bestScore, tied := "", 0.0, false + for k, v := range scores { + switch { + case v > bestScore: + best, bestScore, tied = norm(k), v, false + case v == bestScore && v > 0: + tied = true + } + } + if best == "" || tied { + return "", false + } + return best, true +} + +func exception(w string, conf float64, reason string) Decision { + return Decision{Wing: w, Source: Exception, Confidence: conf, Reason: reason} +} + +func norm(s string) string { return strings.ToLower(strings.TrimSpace(s)) } diff --git a/ingestion/internal/wing/resolve_test.go b/ingestion/internal/wing/resolve_test.go new file mode 100644 index 0000000..7d78e03 --- /dev/null +++ b/ingestion/internal/wing/resolve_test.go @@ -0,0 +1,112 @@ +package wing_test + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/mathiasbq/hyperguild/ingestion/internal/wing" +) + +type fakeClassifier struct { + s wing.Suggestion + err error + calls int +} + +func (f *fakeClassifier) Classify(_ context.Context, _ string, _ []string) (wing.Suggestion, error) { + f.calls++ + return f.s, f.err +} + +type fakeScorer map[string]float64 + +func (f fakeScorer) Score(string) map[string]float64 { return f } + +// eligibleExcept allows every name except the listed ones. Being permissive on +// names it has never heard of means only the "known wing" check can stop an +// invented wing, so a test of that check cannot pass by accident. +func eligibleExcept(names ...string) func(string) bool { + deny := map[string]bool{} + for _, n := range names { + deny[n] = true + } + return func(w string) bool { return !deny[w] } +} + +func newResolver(c wing.Classifier, s wing.Scorer) wing.Resolver { + return wing.Resolver{ + Known: []string{"homelab", "hyperguild", "tapir", "rikatillsammans"}, + Eligible: eligibleExcept("rikatillsammans"), // a client wing: known, never auto + Classifier: c, + Scorer: s, + Threshold: 0.8, + } +} + +func TestResolve_ExplicitSignal(t *testing.T) { + tests := []struct { + name string + note wing.Note + want wing.Source + wantWing string + }{ + {"subfolder of a known eligible wing", wing.Note{Dir: "homelab"}, wing.Explicit, "homelab"}, + {"frontmatter of a known eligible wing", wing.Note{FrontmatterWing: "tapir"}, wing.Explicit, "tapir"}, + {"subfolder and frontmatter agree", wing.Note{Dir: "tapir", FrontmatterWing: "tapir"}, wing.Explicit, "tapir"}, + {"subfolder and frontmatter disagree", wing.Note{Dir: "tapir", FrontmatterWing: "homelab"}, wing.Exception, ""}, + {"unknown wing is never created", wing.Note{Dir: "hoemlab"}, wing.Exception, "hoemlab"}, + {"known but not eligible (client) wing is never auto-assigned", wing.Note{Dir: "rikatillsammans"}, wing.Exception, "rikatillsammans"}, + {"subfolder is case-insensitive", wing.Note{Dir: "HomeLab"}, wing.Explicit, "homelab"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &fakeClassifier{} + d := newResolver(c, fakeScorer{}).Resolve(context.Background(), tt.note) + assert.Equal(t, tt.want, d.Source) + assert.Equal(t, tt.wantWing, d.Wing) + assert.Zero(t, c.calls, "an explicit signal must not spend an LLM call") + assert.NotEmpty(t, d.Reason) + }) + } +} + +func TestResolve_Classified(t *testing.T) { + tests := []struct { + name string + sugg wing.Suggestion + err error + scores fakeScorer + want wing.Source + wantWing string + }{ + {"confident and scorer agrees", wing.Suggestion{Wing: "tapir", Confidence: 0.9}, nil, fakeScorer{"tapir": 3, "homelab": 1}, wing.Classified, "tapir"}, + {"confidence exactly at threshold passes", wing.Suggestion{Wing: "tapir", Confidence: 0.8}, nil, fakeScorer{"tapir": 3}, wing.Classified, "tapir"}, + {"below threshold is an exception, proposal kept", wing.Suggestion{Wing: "tapir", Confidence: 0.79}, nil, fakeScorer{"tapir": 3}, wing.Exception, "tapir"}, + {"scorer disagrees", wing.Suggestion{Wing: "tapir", Confidence: 0.95}, nil, fakeScorer{"homelab": 5, "tapir": 1}, wing.Exception, "tapir"}, + {"scorer has no opinion also needs review", wing.Suggestion{Wing: "tapir", Confidence: 0.95}, nil, fakeScorer{}, wing.Exception, "tapir"}, + {"classifier proposes a client wing", wing.Suggestion{Wing: "rikatillsammans", Confidence: 0.99}, nil, fakeScorer{"rikatillsammans": 9}, wing.Exception, "rikatillsammans"}, + {"classifier invents a wing", wing.Suggestion{Wing: "brand-new-wing", Confidence: 0.99}, nil, fakeScorer{"brand-new-wing": 9}, wing.Exception, "brand-new-wing"}, + {"classifier abstains", wing.Suggestion{Wing: "", Confidence: 0}, nil, fakeScorer{"tapir": 3}, wing.Exception, ""}, + {"classifier error", wing.Suggestion{}, errors.New("llm down"), fakeScorer{"tapir": 3}, wing.Exception, ""}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + c := &fakeClassifier{s: tt.sugg, err: tt.err} + d := newResolver(c, tt.scores).Resolve(context.Background(), wing.Note{Text: "some note"}) + assert.Equal(t, tt.want, d.Source) + assert.Equal(t, tt.wantWing, d.Wing) + assert.NotEmpty(t, d.Reason, "every decision says why, so a spot-check can judge it") + assert.Equal(t, 1, c.calls) + }) + } +} + +func TestResolve_NilDependenciesFailClosed(t *testing.T) { + r := wing.Resolver{Known: []string{"tapir"}, Eligible: eligibleExcept(), Threshold: 0.8} + d := r.Resolve(context.Background(), wing.Note{Text: "x"}) + require.Equal(t, wing.Exception, d.Source, "no classifier configured must route to review, not panic or guess") +}