feat(server): INGEST_WING_ROUTING wires the Router; remove the classifier path (#208)

Removes the LLM classifier, the substring 'BM25' scorer, the agreement gate,
the backtest and the old migrate/wingmigrate tooling: measured at 2 of 55
pages placed (#208), and the migration was built for the per-wing facts layout
this branch replaces. wing now holds the decision types, the Router and
KnownWings. INGEST_WING_ROUTING=1 builds the Router (default wing
INGEST_DEFAULT_WING=web-clip, client pattern from INGEST_CLIENT_BLOCK or
CLAUDE_INGEST_CLIENT_BLOCK); a bad default or pattern is fatal at startup.
Watcher tests rewritten for the Router, including a deliberate subfolder into
any existing wing, typo'd folders parked, forged-key and edit-while-placing
cases.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-20 20:18:39 +02:00
co-authored by Claude Sonnet 5.5
parent 409b85096b
commit 8988c291ca
20 changed files with 263 additions and 1752 deletions
+20 -23
View File
@@ -150,17 +150,6 @@ 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.
@@ -406,26 +395,34 @@ func main() {
}
if watchInterval > 0 {
// INGEST_WING_RESOLVE=1 routes raw/ notes into wiki/<wing>/ 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))
// INGEST_WING_ROUTING=1 files raw/ notes by rule (hyperguild#208): a
// deliberate raw/<wing>/ folder or `wing:` frontmatter, else the default
// wing (INGEST_DEFAULT_WING, "web-clip"); a note whose text matches the
// client-content pattern (INGEST_CLIENT_BLOCK, else
// CLAUDE_INGEST_CLIENT_BLOCK) or that names an unknown wing is parked in
// raw/needs-review/. Off = the retired layout. Set explicitly, a bad
// default or pattern is fatal: silently falling back would write into
// the layout this flag exists to leave. The interface stays nil when off
// (a nil *Router inside it would not be nil).
var placer wing.Placer
if os.Getenv("INGEST_WING_ROUTING") == "1" {
pattern := os.Getenv("INGEST_CLIENT_BLOCK")
if pattern == "" {
pattern = os.Getenv("CLAUDE_INGEST_CLIENT_BLOCK")
}
router, rerr := wing.NewRouter(brainDir, envOr("INGEST_DEFAULT_WING", "web-clip"), pattern)
if rerr != nil {
logger.Error("build wing resolver", "err", rerr)
logger.Error("build wing router", "err", rerr)
os.Exit(1)
}
logger.Info("wing resolver enabled", "wings", len(wingResolver.Known), "threshold", wingResolver.Threshold)
placer = router
logger.Info("wing routing enabled", "default_wing", router.Default, "client_filter", router.ClientBlock != nil)
}
watcher.Start(ctx, watcher.Config{
BrainDir: brainDir,
Interval: time.Duration(watchInterval) * time.Second,
Pipeline: pipelineCfg,
Resolver: wingResolver,
Resolver: placer,
})
}
-20
View File
@@ -100,23 +100,3 @@ func TestClaudeSinkDoesNotWriteThroughASymlinkedArchiveDir(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)
})
}
}
-147
View File
@@ -1,147 +0,0 @@
// 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
}
-92
View File
@@ -1,92 +0,0 @@
// Command wingmigrate moves pages out of the retired wiki/{sources,concepts,
// entities}/ layout into wiki/<wing>/ (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, "|", "/"))
}
}