From 8342de5c49e9d2c4e5f0a2f388f342b44beabb36 Mon Sep 17 00:00:00 2001 From: Mathias Date: Tue, 20 Oct 2026 20:05:29 +0200 Subject: [PATCH] =?UTF-8?q?feat(wing):=20Router=20=E2=80=94=20rules-only?= =?UTF-8?q?=20placement:=20deliberate=20subfolder,=20else=20default=20wing?= =?UTF-8?q?=20(#208)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit No model. A subfolder/frontmatter wing must be a plain name of an existing wing and then places; conflicting signals and client-marker text park; everything else goes to the default wing. Wings are read from disk per decision. Mutation-checked. Co-Authored-By: Claude Sonnet 5.5 --- ingestion/internal/wing/resolve.go | 1 + ingestion/internal/wing/router.go | 70 +++++++++++++++++++++ ingestion/internal/wing/router_test.go | 87 ++++++++++++++++++++++++++ 3 files changed, 158 insertions(+) create mode 100644 ingestion/internal/wing/router.go create mode 100644 ingestion/internal/wing/router_test.go diff --git a/ingestion/internal/wing/resolve.go b/ingestion/internal/wing/resolve.go index 646ed19..f109f07 100644 --- a/ingestion/internal/wing/resolve.go +++ b/ingestion/internal/wing/resolve.go @@ -20,6 +20,7 @@ type Source string const ( Explicit Source = "explicit" // subfolder or frontmatter named the wing + Default Source = "default" // no signal; the configured default wing Classified Source = "classified" // classifier + scorer agreed, confident Exception Source = "exception" // needs a human ) diff --git a/ingestion/internal/wing/router.go b/ingestion/internal/wing/router.go new file mode 100644 index 0000000..1ea5001 --- /dev/null +++ b/ingestion/internal/wing/router.go @@ -0,0 +1,70 @@ +package wing + +import ( + "context" + "fmt" + "regexp" + "slices" + "strings" +) + +// Placer decides where a raw note goes. Router is the production +// implementation; the interface lets the watcher stay ignorant of how. +type Placer interface { + Resolve(ctx context.Context, n Note) Decision +} + +// Router places a note with rules only, no model (hyperguild#208): +// +// 1. A subfolder or frontmatter `wing:` is the owner's deliberate choice. It +// must name a plain, EXISTING wing (a typo never creates one) and then it +// places, whatever the wing is: extraction runs on the local model, and it +// is the owner who dropped the note there. Two signals that disagree park. +// 2. With no signal, a note whose text matches ClientBlock is parked; any +// other note goes to Default, the one wing that is created on first use. +// +// Wings are read from disk on every decision, so a wing created while the +// server runs is usable at once. +type Router struct { + BrainDir string + Default string + ClientBlock *regexp.Regexp // nil = no content check +} + +// Resolve implements Placer. It never returns an error: anything it cannot +// decide safely is an Exception with the reason spelled out. +func (r Router) Resolve(_ context.Context, n Note) Decision { + dir, fm := norm(n.Dir), 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") + } + if r.ClientBlock != nil && r.ClientBlock.MatchString(n.Text) { + return exception("", 0, "text matches the client-content marker; not defaulted into a shared wing") + } + if r.Default == "" || !plainWingName(r.Default) { + return exception("", 0, "no valid default wing configured") + } + return Decision{Wing: r.Default, Source: Default, Confidence: 1, Reason: "no wing named; default wing"} +} + +func (r Router) explicit(w, via string) Decision { + if !plainWingName(w) { + return exception("", 0, fmt.Sprintf("%s names %q, which is not a plain wing name", via, w)) + } + if w == r.Default && plainWingName(w) { + return Decision{Wing: w, Source: Explicit, Confidence: 1, Reason: "named by " + via} + } + known, err := KnownWings(r.BrainDir) + if err != nil { + return exception(w, 0, fmt.Sprintf("cannot list wings: %v", err)) + } + if !slices.Contains(known, w) { + return exception(w, 0, fmt.Sprintf("%s names %q: not an existing wing (new wings are never created from a folder name)", via, strings.TrimSpace(w))) + } + return Decision{Wing: w, Source: Explicit, Confidence: 1, Reason: "named by " + via} +} diff --git a/ingestion/internal/wing/router_test.go b/ingestion/internal/wing/router_test.go new file mode 100644 index 0000000..6c2cd63 --- /dev/null +++ b/ingestion/internal/wing/router_test.go @@ -0,0 +1,87 @@ +package wing_test + +import ( + "context" + "regexp" + "testing" + + "github.com/stretchr/testify/assert" + + "github.com/mathiasbq/hyperguild/ingestion/internal/wing" +) + +func routerBrain(t *testing.T) string { + dir := t.TempDir() + writePage(t, dir, "wiki/homelab/facts/a.md", "x") + writePage(t, dir, "wiki/rikatillsammans/facts/b.md", "x") + return dir +} + +func TestRouter_Decisions(t *testing.T) { + tests := []struct { + name string + note wing.Note + want wing.Source + wantWing string + }{ + {"no signal goes to the default wing", wing.Note{Text: "a talk about agents"}, wing.Default, "web-clip"}, + {"a subfolder naming an existing wing places there", wing.Note{Dir: "homelab", Text: "x"}, wing.Explicit, "homelab"}, + {"a deliberate subfolder into a client wing is the owner's call and is placed", wing.Note{Dir: "rikatillsammans", Text: "x"}, wing.Explicit, "rikatillsammans"}, + {"frontmatter wing places like a subfolder", wing.Note{FrontmatterWing: "homelab", Text: "x"}, wing.Explicit, "homelab"}, + {"subfolder is case-insensitive", wing.Note{Dir: "HomeLab", Text: "x"}, wing.Explicit, "homelab"}, + {"a typo never creates a wing", wing.Note{Dir: "hoemlab", Text: "x"}, wing.Exception, "hoemlab"}, + {"conflicting signals are parked", wing.Note{Dir: "homelab", FrontmatterWing: "rikatillsammans", Text: "x"}, wing.Exception, ""}, + {"the default wing may be named explicitly even before it exists", wing.Note{Dir: "web-clip", Text: "x"}, wing.Explicit, "web-clip"}, + {"client marker with no signal is parked, not defaulted", wing.Note{Text: "notes from the ACME-CLIENT kickoff"}, wing.Exception, ""}, + {"client marker does not block a deliberate subfolder", wing.Note{Dir: "homelab", Text: "ACME-CLIENT"}, wing.Explicit, "homelab"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + r := wing.Router{BrainDir: routerBrain(t), Default: "web-clip", ClientBlock: regexp.MustCompile(`(?i)acme-client`)} + d := r.Resolve(context.Background(), tt.note) + assert.Equal(t, tt.want, d.Source) + assert.Equal(t, tt.wantWing, d.Wing) + assert.NotEmpty(t, d.Reason) + }) + } +} + +func TestRouter_ReadsTheWingsFreshOnEveryDecision(t *testing.T) { + dir := routerBrain(t) + r := wing.Router{BrainDir: dir, Default: "web-clip"} + assert.Equal(t, wing.Exception, r.Resolve(context.Background(), wing.Note{Dir: "newwing", Text: "x"}).Source) + + writePage(t, dir, "wiki/newwing/facts/c.md", "x") // a wing created after startup + assert.Equal(t, wing.Explicit, r.Resolve(context.Background(), wing.Note{Dir: "newwing", Text: "x"}).Source, + "a wing made while the server runs is usable without a restart") +} + +func TestRouter_AnUnreadableBrainParksInsteadOfGuessing(t *testing.T) { + r := wing.Router{BrainDir: t.TempDir() + "/missing", Default: "web-clip"} + d := r.Resolve(context.Background(), wing.Note{Dir: "homelab", Text: "x"}) + assert.Equal(t, wing.Exception, d.Source) +} + +func TestRouter_WithoutADefaultAnUnsignalledNoteIsParked(t *testing.T) { + r := wing.Router{BrainDir: routerBrain(t)} + assert.Equal(t, wing.Exception, r.Resolve(context.Background(), wing.Note{Text: "x"}).Source) +} + +func TestRouter_ADefaultThatIsNotAPlainNameIsRefused(t *testing.T) { + r := wing.Router{BrainDir: routerBrain(t), Default: "../escape"} + assert.Equal(t, wing.Exception, r.Resolve(context.Background(), wing.Note{Text: "x"}).Source) +} + +func TestRouter_ANameThatIsNotPlainIsRefusedAsSuch(t *testing.T) { + for _, bad := range []string{"../x", "a/b", "has space", "dot.ted"} { + t.Run(bad, func(t *testing.T) { + // Default equals the bad name: the "named explicitly" shortcut for the + // default wing must not wave a malformed name through. + r := wing.Router{BrainDir: routerBrain(t), Default: bad} + d := r.Resolve(context.Background(), wing.Note{Dir: bad, Text: "x"}) + assert.Equal(t, wing.Exception, d.Source) + assert.Empty(t, d.Wing, "a malformed name is never echoed as a proposal") + assert.Contains(t, d.Reason, "not a plain wing name") + }) + } +}