feat(wing): Router — rules-only placement: deliberate subfolder, else default wing (#208)
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||
)
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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")
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user