Compare commits
28
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
80f3fd3c5e | ||
|
|
0582dfdf6b | ||
|
|
4c2276b46b | ||
|
|
90752f2ac9 | ||
|
|
27b1579b80 | ||
|
|
49286ba8b8 | ||
|
|
6f5fb481ad | ||
|
|
74d6a08c7b | ||
|
|
87c40fe364 | ||
|
|
6b6d430383 | ||
|
|
ae02d4764c | ||
|
|
8e488b6e4b | ||
|
|
3ccf6b46ac | ||
|
|
44207f8ccf | ||
|
|
603d2f9dfb | ||
|
|
8988c291ca | ||
|
|
6717221989 | ||
|
|
409b85096b | ||
|
|
1025d77795 | ||
|
|
750dc93bdb | ||
|
|
ce5a5268e7 | ||
|
|
8342de5c49 | ||
|
|
d8c7defc78 | ||
|
|
2cc4f1b30d | ||
|
|
858ade6504 | ||
|
|
22d0653b61 | ||
|
|
1a2a93d2f4 | ||
|
|
77c44f298c |
@@ -0,0 +1,57 @@
|
|||||||
|
# The raw/ inbox
|
||||||
|
|
||||||
|
`raw/` is an inbox: only unprocessed material sits there. Syncthing shares it
|
||||||
|
with the machines you drop files from. (hyperguild#205, #208, #209)
|
||||||
|
|
||||||
|
## What happens to a file you drop
|
||||||
|
|
||||||
|
| You drop | Result |
|
||||||
|
|---|---|
|
||||||
|
| `raw/clip.md` (no folder, no `wing:`) | extracted and filed as a source in `wiki/web-clip/sources/`; its concepts and entities update the shared `wiki/concepts/` and `wiki/entities/` pages; the original moves to `raw/processed/<date>/`, so `raw/` is empty again |
|
||||||
|
| `raw/<wing>/clip.md` or `wing: <wing>` in the frontmatter | the same, with the source in `wiki/<wing>/sources/`. The wing must already exist; a typo is parked, never created. This is your deliberate choice and is not second-guessed |
|
||||||
|
| a note whose text matches the client-content pattern, with no folder | parked in `raw/needs-review/`, not extracted |
|
||||||
|
| a folder or `wing:` naming an unknown, reserved or non-ASCII wing, or two disagreeing signals | parked in `raw/needs-review/` |
|
||||||
|
| a note over the size cap, or one that cannot be archived | stays in `raw/`, reported once, not retried until it changes |
|
||||||
|
| a note the model returned nothing usable for (no source page could be written) | fails: the note is kept in `raw/failed/` and the reason is in `log.md`; it is never archived as processed |
|
||||||
|
|
||||||
|
Concepts and entities are shared across wings because their slug is the graph's
|
||||||
|
identity. A source whose slug is taken gets a wing-qualified one (`notes-homelab`).
|
||||||
|
|
||||||
|
## Parked notes
|
||||||
|
|
||||||
|
`brain_pending` lists them with `queue: needs-review`, a proposed wing and the
|
||||||
|
reason. To act on one: `brain_promote` / `brain_reject` (pass `queue` when the
|
||||||
|
same name also sits in the inbox), or move the file into `raw/<wing>/` to have it
|
||||||
|
extracted. Parked pdf/txt files are listed but can only be moved or rejected.
|
||||||
|
|
||||||
|
## Switching it on
|
||||||
|
|
||||||
|
Routing is off by default (the retired layout is used). Set on the ingestion
|
||||||
|
deployment:
|
||||||
|
|
||||||
|
| Variable | Meaning |
|
||||||
|
|---|---|
|
||||||
|
| `INGEST_WING_ROUTING` | `1`/`true` enables the rules above; unset or `0`/`false` is off; any other value stops the server at startup |
|
||||||
|
| `INGEST_DEFAULT_WING` | wing for unsignalled notes (default `web-clip`; must be a plain name, not `sources`/`concepts`/`entities`/`wiki`) |
|
||||||
|
| `INGEST_CLIENT_BLOCK` | case-insensitive regexp over note text; a match parks an unsignalled note. Falls back to `CLAUDE_INGEST_CLIENT_BLOCK`, which the deployment injects as a bare list of client names (`acme|globex`): it is then matched as whole names, with word edges decided by Unicode letters and digits (an underscore or punctuation is a boundary, so `trucker_ecommerce` matches `trucker`; `acmecorp`, `acme2` and plurals do not), and a list that is not a valid regexp, has an empty alternative, or could break out of its group stops the server at startup. A full pattern that matches the empty string is refused at startup too. Matched after folding fullwidth letters, removing zero-width characters, normalising dashes and removing whitespace. Patterns with spaces, `\\s` or `\\b` work (whitespace is collapsed in one view and removed in another). It catches what it names and nothing else: leetspeak, punctuation splits (`a.c.m.e`), encodings and homoglyphs outside a small Cyrillic/Greek table are not caught. Large notes are checked in 1 MiB chunks |
|
||||||
|
|
||||||
|
A bad default or pattern stops the server at startup. A wing created while the
|
||||||
|
server runs is usable without a restart.
|
||||||
|
|
||||||
|
## Known limits
|
||||||
|
|
||||||
|
- The client filter works on 1 MiB chunks that overlap by 4 KiB. A match that can
|
||||||
|
span more than 4 KiB (crafted padding between two words of a multi-word
|
||||||
|
pattern, exactly at a chunk edge) is not guaranteed to be found; this affects
|
||||||
|
notes over 1 MiB only. On a 64 MiB note the check takes about ten seconds.
|
||||||
|
- A pattern that matches the empty string (`.*`, `x*`) parks every unsignalled
|
||||||
|
note. That fails closed and only the operator's own configuration can do it.
|
||||||
|
|
||||||
|
- A deliberate folder into a sensitive wing places the note there, and the shared
|
||||||
|
concept/entity pages then cite that source by title. Client material does not
|
||||||
|
belong in the brain at all; the pattern only guards the unsignalled lane.
|
||||||
|
- A merge into a shared page refreshes `last_updated` and `origin` to the latest
|
||||||
|
ingest; the first wing's attribution is not kept.
|
||||||
|
- A marker file (`<note>.processed`) is only written when a processed original
|
||||||
|
cannot be removed. One at least as new as its note holds the note (and says
|
||||||
|
so once); an older or future-dated one is ignored and removed.
|
||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/api"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/audit"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/audit"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capture"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/capturehttp"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/capturehttp"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/classification"
|
||||||
@@ -90,11 +91,15 @@ func (s *claudeSink) Ingest(ctx context.Context, b claudewatcher.Batch) error {
|
|||||||
sb.WriteString("\n\n")
|
sb.WriteString("\n\n")
|
||||||
}
|
}
|
||||||
slug := "session-" + b.Host + "-" + b.SessionID
|
slug := "session-" + b.Host + "-" + b.SessionID
|
||||||
dest := filepath.Join(s.brainDir, "archive", "claude-sessions", b.Host, slug+".md")
|
dest := "archive/claude-sessions/" + b.Host + "/" + slug + ".md"
|
||||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
// Written through the brain root, never through a symlinked directory
|
||||||
return fmt.Errorf("create claude-sessions archive dir: %w", err)
|
// or file (hyperguild#158).
|
||||||
|
bfs, err := brainfs.Open(s.brainDir)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("open brain: %w", err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(dest, []byte(sb.String()), 0o644); err != nil {
|
defer func() { _ = bfs.Close() }()
|
||||||
|
if err := bfs.PutRegular(dest, []byte(sb.String())); err != nil {
|
||||||
return fmt.Errorf("write claude session archive: %w", err)
|
return fmt.Errorf("write claude session archive: %w", err)
|
||||||
}
|
}
|
||||||
s.logger.Debug("claude session archived (non-indexed)", "path", dest)
|
s.logger.Debug("claude session archived (non-indexed)", "path", dest)
|
||||||
@@ -145,15 +150,35 @@ func envInt(key string, fallback int) int {
|
|||||||
return fallback
|
return fallback
|
||||||
}
|
}
|
||||||
|
|
||||||
// envFloat reads a confidence-style value in [0,1]. Anything unparseable or
|
// ingestClientPattern picks the client-content pattern for raw/ routing.
|
||||||
// outside the range falls back: a threshold of 0 would auto-place everything.
|
// INGEST_CLIENT_BLOCK is a complete regexp and is used as is. Otherwise the
|
||||||
func envFloat(key string, fallback float64) float64 {
|
// shared CLAUDE_INGEST_CLIENT_BLOCK is used: the deployment injects it as a bare
|
||||||
if v := os.Getenv(key); v != "" {
|
// list of client names (regexp alternation), which wing.WrapNames bounds so a
|
||||||
if f, err := strconv.ParseFloat(v, 64); err == nil && f >= 0 && f <= 1 {
|
// short name does not match inside ordinary words, and which it refuses if it
|
||||||
return f
|
// would break out of its group or match the empty string.
|
||||||
|
func ingestClientPattern(getenv func(string) string) (string, error) {
|
||||||
|
if p := getenv("INGEST_CLIENT_BLOCK"); p != "" {
|
||||||
|
return p, nil
|
||||||
}
|
}
|
||||||
|
if names := getenv("CLAUDE_INGEST_CLIENT_BLOCK"); names != "" {
|
||||||
|
return wing.WrapNames(names)
|
||||||
}
|
}
|
||||||
return fallback
|
return "", nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// envFlag reads a boolean switch. Unset is off; a value strconv.ParseBool does
|
||||||
|
// not understand is an error, so a typo ("ture") cannot silently leave a
|
||||||
|
// safety-relevant switch off.
|
||||||
|
func envFlag(key string) (bool, error) {
|
||||||
|
v := os.Getenv(key)
|
||||||
|
if v == "" {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
b, err := strconv.ParseBool(v)
|
||||||
|
if err != nil {
|
||||||
|
return false, fmt.Errorf("%s=%q: want 1, 0, true or false", key, v)
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildNotifier returns the ntfy notifier, or a nil interface when
|
// buildNotifier returns the ntfy notifier, or a nil interface when
|
||||||
@@ -401,26 +426,40 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if watchInterval > 0 {
|
if watchInterval > 0 {
|
||||||
// INGEST_WING_RESOLVE=1 routes raw/ notes into wiki/<wing>/ by
|
// INGEST_WING_ROUTING=1 files raw/ notes by rule (hyperguild#208): a
|
||||||
// subfolder, frontmatter or classifier+scorer, and parks the rest in
|
// deliberate raw/<wing>/ folder or `wing:` frontmatter, else the default
|
||||||
// raw/needs-review/ (hyperguild#205). Off = the retired layout. Set
|
// wing (INGEST_DEFAULT_WING, "web-clip"); a note whose text matches the
|
||||||
// explicitly, a failure to build the resolver is fatal: silently
|
// client-content pattern (INGEST_CLIENT_BLOCK, else
|
||||||
// falling back would write into the layout this flag exists to leave.
|
// CLAUDE_INGEST_CLIENT_BLOCK) or that names an unknown wing is parked in
|
||||||
var wingResolver *wing.Resolver
|
// raw/needs-review/. Off = the retired layout. Set explicitly, a bad
|
||||||
if os.Getenv("INGEST_WING_RESOLVE") == "1" {
|
// default or pattern is fatal: silently falling back would write into
|
||||||
var rerr error
|
// the layout this flag exists to leave. The interface stays nil when off
|
||||||
wingResolver, rerr = wing.NewConfiguredResolver(brainDir, llmClient.Complete, envFloat("INGEST_WING_THRESHOLD", 0.9))
|
// (a nil *Router inside it would not be nil).
|
||||||
if rerr != nil {
|
var placer wing.Placer
|
||||||
logger.Error("build wing resolver", "err", rerr)
|
routing, ferr := envFlag("INGEST_WING_ROUTING")
|
||||||
|
if ferr != nil {
|
||||||
|
logger.Error("INGEST_WING_ROUTING", "err", ferr)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
logger.Info("wing resolver enabled", "wings", len(wingResolver.Known), "threshold", wingResolver.Threshold)
|
if routing {
|
||||||
|
pattern, perr := ingestClientPattern(os.Getenv)
|
||||||
|
if perr != nil {
|
||||||
|
logger.Error("client-content pattern", "err", perr)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
router, rerr := wing.NewRouter(brainDir, envOr("INGEST_DEFAULT_WING", "web-clip"), pattern)
|
||||||
|
if rerr != nil {
|
||||||
|
logger.Error("build wing router", "err", rerr)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
placer = router
|
||||||
|
logger.Info("wing routing enabled", "default_wing", router.Default, "client_filter", router.ClientBlock != nil)
|
||||||
}
|
}
|
||||||
watcher.Start(ctx, watcher.Config{
|
watcher.Start(ctx, watcher.Config{
|
||||||
BrainDir: brainDir,
|
BrainDir: brainDir,
|
||||||
Interval: time.Duration(watchInterval) * time.Second,
|
Interval: time.Duration(watchInterval) * time.Second,
|
||||||
Pipeline: pipelineCfg,
|
Pipeline: pipelineCfg,
|
||||||
Resolver: wingResolver,
|
Resolver: placer,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -77,22 +77,82 @@ func TestAlertingWarnings(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestEnvFloat(t *testing.T) {
|
// hyperguild#158: the claude-session archive was written with os.MkdirAll +
|
||||||
tests := []struct {
|
// os.WriteFile by path, so a symlinked archive directory redirected it.
|
||||||
name string
|
func TestClaudeSinkDoesNotWriteThroughASymlinkedArchiveDir(t *testing.T) {
|
||||||
set string
|
for name, mk := range map[string]func(t *testing.T, dir string) (link, target string){
|
||||||
want float64
|
"outside": func(t *testing.T, _ string) (string, string) { o := t.TempDir(); return o, o },
|
||||||
}{
|
"in-brain": func(t *testing.T, dir string) (string, string) {
|
||||||
{"unset falls back", "", 0.9},
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw"), 0o755))
|
||||||
{"valid value", "0.75", 0.75},
|
return filepath.Join("..", "raw"), filepath.Join(dir, "raw")
|
||||||
{"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},
|
t.Run(name, func(t *testing.T) {
|
||||||
}
|
dir := t.TempDir()
|
||||||
for _, tt := range tests {
|
link, target := mk(t, dir)
|
||||||
t.Run(tt.name, func(t *testing.T) {
|
require.NoError(t, os.Symlink(link, filepath.Join(dir, "archive")))
|
||||||
t.Setenv("TEST_ENV_FLOAT", tt.set)
|
sink := &claudeSink{brainDir: dir, logger: slog.New(slog.NewTextHandler(os.Stderr, nil))}
|
||||||
assert.InDelta(t, tt.want, envFloat("TEST_ENV_FLOAT", 0.9), 1e-9)
|
err := sink.Ingest(context.Background(), claudewatcher.Batch{Host: "koala", SessionID: "abc",
|
||||||
|
Turns: []claudewatcher.Turn{{Type: "assistant", Content: "x"}}})
|
||||||
|
assert.Error(t, err)
|
||||||
|
ents, _ := os.ReadDir(target)
|
||||||
|
assert.Empty(t, ents, "the archive was written at the link's target")
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestEnvFlag(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name, set string
|
||||||
|
want bool
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"unset is off", "", false, false},
|
||||||
|
{"1", "1", true, false},
|
||||||
|
{"true", "true", true, false},
|
||||||
|
{"TRUE", "TRUE", true, false},
|
||||||
|
{"0", "0", false, false},
|
||||||
|
{"false", "false", false, false},
|
||||||
|
{"a typo is an error, not silently off", "ture", false, true},
|
||||||
|
{"yes is not a bool here", "yes", false, true},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
t.Setenv("TEST_ENV_FLAG", tt.set)
|
||||||
|
got, err := envFlag("TEST_ENV_FLAG")
|
||||||
|
if tt.wantErr {
|
||||||
|
assert.Error(t, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.want, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The deployment injects CLAUDE_INGEST_CLIENT_BLOCK as a bare list of client
|
||||||
|
// names (regexp alternation). Used raw, a short name would match inside ordinary
|
||||||
|
// words; wrapped with \b, a name with a non-ASCII edge letter would never match.
|
||||||
|
func TestIngestClientPattern(t *testing.T) {
|
||||||
|
get := func(m map[string]string) func(string) string { return func(k string) string { return m[k] } }
|
||||||
|
t.Run("nothing set", func(t *testing.T) {
|
||||||
|
p, err := ingestClientPattern(get(map[string]string{}))
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Empty(t, p)
|
||||||
|
})
|
||||||
|
t.Run("explicit pattern is a full regexp, used as is", func(t *testing.T) {
|
||||||
|
p, err := ingestClientPattern(get(map[string]string{"INGEST_CLIENT_BLOCK": `acme\s+corp`, "CLAUDE_INGEST_CLIENT_BLOCK": "other"}))
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, `acme\s+corp`, p)
|
||||||
|
})
|
||||||
|
t.Run("the shared names are wrapped", func(t *testing.T) {
|
||||||
|
p, err := ingestClientPattern(get(map[string]string{"CLAUDE_INGEST_CLIENT_BLOCK": "dif|trucker"}))
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Contains(t, p, "dif|trucker")
|
||||||
|
assert.NotEqual(t, "dif|trucker", p)
|
||||||
|
})
|
||||||
|
t.Run("a shared value that would break out of the group is a startup error", func(t *testing.T) {
|
||||||
|
_, err := ingestClientPattern(get(map[string]string{"CLAUDE_INGEST_CLIENT_BLOCK": "x)|.*("}))
|
||||||
|
assert.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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, "|", "/"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
+1
-1
@@ -69,6 +69,6 @@ require (
|
|||||||
golang.org/x/crypto v0.51.0 // indirect
|
golang.org/x/crypto v0.51.0 // indirect
|
||||||
golang.org/x/sync v0.21.0 // indirect
|
golang.org/x/sync v0.21.0 // indirect
|
||||||
golang.org/x/sys v0.45.0
|
golang.org/x/sys v0.45.0
|
||||||
golang.org/x/text v0.39.0 // indirect
|
golang.org/x/text v0.39.0
|
||||||
gopkg.in/yaml.v3 v3.0.1
|
gopkg.in/yaml.v3 v3.0.1
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -83,6 +83,18 @@ func (f *frontmatter) get(key string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// drop removes every `key: value` line for key (nothing else), so a
|
||||||
|
// machine-written key does not travel with a note into the wiki.
|
||||||
|
func (f *frontmatter) drop(key string) {
|
||||||
|
kept := f.lines[:0]
|
||||||
|
for _, l := range f.lines {
|
||||||
|
if l.key != key {
|
||||||
|
kept = append(kept, l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
f.lines = kept
|
||||||
|
}
|
||||||
|
|
||||||
// set overrides the value for an existing key in place, or appends a new
|
// set overrides the value for an existing key in place, or appends a new
|
||||||
// `key: value` line when the key is absent.
|
// `key: value` line when the key is absent.
|
||||||
func (f *frontmatter) set(key, value string) {
|
func (f *frontmatter) set(key, value string) {
|
||||||
|
|||||||
@@ -617,6 +617,7 @@ type promoteRequest struct {
|
|||||||
Wing string `json:"wing"`
|
Wing string `json:"wing"`
|
||||||
Hall string `json:"hall"`
|
Hall string `json:"hall"`
|
||||||
Slug string `json:"slug,omitempty"`
|
Slug string `json:"slug,omitempty"`
|
||||||
|
Queue string `json:"queue,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Promote handles POST /promote — move a raw/ note into the wiki. A bad
|
// Promote handles POST /promote — move a raw/ note into the wiki. A bad
|
||||||
@@ -630,7 +631,7 @@ func (h *Handler) Promote(w http.ResponseWriter, r *http.Request) {
|
|||||||
// Field by field: this door carries no principal, so the promoted note is
|
// Field by field: this door carries no principal, so the promoted note is
|
||||||
// stamped origin: unknown and the body cannot set provenance (#145).
|
// stamped origin: unknown and the body cannot set provenance (#145).
|
||||||
rel, err := PromoteNote(h.brainDir, PromoteOptions{
|
rel, err := PromoteNote(h.brainDir, PromoteOptions{
|
||||||
Filename: req.Filename, Wing: req.Wing, Hall: req.Hall, Slug: req.Slug,
|
Filename: req.Filename, Wing: req.Wing, Hall: req.Hall, Slug: req.Slug, Queue: req.Queue,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeError(w, http.StatusBadRequest, err.Error())
|
writeError(w, http.StatusBadRequest, err.Error())
|
||||||
|
|||||||
@@ -34,7 +34,7 @@ func TestListPending_ShowsParkedNotesWithTheirProposal(t *testing.T) {
|
|||||||
assert.NotContains(t, got[0].Excerpt, "proposed_wing", "excerpt is body, not frontmatter")
|
assert.NotContains(t, got[0].Excerpt, "proposed_wing", "excerpt is body, not frontmatter")
|
||||||
|
|
||||||
assert.Equal(t, "fresh.md", got[1].Filename)
|
assert.Equal(t, "fresh.md", got[1].Filename)
|
||||||
assert.Empty(t, got[1].Queue, "a top-level note has no queue label")
|
assert.Equal(t, QueueInbox, got[1].Queue)
|
||||||
assert.Empty(t, got[1].ProposedWing)
|
assert.Empty(t, got[1].ProposedWing)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ type PendingNote struct {
|
|||||||
// (hyperguild#205): it was not placed automatically, and the proposal says
|
// (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
|
// what the resolver would have done and why it did not. Approve by moving
|
||||||
// the note into raw/<wing>/ and the watcher ingests it there.
|
// the note into raw/<wing>/ and the watcher ingests it there.
|
||||||
Queue string `json:"queue,omitempty"`
|
Queue string `json:"queue"`
|
||||||
ProposedWing string `json:"proposed_wing,omitempty"`
|
ProposedWing string `json:"proposed_wing,omitempty"`
|
||||||
ProposalReason string `json:"proposal_reason,omitempty"`
|
ProposalReason string `json:"proposal_reason,omitempty"`
|
||||||
ProposalConfidence float64 `json:"proposal_confidence,omitempty"`
|
ProposalConfidence float64 `json:"proposal_confidence,omitempty"`
|
||||||
@@ -49,7 +49,7 @@ func ListPending(brainDir string) ([]PendingNote, error) {
|
|||||||
}
|
}
|
||||||
defer func() { _ = root.Close() }()
|
defer func() { _ = root.Close() }()
|
||||||
|
|
||||||
out, err := listNotes(root, "")
|
out, err := listNotes(root, QueueInbox)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -57,7 +57,7 @@ func ListPending(brainDir string) ([]PendingNote, error) {
|
|||||||
// or note there is refused exactly like at the top level (#153).
|
// or note there is refused exactly like at the top level (#153).
|
||||||
if sub, serr := root.OpenRoot("needs-review"); serr == nil {
|
if sub, serr := root.OpenRoot("needs-review"); serr == nil {
|
||||||
defer func() { _ = sub.Close() }()
|
defer func() { _ = sub.Close() }()
|
||||||
parked, perr := listNotes(sub, "needs-review")
|
parked, perr := listNotes(sub, QueueNeedsReview)
|
||||||
if perr != nil {
|
if perr != nil {
|
||||||
return nil, perr
|
return nil, perr
|
||||||
}
|
}
|
||||||
@@ -76,11 +76,37 @@ func listNotes(root *os.Root, queue string) ([]PendingNote, error) {
|
|||||||
}
|
}
|
||||||
out := make([]PendingNote, 0, len(entries))
|
out := make([]PendingNote, 0, len(entries))
|
||||||
for _, e := range entries {
|
for _, e := range entries {
|
||||||
if !e.Type().IsRegular() || !strings.HasSuffix(e.Name(), ".md") || strings.HasPrefix(e.Name(), "tunnel-candidates-") {
|
isMD := strings.HasSuffix(e.Name(), ".md")
|
||||||
|
// A parked pdf or txt is listed too (the watcher parks any supported
|
||||||
|
// file); at the top level only markdown ever was.
|
||||||
|
parkedOther := queue == QueueNeedsReview && (strings.HasSuffix(e.Name(), ".txt") || strings.HasSuffix(e.Name(), ".pdf"))
|
||||||
|
if !e.Type().IsRegular() || (!isMD && !parkedOther) || strings.HasPrefix(e.Name(), "tunnel-candidates-") {
|
||||||
continue // directories, and symlinks whose target may be anywhere (#153)
|
continue // directories, and symlinks whose target may be anywhere (#153)
|
||||||
}
|
}
|
||||||
|
if !isMD {
|
||||||
|
info, ierr := root.Lstat(e.Name())
|
||||||
|
if ierr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, PendingNote{
|
||||||
|
Filename: e.Name(), CreatedAt: info.ModTime().UTC().Format(time.RFC3339), SizeBytes: info.Size(),
|
||||||
|
Excerpt: "(not markdown; the wing proposal is in log.md. Move the file into raw/<wing>/ to ingest it.)",
|
||||||
|
Queue: queue,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
raw, info, readErr := readRawNote(root, e.Name())
|
raw, info, readErr := readRawNote(root, e.Name())
|
||||||
if readErr != nil {
|
if readErr != nil {
|
||||||
|
// Not skipped: a note that cannot be read (over the cap, say) must
|
||||||
|
// not silently vanish from the queue it is waiting in.
|
||||||
|
fi, ierr := root.Lstat(e.Name())
|
||||||
|
if ierr != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
out = append(out, PendingNote{
|
||||||
|
Filename: e.Name(), CreatedAt: fi.ModTime().UTC().Format(time.RFC3339), SizeBytes: fi.Size(),
|
||||||
|
Excerpt: "(cannot be read: " + readErr.Error() + "; promote and reject cannot act on it: delete or move it by hand)", Queue: queue,
|
||||||
|
})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
fm, body := parseFrontmatter(string(raw))
|
fm, body := parseFrontmatter(string(raw))
|
||||||
@@ -95,7 +121,7 @@ func listNotes(root *os.Root, queue string) ([]PendingNote, error) {
|
|||||||
Excerpt: excerpt(body, 200),
|
Excerpt: excerpt(body, 200),
|
||||||
Queue: queue,
|
Queue: queue,
|
||||||
}
|
}
|
||||||
if queue != "" {
|
if queue == QueueNeedsReview {
|
||||||
n.ProposedWing = fm.get("proposed_wing")
|
n.ProposedWing = fm.get("proposed_wing")
|
||||||
n.ProposalReason = unquoteScalar(fm.get("proposal_reason"))
|
n.ProposalReason = unquoteScalar(fm.get("proposal_reason"))
|
||||||
n.ProposalConfidence, _ = strconv.ParseFloat(fm.get("proposal_confidence"), 64)
|
n.ProposalConfidence, _ = strconv.ParseFloat(fm.get("proposal_confidence"), 64)
|
||||||
@@ -111,6 +137,9 @@ type PromoteOptions struct {
|
|||||||
Wing string
|
Wing string
|
||||||
Hall string
|
Hall string
|
||||||
Slug string // optional; defaults to Filename minus date prefix + .md
|
Slug string // optional; defaults to Filename minus date prefix + .md
|
||||||
|
// Queue names which listing the note is in: QueueInbox or QueueNeedsReview.
|
||||||
|
// Empty is accepted only when the name exists in exactly one of them.
|
||||||
|
Queue string
|
||||||
// Origin/WrittenBy re-attribute the promoted note to the promoter: the raw
|
// Origin/WrittenBy re-attribute the promoted note to the promoter: the raw
|
||||||
// note's own frontmatter claims are not provenance (hyperguild#145).
|
// note's own frontmatter claims are not provenance (hyperguild#145).
|
||||||
Origin string
|
Origin string
|
||||||
@@ -133,21 +162,30 @@ func PromoteNote(brainDir string, opts PromoteOptions) (string, error) {
|
|||||||
return "", fmt.Errorf("invalid filename %q", opts.Filename)
|
return "", fmt.Errorf("invalid filename %q", opts.Filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if !strings.HasSuffix(base, ".md") {
|
||||||
|
return "", fmt.Errorf("only a markdown note can be promoted; move %q into raw/<wing>/ to ingest it", base)
|
||||||
|
}
|
||||||
slug := opts.Slug
|
slug := opts.Slug
|
||||||
if slug == "" {
|
if slug == "" {
|
||||||
slug = datePrefix.ReplaceAllString(strings.TrimSuffix(base, ".md"), "")
|
slug = datePrefix.ReplaceAllString(strings.TrimSuffix(base, ".md"), "")
|
||||||
}
|
}
|
||||||
|
// The retired layout's directory names are not wings: a note promoted into
|
||||||
|
// "sources" would land in wiki/sources/<hall>/, inside the retired dir.
|
||||||
|
switch brain.Sanitise(opts.Wing) {
|
||||||
|
case "sources", "concepts", "entities", "wiki":
|
||||||
|
return "", fmt.Errorf("%q is not a wing", opts.Wing)
|
||||||
|
}
|
||||||
// NotePath validates hall + wing + slug; do this before reading anything.
|
// NotePath validates hall + wing + slug; do this before reading anything.
|
||||||
dest, err := brain.NotePath(brainDir, opts.Wing, opts.Hall, slug)
|
dest, err := brain.NotePath(brainDir, opts.Wing, opts.Hall, slug)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
src := filepath.Join(brainDir, "raw", base)
|
raw, srcRel, err := readRawSource(brainDir, base, opts.Queue)
|
||||||
raw, err := readRawSource(brainDir, base)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
src := filepath.Join(brainDir, filepath.FromSlash(srcRel))
|
||||||
|
|
||||||
// Collision: never silently overwrite an existing promoted note.
|
// Collision: never silently overwrite an existing promoted note.
|
||||||
if _, statErr := os.Stat(dest); statErr == nil {
|
if _, statErr := os.Stat(dest); statErr == nil {
|
||||||
@@ -156,6 +194,11 @@ func PromoteNote(brainDir string, opts PromoteOptions) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fm, body := parseFrontmatter(string(raw))
|
fm, body := parseFrontmatter(string(raw))
|
||||||
|
// The resolver's proposal described where the note was going to be placed;
|
||||||
|
// once a human has placed it, it is noise (and `wing` is set below).
|
||||||
|
for _, k := range []string{"proposed_wing", "proposal_confidence", "proposal_reason"} {
|
||||||
|
fm.drop(k)
|
||||||
|
}
|
||||||
now := time.Now().UTC().Format(time.RFC3339)
|
now := time.Now().UTC().Format(time.RFC3339)
|
||||||
fm.set("wing", brain.Sanitise(opts.Wing))
|
fm.set("wing", brain.Sanitise(opts.Wing))
|
||||||
fm.set("hall", opts.Hall)
|
fm.set("hall", opts.Hall)
|
||||||
@@ -193,6 +236,7 @@ func PromoteNote(brainDir string, opts PromoteOptions) (string, error) {
|
|||||||
type RejectOptions struct {
|
type RejectOptions struct {
|
||||||
Filename string // basename in brain/raw/
|
Filename string // basename in brain/raw/
|
||||||
Reason string // optional
|
Reason string // optional
|
||||||
|
Queue string // as PromoteOptions.Queue
|
||||||
}
|
}
|
||||||
|
|
||||||
// RejectNote records a human decision not to promote a brain/raw/ note,
|
// RejectNote records a human decision not to promote a brain/raw/ note,
|
||||||
@@ -215,11 +259,11 @@ func RejectNote(brainDir string, opts RejectOptions) (string, error) {
|
|||||||
return "", fmt.Errorf("invalid filename %q", opts.Filename)
|
return "", fmt.Errorf("invalid filename %q", opts.Filename)
|
||||||
}
|
}
|
||||||
|
|
||||||
src := filepath.Join(brainDir, "raw", base)
|
raw, srcRel, err := readRawSource(brainDir, base, opts.Queue)
|
||||||
raw, err := readRawSource(brainDir, base)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
src := filepath.Join(brainDir, filepath.FromSlash(srcRel))
|
||||||
|
|
||||||
dest := filepath.Join(brainDir, "raw", "rejected", base)
|
dest := filepath.Join(brainDir, "raw", "rejected", base)
|
||||||
if _, statErr := os.Stat(dest); statErr == nil {
|
if _, statErr := os.Stat(dest); statErr == nil {
|
||||||
@@ -227,15 +271,19 @@ func RejectNote(brainDir string, opts RejectOptions) (string, error) {
|
|||||||
return "", fmt.Errorf("a rejected note named %s already exists", filepath.ToSlash(rel))
|
return "", fmt.Errorf("a rejected note named %s already exists", filepath.ToSlash(rel))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
content := string(raw)
|
||||||
|
if strings.HasSuffix(base, ".md") {
|
||||||
fm, body := parseFrontmatter(string(raw))
|
fm, body := parseFrontmatter(string(raw))
|
||||||
fm.set("rejected_at", time.Now().UTC().Format(time.RFC3339))
|
fm.set("rejected_at", time.Now().UTC().Format(time.RFC3339))
|
||||||
if opts.Reason != "" {
|
if opts.Reason != "" {
|
||||||
fm.set("reject_reason", opts.Reason)
|
fm.set("reject_reason", opts.Reason)
|
||||||
}
|
}
|
||||||
|
content = fm.render() + body
|
||||||
|
} // a pdf or txt is moved byte for byte: prepending frontmatter would alter it
|
||||||
|
|
||||||
// Write-then-delete: the source survives any write failure. Both go
|
// Write-then-delete: the source survives any write failure. Both go
|
||||||
// through the brain root (hyperguild#188).
|
// through the brain root (hyperguild#188).
|
||||||
if err := createBrainFile(brainDir, dest, []byte(fm.render()+body)); err != nil {
|
if err := createBrainFile(brainDir, dest, []byte(content)); err != nil {
|
||||||
return "", fmt.Errorf("write rejected note: %w", err)
|
return "", fmt.Errorf("write rejected note: %w", err)
|
||||||
}
|
}
|
||||||
if err := removeBrainFile(brainDir, src); err != nil {
|
if err := removeBrainFile(brainDir, src); err != nil {
|
||||||
@@ -258,25 +306,61 @@ func excerpt(s string, n int) string {
|
|||||||
|
|
||||||
// readRawSource reads a pending note for promote/reject through the raw root,
|
// readRawSource reads a pending note for promote/reject through the raw root,
|
||||||
// refusing symlinks and a symlinked raw/ (hyperguild#153).
|
// refusing symlinks and a symlinked raw/ (hyperguild#153).
|
||||||
func readRawSource(brainDir, base string) ([]byte, error) {
|
func readRawSource(brainDir, base, queue string) ([]byte, string, error) {
|
||||||
|
if queue != "" && queue != QueueInbox && queue != QueueNeedsReview {
|
||||||
|
return nil, "", fmt.Errorf("unknown queue %q (want %q or %q)", queue, QueueInbox, QueueNeedsReview)
|
||||||
|
}
|
||||||
root, _, err := OpenRawRoot(brainDir)
|
root, _, err := OpenRawRoot(brainDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
if errors.Is(err, os.ErrNotExist) {
|
||||||
return nil, fmt.Errorf("pending note %q does not exist in raw/", base)
|
return nil, "", fmt.Errorf("pending note %q does not exist in raw/", base)
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("read source: %w", err)
|
return nil, "", fmt.Errorf("read source: %w", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = root.Close() }()
|
defer func() { _ = root.Close() }()
|
||||||
raw, _, err := readRawNote(root, base)
|
|
||||||
if err != nil {
|
var topRaw, parkedRaw []byte
|
||||||
if errors.Is(err, os.ErrNotExist) {
|
var topErr, parkedErr error
|
||||||
return nil, fmt.Errorf("pending note %q does not exist in raw/", base)
|
if queue != QueueNeedsReview {
|
||||||
|
topRaw, _, topErr = readRawNote(root, base)
|
||||||
}
|
}
|
||||||
return nil, fmt.Errorf("read source: %w", err)
|
if queue != QueueInbox {
|
||||||
|
// The sub-root refuses a symlinked directory like OpenRawRoot does.
|
||||||
|
if sub, serr := root.OpenRoot(parkedDir); serr != nil {
|
||||||
|
parkedErr = serr
|
||||||
|
} else {
|
||||||
|
parkedRaw, _, parkedErr = readRawNote(sub, base)
|
||||||
|
_ = sub.Close()
|
||||||
}
|
}
|
||||||
return raw, nil
|
}
|
||||||
|
haveTop := queue != QueueNeedsReview && topErr == nil
|
||||||
|
haveParked := queue != QueueInbox && parkedErr == nil
|
||||||
|
switch {
|
||||||
|
case haveTop && haveParked:
|
||||||
|
return nil, "", fmt.Errorf("%q is in both raw/ and raw/%s/: name the queue (%q or %q)", base, parkedDir, QueueInbox, QueueNeedsReview)
|
||||||
|
case haveTop:
|
||||||
|
return topRaw, "raw/" + base, nil
|
||||||
|
case haveParked:
|
||||||
|
return parkedRaw, "raw/" + parkedDir + "/" + base, nil
|
||||||
|
}
|
||||||
|
for _, e := range []error{topErr, parkedErr} {
|
||||||
|
if e != nil && !errors.Is(e, os.ErrNotExist) {
|
||||||
|
return nil, "", fmt.Errorf("read source: %w", e)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil, "", fmt.Errorf("pending note %q does not exist in raw/", base)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// parkedDir is where the watcher parks notes it could not place.
|
||||||
|
const parkedDir = "needs-review"
|
||||||
|
|
||||||
|
// The two review queues brain_pending lists. promote and reject take one of
|
||||||
|
// them (or neither, when the name is unambiguous).
|
||||||
|
const (
|
||||||
|
QueueInbox = "inbox" // raw/<name>
|
||||||
|
QueueNeedsReview = "needs-review" // raw/needs-review/<name>
|
||||||
|
)
|
||||||
|
|
||||||
// unquoteScalar undoes the Go-style double quoting the watcher writes around a
|
// 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
|
// free-text value (strconv.Quote keeps it on one line); a bare value is
|
||||||
// returned as is.
|
// returned as is.
|
||||||
|
|||||||
@@ -0,0 +1,210 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// brain_pending lists a top-level note and a parked note of the same name as two
|
||||||
|
// entries with the same filename. Acting on "a.md" must not silently pick one.
|
||||||
|
func TestPromoteNote_ANameInBothQueuesNeedsTheQueueToBeNamed(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeRaw(t, dir, "a.md", "---\ncreated_at: 2026-10-20T08:00:00Z\n---\nTop-level body.\n")
|
||||||
|
writeParked(t, dir, "a.md", parkedNote)
|
||||||
|
|
||||||
|
_, err := PromoteNote(dir, PromoteOptions{Filename: "a.md", Wing: "homelab", Hall: "facts"})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "both")
|
||||||
|
assert.Contains(t, err.Error(), "queue")
|
||||||
|
_, statErr := os.Stat(filepath.Join(dir, "wiki/homelab/facts/a.md"))
|
||||||
|
assert.ErrorIs(t, statErr, os.ErrNotExist, "nothing was promoted")
|
||||||
|
|
||||||
|
rel, err := PromoteNote(dir, PromoteOptions{Filename: "a.md", Wing: "homelab", Hall: "facts", Queue: QueueNeedsReview})
|
||||||
|
require.NoError(t, err)
|
||||||
|
got, _ := os.ReadFile(filepath.Join(dir, rel))
|
||||||
|
assert.Contains(t, string(got), "Body of the parked note.")
|
||||||
|
_, statErr = os.Stat(filepath.Join(dir, "raw", "a.md"))
|
||||||
|
assert.NoError(t, statErr, "the top-level note of the same name is untouched")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromoteNote_QueueInboxSelectsTheTopLevelNote(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeRaw(t, dir, "a.md", "---\ncreated_at: 2026-10-20T08:00:00Z\n---\nTop-level body.\n")
|
||||||
|
writeParked(t, dir, "a.md", parkedNote)
|
||||||
|
|
||||||
|
rel, err := PromoteNote(dir, PromoteOptions{Filename: "a.md", Wing: "homelab", Hall: "facts", Queue: QueueInbox})
|
||||||
|
require.NoError(t, err)
|
||||||
|
got, _ := os.ReadFile(filepath.Join(dir, rel))
|
||||||
|
assert.Contains(t, string(got), "Top-level body.")
|
||||||
|
_, statErr := os.Stat(filepath.Join(dir, "raw", "needs-review", "a.md"))
|
||||||
|
assert.NoError(t, statErr, "the parked note is untouched")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromoteNote_ANamedQueueThatDoesNotHoldTheNoteIsAnError(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeRaw(t, dir, "a.md", "top")
|
||||||
|
_, err := PromoteNote(dir, PromoteOptions{Filename: "a.md", Wing: "homelab", Hall: "facts", Queue: QueueNeedsReview})
|
||||||
|
assert.Error(t, err)
|
||||||
|
_, err = PromoteNote(dir, PromoteOptions{Filename: "a.md", Wing: "homelab", Hall: "facts", Queue: "elsewhere"})
|
||||||
|
assert.Error(t, err, "an unknown queue name is refused")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRejectNote_ANameInBothQueuesNeedsTheQueueToBeNamed(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeRaw(t, dir, "a.md", "top")
|
||||||
|
writeParked(t, dir, "a.md", parkedNote)
|
||||||
|
|
||||||
|
_, err := RejectNote(dir, RejectOptions{Filename: "a.md"})
|
||||||
|
require.Error(t, err)
|
||||||
|
_, err = RejectNote(dir, RejectOptions{Filename: "a.md", Queue: QueueNeedsReview})
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, statErr := os.Stat(filepath.Join(dir, "raw", "a.md"))
|
||||||
|
assert.NoError(t, statErr, "the top-level note is untouched")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromoteNote_NeverIntoARetiredLayoutName(t *testing.T) {
|
||||||
|
for _, w := range []string{"sources", "concepts", "entities", "wiki"} {
|
||||||
|
t.Run(w, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeRaw(t, dir, "a.md", "top")
|
||||||
|
_, err := PromoteNote(dir, PromoteOptions{Filename: "a.md", Wing: w, Hall: "facts"})
|
||||||
|
assert.Error(t, err)
|
||||||
|
_, statErr := os.Stat(filepath.Join(dir, "wiki", w))
|
||||||
|
assert.ErrorIs(t, statErr, os.ErrNotExist)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListPending_LabelsEveryEntryWithItsQueue(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeRaw(t, dir, "fresh.md", "---\ncreated_at: 2026-10-20T08:00:00Z\n---\nx\n")
|
||||||
|
writeParked(t, dir, "p.md", parkedNote)
|
||||||
|
got, err := ListPending(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, got, 2)
|
||||||
|
queues := map[string]string{}
|
||||||
|
for _, n := range got {
|
||||||
|
queues[n.Filename] = n.Queue
|
||||||
|
}
|
||||||
|
assert.Equal(t, QueueInbox, queues["fresh.md"])
|
||||||
|
assert.Equal(t, QueueNeedsReview, queues["p.md"])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parked notes that are not markdown (a pdf, a txt) were invisible: the only
|
||||||
|
// trace was a log.md line.
|
||||||
|
func TestListPending_ShowsParkedNonMarkdownFiles(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw", "needs-review"), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw", "needs-review", "paper.pdf"), []byte("%PDF-1.4 binary"), 0o644))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw", "needs-review", "plain.txt"), []byte("some text"), 0o644))
|
||||||
|
|
||||||
|
got, err := ListPending(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
names := map[string]PendingNote{}
|
||||||
|
for _, n := range got {
|
||||||
|
names[n.Filename] = n
|
||||||
|
}
|
||||||
|
require.Contains(t, names, "paper.pdf")
|
||||||
|
require.Contains(t, names, "plain.txt")
|
||||||
|
assert.Equal(t, QueueNeedsReview, names["paper.pdf"].Queue)
|
||||||
|
assert.Contains(t, names["paper.pdf"].Excerpt, "proposal", "points the reader at where the proposal is")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromoteNote_OnlyMarkdownCanBePromoted(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw", "needs-review"), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw", "needs-review", "paper.pdf"), []byte("%PDF binary"), 0o644))
|
||||||
|
_, err := PromoteNote(dir, PromoteOptions{Filename: "paper.pdf", Wing: "homelab", Hall: "facts"})
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "markdown")
|
||||||
|
_, statErr := os.Stat(filepath.Join(dir, "raw", "needs-review", "paper.pdf"))
|
||||||
|
assert.NoError(t, statErr, "refused, untouched")
|
||||||
|
}
|
||||||
|
|
||||||
|
// brain.Sanitise normalises case, trailing dots and separators; the reserved-name
|
||||||
|
// check must look at the normalised name or "Sources" and "sources." walk past it.
|
||||||
|
func TestPromoteNote_ReservedWingNamesAreRefusedInEveryDisguise(t *testing.T) {
|
||||||
|
for _, w := range []string{"Sources", "sources.", "SOURCES ", "Entities/", "concepts\\", " wiki "} {
|
||||||
|
t.Run(w, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeRaw(t, dir, "a.md", "top")
|
||||||
|
_, err := PromoteNote(dir, PromoteOptions{Filename: "a.md", Wing: w, Hall: "facts"})
|
||||||
|
assert.Error(t, err)
|
||||||
|
_, statErr := os.Stat(filepath.Join(dir, "raw", "a.md"))
|
||||||
|
assert.NoError(t, statErr, "refused, untouched")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reject moves the bytes; it must not prepend frontmatter to a pdf or txt.
|
||||||
|
func TestRejectNote_AParkedNonMarkdownFileIsMovedUntouched(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
pdf := "%PDF-1.4\r\n\x00\xff binary \r\n"
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw", "needs-review"), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw", "needs-review", "paper.pdf"), []byte(pdf), 0o644))
|
||||||
|
|
||||||
|
rel, err := RejectNote(dir, RejectOptions{Filename: "paper.pdf", Reason: "not relevant"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
got, err := os.ReadFile(filepath.Join(dir, rel))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, pdf, string(got), "byte for byte")
|
||||||
|
_, statErr := os.Stat(filepath.Join(dir, "raw", "needs-review", "paper.pdf"))
|
||||||
|
assert.ErrorIs(t, statErr, os.ErrNotExist)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadRawNote_IsBoundedByTheCap(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "big.md"), []byte(strings.Repeat("x", 200)), 0o644))
|
||||||
|
root, err := os.OpenRoot(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = root.Close() }()
|
||||||
|
prev := maxRawNoteBytes
|
||||||
|
maxRawNoteBytes = 100
|
||||||
|
t.Cleanup(func() { maxRawNoteBytes = prev })
|
||||||
|
|
||||||
|
_, _, err = readRawNote(root, "big.md")
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "exceeds")
|
||||||
|
}
|
||||||
|
|
||||||
|
type endless struct{ n int64 }
|
||||||
|
|
||||||
|
func (e *endless) Read(p []byte) (int, error) {
|
||||||
|
if e.n > 8<<20 { // a regression fails the test instead of exhausting memory
|
||||||
|
return 0, errors.New("endless reader drained")
|
||||||
|
}
|
||||||
|
e.n += int64(len(p))
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadCapped_StopsAtTheCap(t *testing.T) {
|
||||||
|
e := &endless{}
|
||||||
|
_, err := readCapped(e, 1000)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.Contains(t, err.Error(), "exceeds")
|
||||||
|
assert.LessOrEqual(t, e.n, int64(1000+1+32*1024), "pulled far more than the cap: %d", e.n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// An .md over the read cap used to vanish from the listing (readRawNote failed
|
||||||
|
// and the entry was skipped). It must stay visible, with the reason.
|
||||||
|
func TestListPending_AnUnreadableNoteIsListedWithTheReason(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw"), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "raw", "huge.md"), []byte(strings.Repeat("x", 200)), 0o644))
|
||||||
|
prev := maxRawNoteBytes
|
||||||
|
maxRawNoteBytes = 100
|
||||||
|
t.Cleanup(func() { maxRawNoteBytes = prev })
|
||||||
|
|
||||||
|
got, err := ListPending(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.Len(t, got, 1)
|
||||||
|
assert.Equal(t, "huge.md", got[0].Filename)
|
||||||
|
assert.Contains(t, got[0].Excerpt, "cannot be read")
|
||||||
|
assert.Contains(t, got[0].Excerpt, "exceeds")
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package api
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
const parkedNote = "---\nproposed_wing: radar\nproposal_confidence: 0.40\nproposal_reason: \"text matches the client-content marker\"\ncreated_at: 2026-10-19T08:00:00Z\n---\nBody of the parked note.\n"
|
||||||
|
|
||||||
|
// brain_pending lists a parked note under its bare filename, so promote and
|
||||||
|
// reject must be able to act on exactly that name (#208: they could not).
|
||||||
|
func TestPromoteNote_ActsOnANoteParkedInNeedsReview(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeParked(t, dir, "unsure.md", parkedNote)
|
||||||
|
|
||||||
|
rel, err := PromoteNote(dir, PromoteOptions{Filename: "unsure.md", Wing: "homelab", Hall: "facts"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "wiki/homelab/facts/unsure.md", rel)
|
||||||
|
|
||||||
|
got, err := os.ReadFile(filepath.Join(dir, "wiki/homelab/facts/unsure.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(got), "Body of the parked note.")
|
||||||
|
assert.Contains(t, string(got), "wing: homelab")
|
||||||
|
assert.NotContains(t, string(got), "proposed_wing", "the resolver's proposal is not part of the promoted note")
|
||||||
|
assert.NotContains(t, string(got), "proposal_reason")
|
||||||
|
|
||||||
|
_, statErr := os.Stat(filepath.Join(dir, "raw", "needs-review", "unsure.md"))
|
||||||
|
assert.ErrorIs(t, statErr, os.ErrNotExist, "the parked note is consumed")
|
||||||
|
pending, err := ListPending(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, pending)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRejectNote_ActsOnANoteParkedInNeedsReview(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
writeParked(t, dir, "unsure.md", parkedNote)
|
||||||
|
|
||||||
|
rel, err := RejectNote(dir, RejectOptions{Filename: "unsure.md", Reason: "not worth keeping"})
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "raw/rejected/unsure.md", rel)
|
||||||
|
_, statErr := os.Stat(filepath.Join(dir, "raw", "needs-review", "unsure.md"))
|
||||||
|
assert.ErrorIs(t, statErr, os.ErrNotExist)
|
||||||
|
pending, _ := ListPending(dir)
|
||||||
|
assert.Empty(t, pending, "a rejected note is not re-listed")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPromoteNote_ASymlinkedNeedsReviewIsNotRead(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
outside := t.TempDir()
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(outside, "secret.md"), []byte("outside"), 0o644))
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "raw"), 0o755))
|
||||||
|
require.NoError(t, os.Symlink(outside, filepath.Join(dir, "raw", "needs-review")))
|
||||||
|
|
||||||
|
_, err := PromoteNote(dir, PromoteOptions{Filename: "secret.md", Wing: "homelab", Hall: "facts"})
|
||||||
|
assert.Error(t, err)
|
||||||
|
_, statErr := os.Stat(filepath.Join(dir, "wiki/homelab/facts/secret.md"))
|
||||||
|
assert.ErrorIs(t, statErr, os.ErrNotExist)
|
||||||
|
}
|
||||||
@@ -38,6 +38,10 @@ func OpenRawRoot(brainDir string) (*os.Root, string, error) {
|
|||||||
return r, p, nil
|
return r, p, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// maxRawNoteBytes bounds what promote, reject and the pending listing read from
|
||||||
|
// one raw/ entry. A variable so a test can lower it.
|
||||||
|
var maxRawNoteBytes int64 = 64 << 20
|
||||||
|
|
||||||
// readRawNote reads one raw/ entry by basename through the root, refusing
|
// readRawNote reads one raw/ entry by basename through the root, refusing
|
||||||
// anything that is not a regular file.
|
// anything that is not a regular file.
|
||||||
//
|
//
|
||||||
@@ -59,9 +63,22 @@ func readRawNote(r *os.Root, base string) ([]byte, os.FileInfo, error) {
|
|||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
defer func() { _ = f.Close() }()
|
defer func() { _ = f.Close() }()
|
||||||
b, err := io.ReadAll(f)
|
b, err := readCapped(f, maxRawNoteBytes)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, fmt.Errorf("%s: %w", base, err)
|
||||||
}
|
}
|
||||||
return b, fi, nil
|
return b, fi, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// readCapped reads r but never more than max+1 bytes, so an oversize or growing
|
||||||
|
// file costs at most that much memory.
|
||||||
|
func readCapped(r io.Reader, max int64) ([]byte, error) {
|
||||||
|
b, err := io.ReadAll(io.LimitReader(r, max+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if int64(len(b)) > max {
|
||||||
|
return nil, fmt.Errorf("note exceeds %d bytes", max)
|
||||||
|
}
|
||||||
|
return b, nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -109,7 +109,7 @@ func (b *Brain) ReadDirRegular(relDir string) ([]string, error) {
|
|||||||
// after following a symlink, and a ".." after a symlinked directory would
|
// after following a symlink, and a ".." after a symlinked directory would
|
||||||
// otherwise pass the check and redirect the read or write.
|
// otherwise pass the check and redirect the read or write.
|
||||||
func cleanRel(rel string) error {
|
func cleanRel(rel string) error {
|
||||||
if rel == "" || path.Clean(rel) != rel || !filepath.IsLocal(rel) || strings.Contains(rel, "\\") {
|
if rel == "" || path.Clean(rel) != rel || !filepath.IsLocal(rel) {
|
||||||
return fmt.Errorf("%q: not a clean brain-relative path: %w", rel, ErrNotRegular)
|
return fmt.Errorf("%q: not a clean brain-relative path: %w", rel, ErrNotRegular)
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -243,6 +243,27 @@ func (b *Brain) RealParent(rel string) error {
|
|||||||
return b.realDir(path.Dir(rel))
|
return b.realDir(path.Dir(rel))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// PutFile opens the brain dir as a root and calls PutRegular, for one-off
|
||||||
|
// writers that hold no Brain.
|
||||||
|
func PutFile(brainDir, rel string, data []byte) error {
|
||||||
|
b, err := Open(brainDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = b.Close() }()
|
||||||
|
return b.PutRegular(rel, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppendFile opens the brain dir as a root and calls AppendRegular.
|
||||||
|
func AppendFile(brainDir, rel string, data []byte) error {
|
||||||
|
b, err := Open(brainDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = b.Close() }()
|
||||||
|
return b.AppendRegular(rel, data)
|
||||||
|
}
|
||||||
|
|
||||||
// mkdirReal makes sure every component of rel exists as a real directory,
|
// mkdirReal makes sure every component of rel exists as a real directory,
|
||||||
// creating missing ones through the root; an existing component that is
|
// creating missing ones through the root; an existing component that is
|
||||||
// not a real directory is ErrNotRegular.
|
// not a real directory is ErrNotRegular.
|
||||||
|
|||||||
@@ -529,3 +529,20 @@ func TestCreateRegularCreatesOnlyNewFilesThroughRealDirectories(t *testing.T) {
|
|||||||
got, _ = os.ReadFile(filepath.Join(dir, "wiki", "new", "facts", "n.md"))
|
got, _ = os.ReadFile(filepath.Join(dir, "wiki", "new", "facts", "n.md"))
|
||||||
assert.Equal(t, "one", string(got))
|
assert.Equal(t, "one", string(got))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// On Linux a backslash is an ordinary filename byte, not a separator, so a
|
||||||
|
// name containing one stays a single in-root component (hyperguild#158: the
|
||||||
|
// watcher retried such a note forever while cleanRel refused it).
|
||||||
|
func TestReadAndPutRegularTreatABackslashAsAFilenameByte(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
b, err := Open(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = b.Close() }()
|
||||||
|
require.NoError(t, b.PutRegular(`a\..\b.md`, []byte("x")))
|
||||||
|
got, err := os.ReadFile(filepath.Join(dir, `a\..\b.md`))
|
||||||
|
require.NoError(t, err, "the name was not kept as one component")
|
||||||
|
assert.Equal(t, "x", string(got))
|
||||||
|
got, err = b.ReadRegular(`a\..\b.md`)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "x", string(got))
|
||||||
|
}
|
||||||
|
|||||||
@@ -88,7 +88,6 @@ func TestNoPackageReferencesExtractTextOutsideExtract(t *testing.T) {
|
|||||||
// osOpenAllowed names the functions in guarded files that may call os.Open*
|
// osOpenAllowed names the functions in guarded files that may call os.Open*
|
||||||
// on a path that is not a raw/ read.
|
// on a path that is not a raw/ read.
|
||||||
var osOpenAllowed = map[string]bool{
|
var osOpenAllowed = map[string]bool{
|
||||||
"internal/watcher/watcher.go:appendWatcherLog": true, // ingest log append, not a raw/ read
|
|
||||||
// Text reads by path; outside this package only TextIn's temp copy reaches
|
// Text reads by path; outside this package only TextIn's temp copy reaches
|
||||||
// it (TestNoPackageReferencesExtractTextOutsideExtract enforces that).
|
// it (TestNoPackageReferencesExtractTextOutsideExtract enforces that).
|
||||||
"internal/extract/extract.go:Text": true,
|
"internal/extract/extract.go:Text": true,
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
package extract
|
package extract
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
@@ -50,6 +51,18 @@ func fileExt(path string) string {
|
|||||||
// external tool (pdf, docmark) are copied to a temp file from the handle
|
// external tool (pdf, docmark) are copied to a temp file from the handle
|
||||||
// opened through the root, then extracted from the copy.
|
// opened through the root, then extracted from the copy.
|
||||||
func TextIn(r *os.Root, rel string) (string, error) {
|
func TextIn(r *os.Root, rel string) (string, error) {
|
||||||
|
return TextInMax(r, rel, DefaultMaxBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultMaxBytes bounds what TextIn reads or copies from one file.
|
||||||
|
const DefaultMaxBytes = 64 << 20
|
||||||
|
|
||||||
|
// ErrTooLarge reports a file that exceeded the read cap while being read: the
|
||||||
|
// size seen at an earlier Lstat is not a bound, the file may have grown.
|
||||||
|
var ErrTooLarge = errors.New("file exceeds the read cap")
|
||||||
|
|
||||||
|
// TextInMax is TextIn with an explicit cap on the bytes read (hyperguild#198).
|
||||||
|
func TextInMax(r *os.Root, rel string, max int64) (string, error) {
|
||||||
fi, err := r.Lstat(rel)
|
fi, err := r.Lstat(rel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("stat %s: %w", rel, err)
|
return "", fmt.Errorf("stat %s: %w", rel, err)
|
||||||
@@ -65,10 +78,13 @@ func TextIn(r *os.Root, rel string) (string, error) {
|
|||||||
ext := strings.ToLower(fileExt(rel))
|
ext := strings.ToLower(fileExt(rel))
|
||||||
switch ext {
|
switch ext {
|
||||||
case ".md", ".txt":
|
case ".md", ".txt":
|
||||||
b, err := io.ReadAll(f)
|
b, err := io.ReadAll(io.LimitReader(f, max+1))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", fmt.Errorf("read %s: %w", rel, err)
|
return "", fmt.Errorf("read %s: %w", rel, err)
|
||||||
}
|
}
|
||||||
|
if int64(len(b)) > max {
|
||||||
|
return "", fmt.Errorf("%s: %w (%d bytes)", rel, ErrTooLarge, max)
|
||||||
|
}
|
||||||
return string(b), nil
|
return string(b), nil
|
||||||
case ".pdf", ".docx", ".xlsx", ".pptx", ".png", ".jpg", ".jpeg":
|
case ".pdf", ".docx", ".xlsx", ".pptx", ".png", ".jpg", ".jpeg":
|
||||||
tmp, err := os.CreateTemp("", "ingest-*"+ext)
|
tmp, err := os.CreateTemp("", "ingest-*"+ext)
|
||||||
@@ -76,10 +92,15 @@ func TextIn(r *os.Root, rel string) (string, error) {
|
|||||||
return "", fmt.Errorf("temp copy: %w", err)
|
return "", fmt.Errorf("temp copy: %w", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = os.Remove(tmp.Name()) }()
|
defer func() { _ = os.Remove(tmp.Name()) }()
|
||||||
if _, err := io.Copy(tmp, f); err != nil {
|
n, err := io.Copy(tmp, io.LimitReader(f, max+1))
|
||||||
|
if err != nil {
|
||||||
_ = tmp.Close()
|
_ = tmp.Close()
|
||||||
return "", fmt.Errorf("copy %s: %w", rel, err)
|
return "", fmt.Errorf("copy %s: %w", rel, err)
|
||||||
}
|
}
|
||||||
|
if n > max {
|
||||||
|
_ = tmp.Close()
|
||||||
|
return "", fmt.Errorf("%s: %w (%d bytes)", rel, ErrTooLarge, max)
|
||||||
|
}
|
||||||
if err := tmp.Close(); err != nil {
|
if err := tmp.Close(); err != nil {
|
||||||
return "", fmt.Errorf("copy %s: %w", rel, err)
|
return "", fmt.Errorf("copy %s: %w", rel, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package extract
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func rootWith(t *testing.T, name string, size int) *os.Root {
|
||||||
|
t.Helper()
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, name), []byte(strings.Repeat("x", size)), 0o644))
|
||||||
|
r, err := os.OpenRoot(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
t.Cleanup(func() { _ = r.Close() })
|
||||||
|
return r
|
||||||
|
}
|
||||||
|
|
||||||
|
// hyperguild#198: the size check in the watcher is a pre-check on Lstat; a note
|
||||||
|
// that grows before it is read must still be capped at the read.
|
||||||
|
func TestTextInMax(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
size int
|
||||||
|
max int64
|
||||||
|
wantErr bool
|
||||||
|
}{
|
||||||
|
{"under the cap", 100, 1024, false},
|
||||||
|
{"exactly at the cap", 1024, 1024, false},
|
||||||
|
{"one byte over", 1025, 1024, true},
|
||||||
|
{"far over", 1 << 20, 1024, true},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
r := rootWith(t, "n.md", tt.size)
|
||||||
|
got, err := TextInMax(r, "n.md", tt.max)
|
||||||
|
if tt.wantErr {
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.True(t, errors.Is(err, ErrTooLarge))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, got, tt.size)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTextInMax_TheBinaryTempCopyIsCappedToo(t *testing.T) {
|
||||||
|
r := rootWith(t, "n.pdf", 4096)
|
||||||
|
_, err := TextInMax(r, "n.pdf", 1024)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.True(t, errors.Is(err, ErrTooLarge), "the temp copy of a pdf must not exceed the cap either: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestTextIn_HasADefaultCap(t *testing.T) {
|
||||||
|
assert.Positive(t, DefaultMaxBytes)
|
||||||
|
}
|
||||||
@@ -94,6 +94,7 @@ func (s *Server) tools() []map[string]any {
|
|||||||
"wing": str("target wing, e.g. jepa-fx"),
|
"wing": str("target wing, e.g. jepa-fx"),
|
||||||
"hall": enum("target hall", halls...),
|
"hall": enum("target hall", halls...),
|
||||||
"slug": str("optional target slug; defaults to filename minus date prefix"),
|
"slug": str("optional target slug; defaults to filename minus date prefix"),
|
||||||
|
"queue": enum("which brain_pending listing the note is in; required only when the name exists in both", "inbox", "needs-review"),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -102,6 +103,7 @@ func (s *Server) tools() []map[string]any {
|
|||||||
"inputSchema": schema([]string{"filename"}, map[string]any{
|
"inputSchema": schema([]string{"filename"}, map[string]any{
|
||||||
"filename": str("basename in brain/raw/, e.g. 2026-04-23-fed-sentiment-scrape.md"),
|
"filename": str("basename in brain/raw/, e.g. 2026-04-23-fed-sentiment-scrape.md"),
|
||||||
"reason": str("optional; why this isn't worth promoting"),
|
"reason": str("optional; why this isn't worth promoting"),
|
||||||
|
"queue": enum("which brain_pending listing the note is in; required only when the name exists in both", "inbox", "needs-review"),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
noteToolDescriptors()[0],
|
noteToolDescriptors()[0],
|
||||||
@@ -383,6 +385,7 @@ type brainPromoteArgs struct {
|
|||||||
Wing string `json:"wing"`
|
Wing string `json:"wing"`
|
||||||
Hall string `json:"hall"`
|
Hall string `json:"hall"`
|
||||||
Slug string `json:"slug,omitempty"`
|
Slug string `json:"slug,omitempty"`
|
||||||
|
Queue string `json:"queue,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// brainPromote moves a raw/ note into the structured wiki (frontmatter
|
// brainPromote moves a raw/ note into the structured wiki (frontmatter
|
||||||
@@ -395,7 +398,7 @@ func (s *Server) brainPromote(ctx context.Context, args json.RawMessage) (json.R
|
|||||||
}
|
}
|
||||||
origin, writtenBy := provenanceFrom(ctx)
|
origin, writtenBy := provenanceFrom(ctx)
|
||||||
relPath, err := api.PromoteNote(s.brainDir, api.PromoteOptions{
|
relPath, err := api.PromoteNote(s.brainDir, api.PromoteOptions{
|
||||||
Filename: a.Filename, Wing: a.Wing, Hall: a.Hall, Slug: a.Slug,
|
Filename: a.Filename, Wing: a.Wing, Hall: a.Hall, Slug: a.Slug, Queue: a.Queue,
|
||||||
Origin: origin, WrittenBy: writtenBy,
|
Origin: origin, WrittenBy: writtenBy,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -408,6 +411,7 @@ func (s *Server) brainPromote(ctx context.Context, args json.RawMessage) (json.R
|
|||||||
type brainRejectArgs struct {
|
type brainRejectArgs struct {
|
||||||
Filename string `json:"filename"`
|
Filename string `json:"filename"`
|
||||||
Reason string `json:"reason,omitempty"`
|
Reason string `json:"reason,omitempty"`
|
||||||
|
Queue string `json:"queue,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// brainReject records a decision not to promote a raw/ note (api.RejectNote:
|
// brainReject records a decision not to promote a raw/ note (api.RejectNote:
|
||||||
@@ -418,7 +422,7 @@ func (s *Server) brainReject(_ context.Context, args json.RawMessage) (json.RawM
|
|||||||
if err := json.Unmarshal(args, &a); err != nil {
|
if err := json.Unmarshal(args, &a); err != nil {
|
||||||
return nil, fmt.Errorf("parse args: %w", err)
|
return nil, fmt.Errorf("parse args: %w", err)
|
||||||
}
|
}
|
||||||
relPath, err := api.RejectNote(s.brainDir, api.RejectOptions{Filename: a.Filename, Reason: a.Reason})
|
relPath, err := api.RejectNote(s.brainDir, api.RejectOptions{Filename: a.Filename, Reason: a.Reason, Queue: a.Queue})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -431,3 +431,28 @@ func TestBrainRejectMissingSourceErrors(t *testing.T) {
|
|||||||
resp := toolCall(t, srv, "brain_reject", map[string]any{"filename": "ghost.md"})
|
resp := toolCall(t, srv, "brain_reject", map[string]any{"filename": "ghost.md"})
|
||||||
require.NotNil(t, resp["error"])
|
require.NotNil(t, resp["error"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// brain_pending lists two entries for one name when a top-level note and a
|
||||||
|
// parked one share it; the tool must let the caller say which it means.
|
||||||
|
func TestBrainPromoteAndRejectTakeTheQueueOfAnAmbiguousName(t *testing.T) {
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
raw := filepath.Join(brainDir, "raw")
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(raw, "needs-review"), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(raw, "a.md"), []byte("---\ncreated_at: 2026-10-20T00:00:00Z\n---\nTOP\n"), 0o644))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(raw, "needs-review", "a.md"), []byte("---\nproposed_wing: homelab\n---\nPARKED\n"), 0o644))
|
||||||
|
srv := mcp.NewServer(brainDir, nil, nil, nil)
|
||||||
|
|
||||||
|
resp := toolCall(t, srv, "brain_promote", map[string]any{"filename": "a.md", "wing": "homelab", "hall": "facts"})
|
||||||
|
require.NotNil(t, resp["error"], "an ambiguous name is refused, not guessed")
|
||||||
|
|
||||||
|
resp = toolCall(t, srv, "brain_promote", map[string]any{"filename": "a.md", "wing": "homelab", "hall": "facts", "queue": "needs-review"})
|
||||||
|
require.Nil(t, resp["error"], "got: %v", resp["error"])
|
||||||
|
got, err := os.ReadFile(filepath.Join(brainDir, "wiki/homelab/facts/a.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(got), "PARKED")
|
||||||
|
|
||||||
|
resp = toolCall(t, srv, "brain_reject", map[string]any{"filename": "a.md", "queue": "inbox", "reason": "no"})
|
||||||
|
require.Nil(t, resp["error"], "got: %v", resp["error"])
|
||||||
|
_, err = os.Stat(filepath.Join(brainDir, "raw/rejected/a.md"))
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,309 +0,0 @@
|
|||||||
// Package migrate moves pages out of the retired wiki/{sources,concepts,
|
|
||||||
// entities}/ layout into wiki/<wing>/{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/<wing>/<hall>/ 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 ""
|
|
||||||
}
|
|
||||||
@@ -1,243 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -7,8 +7,6 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/wiki"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/wiki"
|
||||||
@@ -90,8 +88,7 @@ func BackfillRefs(ctx context.Context, brainDir string) (int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
for relPath, page := range pending {
|
for relPath, page := range pending {
|
||||||
dest := filepath.Join(brainDir, filepath.FromSlash(relPath))
|
if err := bfs.PutRegular(relPath, []byte(page.Content)); err != nil {
|
||||||
if err := os.WriteFile(dest, []byte(page.Content), 0o644); err != nil {
|
|
||||||
return 0, fmt.Errorf("write %s: %w", relPath, err)
|
return 0, fmt.Errorf("write %s: %w", relPath, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,16 +17,18 @@ func BuildPages(rawPages []RawPage, sourceSlug, date string) ([]wiki.Page, []str
|
|||||||
return BuildPagesIn("", rawPages, sourceSlug, date)
|
return BuildPagesIn("", rawPages, sourceSlug, date)
|
||||||
}
|
}
|
||||||
|
|
||||||
// BuildPagesIn is BuildPages for a wing: sources go to wiki/<wing>/sources/,
|
// BuildPagesIn is BuildPages for a wing: a source goes to wiki/<wing>/sources/
|
||||||
// concepts and entities to wiki/<wing>/facts/ with `kind:` recording which.
|
// with wing/hall frontmatter. Concepts and entities stay in the shared
|
||||||
// An empty wing is the retired wiki/{sources,concepts,entities}/ layout.
|
// wiki/concepts/ and wiki/entities/, since their slug is the graph's global
|
||||||
|
// identity and they are cited from many wings. An empty wing is the retired
|
||||||
|
// layout for sources too.
|
||||||
func BuildPagesIn(wing string, rawPages []RawPage, sourceSlug, date string) ([]wiki.Page, []string) {
|
func BuildPagesIn(wing string, rawPages []RawPage, sourceSlug, date string) ([]wiki.Page, []string) {
|
||||||
out := make([]wiki.Page, 0, len(rawPages))
|
out := make([]wiki.Page, 0, len(rawPages))
|
||||||
var warnings []string
|
var warnings []string
|
||||||
for _, rp := range rawPages {
|
for _, rp := range rawPages {
|
||||||
// The type becomes a directory under wiki/; "../raw" wrote outside it
|
// The type becomes a directory under wiki/; "../raw" wrote outside it
|
||||||
// (PR #147 review). Only a plain lower-case name is a page type.
|
// (PR #147 review). Only a plain lower-case name is a page type.
|
||||||
if !validPageType.MatchString(rp.Type) {
|
if !validPageType.MatchString(rp.Type) || (wing != "" && !layoutType(rp.Type)) {
|
||||||
warnings = append(warnings, fmt.Sprintf("skipped page with invalid type %q", rp.Type))
|
warnings = append(warnings, fmt.Sprintf("skipped page with invalid type %q", rp.Type))
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
@@ -40,6 +42,11 @@ func BuildPagesIn(wing string, rawPages []RawPage, sourceSlug, date string) ([]w
|
|||||||
return out, warnings
|
return out, warnings
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// layoutType reports whether a wing-mode page type has a place in the layout.
|
||||||
|
// The type comes from the model, so from the clip being ingested: anything else
|
||||||
|
// would name a directory (wiki/<type>/) of the model's choosing.
|
||||||
|
func layoutType(t string) bool { return t == "source" || t == "concept" || t == "entity" }
|
||||||
|
|
||||||
var validPageType = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
|
var validPageType = regexp.MustCompile(`^[a-z][a-z0-9-]*$`)
|
||||||
|
|
||||||
func computeSlug(rp RawPage, sourceSlug string) string {
|
func computeSlug(rp RawPage, sourceSlug string) string {
|
||||||
@@ -66,20 +73,13 @@ func buildPage(rp RawPage, sourceSlug, date, wing string) wiki.Page {
|
|||||||
dir = "wiki/" + rp.Type
|
dir = "wiki/" + rp.Type
|
||||||
}
|
}
|
||||||
|
|
||||||
hall := ""
|
if wing != "" && rp.Type == "source" {
|
||||||
if wing != "" {
|
dir = "wiki/" + wing + "/sources"
|
||||||
switch rp.Type {
|
|
||||||
case "source":
|
|
||||||
hall = "sources"
|
|
||||||
default:
|
|
||||||
hall = "facts"
|
|
||||||
}
|
|
||||||
dir = "wiki/" + wing + "/" + hall
|
|
||||||
}
|
}
|
||||||
path := dir + "/" + slug + ".md"
|
path := dir + "/" + slug + ".md"
|
||||||
fm := buildFrontmatter(rp, date)
|
fm := buildFrontmatter(rp, date)
|
||||||
if wing != "" {
|
if wing != "" && rp.Type == "source" {
|
||||||
fm = addWingFrontmatter(fm, wing, hall, rp.Type)
|
fm = addWingFrontmatter(fm, wing, "sources")
|
||||||
}
|
}
|
||||||
|
|
||||||
return wiki.Page{
|
return wiki.Page{
|
||||||
@@ -142,14 +142,10 @@ func buildFrontmatter(rp RawPage, date string) string {
|
|||||||
return sb.String()
|
return sb.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
// addWingFrontmatter inserts wing/hall (and kind for facts pages) right after
|
// addWingFrontmatter inserts wing/hall right after the opening fence of an
|
||||||
// the opening fence of an already-built frontmatter block.
|
// already-built frontmatter block.
|
||||||
func addWingFrontmatter(fm, wing, hall, rpType string) string {
|
func addWingFrontmatter(fm, wing, hall string) string {
|
||||||
extra := "wing: " + wing + "\nhall: " + hall + "\n"
|
return strings.Replace(fm, "---\n", "---\nwing: "+wing+"\nhall: "+hall+"\n", 1)
|
||||||
if hall == "facts" {
|
|
||||||
extra += "kind: " + rpType + "\n"
|
|
||||||
}
|
|
||||||
return strings.Replace(fm, "---\n", "---\n"+extra, 1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func yamlScalar(s string) string {
|
func yamlScalar(s string) string {
|
||||||
|
|||||||
@@ -10,8 +10,8 @@ import (
|
|||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -68,6 +68,12 @@ func Run(ctx context.Context, cfg Config, brainDir, content, source string, dryR
|
|||||||
}
|
}
|
||||||
|
|
||||||
sourceSlug := wiki.Slug(source)
|
sourceSlug := wiki.Slug(source)
|
||||||
|
if cfg.Wing != "" {
|
||||||
|
var rerr error
|
||||||
|
if sourceSlug, rerr = resolveSourceSlug(brainDir, cfg.Wing, sourceSlug); rerr != nil {
|
||||||
|
return Result{}, rerr
|
||||||
|
}
|
||||||
|
}
|
||||||
date := time.Now().UTC().Format("2006-01-02")
|
date := time.Now().UTC().Format("2006-01-02")
|
||||||
|
|
||||||
// Drift check BEFORE any extraction: an unchanged source costs a full LLM
|
// Drift check BEFORE any extraction: an unchanged source costs a full LLM
|
||||||
@@ -108,7 +114,19 @@ func Run(ctx context.Context, cfg Config, brainDir, content, source string, dryR
|
|||||||
meta.BodyHash = bodyHash
|
meta.BodyHash = bodyHash
|
||||||
applySourceMeta(allRaw, meta)
|
applySourceMeta(allRaw, meta)
|
||||||
|
|
||||||
return buildAndWrite(ctx, cfg.Wing, allRaw, sourceSlug, date, brainDir, source, inventory, allWarnings, dryRun)
|
res, err := buildAndWrite(ctx, cfg.Wing, allRaw, sourceSlug, date, brainDir, source, inventory, allWarnings, dryRun)
|
||||||
|
if err != nil || cfg.Wing == "" {
|
||||||
|
return res, err
|
||||||
|
}
|
||||||
|
// In a wing every ingest must leave its source page: it is what ties the
|
||||||
|
// note to the wiki (and carries the hash the drift check reads). Without one
|
||||||
|
// nothing of the note exists, and the caller would archive it as processed.
|
||||||
|
for _, p := range res.Pages {
|
||||||
|
if isSourcePath(p) {
|
||||||
|
return res, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return res, fmt.Errorf("no source page was written for %q; nothing of the note was ingested (%s)", source, strings.Join(res.Warnings, "; "))
|
||||||
}
|
}
|
||||||
|
|
||||||
// RunRaw runs the pipeline on pre-parsed RawPages, skipping the LLM extraction
|
// RunRaw runs the pipeline on pre-parsed RawPages, skipping the LLM extraction
|
||||||
@@ -139,14 +157,25 @@ func buildAndWrite(ctx context.Context, wing string, rawPages []RawPage, sourceS
|
|||||||
warnings = append(warnings, linkWarnings...)
|
warnings = append(warnings, linkWarnings...)
|
||||||
withRefs := injectSourceRefs(canonicalized, inventory, brainDir)
|
withRefs := injectSourceRefs(canonicalized, inventory, brainDir)
|
||||||
merged := mergeAll(withRefs)
|
merged := mergeAll(withRefs)
|
||||||
|
if wing != "" {
|
||||||
|
var clashWarnings []string
|
||||||
|
var cerr error
|
||||||
|
merged, clashWarnings, cerr = dropSlugClashes(brainDir, merged)
|
||||||
|
if cerr != nil {
|
||||||
|
return Result{}, cerr
|
||||||
|
}
|
||||||
|
warnings = append(warnings, clashWarnings...)
|
||||||
|
}
|
||||||
|
|
||||||
|
if wing != "" {
|
||||||
|
// The source page carries source_hash, which the drift check trusts: it
|
||||||
|
// goes last, so a batch that fails midway leaves nothing to hide behind.
|
||||||
|
sort.SliceStable(merged, func(i, j int) bool { return !isSourcePath(merged[i].Path) && isSourcePath(merged[j].Path) })
|
||||||
|
}
|
||||||
|
|
||||||
var written []string
|
var written []string
|
||||||
for _, page := range merged {
|
for _, page := range merged {
|
||||||
if !dryRun {
|
if !dryRun {
|
||||||
dest := filepath.Join(brainDir, filepath.FromSlash(page.Path))
|
|
||||||
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
|
||||||
return Result{}, fmt.Errorf("mkdir for %s: %w", page.Path, err)
|
|
||||||
}
|
|
||||||
content, conflicts, err := reviseOrCreate(brainDir, page)
|
content, conflicts, err := reviseOrCreate(brainDir, page)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return Result{}, err
|
return Result{}, err
|
||||||
@@ -165,7 +194,9 @@ func buildAndWrite(ctx context.Context, wing string, rawPages []RawPage, sourceS
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return Result{}, fmt.Errorf("stamp %s: %w", page.Path, err)
|
return Result{}, fmt.Errorf("stamp %s: %w", page.Path, err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(dest, []byte(stamped), 0o644); err != nil {
|
// Written through the brain root, never through a symlinked
|
||||||
|
// directory or page (hyperguild#158).
|
||||||
|
if err := writeBrainFile(brainDir, page.Path, []byte(stamped)); err != nil {
|
||||||
return Result{}, fmt.Errorf("write %s: %w", page.Path, err)
|
return Result{}, fmt.Errorf("write %s: %w", page.Path, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -224,6 +255,17 @@ func readSourcePage(brainDir, wing, sourceSlug string) string {
|
|||||||
return string(raw)
|
return string(raw)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// writeBrainFile creates or replaces one brain-relative file through the
|
||||||
|
// brain root (hyperguild#158).
|
||||||
|
func writeBrainFile(brainDir, rel string, data []byte) error {
|
||||||
|
bfs, err := brainfs.Open(brainDir)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() { _ = bfs.Close() }()
|
||||||
|
return bfs.PutRegular(rel, data)
|
||||||
|
}
|
||||||
|
|
||||||
// readBrainFile reads one brain-relative file through the brain root,
|
// readBrainFile reads one brain-relative file through the brain root,
|
||||||
// regular files only (hyperguild#157).
|
// regular files only (hyperguild#157).
|
||||||
func readBrainFile(brainDir, rel string) ([]byte, error) {
|
func readBrainFile(brainDir, rel string) ([]byte, error) {
|
||||||
@@ -312,3 +354,150 @@ func loadSchema(brainDir string) string {
|
|||||||
}
|
}
|
||||||
return strings.TrimSpace(string(b))
|
return strings.TrimSpace(string(b))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveSourceSlug returns the slug this wing's source page uses. A slug is a
|
||||||
|
// page's graph identity and must be unique brain-wide, but two wings dropping a
|
||||||
|
// file of the same name (notes.md, readme.md) is ordinary, so when the plain
|
||||||
|
// slug is taken by anything but this wing's own source page the wing name is
|
||||||
|
// appended (talk-homelab, then talk-homelab-2, ...). The choice is stable: a
|
||||||
|
// re-ingest finds the page it made, because that page is "its own".
|
||||||
|
func resolveSourceSlug(brainDir, wing, base string) (string, error) {
|
||||||
|
for n := 1; n <= 100; n++ {
|
||||||
|
cand := base
|
||||||
|
switch n {
|
||||||
|
case 1:
|
||||||
|
case 2:
|
||||||
|
cand = base + "-" + wing
|
||||||
|
default:
|
||||||
|
cand = fmt.Sprintf("%s-%s-%d", base, wing, n-1)
|
||||||
|
}
|
||||||
|
existing, err := wiki.FindSlugs(brainDir, cand)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("check source slug: %w", err)
|
||||||
|
}
|
||||||
|
own := "wiki/" + wing + "/sources/" + cand + ".md"
|
||||||
|
free, hasOwn := true, false
|
||||||
|
for _, p := range existing {
|
||||||
|
if p == own {
|
||||||
|
hasOwn = true
|
||||||
|
} else {
|
||||||
|
free = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// The wing's own page is its identity: keep using it even if some other
|
||||||
|
// page shares the slug (that other page is the one dropSlugClashes skips).
|
||||||
|
if hasOwn {
|
||||||
|
return cand, nil
|
||||||
|
}
|
||||||
|
if free {
|
||||||
|
return cand, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("no free source slug for %q in wing %q", base, wing)
|
||||||
|
}
|
||||||
|
|
||||||
|
// dropSlugClashes removes, from the pages about to be written in a wing, any
|
||||||
|
// page whose slug belongs to a different page. A slug is the graph identity, so
|
||||||
|
// two pages must not share one; and a page that shares one cannot be updated
|
||||||
|
// again, because every later ingest sees the other as its clash.
|
||||||
|
//
|
||||||
|
// Two kinds of clash, both by slug, neither failing the ingest (one extracted
|
||||||
|
// page must not be able to lock a source out of re-ingest):
|
||||||
|
// - inside the batch (a clip named notes.md whose model output also has an
|
||||||
|
// entity "Notes"): the source wins over an entity, an entity over a concept;
|
||||||
|
// - against the brain: an entity or concept whose slug is another page's. The
|
||||||
|
// SOURCE page is exempt: it is the note's own output, and its slug was
|
||||||
|
// already chosen free (or its own) by resolveSourceSlug.
|
||||||
|
//
|
||||||
|
// A page's own path is never a clash (a shared entity merging into itself).
|
||||||
|
func dropSlugClashes(brainDir string, pages []wiki.Page) ([]wiki.Page, []string, error) {
|
||||||
|
rank := func(p wiki.Page) int {
|
||||||
|
switch {
|
||||||
|
case isSourcePath(p.Path):
|
||||||
|
return 0
|
||||||
|
case strings.HasPrefix(p.Path, "wiki/entities/"):
|
||||||
|
return 1
|
||||||
|
case strings.HasPrefix(p.Path, "wiki/concepts/"):
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
return 3
|
||||||
|
}
|
||||||
|
slugOf := func(p wiki.Page) string { return strings.TrimSuffix(filepath.Base(p.Path), ".md") }
|
||||||
|
|
||||||
|
// Group by slug; where several pages with different paths want one slug,
|
||||||
|
// the source wins, then a page that already exists on disk (so its update
|
||||||
|
// is not lost), then entity over concept.
|
||||||
|
bySlug := map[string][]wiki.Page{}
|
||||||
|
for _, p := range pages {
|
||||||
|
bySlug[slugOf(p)] = append(bySlug[slugOf(p)], p)
|
||||||
|
}
|
||||||
|
winner := map[string]wiki.Page{}
|
||||||
|
for slug, group := range bySlug {
|
||||||
|
best := group[0]
|
||||||
|
if distinctPaths(group) > 1 {
|
||||||
|
existing, err := wiki.FindSlugs(brainDir, slug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("check slug %q: %w", slug, err)
|
||||||
|
}
|
||||||
|
onDisk := func(p wiki.Page) int {
|
||||||
|
for _, e := range existing {
|
||||||
|
if e == p.Path {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
for _, p := range group[1:] {
|
||||||
|
if rank(p) != rank(best) && (rank(p) == 0 || rank(best) == 0) {
|
||||||
|
if rank(p) < rank(best) {
|
||||||
|
best = p
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if onDisk(p) < onDisk(best) || (onDisk(p) == onDisk(best) && rank(p) < rank(best)) {
|
||||||
|
best = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
winner[slug] = best
|
||||||
|
}
|
||||||
|
|
||||||
|
var kept []wiki.Page
|
||||||
|
var warnings []string
|
||||||
|
for _, p := range pages {
|
||||||
|
slug := slugOf(p)
|
||||||
|
if w := winner[slug]; w.Path != p.Path {
|
||||||
|
warnings = append(warnings, fmt.Sprintf("skipped %s: slug %q is also used by %s in this batch", p.Path, slug, w.Path))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if rank(p) == 0 {
|
||||||
|
kept = append(kept, p)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
existing, err := wiki.FindSlugs(brainDir, slug)
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, fmt.Errorf("check slug %q: %w", slug, err)
|
||||||
|
}
|
||||||
|
clash := ""
|
||||||
|
for _, e := range existing {
|
||||||
|
if e != p.Path {
|
||||||
|
clash = e
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if clash != "" {
|
||||||
|
warnings = append(warnings, fmt.Sprintf("skipped %s: slug %q is already used by %s", p.Path, slug, clash))
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
kept = append(kept, p)
|
||||||
|
}
|
||||||
|
return kept, warnings, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func distinctPaths(pages []wiki.Page) int {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
for _, p := range pages {
|
||||||
|
seen[p.Path] = true
|
||||||
|
}
|
||||||
|
return len(seen)
|
||||||
|
}
|
||||||
|
|||||||
@@ -32,6 +32,13 @@ func Resolve(proposed []wiki.Page, inventory map[wiki.PageType][]wiki.Entry) []w
|
|||||||
|
|
||||||
out := make([]wiki.Page, 0, len(proposed))
|
out := make([]wiki.Page, 0, len(proposed))
|
||||||
for _, page := range proposed {
|
for _, page := range proposed {
|
||||||
|
if isWingSourcePath(page.Path) {
|
||||||
|
// In a wing the source's path is its filename-derived slug. Matching
|
||||||
|
// by TITLE would merge two different notes the model happened to
|
||||||
|
// title alike ("Home", "Untitled") into one page.
|
||||||
|
out = append(out, page)
|
||||||
|
continue
|
||||||
|
}
|
||||||
pt := pageTypeOf(page)
|
pt := pageTypeOf(page)
|
||||||
title := extractTitle(page.Content)
|
title := extractTitle(page.Content)
|
||||||
k := key{pt: pt, normalized: normalizeTitle(title)}
|
k := key{pt: pt, normalized: normalizeTitle(title)}
|
||||||
@@ -56,17 +63,12 @@ func normalizeTitle(s string) string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// pageTypeOf is the page's type: from its directory in the retired layout, or
|
// 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/).
|
// "sources" for wiki/<wing>/sources/<slug>.md. Concepts and entities are always
|
||||||
|
// in their shared directories.
|
||||||
func pageTypeOf(p wiki.Page) wiki.PageType {
|
func pageTypeOf(p wiki.Page) wiki.PageType {
|
||||||
parts := strings.Split(filepath.ToSlash(p.Path), "/")
|
parts := strings.Split(filepath.ToSlash(p.Path), "/")
|
||||||
if len(parts) >= 4 && parts[0] == "wiki" {
|
if len(parts) == 4 && parts[0] == "wiki" && parts[2] == "sources" {
|
||||||
switch parts[2] {
|
|
||||||
case "sources":
|
|
||||||
return wiki.PageTypeSource
|
return wiki.PageTypeSource
|
||||||
case "facts":
|
|
||||||
pt, _ := wiki.KindFromContent(p.Content)
|
|
||||||
return pt
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return pageTypeFromPath(p.Path)
|
return pageTypeFromPath(p.Path)
|
||||||
}
|
}
|
||||||
@@ -102,3 +104,9 @@ func extractTitle(content string) string {
|
|||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isWingSourcePath reports whether path is wiki/<wing>/sources/<slug>.md.
|
||||||
|
func isWingSourcePath(path string) bool {
|
||||||
|
parts := strings.Split(filepath.ToSlash(path), "/")
|
||||||
|
return len(parts) == 4 && parts[0] == "wiki" && parts[2] == "sources"
|
||||||
|
}
|
||||||
|
|||||||
@@ -165,3 +165,53 @@ func TestBackfillRefsDoesNotCopyAPageSwappedInBehindASymlink(t *testing.T) {
|
|||||||
require.Contains(t, read(), "[[s|S]]", "positive control: the regular page gets the source ref")
|
require.Contains(t, read(), "[[s|S]]", "positive control: the regular page gets the source ref")
|
||||||
brainfstest.AssertNoLeakFrom(t, page, inside, target, 3*time.Second, read)
|
brainfstest.AssertNoLeakFrom(t, page, inside, target, 3*time.Second, read)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hyperguild#158: BackfillRefs read a regular concept page, a symlink could
|
||||||
|
// be swapped in, and os.WriteFile then followed it and overwrote the link's
|
||||||
|
// target. The target is not restored between rounds here, so any
|
||||||
|
// write-through shows.
|
||||||
|
func TestBackfillRefsNeverWritesThroughAPageSwappedForASymlink(t *testing.T) {
|
||||||
|
brain := t.TempDir()
|
||||||
|
mkdirs(t, brain, "wiki/sources", "wiki/concepts", "raw")
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(brain, "wiki", "sources", "s.md"), []byte("---\ntitle: S\n---\n[[c|C]]\n"), 0o644))
|
||||||
|
page := filepath.Join(brain, "wiki", "concepts", "c.md")
|
||||||
|
target := filepath.Join(brain, "raw", "secret.md")
|
||||||
|
require.NoError(t, os.WriteFile(target, []byte("untouched"), 0o644))
|
||||||
|
inside := "---\ntitle: C\n---\nbody\n"
|
||||||
|
require.NoError(t, os.WriteFile(page, []byte(inside), 0o644))
|
||||||
|
brainfstest.StartSwap(t, page, inside, target, false)
|
||||||
|
ok := 0
|
||||||
|
for deadline := time.Now().Add(2 * time.Second); time.Now().Before(deadline); {
|
||||||
|
if _, err := BackfillRefs(context.Background(), brain); err == nil {
|
||||||
|
ok++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got, _ := os.ReadFile(target)
|
||||||
|
assert.Equal(t, "untouched", string(got), "BackfillRefs wrote through a swapped-in symlink")
|
||||||
|
assert.Positive(t, ok, "positive control: some backfills ran")
|
||||||
|
}
|
||||||
|
|
||||||
|
// hyperguild#158: buildAndWrite wrote each page with os.WriteFile by path, so
|
||||||
|
// a page read as regular and swapped for a symlink before the write was
|
||||||
|
// written through. The target is not restored between rounds, so any
|
||||||
|
// write-through shows.
|
||||||
|
func TestBuildAndWriteNeverWritesThroughAPageSwappedForASymlink(t *testing.T) {
|
||||||
|
brain := t.TempDir()
|
||||||
|
mkdirs(t, brain, "wiki/sources", "wiki/concepts", "wiki/entities", "raw")
|
||||||
|
page := filepath.Join(brain, "wiki", "concepts", "zq-concept.md")
|
||||||
|
target := filepath.Join(brain, "raw", "secret.md")
|
||||||
|
require.NoError(t, os.WriteFile(target, []byte("untouched"), 0o644))
|
||||||
|
inside := "---\ntitle: Zq Concept\n---\nbody\n"
|
||||||
|
require.NoError(t, os.WriteFile(page, []byte(inside), 0o644))
|
||||||
|
pages := []RawPage{{Title: "Zq Concept", Type: "concept", Content: "## About\n\nmore\n"}}
|
||||||
|
brainfstest.StartSwap(t, page, inside, target, false)
|
||||||
|
ok := 0
|
||||||
|
for deadline := time.Now().Add(2 * time.Second); time.Now().Before(deadline); {
|
||||||
|
if _, err := RunRaw(context.Background(), brain, "zq source", pages, false); err == nil {
|
||||||
|
ok++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
got, _ := os.ReadFile(target)
|
||||||
|
assert.Equal(t, "untouched", string(got), "buildAndWrite wrote through a swapped-in symlink")
|
||||||
|
assert.Positive(t, ok, "positive control: some runs returned without error")
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
@@ -20,12 +21,14 @@ func wingCfg(wing string, pages ...RawPage) Config {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func sourceAndEntity() []RawPage {
|
func sourceEntityConcept() []RawPage {
|
||||||
return []RawPage{
|
return []RawPage{
|
||||||
{Title: "Dark Factory Talk", Type: "source", Subtype: "video", Domain: "ai",
|
{Title: "Dark Factory Talk", Type: "source", Subtype: "video", Domain: "ai",
|
||||||
Content: "## Summary\n\nA talk.\n\n## Entities Mentioned\n\n[[Jev]]\n"},
|
Content: "## Summary\n\nA talk.\n\n## Entities Mentioned\n\n[[Jev]]\n\n## Concepts Introduced or Reinforced\n\n[[Dark Factory]]\n"},
|
||||||
{Title: "Jev", Type: "entity", Subtype: "tool", Domain: "ai",
|
{Title: "Jev", Type: "entity", Subtype: "tool", Domain: "ai",
|
||||||
Content: "## Definition\n\nA decision model.\n\n## Sources\n"},
|
Content: "## Definition\n\nA decision model.\n\n## Sources\n"},
|
||||||
|
{Title: "Dark Factory", Type: "concept", Domain: "ai",
|
||||||
|
Content: "## Definition\n\nA fully automated build line.\n\n## Sources\n"},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,73 +44,141 @@ func read(t *testing.T, dir, rel string) string {
|
|||||||
return string(b)
|
return string(b)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRun_WithAWingWritesIntoThatWingsHalls(t *testing.T) {
|
// With a wing, the SOURCE belongs to the wing; concepts and entities are the
|
||||||
|
// shared cross-wing layer (wiki/concepts, wiki/entities) because their slug is
|
||||||
|
// the graph's global identity and they are cited from many wings.
|
||||||
|
func TestRun_WithAWingTheSourceIsPerWingAndConceptsEntitiesAreShared(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
_, err := Run(context.Background(), wingCfg("radar", sourceAndEntity()...), dir, "body of the talk", "dark-factory-talk", false)
|
_, err := Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "body of the talk", "dark-factory-talk", false)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
src := read(t, dir, "wiki/radar/sources/dark-factory-talk.md")
|
src := read(t, dir, "wiki/web-clip/sources/dark-factory-talk.md")
|
||||||
assert.Contains(t, src, "wing: radar")
|
assert.Contains(t, src, "wing: web-clip")
|
||||||
assert.Contains(t, src, "hall: sources")
|
assert.Contains(t, src, "hall: sources")
|
||||||
|
assert.True(t, exists(dir, "wiki/entities/jev.md"))
|
||||||
ent := read(t, dir, "wiki/radar/facts/jev.md")
|
assert.True(t, exists(dir, "wiki/concepts/dark-factory.md"))
|
||||||
assert.Contains(t, ent, "wing: radar")
|
assert.NotContains(t, read(t, dir, "wiki/entities/jev.md"), "wing:", "a shared page belongs to no single wing")
|
||||||
assert.Contains(t, ent, "hall: facts")
|
assert.False(t, exists(dir, "wiki/web-clip/facts"), "no per-wing copy of a shared page")
|
||||||
assert.Contains(t, ent, "kind: entity")
|
assert.False(t, exists(dir, "wiki/sources"), "the retired source layout is not used")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRun_WithAWingDoesNotTouchTheRetiredLayout(t *testing.T) {
|
func TestRun_WithAWingTheSharedPageLinksBackToTheSource(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
_, err := Run(context.Background(), wingCfg("radar", sourceAndEntity()...), dir, "body of the talk", "dark-factory-talk", false)
|
_, err := Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "body", "dark-factory-talk", false)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
for _, retired := range []string{"wiki/sources", "wiki/concepts", "wiki/entities"} {
|
assert.Contains(t, read(t, dir, "wiki/entities/jev.md"), "[[dark-factory-talk|Dark Factory Talk]]")
|
||||||
assert.False(t, exists(dir, retired), "%s is the retired layout and must not be written", retired)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRun_WithAWingTheSourceLinksBackFromTheEntity(t *testing.T) {
|
func TestRun_TheSameEntityCitedFromTwoWingsIsOnePageWithBothSources(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
_, err := Run(context.Background(), wingCfg("radar", sourceAndEntity()...), dir, "body of the talk", "dark-factory-talk", false)
|
_, err := Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "first body", "talk-one", 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)
|
require.NoError(t, err)
|
||||||
|
// A wing must already exist to be named; create it the way a note would.
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/homelab/facts"), 0o755))
|
||||||
|
|
||||||
again := []RawPage{
|
again := []RawPage{
|
||||||
{Title: "Second Talk", Type: "source", Subtype: "video", Content: "## Summary\n\nMore.\n\n## Entities Mentioned\n\n[[The Jev]]\n"},
|
{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"},
|
{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)
|
_, err = Run(context.Background(), wingCfg("homelab", again...), dir, "second body", "talk-two", false)
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
files, err := filepath.Glob(filepath.Join(dir, "wiki/radar/facts/*.md"))
|
files, err := filepath.Glob(filepath.Join(dir, "wiki/entities/*.md"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.Len(t, files, 1, "an entity matched by title (leading article ignored) is merged, not duplicated")
|
assert.Len(t, files, 1, "one entity page, matched by title with the leading article ignored")
|
||||||
assert.Contains(t, read(t, dir, "wiki/radar/facts/jev.md"), "talk-two")
|
jev := read(t, dir, "wiki/entities/jev.md")
|
||||||
|
assert.Contains(t, jev, "talk-one")
|
||||||
|
assert.Contains(t, jev, "talk-two")
|
||||||
|
assert.False(t, exists(dir, "wiki/homelab/facts/jev.md"), "no second copy in the second wing")
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRun_WithAWingNeverMergesIntoAnotherWingsPage(t *testing.T) {
|
// A source slug is the graph's identity too: a second wing must not take over
|
||||||
|
// the slug of a page that lives elsewhere. Two wings dropping a file of the same
|
||||||
|
// name (notes.md, readme.md) is ordinary, so the second source gets a distinct,
|
||||||
|
// stable slug instead of failing.
|
||||||
|
func TestRun_ASourceWhoseSlugIsTakenElsewhereGetsADistinctStableSlug(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
other := "---\ntitle: Jev\nwing: client-x\nhall: facts\nkind: entity\n---\n\n## Definition\n\nPrivate to another wing.\n"
|
_, err := Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "body", "talk", false)
|
||||||
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)
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/homelab/facts"), 0o755))
|
||||||
|
|
||||||
assert.Equal(t, other, read(t, dir, "wiki/client-x/facts/jev.md"), "another wing's page is never read into or rewritten")
|
_, err = Run(context.Background(), wingCfg("homelab", sourceEntityConcept()...), dir, "different body", "talk", false)
|
||||||
assert.True(t, exists(dir, "wiki/radar/facts/jev.md"))
|
require.NoError(t, err)
|
||||||
|
assert.True(t, exists(dir, "wiki/web-clip/sources/talk.md"), "the first wing's page is untouched")
|
||||||
|
assert.True(t, exists(dir, "wiki/homelab/sources/talk-homelab.md"), "the second gets a wing-qualified slug")
|
||||||
|
assert.False(t, exists(dir, "wiki/homelab/sources/talk.md"))
|
||||||
|
|
||||||
|
_, err = Run(context.Background(), wingCfg("homelab", sourceEntityConcept()...), dir, "different body, edited", "talk", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
files, _ := filepath.Glob(filepath.Join(dir, "wiki/homelab/sources/*.md"))
|
||||||
|
assert.Len(t, files, 1, "re-ingesting finds the same page, it does not make talk-homelab-2")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRun_ASourceWhoseSlugIsTakenByARetiredPageOrASharedPageGetsADistinctSlug(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/sources"), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "wiki/sources/talk.md"), []byte("---\ntitle: Talk\n---\nold"), 0o644))
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/entities"), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "wiki/entities/other.md"), []byte("---\ntitle: Other\n---\nx"), 0o644))
|
||||||
|
|
||||||
|
_, err := Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "body", "talk", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, exists(dir, "wiki/web-clip/sources/talk-web-clip.md"))
|
||||||
|
assert.Equal(t, "---\ntitle: Talk\n---\nold", read(t, dir, "wiki/sources/talk.md"), "the retired page is untouched")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The other direction: a model-extracted entity or concept whose slug is a
|
||||||
|
// source's slug in some wing would lock that source out of re-ingest. The page
|
||||||
|
// is skipped with a warning; the ingest itself succeeds.
|
||||||
|
func TestRun_AnEntityWhoseSlugIsASourceElsewhereIsSkippedNotWritten(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
_, err := Run(context.Background(), wingCfg("web-clip", []RawPage{
|
||||||
|
{Title: "Jev", Type: "source", Subtype: "video", Content: "## Summary\n\nA clip titled Jev.\n"},
|
||||||
|
}...), dir, "body", "jev", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/homelab/facts"), 0o755))
|
||||||
|
|
||||||
|
res, err := Run(context.Background(), wingCfg("homelab", sourceEntityConcept()...), dir, "another body", "other-talk", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, exists(dir, "wiki/entities/jev.md"), "the entity would take the source's slug")
|
||||||
|
assert.Contains(t, strings.Join(res.Warnings, "\n"), "jev")
|
||||||
|
|
||||||
|
_, err = Run(context.Background(), wingCfg("web-clip", []RawPage{
|
||||||
|
{Title: "Jev", Type: "source", Subtype: "video", Content: "## Summary\n\nEdited.\n"},
|
||||||
|
}...), dir, "body, edited", "jev", false)
|
||||||
|
require.NoError(t, err, "the first wing can still re-ingest its own source")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The type comes from the model, so from the clip: in a wing it may only be one
|
||||||
|
// of the three the layout has a place for.
|
||||||
|
func TestRun_WithAWingAModelChosenTypeCannotWriteOutsideTheLayout(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/alpha/facts"), 0o755))
|
||||||
|
pages := append(sourceEntityConcept(),
|
||||||
|
RawPage{Title: "Planted", Type: "sources", Content: "## x\n\nplanted\n"},
|
||||||
|
RawPage{Title: "Loose", Type: "alpha", Content: "## x\n\nloose\n"},
|
||||||
|
RawPage{Title: "Sneaky", Type: "homelab", Content: "## x\n\nsneaky\n"})
|
||||||
|
res, err := Run(context.Background(), wingCfg("web-clip", pages...), dir, "body", "talk", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, exists(dir, "wiki/sources/planted.md"))
|
||||||
|
assert.False(t, exists(dir, "wiki/alpha/loose.md"))
|
||||||
|
assert.False(t, exists(dir, "wiki/homelab"))
|
||||||
|
assert.Contains(t, strings.Join(res.Warnings, "\n"), "invalid type")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRun_ReIngestingTheSameSourceIntoItsOwnWingIsNotACollision(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
_, err := Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "body one", "talk", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "body two, edited", "talk", false)
|
||||||
|
require.NoError(t, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRun_WithAWingRejectsANameThatIsNotAPlainWing(t *testing.T) {
|
func TestRun_WithAWingRejectsANameThatIsNotAPlainWing(t *testing.T) {
|
||||||
for _, bad := range []string{"../escape", "a/b", "Radar", "has space", "."} {
|
for _, bad := range []string{"../escape", "a/b", "Radar", "has space", "."} {
|
||||||
t.Run(bad, func(t *testing.T) {
|
t.Run(bad, func(t *testing.T) {
|
||||||
dir := t.TempDir()
|
dir := t.TempDir()
|
||||||
_, err := Run(context.Background(), wingCfg(bad, sourceAndEntity()...), dir, "body", "talk", false)
|
_, err := Run(context.Background(), wingCfg(bad, sourceEntityConcept()...), dir, "body", "talk", false)
|
||||||
require.Error(t, err)
|
require.Error(t, err)
|
||||||
assert.Len(t, listAll(t, dir), 1, "nothing is written for a rejected wing (only the temp dir itself exists)")
|
assert.Len(t, listAll(t, dir), 1, "nothing is written for a rejected wing (only the temp dir itself exists)")
|
||||||
})
|
})
|
||||||
@@ -121,18 +192,205 @@ func listAll(t *testing.T, dir string) []string {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
// The dangerous path is the back-reference: a source that links [[Jev]] without
|
// Title resolution can mask a wrong slug choice at the Run level, so the
|
||||||
// proposing a Jev page makes the pipeline go and edit an existing Jev page. That
|
// stability of the choice is pinned directly: the page this wing made earlier
|
||||||
// lookup must be confined to the target wing.
|
// under the qualified slug is "its own" and is found again.
|
||||||
func TestRun_WithAWingASourceLinkingToAnotherWingsEntityDoesNotEditIt(t *testing.T) {
|
func TestResolveSourceSlug(t *testing.T) {
|
||||||
dir := t.TempDir()
|
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"
|
for _, rel := range []string{
|
||||||
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/client-x/facts"), 0o755))
|
"wiki/web-clip/sources/talk.md",
|
||||||
require.NoError(t, os.WriteFile(filepath.Join(dir, "wiki/client-x/facts/jev.md"), []byte(other), 0o644))
|
"wiki/homelab/sources/talk-homelab.md",
|
||||||
|
"wiki/entities/kube.md",
|
||||||
|
} {
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(filepath.Join(dir, rel)), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, rel), []byte("---\ntitle: x\n---\n"), 0o644))
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name, wing, base, want string
|
||||||
|
}{
|
||||||
|
{"free slug is used as is", "homelab", "fresh", "fresh"},
|
||||||
|
{"own page under the plain slug", "web-clip", "talk", "talk"},
|
||||||
|
{"taken elsewhere: wing-qualified", "tapir", "talk", "talk-tapir"},
|
||||||
|
{"own earlier qualified page is found again", "homelab", "talk", "talk-homelab"},
|
||||||
|
{"taken by a shared page", "tapir", "kube", "kube-tapir"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got, err := resolveSourceSlug(dir, tt.wing, tt.base)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.want, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onlySource := sourceAndEntity()[:1]
|
func TestResolveSourceSlug_AFurtherClashGetsANumber(t *testing.T) {
|
||||||
_, err := Run(context.Background(), wingCfg("radar", onlySource...), dir, "body", "dark-factory-talk", false)
|
dir := t.TempDir()
|
||||||
|
for _, rel := range []string{"wiki/a/sources/talk.md", "wiki/entities/talk-b.md"} {
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(filepath.Join(dir, rel)), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, rel), []byte("x"), 0o644))
|
||||||
|
}
|
||||||
|
got, err := resolveSourceSlug(dir, "b", "talk")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "talk-b-2", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
// From the second review of #211: slug clashes INSIDE one batch. dropSlugClashes
|
||||||
|
// only looked at what was already on disk, so a source and an entity extracted
|
||||||
|
// together with one slug were both written, and neither could ever be updated.
|
||||||
|
func TestRun_ASourceAndAnEntityWithOneSlugInTheSameBatchWriteOnlyTheSource(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
pages := []RawPage{
|
||||||
|
{Title: "Notes", Type: "source", Subtype: "note", Content: "## Summary\n\nfirst\n"},
|
||||||
|
{Title: "Notes", Type: "entity", Subtype: "tool", Content: "## Definition\n\nan entity named like its source\n"},
|
||||||
|
}
|
||||||
|
res, err := Run(context.Background(), wingCfg("homelab", pages...), dir, "body one", "notes", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, exists(dir, "wiki/homelab/sources/notes.md"))
|
||||||
|
assert.False(t, exists(dir, "wiki/entities/notes.md"), "two pages must not share a slug")
|
||||||
|
assert.Contains(t, strings.Join(res.Warnings, "\n"), "notes")
|
||||||
|
|
||||||
|
pages[0].Content = "## Summary\n\nfirst\n\n## Key Claims\n\n- second\n"
|
||||||
|
_, err = Run(context.Background(), wingCfg("homelab", pages...), dir, "body two, changed", "notes", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, read(t, dir, "wiki/homelab/sources/notes.md"), "second", "the source can still be updated")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRun_AConceptAndAnEntityWithOneSlugInTheSameBatchWriteOne(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
pages := []RawPage{
|
||||||
|
{Title: "Talk", Type: "source", Subtype: "video", Content: "## Summary\n\nx\n"},
|
||||||
|
{Title: "Foo", Type: "concept", Content: "## Definition\n\na concept\n"},
|
||||||
|
{Title: "Foo", Type: "entity", Subtype: "tool", Content: "## Definition\n\nan entity\n"},
|
||||||
|
}
|
||||||
|
res, err := Run(context.Background(), wingCfg("homelab", pages...), dir, "body", "talk", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
written := 0
|
||||||
|
for _, rel := range []string{"wiki/concepts/foo.md", "wiki/entities/foo.md"} {
|
||||||
|
if exists(dir, rel) {
|
||||||
|
written++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
assert.Equal(t, 1, written, "one slug, one page")
|
||||||
|
assert.Contains(t, strings.Join(res.Warnings, "\n"), "foo")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A brain that already holds the bad pair (written before this fix) must not
|
||||||
|
// lock the source out: the source page is the note's own output and is never
|
||||||
|
// the one skipped.
|
||||||
|
func TestRun_AnExistingSourceEntityPairDoesNotLockTheSourceOut(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/homelab/sources"), 0o755))
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/entities"), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "wiki/homelab/sources/notes.md"),
|
||||||
|
[]byte("---\ntitle: 'Notes'\ntype: 'note'\nwing: homelab\nhall: sources\naliases:\n - 'Notes'\n---\n\n## Summary\n\nold\n"), 0o644))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "wiki/entities/notes.md"),
|
||||||
|
[]byte("---\ntitle: 'Notes'\naliases:\n - 'Notes'\n---\n\n## Definition\n\nstale entity\n"), 0o644))
|
||||||
|
|
||||||
|
pages := []RawPage{{Title: "Notes", Type: "source", Subtype: "note", Content: "## Summary\n\nnew text\n\n## Key Claims\n\n- fresh\n"}}
|
||||||
|
_, err := Run(context.Background(), wingCfg("homelab", pages...), dir, "changed body", "notes", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, read(t, dir, "wiki/homelab/sources/notes.md"), "fresh", "the source took the update")
|
||||||
|
assert.Contains(t, read(t, dir, "wiki/entities/notes.md"), "stale entity", "the entity is untouched")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 3 of #211: a note must never leave the inbox as "processed" when nothing
|
||||||
|
// of it was written.
|
||||||
|
func TestRun_WithAWingAnIngestThatWritesNoSourcePageIsAnError(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
pages []RawPage
|
||||||
|
prep func(t *testing.T, dir string)
|
||||||
|
}{
|
||||||
|
{"the model returned nothing", nil, nil},
|
||||||
|
{"only pages of a type the layout has no place for", []RawPage{{Title: "Claim", Type: "claim", Content: "## x\n\ny\n"}}, nil},
|
||||||
|
{"only an entity, and its slug is taken by a source", []RawPage{{Title: "S1", Type: "entity", Subtype: "tool", Content: "## Definition\n\nx\n"}},
|
||||||
|
func(t *testing.T, dir string) {
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/web-clip/sources"), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "wiki/web-clip/sources/s1.md"), []byte("---\ntitle: 'S1'\n---\nx"), 0o644))
|
||||||
|
}},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if tt.prep != nil {
|
||||||
|
tt.prep(t, dir)
|
||||||
|
}
|
||||||
|
res, err := Run(context.Background(), wingCfg("web-clip", tt.pages...), dir, "a note body", "other-note", false)
|
||||||
|
require.Error(t, err, "nothing was ingested; the caller must not archive it as done")
|
||||||
|
assert.Empty(t, res.Pages)
|
||||||
|
assert.Contains(t, err.Error(), "no source page")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRun_WithAWingAnUnchangedSourceIsStillASkipNotAnError(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
_, err := Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "same body", "talk", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
res, err := Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "same body", "talk", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, res.Skipped)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title resolution must not merge two different notes into one source page just
|
||||||
|
// because the model titled both "Home": in a wing the source path is already
|
||||||
|
// deterministic (the filename), so the title must not redirect it.
|
||||||
|
func TestRun_WithAWingTwoNotesTheModelTitlesAlikeKeepTheirOwnSourcePages(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
home := func(body string) []RawPage {
|
||||||
|
return []RawPage{{Title: "Home", Type: "source", Subtype: "article", Content: "## Summary\n\n" + body + "\n"}}
|
||||||
|
}
|
||||||
|
_, err := Run(context.Background(), wingCfg("web-clip", home("note A text")...), dir, "body A", "note-a", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
_, err = Run(context.Background(), wingCfg("web-clip", home("note B text")...), dir, "body B", "note-b", false)
|
||||||
require.NoError(t, err)
|
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")
|
assert.True(t, exists(dir, "wiki/web-clip/sources/note-a.md"))
|
||||||
|
assert.True(t, exists(dir, "wiki/web-clip/sources/note-b.md"), "note B has its own page")
|
||||||
|
assert.Contains(t, read(t, dir, "wiki/web-clip/sources/note-a.md"), "note A text")
|
||||||
|
assert.Contains(t, read(t, dir, "wiki/web-clip/sources/note-b.md"), "note B text")
|
||||||
|
|
||||||
|
res, err := Run(context.Background(), wingCfg("web-clip", home("note A text")...), dir, "body A", "note-a", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.True(t, res.Skipped, "note A's hash was not overwritten by note B")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Both a concept and an entity named "Foo" arrive and the CONCEPT already exists
|
||||||
|
// on disk: the page that exists wins the tie, so its source reference is added.
|
||||||
|
func TestRun_ABatchTieBetweenAConceptAndAnEntityPrefersThePageAlreadyOnDisk(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/concepts"), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "wiki/concepts/foo.md"),
|
||||||
|
[]byte("---\ntitle: 'Foo'\naliases:\n - 'Foo'\n---\n\n## Definition\n\nexisting concept\n\n## Sources\n"), 0o644))
|
||||||
|
pages := []RawPage{
|
||||||
|
{Title: "Talk", Type: "source", Subtype: "video", Content: "## Summary\n\nx\n\n## Concepts Introduced or Reinforced\n\n[[Foo]]\n"},
|
||||||
|
{Title: "Foo", Type: "entity", Subtype: "tool", Content: "## Definition\n\nan entity\n"},
|
||||||
|
{Title: "Foo", Type: "concept", Content: "## Definition\n\na concept\n\n## Sources\n"},
|
||||||
|
}
|
||||||
|
_, err := Run(context.Background(), wingCfg("web-clip", pages...), dir, "body", "talk", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, exists(dir, "wiki/entities/foo.md"))
|
||||||
|
assert.Contains(t, read(t, dir, "wiki/concepts/foo.md"), "talk", "the existing concept got the new source reference")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pass 4 of #211: the source page carries the hash the drift check reads. If it
|
||||||
|
// is written FIRST and a later page fails to write, the next ingest of the same
|
||||||
|
// content is skipped and the rest of the batch is lost for good. Written LAST,
|
||||||
|
// a failed batch leaves no hash and is simply redone.
|
||||||
|
func TestRun_WithAWingAFailedBatchLeavesNoSourcePageToHideBehind(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
// An entity path that cannot be written: a directory where the page goes.
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(dir, "wiki/entities/jev.md"), 0o755))
|
||||||
|
|
||||||
|
_, err := Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "same body", "talk", false)
|
||||||
|
require.Error(t, err, "the entity write fails")
|
||||||
|
assert.False(t, exists(dir, "wiki/web-clip/sources/talk.md"),
|
||||||
|
"the source page (and its hash) must not exist when the batch did not complete")
|
||||||
|
|
||||||
|
require.NoError(t, os.RemoveAll(filepath.Join(dir, "wiki/entities/jev.md")))
|
||||||
|
res, err := Run(context.Background(), wingCfg("web-clip", sourceEntityConcept()...), dir, "same body", "talk", false)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, res.Skipped, "the retry is a real ingest, not a hash skip")
|
||||||
|
assert.True(t, exists(dir, "wiki/entities/jev.md"))
|
||||||
|
assert.True(t, exists(dir, "wiki/web-clip/sources/talk.md"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,116 @@
|
|||||||
|
package watcher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
// These tests are the sprint's goal (hyperguild#209) stated as behaviour, with
|
||||||
|
// the real pipeline and real file layout and only the model stubbed: a person
|
||||||
|
// drops a clip into raw/ and does nothing else.
|
||||||
|
|
||||||
|
func acceptanceCfg(t *testing.T) (Config, string, *atomic.Int32) {
|
||||||
|
t.Helper()
|
||||||
|
brainDir := t.TempDir()
|
||||||
|
var calls atomic.Int32
|
||||||
|
// Each call extracts a source with its own title, as real clips have: two
|
||||||
|
// sources with one title are deliberately merged into one page by the
|
||||||
|
// pipeline's title resolution.
|
||||||
|
pagesFor := func(n int32) []pipeline.RawPage {
|
||||||
|
return []pipeline.RawPage{
|
||||||
|
{Title: fmt.Sprintf("Dark Factory Talk %d", n), Type: "source", Subtype: "video", Domain: "ai",
|
||||||
|
Content: "## Summary\n\nA talk.\n\n## Entities Mentioned\n\n[[Jev]]\n\n## Concepts Introduced or Reinforced\n\n[[Dark Factory]]\n"},
|
||||||
|
{Title: "Jev", Type: "entity", Subtype: "tool", Content: "## Definition\n\nA decision model.\n\n## Sources\n"},
|
||||||
|
{Title: "Dark Factory", Type: "concept", Content: "## Definition\n\nA fully automated build line.\n\n## Sources\n"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Config{
|
||||||
|
BrainDir: brainDir,
|
||||||
|
Resolver: wing.Router{BrainDir: brainDir, Default: "web-clip", ClientBlock: regexp.MustCompile(`(?i)acme-client`)},
|
||||||
|
Pipeline: pipeline.Config{
|
||||||
|
Schema: "# Schema",
|
||||||
|
Complete: func(context.Context, string, string) (string, error) {
|
||||||
|
return mustJSONPages(pagesFor(calls.Add(1))), nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}, brainDir, &calls
|
||||||
|
}
|
||||||
|
|
||||||
|
// topLevel lists the regular files directly in raw/: the inbox.
|
||||||
|
func topLevel(t *testing.T, brainDir string) []string {
|
||||||
|
t.Helper()
|
||||||
|
entries, err := os.ReadDir(filepath.Join(brainDir, "raw"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
var out []string
|
||||||
|
for _, e := range entries {
|
||||||
|
if e.Type().IsRegular() {
|
||||||
|
out = append(out, e.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAcceptance_ADroppedClipLandsInWebClipAndTheInboxIsEmpty(t *testing.T) {
|
||||||
|
cfg, brainDir, calls := acceptanceCfg(t)
|
||||||
|
put(t, brainDir, "dark-factory-talk.md", "Transcript of a talk about dark factories.")
|
||||||
|
|
||||||
|
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
|
||||||
|
|
||||||
|
assert.True(t, has(brainDir, "wiki/web-clip/sources/dark-factory-talk.md"), "the clip is a page in web-clip")
|
||||||
|
assert.True(t, has(brainDir, "wiki/entities/jev.md"), "its entity is in the shared layer")
|
||||||
|
assert.True(t, has(brainDir, "wiki/concepts/dark-factory.md"), "its concept is in the shared layer")
|
||||||
|
assert.Empty(t, topLevel(t, brainDir), "raw/ holds nothing unprocessed")
|
||||||
|
assert.False(t, has(brainDir, "raw/needs-review"), "and nothing waits for a human")
|
||||||
|
assert.Equal(t, int32(1), calls.Load(), "one extraction, no classification step")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAcceptance_TheSameEntityFromTwoClipsIsOnePage(t *testing.T) {
|
||||||
|
cfg, brainDir, _ := acceptanceCfg(t)
|
||||||
|
put(t, brainDir, "talk-one.md", "First talk.")
|
||||||
|
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
|
||||||
|
put(t, brainDir, "talk-two.md", "Second talk, different words.")
|
||||||
|
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
|
||||||
|
|
||||||
|
files, err := filepath.Glob(filepath.Join(brainDir, "wiki", "entities", "*.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, files, 1)
|
||||||
|
b, _ := os.ReadFile(filepath.Join(brainDir, "wiki/entities/jev.md"))
|
||||||
|
assert.True(t, strings.Contains(string(b), "talk-one") && strings.Contains(string(b), "talk-two"), "both sources are cited")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAcceptance_AClipThatLooksLikeClientMaterialIsParkedNotFiled(t *testing.T) {
|
||||||
|
cfg, brainDir, calls := acceptanceCfg(t)
|
||||||
|
put(t, brainDir, "kickoff.md", "Notes from the ACME-CLIENT kickoff call.")
|
||||||
|
|
||||||
|
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
|
||||||
|
|
||||||
|
assert.Zero(t, calls.Load(), "not even extracted")
|
||||||
|
assert.False(t, has(brainDir, "wiki/web-clip"), "never filed into a shared wing")
|
||||||
|
assert.True(t, has(brainDir, "raw/needs-review/kickoff.md"), "waits for the owner")
|
||||||
|
assert.Empty(t, topLevel(t, brainDir))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAcceptance_ASubfolderNamingAnExistingWingPlacesThereAndATypoParks(t *testing.T) {
|
||||||
|
cfg, brainDir, _ := acceptanceCfg(t)
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(brainDir, "wiki", "homelab", "facts"), 0o755))
|
||||||
|
put(t, brainDir, "homelab/ops-note.md", "A note about the cluster.")
|
||||||
|
put(t, brainDir, "hoemlab/typo-note.md", "A misfiled note.")
|
||||||
|
|
||||||
|
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
|
||||||
|
|
||||||
|
assert.True(t, has(brainDir, "wiki/homelab/sources/ops-note.md"))
|
||||||
|
assert.False(t, has(brainDir, "wiki/hoemlab"), "a typo never creates a wing")
|
||||||
|
assert.True(t, has(brainDir, "raw/needs-review/typo-note.md"))
|
||||||
|
}
|
||||||
@@ -20,6 +20,13 @@ import (
|
|||||||
// root, never replacing a parked note: on a name clash it tries -2, -3, ...
|
// 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.
|
// A needs-review that is a symlink, or any non-directory, is refused.
|
||||||
func createParked(brainDir, filename string, data []byte) error {
|
func createParked(brainDir, filename string, data []byte) error {
|
||||||
|
return createUnique(brainDir, "raw/needs-review", filename, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// createUnique writes data to dirRel/filename through the brain root, never
|
||||||
|
// replacing an existing file: on a name clash it tries -2, -3, ... A dirRel that
|
||||||
|
// is a symlink, or any non-directory, is refused.
|
||||||
|
func createUnique(brainDir, dirRel, filename string, data []byte) error {
|
||||||
bfs, err := brainfs.Open(brainDir)
|
bfs, err := brainfs.Open(brainDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
@@ -32,7 +39,7 @@ func createParked(brainDir, filename string, data []byte) error {
|
|||||||
if n > 1 {
|
if n > 1 {
|
||||||
name = fmt.Sprintf("%s-%d%s", stem, n, ext)
|
name = fmt.Sprintf("%s-%d%s", stem, n, ext)
|
||||||
}
|
}
|
||||||
err := bfs.CreateRegular("raw/needs-review/"+name, data)
|
err := bfs.CreateRegular(dirRel+"/"+name, data)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
@@ -40,5 +47,5 @@ func createParked(brainDir, filename string, data []byte) error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return fmt.Errorf("no free name for %q in raw/needs-review", filename)
|
return fmt.Errorf("no free name for %q in %s", filename, dirRel)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package watcher
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
@@ -14,6 +15,7 @@ import (
|
|||||||
"unicode"
|
"unicode"
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brain"
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/extract"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/extract"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/wing"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/wing"
|
||||||
@@ -25,23 +27,29 @@ type Config struct {
|
|||||||
Interval time.Duration
|
Interval time.Duration
|
||||||
Pipeline pipeline.Config
|
Pipeline pipeline.Config
|
||||||
|
|
||||||
// stuck remembers notes that were ingested but could neither be removed
|
// MaxNoteBytes refuses a note larger than this before anything is read into
|
||||||
// from raw/ nor marked (e.g. a read-only raw/), so later polls skip them
|
// memory (hyperguild#198). 0 means defaultMaxNoteBytes.
|
||||||
// instead of ingesting them again. In memory only: a restart retries once.
|
MaxNoteBytes int64
|
||||||
stuck *sync.Map
|
|
||||||
|
|
||||||
// Resolver, when set, decides each note's wing (hyperguild#205): explicit
|
// Resolver, when set, decides each note's wing (hyperguild#205, #208): a
|
||||||
// subfolder/frontmatter, else classifier + scorer. A note it cannot place
|
// deliberate subfolder/frontmatter wing, else the default wing. A note it
|
||||||
// is parked in raw/needs-review/ for a human. Nil keeps the retired layout.
|
// cannot place is parked in raw/needs-review/ for a human. Nil keeps the
|
||||||
Resolver *wing.Resolver
|
// retired layout. Set it only to a non-nil value: a nil pointer inside the
|
||||||
|
// interface is not nil.
|
||||||
|
Resolver wing.Placer
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// defaultMaxNoteBytes bounds a single note: extraction and the archive copy both
|
||||||
|
// hold it in memory.
|
||||||
|
const defaultMaxNoteBytes = 64 << 20
|
||||||
|
|
||||||
|
// errTooLarge marks a note refused for its size; like a refused write it is
|
||||||
|
// reported once and not retried until the note changes.
|
||||||
|
var errTooLarge = extract.ErrTooLarge
|
||||||
|
|
||||||
// Start launches the watcher in a background goroutine.
|
// Start launches the watcher in a background goroutine.
|
||||||
// It returns immediately. The watcher stops when ctx is cancelled.
|
// It returns immediately. The watcher stops when ctx is cancelled.
|
||||||
func Start(ctx context.Context, cfg Config) {
|
func Start(ctx context.Context, cfg Config) {
|
||||||
if cfg.stuck == nil {
|
|
||||||
cfg.stuck = &sync.Map{}
|
|
||||||
}
|
|
||||||
go func() {
|
go func() {
|
||||||
ticker := time.NewTicker(cfg.Interval)
|
ticker := time.NewTicker(cfg.Interval)
|
||||||
defer ticker.Stop()
|
defer ticker.Stop()
|
||||||
@@ -124,21 +132,34 @@ func processDir(ctx context.Context, cfg Config, date string) []error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if cfg.stuck != nil {
|
|
||||||
if _, skip := cfg.stuck.Load(path); skip {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip files that have already been processed or permanently failed.
|
// Skip files that have already been processed or permanently failed.
|
||||||
if _, err := os.Stat(path + ".processed"); err == nil {
|
// Any entry at the marker path counts (Lstat, not Stat): a dangling
|
||||||
|
// marker symlink made os.Stat report nothing and the note was
|
||||||
|
// reprocessed every interval (hyperguild#158).
|
||||||
|
if skip, stale := markerBlocks(path); skip {
|
||||||
|
warnMarkerOnce(path)
|
||||||
return nil
|
return nil
|
||||||
|
} else {
|
||||||
|
for _, m := range stale {
|
||||||
|
slog.Warn("watcher: ignoring a stale marker older than its note", "marker", m)
|
||||||
|
if rmErr := root.Remove(relOf(rawDir, m)); rmErr != nil {
|
||||||
|
slog.Error("watcher: failed to remove a stale marker", "marker", m, "error", rmErr)
|
||||||
}
|
}
|
||||||
if _, err := os.Stat(path + ".failed"); err == nil {
|
}
|
||||||
|
}
|
||||||
|
// A note whose archive copy or marker was refused is not retried
|
||||||
|
// until it changes: a refusal has no marker to stop the next round,
|
||||||
|
// and each round can cost an LLM call (hyperguild#158).
|
||||||
|
info, ierr := d.Info()
|
||||||
|
if ierr == nil && refusedUnchanged(path, info) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := processFile(ctx, cfg, root, rawDir, path, date); err != nil {
|
if err := processFile(ctx, cfg, root, rawDir, path, date); err != nil {
|
||||||
|
if ierr == nil && (errors.Is(err, brainfs.ErrNotRegular) || errors.Is(err, errTooLarge)) {
|
||||||
|
rememberRefused(path, info)
|
||||||
|
slog.Error("watcher: note refused; not retried until it changes", "file", filepath.Base(path), "error", err)
|
||||||
|
}
|
||||||
errs = append(errs, fmt.Errorf("process %s: %w", filepath.Base(path), err))
|
errs = append(errs, fmt.Errorf("process %s: %w", filepath.Base(path), err))
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -167,7 +188,14 @@ func processFile(ctx context.Context, cfg Config, root *os.Root, rawDir, path, d
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("stat before ingest: %w", err)
|
return fmt.Errorf("stat before ingest: %w", err)
|
||||||
}
|
}
|
||||||
content, err := extract.TextIn(root, rel)
|
limit := cfg.MaxNoteBytes
|
||||||
|
if limit <= 0 {
|
||||||
|
limit = defaultMaxNoteBytes
|
||||||
|
}
|
||||||
|
if before.Size() > limit {
|
||||||
|
return fmt.Errorf("%s is %d bytes, over the %d byte cap: %w", filename, before.Size(), limit, errTooLarge)
|
||||||
|
}
|
||||||
|
content, err := extract.TextInMax(root, rel, limit)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("extract text: %w", err)
|
return fmt.Errorf("extract text: %w", err)
|
||||||
}
|
}
|
||||||
@@ -183,20 +211,16 @@ func processFile(ctx context.Context, cfg Config, root *os.Root, rawDir, path, d
|
|||||||
}
|
}
|
||||||
_, runErr := pipeline.Run(ctx, pcfg, cfg.BrainDir, content, source, false)
|
_, runErr := pipeline.Run(ctx, pcfg, cfg.BrainDir, content, source, false)
|
||||||
if runErr != nil {
|
if runErr != nil {
|
||||||
// Copy to failed/ and leave a .failed marker so we don't retry.
|
// Copy to failed/ and leave a .failed marker so we don't retry. Both
|
||||||
failedDir := filepath.Join(cfg.BrainDir, "raw", "failed")
|
// written through the brain root (hyperguild#158).
|
||||||
if mkErr := os.MkdirAll(failedDir, 0o755); mkErr != nil {
|
if cpErr := copyFromRoot(root, rel, cfg.BrainDir, "raw/failed", filename, limit); cpErr != nil {
|
||||||
return fmt.Errorf("mkdir failed dir: %w", mkErr)
|
|
||||||
}
|
|
||||||
dest := filepath.Join(failedDir, filename)
|
|
||||||
if cpErr := copyFromRoot(root, rel, dest); cpErr != nil {
|
|
||||||
return fmt.Errorf("copy to failed: %w", cpErr)
|
return fmt.Errorf("copy to failed: %w", cpErr)
|
||||||
}
|
}
|
||||||
if changedSince(root, rel, before) {
|
if changedSince(root, rel, before) {
|
||||||
slog.Warn("watcher: note changed during ingest; leaving it in raw/ for the next poll", "file", filename)
|
slog.Warn("watcher: note changed during ingest; leaving it in raw/ for the next poll", "file", filename)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
retire(cfg, root, rel, path, ".failed", runErr.Error())
|
retire(cfg, root, rel, path, ".failed", runErr.Error(), before)
|
||||||
|
|
||||||
slog.Warn("watcher: file failed", "file", filename, "error", runErr)
|
slog.Warn("watcher: file failed", "file", filename, "error", runErr)
|
||||||
|
|
||||||
@@ -208,25 +232,15 @@ func processFile(ctx context.Context, cfg Config, root *os.Root, rawDir, path, d
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Copy to processed/YYYY-MM-DD/ and leave a .processed marker so we don't re-ingest.
|
// Copy to processed/YYYY-MM-DD/ and leave a .processed marker so we don't re-ingest.
|
||||||
processedDir := filepath.Join(cfg.BrainDir, "raw", "processed", date)
|
// Written through the brain root (hyperguild#158).
|
||||||
if err := os.MkdirAll(processedDir, 0o755); err != nil {
|
if err := copyFromRoot(root, rel, cfg.BrainDir, "raw/processed/"+date, filename, limit); err != nil {
|
||||||
return fmt.Errorf("mkdir processed dir: %w", err)
|
|
||||||
}
|
|
||||||
dest := filepath.Join(processedDir, filename)
|
|
||||||
if _, err := os.Stat(dest); err == nil {
|
|
||||||
// Archive copy already exists; append timestamp to avoid overwriting.
|
|
||||||
ext := filepath.Ext(filename)
|
|
||||||
base := strings.TrimSuffix(filename, ext)
|
|
||||||
dest = filepath.Join(processedDir, base+"-"+time.Now().UTC().Format("150405")+ext)
|
|
||||||
}
|
|
||||||
if err := copyFromRoot(root, rel, dest); err != nil {
|
|
||||||
return fmt.Errorf("copy to processed: %w", err)
|
return fmt.Errorf("copy to processed: %w", err)
|
||||||
}
|
}
|
||||||
if changedSince(root, rel, before) {
|
if changedSince(root, rel, before) {
|
||||||
slog.Warn("watcher: note changed during ingest; leaving it in raw/ for the next poll", "file", filename)
|
slog.Warn("watcher: note changed during ingest; leaving it in raw/ for the next poll", "file", filename)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
retire(cfg, root, rel, path, ".processed", date)
|
retire(cfg, root, rel, path, ".processed", date, before)
|
||||||
|
|
||||||
slog.Info("watcher: file processed", "file", filename, "source", source)
|
slog.Info("watcher: file processed", "file", filename, "source", source)
|
||||||
return nil
|
return nil
|
||||||
@@ -246,19 +260,45 @@ func changedSince(root *os.Root, rel string, before os.FileInfo) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// retire removes an archived original from raw/ (inbox zero). If the removal
|
// retire removes an archived original from raw/ (inbox zero). If the removal
|
||||||
// fails it falls back to a marker file so the watcher still skips the file.
|
// fails it falls back to a marker written through the brain root (#158), and if
|
||||||
func retire(cfg Config, root *os.Root, rel, path, markerSuffix, markerBody string) {
|
// even that fails the note is remembered, with the size and mtime it had, so it
|
||||||
|
// is not ingested again until it changes.
|
||||||
|
func retire(cfg Config, root *os.Root, rel, path, markerSuffix, markerBody string, before os.FileInfo) {
|
||||||
rmErr := root.Remove(rel)
|
rmErr := root.Remove(rel)
|
||||||
if rmErr == nil {
|
if rmErr == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
slog.Error("watcher: failed to remove archived original; writing marker", "file", rel, "error", rmErr)
|
slog.Error("watcher: failed to remove archived original; writing marker", "file", rel, "error", rmErr)
|
||||||
if err := os.WriteFile(path+markerSuffix, []byte(markerBody), 0o644); err != nil {
|
if err := putBrainFile(cfg.BrainDir, "raw/"+filepath.ToSlash(rel)+markerSuffix, []byte(markerBody)); err != nil {
|
||||||
slog.Error("watcher: failed to write marker; skipping this note until restart", "file", rel, "error", err)
|
slog.Error("watcher: failed to write marker; skipping this note until it changes", "file", rel, "error", err)
|
||||||
if cfg.stuck != nil {
|
rememberRefused(path, before)
|
||||||
cfg.stuck.Store(path, struct{}{})
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// refused remembers notes whose archive copy or marker write was refused,
|
||||||
|
// by path, with the size and mtime they had, for the life of the process.
|
||||||
|
var refused sync.Map
|
||||||
|
|
||||||
|
type refusedStamp struct {
|
||||||
|
size int64
|
||||||
|
mtime time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func rememberRefused(path string, info os.FileInfo) {
|
||||||
|
refused.Store(path, refusedStamp{size: info.Size(), mtime: info.ModTime()})
|
||||||
|
}
|
||||||
|
|
||||||
|
func refusedUnchanged(path string, info os.FileInfo) bool {
|
||||||
|
v, ok := refused.Load(path)
|
||||||
|
if !ok {
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
st := v.(refusedStamp)
|
||||||
|
if st.size == info.Size() && st.mtime.Equal(info.ModTime()) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
refused.Delete(path)
|
||||||
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
// deriveSource turns a filename into a human-readable source name.
|
// deriveSource turns a filename into a human-readable source name.
|
||||||
@@ -285,44 +325,63 @@ func appendWatcherLog(brainDir, filename string, runErr error, date string) erro
|
|||||||
entry := fmt.Sprintf("## %s — watcher error\n\n- **File:** %s\n- **Error:** %s\n\n",
|
entry := fmt.Sprintf("## %s — watcher error\n\n- **File:** %s\n- **Error:** %s\n\n",
|
||||||
date, filename, runErr.Error())
|
date, filename, runErr.Error())
|
||||||
|
|
||||||
logPath := filepath.Join(brainDir, "log.md")
|
if err := brainfs.AppendFile(brainDir, "log.md", []byte(entry)); err != nil {
|
||||||
f, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("open log: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if _, err = f.WriteString(entry); err != nil {
|
|
||||||
f.Close() //nolint:errcheck
|
|
||||||
return fmt.Errorf("write log: %w", err)
|
return fmt.Errorf("write log: %w", err)
|
||||||
}
|
}
|
||||||
return f.Close()
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// copyFromRoot archives a raw/ file read through the root, refusing anything
|
// putBrainFile creates or replaces a brain-relative file through the brain
|
||||||
// that is not a regular file, so a swapped-in symlink cannot copy an outside
|
// root, never through a symlinked directory or file (hyperguild#158).
|
||||||
// file into raw/processed/ or raw/failed/ (hyperguild#153).
|
func putBrainFile(brainDir, rel string, data []byte) error {
|
||||||
func copyFromRoot(root *os.Root, rel, dst string) error {
|
return brainfs.PutFile(brainDir, rel, data)
|
||||||
fi, err := root.Lstat(rel)
|
}
|
||||||
|
|
||||||
|
// copyFromRoot archives a raw/ file read through the root into dstDir under a
|
||||||
|
// name no existing file has (so a note never replaces another's copy: the copy
|
||||||
|
// is the only one left once the original is retired), refusing anything that is
|
||||||
|
// not a regular file and anything over max bytes at the read, not just at the
|
||||||
|
// earlier Lstat (hyperguild#158, #198).
|
||||||
|
func copyFromRoot(root *os.Root, rel, brainDir, dstDir, filename string, max int64) error {
|
||||||
|
data, err := readNoteMax(root, rel, max)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
return createUnique(brainDir, dstDir, filename, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// readNoteMax reads a raw/ note through the root, at most max bytes.
|
||||||
|
func readNoteMax(root *os.Root, rel string, max int64) ([]byte, error) {
|
||||||
|
fi, err := root.Lstat(rel)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
if !fi.Mode().IsRegular() {
|
if !fi.Mode().IsRegular() {
|
||||||
return fmt.Errorf("%s: not a regular file", rel)
|
return nil, fmt.Errorf("%s: not a regular file", rel)
|
||||||
}
|
}
|
||||||
in, _, err := extract.OpenRegular(root, rel)
|
in, _, err := extract.OpenRegular(root, rel)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer func() { _ = in.Close() }()
|
defer func() { _ = in.Close() }()
|
||||||
out, err := os.Create(dst)
|
data, err := readAllMax(in, max)
|
||||||
|
if errors.Is(err, errTooLarge) {
|
||||||
|
return nil, fmt.Errorf("%s: %w (%d bytes)", rel, errTooLarge, max)
|
||||||
|
}
|
||||||
|
return data, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// readAllMax reads r but never more than max+1 bytes, so an oversize or growing
|
||||||
|
// file costs at most that much memory, and reports errTooLarge past max.
|
||||||
|
func readAllMax(r io.Reader, max int64) ([]byte, error) {
|
||||||
|
data, err := io.ReadAll(io.LimitReader(r, max+1))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return nil, err
|
||||||
}
|
}
|
||||||
if _, err := io.Copy(out, in); err != nil {
|
if int64(len(data)) > max {
|
||||||
_ = out.Close()
|
return nil, errTooLarge
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
return out.Close()
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// topDir is the first directory of a raw/-relative path, "" for a top-level file.
|
// topDir is the first directory of a raw/-relative path, "" for a top-level file.
|
||||||
@@ -333,14 +392,24 @@ func topDir(rel string) string {
|
|||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
// frontmatterWing reads `wing:` from a note's leading frontmatter, "" if none.
|
// frontmatterWing reads a top-level `wing:` from a note's leading frontmatter,
|
||||||
|
// "" if there is none. The block counts only if it has a real closing fence (a
|
||||||
|
// line that is exactly "---"), and an indented `wing:` belongs to some other
|
||||||
|
// mapping, not to the note.
|
||||||
func frontmatterWing(content string) string {
|
func frontmatterWing(content string) string {
|
||||||
|
content = strings.TrimPrefix(content, "\ufeff") // a byte-order mark must not hide the block
|
||||||
content = strings.ReplaceAll(content, "\r\n", "\n")
|
content = strings.ReplaceAll(content, "\r\n", "\n")
|
||||||
if !strings.HasPrefix(content, "---\n") {
|
if !strings.HasPrefix(content, "---\n") {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
block, _, _ := strings.Cut(content[4:], "\n---")
|
block, _, found := cutFence(content[4:])
|
||||||
|
if !found {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
for _, line := range strings.Split(block, "\n") {
|
for _, line := range strings.Split(block, "\n") {
|
||||||
|
if line == "" || line[0] == ' ' || line[0] == '\t' {
|
||||||
|
continue
|
||||||
|
}
|
||||||
if key, val, ok := strings.Cut(line, ":"); ok && strings.TrimSpace(key) == "wing" {
|
if key, val, ok := strings.Cut(line, ":"); ok && strings.TrimSpace(key) == "wing" {
|
||||||
return strings.Trim(strings.TrimSpace(val), `"'`)
|
return strings.Trim(strings.TrimSpace(val), `"'`)
|
||||||
}
|
}
|
||||||
@@ -362,12 +431,8 @@ func parkForReview(cfg Config, root *os.Root, rel, path, filename, content strin
|
|||||||
var data []byte
|
var data []byte
|
||||||
if strings.ToLower(filepath.Ext(filename)) == ".pdf" {
|
if strings.ToLower(filepath.Ext(filename)) == ".pdf" {
|
||||||
// Binary: park the bytes untouched; the proposal is in log.md only.
|
// Binary: park the bytes untouched; the proposal is in log.md only.
|
||||||
in, _, err := extract.OpenRegular(root, rel)
|
var err error
|
||||||
if err != nil {
|
data, err = readNoteMax(root, rel, defaultMaxNoteBytes)
|
||||||
return fmt.Errorf("park pdf: %w", err)
|
|
||||||
}
|
|
||||||
data, err = io.ReadAll(in)
|
|
||||||
_ = in.Close()
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("park pdf: %w", err)
|
return fmt.Errorf("park pdf: %w", err)
|
||||||
}
|
}
|
||||||
@@ -385,7 +450,7 @@ func parkForReview(cfg Config, root *os.Root, rel, path, filename, content strin
|
|||||||
slog.Warn("watcher: note changed while it was being resolved; leaving the newer version in raw/", "file", filename)
|
slog.Warn("watcher: note changed while it was being resolved; leaving the newer version in raw/", "file", filename)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
retire(cfg, root, rel, path, ".processed", date)
|
retire(cfg, root, rel, path, ".processed", date, before)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -437,3 +502,54 @@ func cutFence(s string) (block, rest string, found bool) {
|
|||||||
}
|
}
|
||||||
return "", "", false
|
return "", "", false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// markerBlocks reports whether a marker next to path means "already handled".
|
||||||
|
// Any non-regular entry at the marker path counts (a dangling symlink must not
|
||||||
|
// be written through, hyperguild#158). A regular marker describes an EARLIER
|
||||||
|
// note of the same name, so it does not block this one, and is returned in
|
||||||
|
// stale, when it is older than the note or dated implausibly far in the future
|
||||||
|
// (clock skew, a peer): those are removed by the caller. Heuristic by nature:
|
||||||
|
// a marker at least as new as the note still means "done".
|
||||||
|
func markerBlocks(path string) (skip bool, stale []string) {
|
||||||
|
note, err := os.Lstat(path)
|
||||||
|
if err != nil {
|
||||||
|
return false, nil
|
||||||
|
}
|
||||||
|
for _, suffix := range []string{".processed", ".failed"} {
|
||||||
|
m, err := os.Lstat(path + suffix)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if !m.Mode().IsRegular() {
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
if m.ModTime().Before(note.ModTime()) || m.ModTime().After(time.Now().Add(markerSkew)) {
|
||||||
|
stale = append(stale, path+suffix)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return true, nil
|
||||||
|
}
|
||||||
|
return false, stale
|
||||||
|
}
|
||||||
|
|
||||||
|
// markerSkew is how far ahead of now a marker may be dated before it is not
|
||||||
|
// believed.
|
||||||
|
const markerSkew = 10 * time.Minute
|
||||||
|
|
||||||
|
// markerWarned remembers notes already reported as held by a marker, so the
|
||||||
|
// 30-second poll says it once instead of forever.
|
||||||
|
var markerWarned sync.Map
|
||||||
|
|
||||||
|
func warnMarkerOnce(path string) {
|
||||||
|
if _, seen := markerWarned.LoadOrStore(path, struct{}{}); !seen {
|
||||||
|
slog.Warn("watcher: note is held by a marker newer than itself and will not be processed; delete the marker or edit the note", "file", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func relOf(rawDir, path string) string {
|
||||||
|
rel, err := filepath.Rel(rawDir, path)
|
||||||
|
if err != nil {
|
||||||
|
return filepath.Base(path)
|
||||||
|
}
|
||||||
|
return rel
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ package watcher
|
|||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -409,12 +411,17 @@ func TestProcessDir_DoesNotReingestANoteItCannotRetire(t *testing.T) {
|
|||||||
},
|
},
|
||||||
Schema: "# Schema",
|
Schema: "# Schema",
|
||||||
},
|
},
|
||||||
stuck: &sync.Map{},
|
|
||||||
}
|
}
|
||||||
for i := 0; i < 3; i++ {
|
for i := 0; i < 3; i++ {
|
||||||
processDir(context.Background(), cfg, "2026-10-19")
|
processDir(context.Background(), cfg, "2026-10-19")
|
||||||
}
|
}
|
||||||
assert.Equal(t, int32(1), calls.Load(), "a note that cannot be retired is ingested once, not once per poll")
|
// The LLM count alone proves nothing: pipeline.Run's source-hash check skips
|
||||||
|
// extraction on an unchanged note, so a re-run is invisible there. What a
|
||||||
|
// re-run does leave behind is another archive copy, every poll.
|
||||||
|
archived, err := filepath.Glob(filepath.Join(rawDir, "processed", "2026-10-19", "*"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, archived, 1, "a note that cannot be retired is archived once, not once per poll")
|
||||||
|
assert.Equal(t, int32(1), calls.Load())
|
||||||
}
|
}
|
||||||
|
|
||||||
// If the archive copy cannot be written, the original is the only copy left:
|
// If the archive copy cannot be written, the original is the only copy left:
|
||||||
@@ -470,3 +477,236 @@ func TestProcessDir_KeepsANoteThatChangedWhileItWasBeingIngested(t *testing.T) {
|
|||||||
require.NoError(t, err, "a note that changed during ingest must stay in raw/")
|
require.NoError(t, err, "a note that changed during ingest must stay in raw/")
|
||||||
assert.Equal(t, "newer and longer content", string(got))
|
assert.Equal(t, "newer and longer content", string(got))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// hyperguild#198: an oversized note must not be read into memory. It is left in
|
||||||
|
// raw/ (visible, not lost), reported once, and not retried until it changes.
|
||||||
|
func TestProcessDir_ANoteOverTheSizeCapIsRefusedOnceAndKept(t *testing.T) {
|
||||||
|
brainDir := setupBrainDir(t)
|
||||||
|
big := filepath.Join(brainDir, "raw", "big.md")
|
||||||
|
require.NoError(t, os.WriteFile(big, []byte(strings.Repeat("x", 2048)), 0o644))
|
||||||
|
var calls atomic.Int32
|
||||||
|
cfg := Config{
|
||||||
|
BrainDir: brainDir,
|
||||||
|
MaxNoteBytes: 1024,
|
||||||
|
Pipeline: pipeline.Config{
|
||||||
|
Complete: func(ctx context.Context, s, u string) (string, error) {
|
||||||
|
calls.Add(1)
|
||||||
|
return successComplete(pipeline.RawPage{Title: "Big", Type: "source", Content: "x\n"})(ctx, s, u)
|
||||||
|
},
|
||||||
|
Schema: "# Schema",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
first := processDir(context.Background(), cfg, "2026-10-20")
|
||||||
|
require.NotEmpty(t, first, "the refusal is reported")
|
||||||
|
assert.Contains(t, first[0].Error(), "over the 1024 byte cap")
|
||||||
|
second := processDir(context.Background(), cfg, "2026-10-20")
|
||||||
|
assert.Empty(t, second, "and reported once, not every poll")
|
||||||
|
|
||||||
|
assert.Zero(t, calls.Load(), "no extraction")
|
||||||
|
_, err := os.Stat(big)
|
||||||
|
assert.NoError(t, err, "the note stays in raw/")
|
||||||
|
entries, _ := os.ReadDir(filepath.Join(brainDir, "raw", "processed"))
|
||||||
|
assert.Empty(t, entries, "nothing was archived")
|
||||||
|
}
|
||||||
|
|
||||||
|
// hyperguild#199: a name near NAME_MAX cannot take a ".processed" suffix. Under
|
||||||
|
// inbox zero the marker is only the fallback when removal fails, and any failure
|
||||||
|
// there is remembered, so the note is still not handled every poll.
|
||||||
|
func TestProcessDir_ALongNameWhoseMarkerCannotBeWrittenIsNotHandledEveryPoll(t *testing.T) {
|
||||||
|
brainDir := setupBrainDir(t)
|
||||||
|
rawDir := filepath.Join(brainDir, "raw")
|
||||||
|
name := strings.Repeat("a", 250) + ".md" // 253 bytes; +".processed" exceeds 255
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(rawDir, name), []byte("Content."), 0o644))
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(rawDir, "processed"), 0o755))
|
||||||
|
require.NoError(t, os.Chmod(rawDir, 0o555)) // removal fails, so the marker fallback runs
|
||||||
|
t.Cleanup(func() { _ = os.Chmod(rawDir, 0o755) })
|
||||||
|
cfg := Config{BrainDir: brainDir, Pipeline: pipeline.Config{
|
||||||
|
Complete: successComplete(pipeline.RawPage{Title: "Long", Type: "source", Content: "x\n"}), Schema: "# Schema"}}
|
||||||
|
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
errs := processDir(context.Background(), cfg, "2026-10-20")
|
||||||
|
// Without the remember step the second poll tries to archive a second
|
||||||
|
// copy under a name that is too long and errors, every poll.
|
||||||
|
assert.Empty(t, errs, "poll %d handled the note again", i)
|
||||||
|
}
|
||||||
|
archived, err := filepath.Glob(filepath.Join(rawDir, "processed", "2026-10-20", "*"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, archived, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Three notes named n.md in three folders, one poll. The archive and failed/
|
||||||
|
// copies are the only copy once the originals are retired, so none may replace
|
||||||
|
// another.
|
||||||
|
func TestProcessDir_SameNamedNotesAreAllArchivedNoneOverwritten(t *testing.T) {
|
||||||
|
for _, outcome := range []string{"processed", "failed"} {
|
||||||
|
t.Run(outcome, func(t *testing.T) {
|
||||||
|
brainDir := setupBrainDir(t)
|
||||||
|
for _, d := range []string{"a", "b", "c"} {
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(brainDir, "raw", d), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(brainDir, "raw", d, "n.md"), []byte("CONTENT-"+d), 0o644))
|
||||||
|
}
|
||||||
|
cfg := Config{BrainDir: brainDir, Pipeline: pipeline.Config{Schema: "# Schema",
|
||||||
|
Complete: successComplete(pipeline.RawPage{Title: "N", Type: "source", Content: "x\n"})}}
|
||||||
|
if outcome == "failed" {
|
||||||
|
cfg.Pipeline.Complete = errorComplete
|
||||||
|
}
|
||||||
|
processDir(context.Background(), cfg, "2026-10-20")
|
||||||
|
|
||||||
|
var all string
|
||||||
|
files, _ := filepath.Glob(filepath.Join(brainDir, "raw", outcome, "*", "*"))
|
||||||
|
more, _ := filepath.Glob(filepath.Join(brainDir, "raw", outcome, "*"))
|
||||||
|
for _, f := range append(files, more...) {
|
||||||
|
if b, err := os.ReadFile(f); err == nil {
|
||||||
|
all += string(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, d := range []string{"a", "b", "c"} {
|
||||||
|
assert.Contains(t, all, "CONTENT-"+d, "the copy of note %s was overwritten", d)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A marker older than the note describes an earlier note of the same name; it
|
||||||
|
// must not hide the new one, silently, forever (the legacy .processed markers
|
||||||
|
// from before inbox zero are exactly this).
|
||||||
|
func TestProcessDir_AStaleMarkerDoesNotHideANewerNoteOfTheSameName(t *testing.T) {
|
||||||
|
brainDir := setupBrainDir(t)
|
||||||
|
rawDir := filepath.Join(brainDir, "raw")
|
||||||
|
marker := filepath.Join(rawDir, "idea.md.processed")
|
||||||
|
require.NoError(t, os.WriteFile(marker, []byte("2026-06-16"), 0o644))
|
||||||
|
old := time.Now().Add(-48 * time.Hour)
|
||||||
|
require.NoError(t, os.Chtimes(marker, old, old))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(rawDir, "idea.md"), []byte("A new idea, same file name."), 0o644))
|
||||||
|
|
||||||
|
var calls atomic.Int32
|
||||||
|
cfg := Config{BrainDir: brainDir, Pipeline: pipeline.Config{Schema: "# Schema",
|
||||||
|
Complete: func(ctx context.Context, s, u string) (string, error) {
|
||||||
|
calls.Add(1)
|
||||||
|
return successComplete(pipeline.RawPage{Title: "Idea", Type: "source", Content: "x\n"})(ctx, s, u)
|
||||||
|
}}}
|
||||||
|
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
|
||||||
|
|
||||||
|
assert.Equal(t, int32(1), calls.Load(), "the new note is processed")
|
||||||
|
_, err := os.Stat(filepath.Join(rawDir, "idea.md"))
|
||||||
|
assert.ErrorIs(t, err, os.ErrNotExist, "and retired")
|
||||||
|
_, err = os.Lstat(marker)
|
||||||
|
assert.ErrorIs(t, err, os.ErrNotExist, "the stale marker is cleaned up")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessDir_AMarkerNewerThanTheNoteStillMeansDone(t *testing.T) {
|
||||||
|
brainDir := setupBrainDir(t)
|
||||||
|
rawDir := filepath.Join(brainDir, "raw")
|
||||||
|
note := filepath.Join(rawDir, "stuck.md")
|
||||||
|
require.NoError(t, os.WriteFile(note, []byte("Content."), 0o644))
|
||||||
|
past := time.Now().Add(-time.Hour)
|
||||||
|
require.NoError(t, os.Chtimes(note, past, past))
|
||||||
|
require.NoError(t, os.WriteFile(note+".processed", []byte("d"), 0o644)) // newer than the note
|
||||||
|
|
||||||
|
var calls atomic.Int32
|
||||||
|
cfg := Config{BrainDir: brainDir, Pipeline: pipeline.Config{Schema: "# Schema",
|
||||||
|
Complete: func(context.Context, string, string) (string, error) { calls.Add(1); return "[]", nil }}}
|
||||||
|
processDir(context.Background(), cfg, "2026-10-20")
|
||||||
|
assert.Zero(t, calls.Load(), "a fallback marker written after the note still stops reprocessing")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The archive copy is a second read of the note; the cap must hold there too,
|
||||||
|
// not only at the earlier Lstat (a note can grow in between).
|
||||||
|
func TestReadNoteMax(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(dir, "n.md"), []byte(strings.Repeat("x", 100)), 0o644))
|
||||||
|
root, err := os.OpenRoot(dir)
|
||||||
|
require.NoError(t, err)
|
||||||
|
defer func() { _ = root.Close() }()
|
||||||
|
|
||||||
|
got, err := readNoteMax(root, "n.md", 100)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, got, 100, "exactly at the cap is allowed")
|
||||||
|
|
||||||
|
_, err = readNoteMax(root, "n.md", 99)
|
||||||
|
require.Error(t, err)
|
||||||
|
assert.ErrorIs(t, err, errTooLarge)
|
||||||
|
}
|
||||||
|
|
||||||
|
// countingReader reports how much was pulled from it: an endless file must be
|
||||||
|
// abandoned at max+1 bytes, not slurped.
|
||||||
|
type countingReader struct{ n int64 }
|
||||||
|
|
||||||
|
func (c *countingReader) Read(p []byte) (int, error) {
|
||||||
|
if c.n > 8<<20 { // a regression must fail the test, not exhaust memory
|
||||||
|
return 0, errors.New("endless reader drained")
|
||||||
|
}
|
||||||
|
for i := range p {
|
||||||
|
p[i] = 'x'
|
||||||
|
}
|
||||||
|
c.n += int64(len(p))
|
||||||
|
return len(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestReadAllMax_StopsReadingAtTheCap(t *testing.T) {
|
||||||
|
c := &countingReader{}
|
||||||
|
_, err := readAllMax(c, 1000)
|
||||||
|
require.ErrorIs(t, err, errTooLarge)
|
||||||
|
assert.LessOrEqual(t, c.n, int64(1000+1+32*1024), "pulled far more than the cap from an endless reader: %d", c.n)
|
||||||
|
}
|
||||||
|
|
||||||
|
// From the second review of #210: a marker dated in the future (clock skew,
|
||||||
|
// Syncthing) must not hide the note for good, and when a marker does block it
|
||||||
|
// has to say so, once.
|
||||||
|
func TestProcessDir_AFutureDatedMarkerDoesNotHideTheNote(t *testing.T) {
|
||||||
|
brainDir := setupBrainDir(t)
|
||||||
|
rawDir := filepath.Join(brainDir, "raw")
|
||||||
|
marker := filepath.Join(rawDir, "idea.md.processed")
|
||||||
|
require.NoError(t, os.WriteFile(marker, []byte("x"), 0o644))
|
||||||
|
future := time.Now().Add(72 * time.Hour)
|
||||||
|
require.NoError(t, os.Chtimes(marker, future, future))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(rawDir, "idea.md"), []byte("A new idea."), 0o644))
|
||||||
|
|
||||||
|
var calls atomic.Int32
|
||||||
|
cfg := Config{BrainDir: brainDir, Pipeline: pipeline.Config{Schema: "# Schema",
|
||||||
|
Complete: func(ctx context.Context, s, u string) (string, error) {
|
||||||
|
calls.Add(1)
|
||||||
|
return successComplete(pipeline.RawPage{Title: "Idea", Type: "source", Content: "x\n"})(ctx, s, u)
|
||||||
|
}}}
|
||||||
|
processDir(context.Background(), cfg, "2026-10-20")
|
||||||
|
assert.Equal(t, int32(1), calls.Load(), "a marker from the future is not evidence the note was handled")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessDir_BothStaleMarkersAreRemoved(t *testing.T) {
|
||||||
|
brainDir := setupBrainDir(t)
|
||||||
|
rawDir := filepath.Join(brainDir, "raw")
|
||||||
|
old := time.Now().Add(-48 * time.Hour)
|
||||||
|
for _, suffix := range []string{".processed", ".failed"} {
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(rawDir, "idea.md"+suffix), []byte("x"), 0o644))
|
||||||
|
require.NoError(t, os.Chtimes(filepath.Join(rawDir, "idea.md"+suffix), old, old))
|
||||||
|
}
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(rawDir, "idea.md"), []byte("A new idea."), 0o644))
|
||||||
|
cfg := Config{BrainDir: brainDir, Pipeline: pipeline.Config{Schema: "# Schema",
|
||||||
|
Complete: successComplete(pipeline.RawPage{Title: "Idea", Type: "source", Content: "x\n"})}}
|
||||||
|
processDir(context.Background(), cfg, "2026-10-20")
|
||||||
|
for _, suffix := range []string{".processed", ".failed"} {
|
||||||
|
_, err := os.Lstat(filepath.Join(rawDir, "idea.md"+suffix))
|
||||||
|
assert.ErrorIs(t, err, os.ErrNotExist, "orphan %s marker left behind", suffix)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessDir_AMarkerThatBlocksANoteIsReportedOnce(t *testing.T) {
|
||||||
|
var logs strings.Builder
|
||||||
|
prev := slog.Default()
|
||||||
|
slog.SetDefault(slog.New(slog.NewTextHandler(&logs, nil)))
|
||||||
|
t.Cleanup(func() { slog.SetDefault(prev) })
|
||||||
|
|
||||||
|
brainDir := setupBrainDir(t)
|
||||||
|
rawDir := filepath.Join(brainDir, "raw")
|
||||||
|
note := filepath.Join(rawDir, "held.md")
|
||||||
|
require.NoError(t, os.WriteFile(note, []byte("Content."), 0o644))
|
||||||
|
past := time.Now().Add(-time.Hour)
|
||||||
|
require.NoError(t, os.Chtimes(note, past, past))
|
||||||
|
require.NoError(t, os.WriteFile(note+".processed", []byte("d"), 0o644)) // newer: blocks
|
||||||
|
cfg := Config{BrainDir: brainDir, Pipeline: pipeline.Config{Schema: "# Schema", Complete: errorComplete}}
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
processDir(context.Background(), cfg, "2026-10-20")
|
||||||
|
}
|
||||||
|
assert.Equal(t, 1, strings.Count(logs.String(), "held by a marker"), "said once, not every poll: %s", logs.String())
|
||||||
|
}
|
||||||
|
|||||||
@@ -2,9 +2,10 @@ package watcher
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
"encoding/json"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"regexp"
|
||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -16,37 +17,11 @@ import (
|
|||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/wing"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/wing"
|
||||||
)
|
)
|
||||||
|
|
||||||
type stubClassifier struct {
|
func wingTestCfg(t *testing.T, mk func(brainDir string) wing.Placer, llmCalls *atomic.Int32) (Config, string) {
|
||||||
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()
|
t.Helper()
|
||||||
brainDir := setupBrainDir(t)
|
brainDir := setupBrainDir(t)
|
||||||
return Config{
|
cfg := Config{
|
||||||
BrainDir: brainDir,
|
BrainDir: brainDir,
|
||||||
Resolver: r,
|
|
||||||
Pipeline: pipeline.Config{
|
Pipeline: pipeline.Config{
|
||||||
Schema: "# Schema",
|
Schema: "# Schema",
|
||||||
Complete: func(ctx context.Context, s, u string) (string, error) {
|
Complete: func(ctx context.Context, s, u string) (string, error) {
|
||||||
@@ -54,7 +29,21 @@ func wingTestCfg(t *testing.T, r *wing.Resolver, llmCalls *atomic.Int32) (Config
|
|||||||
return successComplete(pipeline.RawPage{Title: "Talk", Type: "source", Subtype: "video", Content: "## Summary\n\nx\n"})(ctx, s, u)
|
return successComplete(pipeline.RawPage{Title: "Talk", Type: "source", Subtype: "video", Content: "## Summary\n\nx\n"})(ctx, s, u)
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}, brainDir
|
}
|
||||||
|
if mk != nil {
|
||||||
|
cfg.Resolver = mk(brainDir)
|
||||||
|
}
|
||||||
|
return cfg, brainDir
|
||||||
|
}
|
||||||
|
|
||||||
|
// router is the production Router with a client-content marker.
|
||||||
|
func router(brainDir string) wing.Placer {
|
||||||
|
return wing.Router{BrainDir: brainDir, Default: "web-clip", ClientBlock: regexp.MustCompile(`(?i)acme-client`)}
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeWing(t *testing.T, brainDir, name string) {
|
||||||
|
t.Helper()
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Join(brainDir, "wiki", name, "facts"), 0o755))
|
||||||
}
|
}
|
||||||
|
|
||||||
func put(t *testing.T, brainDir, rel, body string) {
|
func put(t *testing.T, brainDir, rel, body string) {
|
||||||
@@ -69,70 +58,91 @@ func has(brainDir, rel string) bool {
|
|||||||
return err == nil
|
return err == nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func mustJSONPages(pages []pipeline.RawPage) string {
|
||||||
|
b, err := json.Marshal(pages)
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func parkedBody(t *testing.T, brainDir, name string) string {
|
||||||
|
t.Helper()
|
||||||
|
b, err := os.ReadFile(filepath.Join(brainDir, "raw", "needs-review", name))
|
||||||
|
require.NoError(t, err)
|
||||||
|
return string(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
func linesStartingWith(s, prefix string) int {
|
||||||
|
n := 0
|
||||||
|
for _, l := range strings.Split(s, "\n") {
|
||||||
|
if strings.HasPrefix(l, prefix) {
|
||||||
|
n++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return n
|
||||||
|
}
|
||||||
|
|
||||||
func TestProcessDir_ASubfolderNamesTheWing(t *testing.T) {
|
func TestProcessDir_ASubfolderNamesTheWing(t *testing.T) {
|
||||||
var llmCalls atomic.Int32
|
var llmCalls atomic.Int32
|
||||||
clf := &stubClassifier{}
|
cfg, brainDir := wingTestCfg(t, router, &llmCalls)
|
||||||
cfg, brainDir := wingTestCfg(t, wingResolver(clf, stubScorer{}), &llmCalls)
|
makeWing(t, brainDir, "radar")
|
||||||
put(t, brainDir, "radar/dark-factory.md", "A talk about factories.")
|
put(t, brainDir, "radar/dark-factory.md", "A talk about factories.")
|
||||||
|
|
||||||
errs := processDir(context.Background(), cfg, "2026-10-20")
|
require.Empty(t, 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.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.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) {
|
func TestProcessDir_ADeliberateSubfolderIntoAnyExistingWingPlaces(t *testing.T) {
|
||||||
var llmCalls atomic.Int32
|
var llmCalls atomic.Int32
|
||||||
clf := &stubClassifier{s: wing.Suggestion{Wing: "radar", Confidence: 0.5, Reason: "maybe"}}
|
cfg, brainDir := wingTestCfg(t, router, &llmCalls)
|
||||||
cfg, brainDir := wingTestCfg(t, wingResolver(clf, stubScorer{"radar": 3}), &llmCalls)
|
makeWing(t, brainDir, "rikatillsammans")
|
||||||
put(t, brainDir, "unsure.md", "Some note.")
|
put(t, brainDir, "rikatillsammans/meeting.md", "Notes the owner put there on purpose.")
|
||||||
|
|
||||||
errs := processDir(context.Background(), cfg, "2026-10-20")
|
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
|
||||||
|
|
||||||
|
assert.True(t, has(brainDir, "wiki/rikatillsammans/sources/meeting.md"),
|
||||||
|
"a folder is the owner's deliberate choice; extraction runs on the local model")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessDir_ANoteTheRouterCannotPlaceIsParkedNotIngested(t *testing.T) {
|
||||||
|
var llmCalls atomic.Int32
|
||||||
|
cfg, brainDir := wingTestCfg(t, router, &llmCalls)
|
||||||
|
put(t, brainDir, "kickoff.md", "Some note about the ACME-CLIENT kickoff.")
|
||||||
|
|
||||||
|
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
|
||||||
|
|
||||||
require.Empty(t, errs)
|
|
||||||
assert.Zero(t, llmCalls.Load(), "no extraction runs for an unplaced note")
|
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/")
|
assert.False(t, has(brainDir, "raw/kickoff.md"), "leaves the top of raw/")
|
||||||
parked, err := os.ReadFile(filepath.Join(brainDir, "raw", "needs-review", "unsure.md"))
|
parked := parkedBody(t, brainDir, "kickoff.md")
|
||||||
require.NoError(t, err)
|
assert.Contains(t, parked, "proposal_reason:")
|
||||||
assert.Contains(t, string(parked), "proposed_wing: radar")
|
assert.Contains(t, parked, "client-content marker")
|
||||||
assert.Contains(t, string(parked), "below threshold")
|
assert.Contains(t, parked, "ACME-CLIENT kickoff", "the note body is preserved")
|
||||||
assert.Contains(t, string(parked), "Some note.", "the note body is preserved")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProcessDir_ANoteInAClientWingFolderIsParked(t *testing.T) {
|
func TestProcessDir_ATypoedWingFolderIsParkedAndNeverCreated(t *testing.T) {
|
||||||
var llmCalls atomic.Int32
|
var llmCalls atomic.Int32
|
||||||
cfg, brainDir := wingTestCfg(t, wingResolver(&stubClassifier{}, stubScorer{}), &llmCalls)
|
cfg, brainDir := wingTestCfg(t, router, &llmCalls)
|
||||||
put(t, brainDir, "client-x/secret.md", "Client material.")
|
makeWing(t, brainDir, "homelab")
|
||||||
|
put(t, brainDir, "hoemlab/n.md", "Note.")
|
||||||
|
|
||||||
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
|
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
|
||||||
|
|
||||||
assert.Zero(t, llmCalls.Load())
|
assert.False(t, has(brainDir, "wiki/hoemlab"))
|
||||||
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"))
|
assert.True(t, has(brainDir, "raw/needs-review/n.md"))
|
||||||
|
assert.Zero(t, llmCalls.Load())
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestProcessDir_ParkedNotesAreNotReprocessed(t *testing.T) {
|
func TestProcessDir_ParkedNotesAreNotReprocessed(t *testing.T) {
|
||||||
var llmCalls atomic.Int32
|
var llmCalls atomic.Int32
|
||||||
clf := &stubClassifier{s: wing.Suggestion{Wing: "radar", Confidence: 0.99}}
|
cfg, brainDir := wingTestCfg(t, router, &llmCalls)
|
||||||
cfg, brainDir := wingTestCfg(t, wingResolver(clf, stubScorer{"radar": 3}), &llmCalls)
|
|
||||||
put(t, brainDir, "needs-review/waiting.md", "---\nproposed_wing: radar\n---\nA parked note.")
|
put(t, brainDir, "needs-review/waiting.md", "---\nproposed_wing: radar\n---\nA parked note.")
|
||||||
|
|
||||||
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
|
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.Zero(t, llmCalls.Load(), "needs-review/ is a human queue, like rejected/")
|
||||||
assert.True(t, has(brainDir, "raw/needs-review/waiting.md"))
|
assert.True(t, has(brainDir, "raw/needs-review/waiting.md"))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -146,141 +156,68 @@ func TestProcessDir_WithoutAResolverTheRetiredLayoutIsUnchanged(t *testing.T) {
|
|||||||
assert.True(t, has(brainDir, "wiki/sources/plain.md"))
|
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
|
// The reason comes from file and folder names and ends up in frontmatter: a
|
||||||
// not be able to start a new key (PR #147: a title once forged `origin:`).
|
// newline in it must not be able to start a new key (PR #147: a title once
|
||||||
func TestProcessDir_AProposalReasonCannotForgeFrontmatterKeys(t *testing.T) {
|
// forged `origin:`).
|
||||||
|
func TestProcessDir_AFolderNameCannotForgeFrontmatterKeys(t *testing.T) {
|
||||||
var llmCalls atomic.Int32
|
var llmCalls atomic.Int32
|
||||||
// A classifier error is the path by which model text reaches the reason.
|
cfg, brainDir := wingTestCfg(t, router, &llmCalls)
|
||||||
clf := &stubClassifier{err: errors.New("bad reply\norigin: trusted\nwing: homelab")}
|
put(t, brainDir, "radar\nproposal_confidence: 0.99\nwing: homelab/forge.md", "Note.")
|
||||||
cfg, brainDir := wingTestCfg(t, wingResolver(clf, stubScorer{}), &llmCalls)
|
|
||||||
put(t, brainDir, "forge.md", "Note.")
|
|
||||||
|
|
||||||
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
|
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
|
||||||
|
|
||||||
parked, err := os.ReadFile(filepath.Join(brainDir, "raw", "needs-review", "forge.md"))
|
entries, err := os.ReadDir(filepath.Join(brainDir, "raw", "needs-review"))
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.NotContains(t, string(parked), "\norigin: trusted", "no injected key at line start")
|
require.Len(t, entries, 1)
|
||||||
assert.NotContains(t, string(parked), "\nwing: homelab")
|
b := parkedBody(t, brainDir, entries[0].Name())
|
||||||
|
assert.Equal(t, 1, linesStartingWith(b, "proposal_confidence:"), "exactly one confidence key at line start")
|
||||||
|
assert.Equal(t, 0, linesStartingWith(b, "wing:"), "no injected wing key")
|
||||||
|
assert.Equal(t, 0, linesStartingWith(b, "proposed_wing:"), "a non-plain wing name is never a proposal")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Syncthing can bring a removed note back (an offline peer, a restored backup).
|
func TestProcessDir_ANotesOwnProposalKeysDoNotOutrankTheRouters(t *testing.T) {
|
||||||
// The source-hash drift check must keep that from costing a second extraction.
|
|
||||||
func TestProcessDir_ANoteThatReappearsUnchangedIsNotExtractedAgain(t *testing.T) {
|
|
||||||
var llmCalls atomic.Int32
|
var llmCalls atomic.Int32
|
||||||
cfg, brainDir := wingTestCfg(t, wingResolver(&stubClassifier{}, stubScorer{}), &llmCalls)
|
cfg, brainDir := wingTestCfg(t, router, &llmCalls)
|
||||||
|
put(t, brainDir, "n.md", "---\nproposed_wing: homelab\nproposal_confidence: 1.00\nproposal_reason: \"self-approved\"\n---\nACME-CLIENT body.")
|
||||||
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"))
|
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
|
||||||
|
|
||||||
b, err := os.ReadFile(filepath.Join(brainDir, "raw", "needs-review", "n.md"))
|
b := parkedBody(t, brainDir, "n.md")
|
||||||
require.NoError(t, err)
|
assert.NotContains(t, b, "self-approved")
|
||||||
assert.NotContains(t, string(b), "\nproposal_confidence: 0.99")
|
assert.Equal(t, 1, linesStartingWith(b, "proposal_reason:"))
|
||||||
assert.NotContains(t, string(b), "approved by operator\"\n")
|
assert.Equal(t, 0, linesStartingWith(b, "proposed_wing:"))
|
||||||
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) {
|
func TestProcessDir_ACRLFNoteWithAWingKeyIsStillRead(t *testing.T) {
|
||||||
var llmCalls atomic.Int32
|
var llmCalls atomic.Int32
|
||||||
clf := &stubClassifier{}
|
cfg, brainDir := wingTestCfg(t, router, &llmCalls)
|
||||||
cfg, brainDir := wingTestCfg(t, wingResolver(clf, stubScorer{}), &llmCalls)
|
makeWing(t, brainDir, "radar")
|
||||||
put(t, brainDir, "n.md", "---\r\nwing: radar\r\n---\r\nBody.\r\n")
|
put(t, brainDir, "n.md", "---\r\nwing: radar\r\n---\r\nBody.\r\n")
|
||||||
|
|
||||||
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
|
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.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
|
// editingPlacer rewrites the note while the placement decision is being made,
|
||||||
// value is not at the start of a line and is not a key.
|
// then parks it: the realistic window is a slow decision with Syncthing
|
||||||
func linesStartingWith(s, prefix string) int {
|
// delivering a newer version meanwhile.
|
||||||
n := 0
|
type editingPlacer struct{ edit func() }
|
||||||
for _, l := range strings.Split(s, "\n") {
|
|
||||||
if strings.HasPrefix(l, prefix) {
|
func (e editingPlacer) Resolve(context.Context, wing.Note) wing.Decision {
|
||||||
n++
|
e.edit()
|
||||||
}
|
return wing.Decision{Source: wing.Exception, Reason: "test: parked after an edit"}
|
||||||
}
|
|
||||||
return n
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolving costs an LLM call. If Syncthing delivers a newer version of the note
|
func TestProcessDir_ANoteEditedWhileItIsBeingPlacedIsNotDeleted(t *testing.T) {
|
||||||
// meanwhile, deleting the original loses that version: the parked copy is stale.
|
|
||||||
func TestProcessDir_ANoteEditedWhileTheResolverRunsIsNotDeleted(t *testing.T) {
|
|
||||||
var llmCalls atomic.Int32
|
var llmCalls atomic.Int32
|
||||||
var notePath string
|
var notePath string
|
||||||
clf := &editingClassifier{}
|
cfg, brainDir := wingTestCfg(t, func(string) wing.Placer {
|
||||||
cfg, brainDir := wingTestCfg(t, wingResolver(clf, stubScorer{}), &llmCalls)
|
return editingPlacer{edit: func() {
|
||||||
|
require.NoError(t, os.WriteFile(notePath, []byte("NEWER and longer version"), 0o644))
|
||||||
|
}}
|
||||||
|
}, &llmCalls)
|
||||||
notePath = filepath.Join(brainDir, "raw", "n.md")
|
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")
|
put(t, brainDir, "n.md", "ORIGINAL")
|
||||||
|
|
||||||
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
|
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
|
||||||
@@ -290,21 +227,14 @@ func TestProcessDir_ANoteEditedWhileTheResolverRunsIsNotDeleted(t *testing.T) {
|
|||||||
assert.Equal(t, "NEWER and longer version", string(got))
|
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
|
// A planted symlink at raw/needs-review must not redirect a parked note out of
|
||||||
// the brain, nor lose it from the review queue.
|
// the brain, nor lose it from the review queue.
|
||||||
func TestProcessDir_AParkedNoteIsNeverWrittenThroughASymlinkedNeedsReview(t *testing.T) {
|
func TestProcessDir_AParkedNoteIsNeverWrittenThroughASymlinkedNeedsReview(t *testing.T) {
|
||||||
var llmCalls atomic.Int32
|
var llmCalls atomic.Int32
|
||||||
cfg, brainDir := wingTestCfg(t, wingResolver(&stubClassifier{}, stubScorer{}), &llmCalls)
|
cfg, brainDir := wingTestCfg(t, router, &llmCalls)
|
||||||
outside := t.TempDir()
|
outside := t.TempDir()
|
||||||
require.NoError(t, os.Symlink(outside, filepath.Join(brainDir, "raw", "needs-review")))
|
require.NoError(t, os.Symlink(outside, filepath.Join(brainDir, "raw", "needs-review")))
|
||||||
put(t, brainDir, "n.md", "Note.")
|
put(t, brainDir, "n.md", "ACME-CLIENT note.")
|
||||||
|
|
||||||
errs := processDir(context.Background(), cfg, "2026-10-20")
|
errs := processDir(context.Background(), cfg, "2026-10-20")
|
||||||
|
|
||||||
@@ -329,3 +259,100 @@ func TestWithProposal_ANonFenceDashLineDoesNotCloseTheBlock(t *testing.T) {
|
|||||||
assert.Contains(t, strings.Join(lines[:closing], "\n"), "proposal_reason:")
|
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")
|
assert.NotContains(t, strings.Join(lines[:closing], "\n"), "foo: bar", "the unfenced note is body, not part of the block")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 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, router, &llmCalls)
|
||||||
|
for i, body := range []string{"ONE", "TWO", "THREE", "FOUR"} {
|
||||||
|
put(t, brainDir, filepath.Join("typo"+string(rune('1'+i)), "x.md"), body)
|
||||||
|
}
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, router, &llmCalls)
|
||||||
|
makeWing(t, brainDir, "radar")
|
||||||
|
|
||||||
|
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, router, &llmCalls)
|
||||||
|
makeWing(t, brainDir, "radar")
|
||||||
|
|
||||||
|
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")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFrontmatterWing(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name, in, want string
|
||||||
|
}{
|
||||||
|
{"plain key", "---\nwing: radar\n---\nbody", "radar"},
|
||||||
|
{"quoted", "---\nwing: \"radar\"\n---\nbody", "radar"},
|
||||||
|
{"CRLF", "---\r\nwing: radar\r\n---\r\nbody", "radar"},
|
||||||
|
{"no frontmatter", "wing: radar\nbody", ""},
|
||||||
|
{"an indented key belongs to another mapping", "---\nmeta:\n wing: radar\n---\nbody", ""},
|
||||||
|
{"a key before a non-fence dash line is not frontmatter", "---\nwing: radar\n----\nbody", ""},
|
||||||
|
{"no closing fence", "---\nwing: radar\nbody", ""},
|
||||||
|
{"closing fence at end of input", "---\nwing: radar\n---", "radar"},
|
||||||
|
{"another key first", "---\ntitle: x\nwing: radar\n---\nbody", "radar"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
assert.Equal(t, tt.want, frontmatterWing(tt.in))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFrontmatterWing_ABOMDoesNotHideTheKey(t *testing.T) {
|
||||||
|
assert.Equal(t, "homelab", frontmatterWing("\ufeff---\nwing: homelab\n---\nbody"))
|
||||||
|
}
|
||||||
|
|
||||||
|
// A note that produced nothing must not leave the inbox as "processed".
|
||||||
|
func TestProcessDir_AWingIngestThatWritesNothingFailsTheNoteVisibly(t *testing.T) {
|
||||||
|
var llmCalls atomic.Int32
|
||||||
|
cfg, brainDir := wingTestCfg(t, router, &llmCalls)
|
||||||
|
cfg.Pipeline.Complete = func(context.Context, string, string) (string, error) { return "[]", nil }
|
||||||
|
put(t, brainDir, "empty-result.md", "A note the model returned nothing for.")
|
||||||
|
|
||||||
|
require.Empty(t, processDir(context.Background(), cfg, "2026-10-20"))
|
||||||
|
|
||||||
|
assert.True(t, has(brainDir, "raw/failed/empty-result.md"), "kept where a human looks")
|
||||||
|
processed, _ := filepath.Glob(filepath.Join(brainDir, "raw", "processed", "*", "*"))
|
||||||
|
assert.Empty(t, processed, "never archived as processed")
|
||||||
|
logb, err := os.ReadFile(filepath.Join(brainDir, "log.md"))
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Contains(t, string(logb), "no source page")
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,190 @@
|
|||||||
|
package watcher
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
|
||||||
|
"github.com/mathiasbq/hyperguild/ingestion/internal/pipeline"
|
||||||
|
)
|
||||||
|
|
||||||
|
func okPipeline() pipeline.Config {
|
||||||
|
return pipeline.Config{Complete: func(context.Context, string, string) (string, error) {
|
||||||
|
b, _ := json.Marshal([]pipeline.RawPage{{Title: "X", Type: "source", Subtype: "article", Content: "## S\n\nx\n"}})
|
||||||
|
return string(b), nil
|
||||||
|
}, Schema: "# Schema"}
|
||||||
|
}
|
||||||
|
|
||||||
|
func failingPipeline() pipeline.Config {
|
||||||
|
return pipeline.Config{Complete: func(context.Context, string, string) (string, error) {
|
||||||
|
return "", errors.New("model down")
|
||||||
|
}, Schema: "# Schema"}
|
||||||
|
}
|
||||||
|
|
||||||
|
// hyperguild#158: the watcher wrote the archive copy under raw/processed or
|
||||||
|
// raw/failed, and its log.md entry, by path, so a symlinked archive
|
||||||
|
// directory or log redirected the write elsewhere in the brain or outside
|
||||||
|
// it. (A pre-planted marker symlink makes the watcher skip the note, so the
|
||||||
|
// marker write itself is reachable only by a race.)
|
||||||
|
func TestProcessDirDoesNotWriteThroughASymlinkedArchiveDir(t *testing.T) {
|
||||||
|
for name, pcfg := range map[string]pipeline.Config{"processed": okPipeline(), "failed": failingPipeline()} {
|
||||||
|
t.Run(name, func(t *testing.T) {
|
||||||
|
brainDir := setupBrainDir(t)
|
||||||
|
outside := t.TempDir()
|
||||||
|
victim := filepath.Join(brainDir, "wiki", "concepts", "v.md")
|
||||||
|
require.NoError(t, os.WriteFile(victim, []byte("untouched"), 0o644))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(brainDir, "raw", "n.md"), []byte("note"), 0o644))
|
||||||
|
require.NoError(t, os.Symlink(outside, filepath.Join(brainDir, "raw", name)))
|
||||||
|
_ = processDir(context.Background(), Config{BrainDir: brainDir, Interval: time.Hour, Pipeline: pcfg}, "2026-10-05")
|
||||||
|
got, _ := os.ReadFile(victim)
|
||||||
|
assert.Equal(t, "untouched", string(got))
|
||||||
|
ents, _ := os.ReadDir(outside)
|
||||||
|
assert.Empty(t, ents, "the archive copy was written outside the brain")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProcessDirWritesTheArchiveInRealDirectoriesAndRetiresTheOriginal(t *testing.T) {
|
||||||
|
brainDir := setupBrainDir(t)
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(brainDir, "raw", "n.md"), []byte("note"), 0o644))
|
||||||
|
errs := processDir(context.Background(), Config{BrainDir: brainDir, Interval: time.Hour, Pipeline: okPipeline()}, "2026-10-05")
|
||||||
|
assert.Empty(t, errs)
|
||||||
|
// Inbox zero (v0.25.0): no marker is written beside a note that was removed.
|
||||||
|
_, err := os.Stat(filepath.Join(brainDir, "raw", "n.md.processed"))
|
||||||
|
assert.ErrorIs(t, err, os.ErrNotExist, "a marker is only the fallback when the removal fails")
|
||||||
|
_, err = os.Stat(filepath.Join(brainDir, "raw", "n.md"))
|
||||||
|
assert.ErrorIs(t, err, os.ErrNotExist, "positive control: the original is retired")
|
||||||
|
_, err = os.Stat(filepath.Join(brainDir, "raw", "processed", "2026-10-05", "n.md"))
|
||||||
|
assert.NoError(t, err, "positive control: archive copy written")
|
||||||
|
}
|
||||||
|
|
||||||
|
// The log.md entry for a failed note is appended through the brain root: with
|
||||||
|
// a real raw/failed (so the archive copy succeeds and the log is reached), a
|
||||||
|
// symlinked log.md is not written through.
|
||||||
|
func TestProcessDirDoesNotAppendTheLogThroughASymlink(t *testing.T) {
|
||||||
|
brainDir := setupBrainDir(t)
|
||||||
|
victim := filepath.Join(brainDir, "wiki", "concepts", "v.md")
|
||||||
|
require.NoError(t, os.WriteFile(victim, []byte("untouched"), 0o644))
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(brainDir, "raw", "n.md"), []byte("note"), 0o644))
|
||||||
|
require.NoError(t, os.Symlink(filepath.Join("wiki", "concepts", "v.md"), filepath.Join(brainDir, "log.md")))
|
||||||
|
_ = processDir(context.Background(), Config{BrainDir: brainDir, Interval: time.Hour, Pipeline: failingPipeline()}, "2026-10-05")
|
||||||
|
_, err := os.Stat(filepath.Join(brainDir, "raw", "failed", "n.md"))
|
||||||
|
require.NoError(t, err, "positive control: the archive copy was written, so the log append was reached")
|
||||||
|
got, _ := os.ReadFile(victim)
|
||||||
|
assert.Equal(t, "untouched", string(got), "the log was appended through a symlink")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A marker path holding anything (here a dangling symlink) counts as a
|
||||||
|
// marker: the note is skipped and nothing is created at the link's target.
|
||||||
|
// (os.Stat followed the link, saw nothing, and processed the note again.)
|
||||||
|
func TestProcessDirTreatsAnyEntryAtTheMarkerPathAsAMarker(t *testing.T) {
|
||||||
|
for _, kind := range []string{"processed", "failed"} {
|
||||||
|
t.Run(kind, func(t *testing.T) {
|
||||||
|
brainDir := setupBrainDir(t)
|
||||||
|
outside := filepath.Join(t.TempDir(), "created")
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(brainDir, "raw", "n.md"), []byte("note"), 0o644))
|
||||||
|
require.NoError(t, os.Symlink(outside, filepath.Join(brainDir, "raw", "n.md."+kind)))
|
||||||
|
calls := 0
|
||||||
|
cfg := Config{BrainDir: brainDir, Interval: time.Hour, Pipeline: pipeline.Config{
|
||||||
|
Complete: func(context.Context, string, string) (string, error) { calls++; return "[]", nil }, Schema: "# Schema"}}
|
||||||
|
_ = processDir(context.Background(), cfg, "2026-10-05")
|
||||||
|
assert.Zero(t, calls, "the note was processed despite an entry at its marker path")
|
||||||
|
_, err := os.Lstat(outside)
|
||||||
|
assert.True(t, os.IsNotExist(err), "a file was created at the marker link's target")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A link planted at the marker path while pipeline.Run is in flight (in
|
||||||
|
// production, an LLM call) ends up with its target untouched, and the note is
|
||||||
|
// processed once, even after the link is removed (as symlinkguard does). Under
|
||||||
|
// inbox zero the original is removed, so the marker write that this scenario
|
||||||
|
// was written to redirect is never reached: this test does NOT pin that the
|
||||||
|
// marker-fallback write goes through the brain root (see the PR description).
|
||||||
|
func TestProcessDirLeavesAMarkerLinkPlantedDuringTheRunUntouchedAndProcessesTheNoteOnce(t *testing.T) {
|
||||||
|
for _, kind := range []string{"processed", "failed"} {
|
||||||
|
for _, target := range []string{"outside", "in-brain"} {
|
||||||
|
t.Run(kind+"/"+target, func(t *testing.T) {
|
||||||
|
brainDir := setupBrainDir(t)
|
||||||
|
raw := filepath.Join(brainDir, "raw")
|
||||||
|
marker := filepath.Join(raw, "n.md."+kind)
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(raw, "n.md"), []byte("note"), 0o644))
|
||||||
|
// The in-brain target is relative, so os.Root itself would
|
||||||
|
// follow it; only the regular-file check refuses it.
|
||||||
|
victim := filepath.Join(raw, "victim")
|
||||||
|
link := filepath.Join(t.TempDir(), "created")
|
||||||
|
if target == "in-brain" {
|
||||||
|
require.NoError(t, os.WriteFile(victim, []byte("untouched"), 0o644))
|
||||||
|
link = "victim"
|
||||||
|
}
|
||||||
|
ok := okPipeline().Complete
|
||||||
|
calls := 0
|
||||||
|
cfg := Config{BrainDir: brainDir, Interval: time.Hour, Pipeline: pipeline.Config{Schema: "# Schema",
|
||||||
|
Complete: func(ctx context.Context, sys, user string) (string, error) {
|
||||||
|
calls++
|
||||||
|
require.NoError(t, os.Symlink(link, marker))
|
||||||
|
if kind == "failed" {
|
||||||
|
return "", errors.New("model down")
|
||||||
|
}
|
||||||
|
return ok(ctx, sys, user)
|
||||||
|
}}}
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
_ = processDir(context.Background(), cfg, "2026-10-05")
|
||||||
|
_ = os.Remove(marker)
|
||||||
|
}
|
||||||
|
assert.Equal(t, 1, calls, "the note was retried after the link was removed")
|
||||||
|
if target == "in-brain" {
|
||||||
|
got, err := os.ReadFile(victim)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "untouched", string(got), "the marker was written through the link")
|
||||||
|
} else {
|
||||||
|
_, err := os.Lstat(link)
|
||||||
|
assert.True(t, os.IsNotExist(err), "the marker was written through the link")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A refused archive copy must not retry every interval: the note is
|
||||||
|
// remembered and skipped until it changes (a symlinked raw/failed cost one
|
||||||
|
// LLM call per round before).
|
||||||
|
func TestProcessDirDoesNotRetryANoteWhoseArchiveCopyIsRefused(t *testing.T) {
|
||||||
|
brainDir := setupBrainDir(t)
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(brainDir, "raw", "n.md"), []byte("note"), 0o644))
|
||||||
|
require.NoError(t, os.Symlink(t.TempDir(), filepath.Join(brainDir, "raw", "failed")))
|
||||||
|
calls := 0
|
||||||
|
cfg := Config{BrainDir: brainDir, Interval: time.Hour, Pipeline: pipeline.Config{
|
||||||
|
Complete: func(context.Context, string, string) (string, error) { calls++; return "", errors.New("model down") }, Schema: "# Schema"}}
|
||||||
|
for i := 0; i < 3; i++ {
|
||||||
|
_ = processDir(context.Background(), cfg, "2026-10-05")
|
||||||
|
}
|
||||||
|
assert.Equal(t, 1, calls, "the refused note was retried every round")
|
||||||
|
|
||||||
|
// Once the note changes it is tried again.
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(brainDir, "raw", "n.md"), []byte("note, edited"), 0o644))
|
||||||
|
future := time.Now().Add(2 * time.Second)
|
||||||
|
require.NoError(t, os.Chtimes(filepath.Join(brainDir, "raw", "n.md"), future, future))
|
||||||
|
_ = processDir(context.Background(), cfg, "2026-10-05")
|
||||||
|
assert.Equal(t, 2, calls, "positive control: a changed note is tried again")
|
||||||
|
}
|
||||||
|
|
||||||
|
// A note whose name has a backslash is legal on Linux and must be processed
|
||||||
|
// (cleanRel refused it, so it was never archived or marked).
|
||||||
|
func TestProcessDirProcessesANoteWithABackslashInItsName(t *testing.T) {
|
||||||
|
brainDir := setupBrainDir(t)
|
||||||
|
require.NoError(t, os.WriteFile(filepath.Join(brainDir, "raw", "a\\b.md"), []byte("note"), 0o644))
|
||||||
|
errs := processDir(context.Background(), Config{BrainDir: brainDir, Interval: time.Hour, Pipeline: okPipeline()}, "2026-10-05")
|
||||||
|
assert.Empty(t, errs)
|
||||||
|
_, err := os.Stat(filepath.Join(brainDir, "raw", "processed", "2026-10-05", "a\\b.md"))
|
||||||
|
assert.NoError(t, err, "archived")
|
||||||
|
_, err = os.Stat(filepath.Join(brainDir, "raw", "a\\b.md"))
|
||||||
|
assert.ErrorIs(t, err, os.ErrNotExist, "and retired")
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
package wiki
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
func put(t *testing.T, dir, rel string) {
|
||||||
|
t.Helper()
|
||||||
|
full := filepath.Join(dir, rel)
|
||||||
|
require.NoError(t, os.MkdirAll(filepath.Dir(full), 0o755))
|
||||||
|
require.NoError(t, os.WriteFile(full, []byte("---\ntitle: x\n---\n"), 0o644))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindSlugs(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
put(t, dir, "wiki/sources/legacy-src.md")
|
||||||
|
put(t, dir, "wiki/concepts/a-concept.md")
|
||||||
|
put(t, dir, "wiki/entities/an-entity.md")
|
||||||
|
put(t, dir, "wiki/homelab/failures/in-a-hall.md")
|
||||||
|
put(t, dir, "wiki/web-clip/sources/a-clip.md")
|
||||||
|
// One slug in two places: the first hit must not hide the second.
|
||||||
|
put(t, dir, "wiki/sources/shared-slug.md")
|
||||||
|
put(t, dir, "wiki/entities/shared-slug.md")
|
||||||
|
put(t, dir, "wiki/aaa/facts/shared-slug.md")
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
slug string
|
||||||
|
want []string
|
||||||
|
}{
|
||||||
|
{"legacy-src", []string{"wiki/sources/legacy-src.md"}},
|
||||||
|
{"a-concept", []string{"wiki/concepts/a-concept.md"}},
|
||||||
|
{"an-entity", []string{"wiki/entities/an-entity.md"}},
|
||||||
|
{"in-a-hall", []string{"wiki/homelab/failures/in-a-hall.md"}},
|
||||||
|
{"a-clip", []string{"wiki/web-clip/sources/a-clip.md"}},
|
||||||
|
{"shared-slug", []string{"wiki/aaa/facts/shared-slug.md", "wiki/entities/shared-slug.md", "wiki/sources/shared-slug.md"}},
|
||||||
|
{"nothing-here", nil},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.slug, func(t *testing.T) {
|
||||||
|
got, err := FindSlugs(dir, tt.slug)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, tt.want, got)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFindSlugs_NoWikiDirFindsNothing(t *testing.T) {
|
||||||
|
got, err := FindSlugs(t.TempDir(), "x")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Empty(t, got)
|
||||||
|
}
|
||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
"sort"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
|
"github.com/mathiasbq/hyperguild/ingestion/internal/brainfs"
|
||||||
@@ -97,23 +98,26 @@ func readFrontmatter(bfs *brainfs.Brain, rel, fallbackSlug string) (title string
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadWingInventory returns the pages of ONE wing, grouped by type. Sources
|
// LoadWingInventory returns what an ingest into one wing can match against:
|
||||||
// come from wiki/<wing>/sources/; concepts and entities live together in
|
// that wing's sources (wiki/<wing>/sources/) and the SHARED concepts and
|
||||||
// wiki/<wing>/facts/ and are told apart by their `kind:` frontmatter. A facts
|
// entities (wiki/concepts/, wiki/entities/), which belong to no single wing
|
||||||
// page without a kind (a hand-written note) is left out, so extraction can
|
// because their slug is the graph's global identity. Other wings' sources are
|
||||||
// never merge into something it did not create. Other wings are never read.
|
// not read.
|
||||||
func LoadWingInventory(brainDir, wing string) (map[PageType][]Entry, error) {
|
func LoadWingInventory(brainDir, wing string) (map[PageType][]Entry, error) {
|
||||||
result := map[PageType][]Entry{PageTypeConcept: {}, PageTypeEntity: {}, PageTypeSource: {}}
|
result, err := LoadInventory(brainDir)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
result[PageTypeSource] = []Entry{} // the retired source layout is not this wing's
|
||||||
bfs, err := brainfs.Open(brainDir)
|
bfs, err := brainfs.Open(brainDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("open brain: %w", err)
|
return nil, fmt.Errorf("open brain: %w", err)
|
||||||
}
|
}
|
||||||
defer func() { _ = bfs.Close() }()
|
defer func() { _ = bfs.Close() }()
|
||||||
for _, hall := range []string{"sources", "facts"} {
|
dir := "wiki/" + wing + "/sources"
|
||||||
dir := "wiki/" + wing + "/" + hall
|
|
||||||
names, err := bfs.ReadDirRegular(dir)
|
names, err := bfs.ReadDirRegular(dir)
|
||||||
if errors.Is(err, fs.ErrNotExist) {
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
continue
|
return result, nil
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("read dir %s: %w", dir, err)
|
return nil, fmt.Errorf("read dir %s: %w", dir, err)
|
||||||
@@ -124,51 +128,67 @@ func LoadWingInventory(brainDir, wing string) (map[PageType][]Entry, error) {
|
|||||||
}
|
}
|
||||||
rel := dir + "/" + name
|
rel := dir + "/" + name
|
||||||
slug := strings.TrimSuffix(name, ".md")
|
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)
|
title, aliases := readFrontmatter(bfs, rel, slug)
|
||||||
result[pt] = append(result[pt], Entry{Slug: slug, Title: title, Aliases: aliases, Type: pt, Path: rel})
|
result[PageTypeSource] = append(result[PageTypeSource], Entry{Slug: slug, Title: title, Aliases: aliases, Type: PageTypeSource, Path: rel})
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// kindOf reads `kind:` from a facts page's frontmatter.
|
// FindSlugs returns the brain-relative path of EVERY existing page whose
|
||||||
func kindOf(bfs *brainfs.Brain, rel string) (PageType, bool) {
|
// basename is slug, searching the retired dirs (wiki/{sources,concepts,
|
||||||
content, err := bfs.ReadRegular(rel)
|
// entities}/) and every wing hall (wiki/<wing>/<hall>/), sorted. The slug is a
|
||||||
|
// page's graph identity, so two pages must not share one; returning all of
|
||||||
|
// them (not the first) is what lets a caller tell "only my own page" from "my
|
||||||
|
// own page and somebody else's".
|
||||||
|
func FindSlugs(brainDir, slug string) ([]string, error) {
|
||||||
|
bfs, err := brainfs.Open(brainDir)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", false
|
return nil, fmt.Errorf("open brain: %w", err)
|
||||||
}
|
}
|
||||||
return KindFromContent(string(content))
|
defer func() { _ = bfs.Close() }()
|
||||||
}
|
file := slug + ".md"
|
||||||
|
var found []string
|
||||||
// KindFromContent maps frontmatter `kind: concept|entity` to a PageType.
|
check := func(dir string) error {
|
||||||
func KindFromContent(content string) (PageType, bool) {
|
names, err := bfs.ReadDirRegular(dir)
|
||||||
inFM := false
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
for _, line := range strings.Split(content, "\n") {
|
return nil
|
||||||
if strings.TrimSpace(line) == "---" {
|
|
||||||
if inFM {
|
|
||||||
break
|
|
||||||
}
|
}
|
||||||
inFM = true
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, n := range names {
|
||||||
|
if n == file {
|
||||||
|
found = append(found, dir+"/"+file)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, d := range []string{"wiki/sources", "wiki/concepts", "wiki/entities"} {
|
||||||
|
if err := check(d); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
wings, err := bfs.ReadDirDirs("wiki")
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return found, nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, w := range wings {
|
||||||
|
if w == "sources" || w == "concepts" || w == "entities" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if !inFM {
|
halls, err := bfs.ReadDirDirs("wiki/" + w)
|
||||||
|
if err != nil {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if key, val, ok := strings.Cut(line, ":"); ok && strings.TrimSpace(key) == "kind" {
|
for _, h := range halls {
|
||||||
switch strings.Trim(strings.TrimSpace(val), `"'`) {
|
if err := check("wiki/" + w + "/" + h); err != nil {
|
||||||
case "concept":
|
return nil, err
|
||||||
return PageTypeConcept, true
|
|
||||||
case "entity":
|
|
||||||
return PageTypeEntity, true
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "", false
|
sort.Strings(found)
|
||||||
|
return found, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,43 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,38 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,85 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,61 +0,0 @@
|
|||||||
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)
|
|
||||||
}
|
|
||||||
@@ -1,32 +0,0 @@
|
|||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,47 +0,0 @@
|
|||||||
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"))
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
package wing_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
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))
|
||||||
|
}
|
||||||
@@ -1,99 +0,0 @@
|
|||||||
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": "<name or none>", "confidence": <0..1>, "reason": "<one short sentence>"}.
|
|
||||||
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
|
|
||||||
}
|
|
||||||
@@ -1,67 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
@@ -1,17 +1,17 @@
|
|||||||
// Package wing decides which brain wing an incoming raw note belongs to
|
// Package wing decides which brain wing an incoming raw note belongs to
|
||||||
// without the human typing it (hyperguild#205).
|
// without the human typing it (hyperguild#205, #208).
|
||||||
//
|
//
|
||||||
// The contract is conservative: a note is placed automatically only when the
|
// An earlier design classified each note with a local model and cross-checked
|
||||||
// decision is explicit, or when two independent opinions (an LLM and a
|
// it with a text score. Measured against pages whose wing a human had chosen,
|
||||||
// deterministic scorer) agree with enough confidence on a wing that is both
|
// the model was right 60% of the time with a saturated self-reported
|
||||||
// known and eligible. Everything else is an Exception for a human to approve.
|
// confidence, and the cross-check was right 11% of the time, so the gate it
|
||||||
// A wing is never created here, and a wing the caller does not mark eligible
|
// formed placed 2 of 55 pages (hyperguild#208). The Router that replaced it
|
||||||
// (client wings, anything not deliberately tagged) is never auto-assigned.
|
// decides with rules only: a deliberate folder or frontmatter wing, else one
|
||||||
|
// default wing; nothing it cannot decide safely is guessed.
|
||||||
package wing
|
package wing
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"fmt"
|
|
||||||
"strings"
|
"strings"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -20,36 +20,17 @@ type Source string
|
|||||||
|
|
||||||
const (
|
const (
|
||||||
Explicit Source = "explicit" // subfolder or frontmatter named the wing
|
Explicit Source = "explicit" // subfolder or frontmatter named the wing
|
||||||
Classified Source = "classified" // classifier + scorer agreed, confident
|
Default Source = "default" // no signal; the configured default wing
|
||||||
Exception Source = "exception" // needs a human
|
Exception Source = "exception" // needs a human
|
||||||
)
|
)
|
||||||
|
|
||||||
// Note is what the resolver sees of an incoming raw note.
|
// Note is what the Placer sees of an incoming raw note.
|
||||||
type Note struct {
|
type Note struct {
|
||||||
Dir string // first subfolder under raw/, "" when dropped at the top
|
Dir string // first subfolder under raw/, "" when dropped at the top
|
||||||
FrontmatterWing string // `wing:` from the note's frontmatter, "" if absent
|
FrontmatterWing string // `wing:` from the note's frontmatter, "" if absent
|
||||||
Text string
|
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
|
// 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.
|
// there was one (so a reviewer can approve it) and is empty otherwise.
|
||||||
type Decision struct {
|
type Decision struct {
|
||||||
@@ -59,114 +40,10 @@ type Decision struct {
|
|||||||
Reason string
|
Reason string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Resolver holds the policy. Known is every existing wing; Eligible reports
|
// Placer decides where a raw note goes. Router is the production
|
||||||
// whether a known wing may be assigned automatically.
|
// implementation; the interface lets the watcher stay ignorant of how.
|
||||||
type Resolver struct {
|
type Placer interface {
|
||||||
Known []string
|
Resolve(ctx context.Context, n Note) Decision
|
||||||
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 {
|
func exception(w string, conf float64, reason string) Decision {
|
||||||
|
|||||||
@@ -1,112 +0,0 @@
|
|||||||
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")
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
package wing
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
"unicode"
|
||||||
|
|
||||||
|
xnorm "golang.org/x/text/unicode/norm"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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
|
||||||
|
|
||||||
|
// FilterChunk is how many bytes of a note the content filter folds and
|
||||||
|
// matches at a time (0 = 1 MiB). Folding allocates several times its input,
|
||||||
|
// so a 64 MiB note is checked in pieces (with an overlap, so a marker on a
|
||||||
|
// boundary is still found) instead of all at once.
|
||||||
|
FilterChunk int
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
for _, raw := range []string{n.Dir, n.FrontmatterWing} {
|
||||||
|
if !isASCII(raw) {
|
||||||
|
// strings.ToLower folds some non-ASCII runes to ASCII (the Kelvin
|
||||||
|
// sign becomes "k"), which would let a lookalike name a real wing.
|
||||||
|
return exception("", 0, fmt.Sprintf("wing name %q contains non-ASCII characters", strings.TrimSpace(raw)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
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 && matchesClient(r.ClientBlock, n.Text, r.FilterChunk) {
|
||||||
|
return exception("", 0, "text matches the client-content marker; not defaulted into a shared wing")
|
||||||
|
}
|
||||||
|
if r.Default == "" || !usableWingName(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 !usableWingName(w) {
|
||||||
|
return exception("", 0, fmt.Sprintf("%s names %q, which is not a usable wing name", via, w))
|
||||||
|
}
|
||||||
|
if w == r.Default {
|
||||||
|
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}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewRouter builds the production Router. defaultWing must be a plain wing
|
||||||
|
// name; clientPattern, when set, is a case-insensitive regexp over note text
|
||||||
|
// whose match parks an unsignalled note instead of filing it. Both fail at
|
||||||
|
// startup rather than later: a client filter that silently does nothing is
|
||||||
|
// worse than none.
|
||||||
|
func NewRouter(brainDir, defaultWing, clientPattern string) (Router, error) {
|
||||||
|
if defaultWing == "" || !usableWingName(defaultWing) {
|
||||||
|
return Router{}, fmt.Errorf("default wing %q is not a usable wing name", defaultWing)
|
||||||
|
}
|
||||||
|
r := Router{BrainDir: brainDir, Default: defaultWing}
|
||||||
|
if clientPattern != "" {
|
||||||
|
re, err := regexp.Compile("(?i)" + clientPattern)
|
||||||
|
if err != nil {
|
||||||
|
return Router{}, fmt.Errorf("client-content pattern: %w", err)
|
||||||
|
}
|
||||||
|
if re.MatchString("x") || re.MatchString("") {
|
||||||
|
return Router{}, fmt.Errorf("client-content pattern %q matches text that names no client; it would park every note", clientPattern)
|
||||||
|
}
|
||||||
|
r.ClientBlock = re
|
||||||
|
}
|
||||||
|
return r, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// reservedWingNames are directory names under wiki/ that are not wings: the
|
||||||
|
// retired layout and the root. A note must never be proposed into one.
|
||||||
|
var reservedWingNames = map[string]bool{"sources": true, "concepts": true, "entities": true, "wiki": true}
|
||||||
|
|
||||||
|
// usableWingName: a plain name that is not reserved.
|
||||||
|
func usableWingName(w string) bool { return plainWingName(w) && !reservedWingNames[w] }
|
||||||
|
|
||||||
|
func isASCII(s string) bool {
|
||||||
|
for _, r := range s {
|
||||||
|
if r > unicode.MaxASCII {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
const (
|
||||||
|
defaultFilterChunk = 1 << 20
|
||||||
|
filterOverlap = 4096
|
||||||
|
)
|
||||||
|
|
||||||
|
// matchesClient applies the client-content pattern to the text in bounded
|
||||||
|
// chunks, each overlapping the next, so memory stays proportional to the chunk
|
||||||
|
// and a marker on a chunk edge is still found.
|
||||||
|
func matchesClient(re *regexp.Regexp, text string, chunk int) bool {
|
||||||
|
if chunk <= 0 {
|
||||||
|
chunk = defaultFilterChunk
|
||||||
|
}
|
||||||
|
overlap := filterOverlap
|
||||||
|
if overlap >= chunk {
|
||||||
|
overlap = chunk / 2
|
||||||
|
}
|
||||||
|
for start := 0; start < len(text); {
|
||||||
|
end := start + chunk
|
||||||
|
if end >= len(text) {
|
||||||
|
end = len(text)
|
||||||
|
}
|
||||||
|
if matchesFolded(re, text[start:end]) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
if end == len(text) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
// A cut inside a rune is harmless: the overlap carries every match that
|
||||||
|
// spans at most filterOverlap bytes whole into the next chunk. A match
|
||||||
|
// longer than that (a pattern like `acme.{5000}corp`, or padding
|
||||||
|
// between two words) can be missed on a chunk edge; documented limit.
|
||||||
|
start = end - overlap
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// matchesFolded matches the pattern against the chunk as it is and against
|
||||||
|
// folded forms, so the cheap evasions do not defeat it. Folding: NFKD (fullwidth
|
||||||
|
// letters, accents become base letter + mark), then marks, format characters and
|
||||||
|
// invisible fillers dropped, Cyrillic/Greek lookalikes mapped to Latin, every
|
||||||
|
// dash made "-". Views: whitespace collapsed to one space (so "acme corp" and
|
||||||
|
// \s work), whitespace removed (so "acme-\nclient" works), and the same with a
|
||||||
|
// soft hyphen read as a dash. It catches what it names and nothing else:
|
||||||
|
// leetspeak, punctuation splits (a.c.m.e), encodings and the many homoglyphs
|
||||||
|
// not in the small table below are not caught. It is a filter, not a classifier.
|
||||||
|
func matchesFolded(re *regexp.Regexp, chunk string) bool {
|
||||||
|
if re.MatchString(chunk) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
decomposed := xnorm.NFKD.String(chunk)
|
||||||
|
return re.MatchString(fold(decomposed, false, true)) ||
|
||||||
|
re.MatchString(fold(decomposed, false, false)) ||
|
||||||
|
re.MatchString(fold(decomposed, true, false))
|
||||||
|
}
|
||||||
|
|
||||||
|
// fold builds one view. softAsDash reads U+00AD as "-" instead of dropping it;
|
||||||
|
// keepSpace collapses whitespace to a single space instead of removing it.
|
||||||
|
func fold(s string, softAsDash, keepSpace bool) string {
|
||||||
|
var b strings.Builder
|
||||||
|
b.Grow(len(s))
|
||||||
|
space := false
|
||||||
|
for _, r := range s {
|
||||||
|
switch {
|
||||||
|
case r == '\u00ad' && softAsDash:
|
||||||
|
b.WriteRune('-')
|
||||||
|
space = false
|
||||||
|
continue
|
||||||
|
case unicode.Is(unicode.Mn, r) || unicode.Is(unicode.Me, r) || unicode.Is(unicode.Mc, r) ||
|
||||||
|
unicode.Is(unicode.Cf, r) || invisibleFiller[r]:
|
||||||
|
continue
|
||||||
|
case unicode.IsSpace(r) || unicode.Is(unicode.Zs, r):
|
||||||
|
if keepSpace && !space {
|
||||||
|
b.WriteByte(' ')
|
||||||
|
space = true
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
space = false
|
||||||
|
switch {
|
||||||
|
case unicode.Is(unicode.Pd, r) || r == '\u2212':
|
||||||
|
b.WriteRune('-')
|
||||||
|
default:
|
||||||
|
if l, ok := latinLookalike[r]; ok {
|
||||||
|
r = l
|
||||||
|
}
|
||||||
|
b.WriteRune(r)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return b.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// invisibleFiller are characters that render as nothing but are not format
|
||||||
|
// characters: Hangul fillers and the blank Braille pattern.
|
||||||
|
var invisibleFiller = map[rune]bool{0x115F: true, 0x1160: true, 0x3164: true, 0xFFA0: true, 0x2800: true}
|
||||||
|
|
||||||
|
// latinLookalike maps the commonest Cyrillic and Greek letters that read as
|
||||||
|
// Latin ones. Deliberately small; see matchesFolded.
|
||||||
|
var latinLookalike = map[rune]rune{
|
||||||
|
'\u0430': 'a', '\u0435': 'e', '\u043e': 'o', '\u0440': 'p', '\u0441': 'c', '\u0445': 'x', '\u0443': 'y',
|
||||||
|
'\u0456': 'i', '\u0458': 'j', '\u0455': 's', '\u04bb': 'h', '\u0501': 'd', '\u043a': 'k', '\u043d': 'h', '\u0442': 't',
|
||||||
|
'\u0410': 'A', '\u0412': 'B', '\u0415': 'E', '\u041a': 'K', '\u041c': 'M', '\u041d': 'H', '\u041e': 'O', '\u0420': 'P', '\u0421': 'C', '\u0422': 'T', '\u0425': 'X',
|
||||||
|
'\u03bf': 'o', '\u03bd': 'v', '\u03c1': 'p', '\u0391': 'A', '\u0392': 'B', '\u0395': 'E', '\u039f': 'O', '\u03a1': 'P',
|
||||||
|
}
|
||||||
|
|
||||||
|
// WrapNames turns a bare list of client names (a regexp alternation such as
|
||||||
|
// "acme|globex") into a pattern that matches a name only as a whole word, with
|
||||||
|
// word edges decided by Unicode letters and digits: Go's \b is ASCII-only, so
|
||||||
|
// with it a name that begins or ends in a letter like Å, é or Ø would never
|
||||||
|
// match. An underscore or punctuation is a boundary (trucker_ecommerce matches
|
||||||
|
// "trucker"), a letter or digit glued on is not (acmecorp, acme2).
|
||||||
|
//
|
||||||
|
// The list is validated first, on its own: it must compile as a regexp, must not
|
||||||
|
// match the empty string (an empty alternative such as "a||b" would match
|
||||||
|
// everywhere), and must be self-contained: a value like "x)|.*(" compiles alone
|
||||||
|
// only if it is balanced, so it cannot break out of the group it is put in.
|
||||||
|
func WrapNames(names string) (string, error) {
|
||||||
|
if strings.TrimSpace(names) == "" {
|
||||||
|
return "", fmt.Errorf("client names list is empty")
|
||||||
|
}
|
||||||
|
re, err := regexp.Compile("(?i)" + names)
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("client names are not a valid regexp alternation: %w", err)
|
||||||
|
}
|
||||||
|
if re.MatchString("") {
|
||||||
|
return "", fmt.Errorf("client names contain an empty alternative; it would match everywhere")
|
||||||
|
}
|
||||||
|
return `(?:^|[^\p{L}\p{N}])(?:` + names + `)(?:[^\p{L}\p{N}]|$)`, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,292 @@
|
|||||||
|
package wing_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
"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 usable wing name")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewRouter(t *testing.T) {
|
||||||
|
dir := routerBrain(t)
|
||||||
|
t.Run("valid", func(t *testing.T) {
|
||||||
|
r, err := wing.NewRouter(dir, "web-clip", "acme-client|globex")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
d := r.Resolve(context.Background(), wing.Note{Text: "kickoff with GLOBEX"})
|
||||||
|
assert.Equal(t, wing.Exception, d.Source, "the marker is matched case-insensitively")
|
||||||
|
})
|
||||||
|
t.Run("no marker pattern means no content check", func(t *testing.T) {
|
||||||
|
r, err := wing.NewRouter(dir, "web-clip", "")
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, wing.Default, r.Resolve(context.Background(), wing.Note{Text: "anything"}).Source)
|
||||||
|
})
|
||||||
|
t.Run("a default that is not a plain wing name is a startup error", func(t *testing.T) {
|
||||||
|
_, err := wing.NewRouter(dir, "Web Clip", "")
|
||||||
|
assert.Error(t, err)
|
||||||
|
})
|
||||||
|
t.Run("an empty default is a startup error", func(t *testing.T) {
|
||||||
|
_, err := wing.NewRouter(dir, "", "")
|
||||||
|
assert.Error(t, err)
|
||||||
|
})
|
||||||
|
t.Run("a pattern that does not compile is a startup error", func(t *testing.T) {
|
||||||
|
_, err := wing.NewRouter(dir, "web-clip", "([unclosed")
|
||||||
|
assert.Error(t, err, "a client filter that silently does nothing is worse than none")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// From the review of #211: the content filter is the only guard on the default
|
||||||
|
// lane, so it must survive the cheap evasions.
|
||||||
|
func TestRouter_ClientMarkerSurvivesCheapEvasions(t *testing.T) {
|
||||||
|
r := wing.Router{BrainDir: routerBrain(t), Default: "web-clip", ClientBlock: regexp.MustCompile(`(?i)acme-client`)}
|
||||||
|
tests := []struct{ name, text string }{
|
||||||
|
{"non-breaking hyphen", "notes on acme‑client"},
|
||||||
|
{"zero-width joiner inside", "notes on acme\u200d-client"},
|
||||||
|
{"zero-width space inside", "notes on ac\u200bme-client"},
|
||||||
|
{"split across lines", "notes on acme-\nclient"},
|
||||||
|
{"spaced hyphen", "notes on acme - client"},
|
||||||
|
{"fullwidth letters", "notes on acme-client"},
|
||||||
|
{"soft hyphen", "notes on acme\u00ad-client"},
|
||||||
|
{"en dash", "notes on acme–client"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
d := r.Resolve(context.Background(), wing.Note{Text: tt.text})
|
||||||
|
assert.Equal(t, wing.Exception, d.Source, tt.text)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
t.Run("an unrelated text still files", func(t *testing.T) {
|
||||||
|
d := r.Resolve(context.Background(), wing.Note{Text: "a talk about agents and a client-server model"})
|
||||||
|
assert.Equal(t, wing.Default, d.Source)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRouter_FolderNamesAreAsciiAndNotReserved(t *testing.T) {
|
||||||
|
dir := routerBrain(t)
|
||||||
|
writePage(t, dir, "wiki/kubernetes/facts/a.md", "x")
|
||||||
|
r := wing.Router{BrainDir: dir, Default: "web-clip"}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
dir string
|
||||||
|
}{
|
||||||
|
{"Kelvin sign lowercases to an ASCII k", "Kubernetes"},
|
||||||
|
{"cyrillic lookalike", "h\u043emelab"},
|
||||||
|
{"retired layout name: sources", "sources"},
|
||||||
|
{"retired layout name: concepts", "concepts"},
|
||||||
|
{"retired layout name: entities", "entities"},
|
||||||
|
{"wiki", "wiki"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
d := r.Resolve(context.Background(), wing.Note{Dir: tt.dir, Text: "x"})
|
||||||
|
assert.Equal(t, wing.Exception, d.Source)
|
||||||
|
assert.Empty(t, d.Wing, "never proposed as a wing")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
t.Run("plain ASCII case folding still works", func(t *testing.T) {
|
||||||
|
d := r.Resolve(context.Background(), wing.Note{Dir: "KubernetEs", Text: "x"})
|
||||||
|
assert.Equal(t, wing.Explicit, d.Source)
|
||||||
|
assert.Equal(t, "kubernetes", d.Wing)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewRouter_AReservedDefaultIsRefused(t *testing.T) {
|
||||||
|
_, err := wing.NewRouter(routerBrain(t), "sources", "")
|
||||||
|
assert.Error(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// From the second review of #211: the multi-word, \s and \b patterns an operator
|
||||||
|
// would actually write, and the next tier of evasions.
|
||||||
|
func TestRouter_ClientMarkerHandlesRealisticPatternsAndHarderEvasions(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name, pattern, text string
|
||||||
|
}{
|
||||||
|
{"space in pattern vs NBSP", `acme corp`, "about acme\u00a0corp today"},
|
||||||
|
{"space in pattern vs two spaces", `acme corp`, "about acme corp today"},
|
||||||
|
{"space in pattern vs newline", `acme corp`, "about acme\ncorp today"},
|
||||||
|
{"backslash-s pattern vs NBSP", `acme\s+corp`, "about acme\u00a0corp today"},
|
||||||
|
{"word boundary vs a zero-width char inside", `\bacme\b`, "the ac\u200bme corp"},
|
||||||
|
{"dash pattern vs a soft hyphen as the dash", `acme-client`, "acme\u00adclient"},
|
||||||
|
{"cyrillic a", `acme-client`, "\u0430cme-client"},
|
||||||
|
{"cyrillic e", `acme-client`, "acm\u0435-client"},
|
||||||
|
{"cyrillic o and c", `coop`, "c\u043e\u043ep"},
|
||||||
|
{"combining acute", `acme-client`, "a\u0301cme-client"},
|
||||||
|
{"precomposed accent", `acme-client`, "\u00e1cme-client"},
|
||||||
|
{"hangul filler", `acme-client`, "ac\u3164me-client"},
|
||||||
|
{"halfwidth hangul filler", `acme-client`, "ac\uffa0me-client"},
|
||||||
|
{"braille blank", `acme-client`, "ac\u2800me-client"},
|
||||||
|
{"grapheme joiner", `acme-client`, "ac\u034fme-client"},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
r, err := wing.NewRouter(routerBrain(t), "web-clip", tt.pattern)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.Equal(t, wing.Exception, r.Resolve(context.Background(), wing.Note{Text: tt.text}).Source, "%q should be caught by %q", tt.text, tt.pattern)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
t.Run("ordinary text is not caught by a multi-word pattern", func(t *testing.T) {
|
||||||
|
r, _ := wing.NewRouter(routerBrain(t), "web-clip", `acme corp`)
|
||||||
|
assert.Equal(t, wing.Default, r.Resolve(context.Background(), wing.Note{Text: "acme is not corp, and corp is not acme"}).Source)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// The folding allocates several times the text, so a large note is checked in
|
||||||
|
// bounded chunks. A marker that straddles a chunk edge must still be found.
|
||||||
|
func TestRouter_ClientMarkerIsFoundAcrossAChunkBoundary(t *testing.T) {
|
||||||
|
r, err := wing.NewRouter(routerBrain(t), "web-clip", `acme-client`)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
r.FilterChunk = 64
|
||||||
|
for _, at := range []int{0, 30, 55, 58, 62, 64, 70, 120, 190} {
|
||||||
|
text := strings.Repeat("x", at) + "acme-client" + strings.Repeat("y", 200)
|
||||||
|
assert.Equal(t, wing.Exception, r.Resolve(context.Background(), wing.Note{Text: text}).Source, "marker at offset %d was missed", at)
|
||||||
|
}
|
||||||
|
clean := strings.Repeat("lorem ipsum ", 100)
|
||||||
|
assert.Equal(t, wing.Default, r.Resolve(context.Background(), wing.Note{Text: clean}).Source)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRouter_ClientMarkerChunkingDoesNotSplitAMultiByteRune(t *testing.T) {
|
||||||
|
r, err := wing.NewRouter(routerBrain(t), "web-clip", `acme-client`)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
r.FilterChunk = 65 // odd, over two-byte runes: every boundary falls inside a rune unless it is moved
|
||||||
|
for pad := 0; pad < 8; pad++ {
|
||||||
|
text := strings.Repeat("\u00e9", 40+pad) + "acme-client" + strings.Repeat("\u00e9", 40)
|
||||||
|
assert.Equal(t, wing.Exception, r.Resolve(context.Background(), wing.Note{Text: text}).Source, "pad %d", pad)
|
||||||
|
}
|
||||||
|
clean := strings.Repeat("\u00e9", 300)
|
||||||
|
assert.Equal(t, wing.Default, r.Resolve(context.Background(), wing.Note{Text: clean}).Source, "no panic or false match on a text of 2-byte runes")
|
||||||
|
}
|
||||||
|
|
||||||
|
// From the review of #214: the shared secret is a bare list of client names.
|
||||||
|
func TestWrapNames(t *testing.T) {
|
||||||
|
park := func(t *testing.T, names, text string) bool {
|
||||||
|
t.Helper()
|
||||||
|
pat, err := wing.WrapNames(names)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
r, err := wing.NewRouter(routerBrain(t), "web-clip", pat)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
return r.Resolve(context.Background(), wing.Note{Text: text}).Source == wing.Exception
|
||||||
|
}
|
||||||
|
t.Run("a short name does not match inside ordinary words", func(t *testing.T) {
|
||||||
|
assert.False(t, park(t, "dif|trucker", "a different approach to modifying prompts"))
|
||||||
|
assert.True(t, park(t, "dif|trucker", "kickoff for the DIF project"))
|
||||||
|
})
|
||||||
|
t.Run("names with a non-ASCII letter at the edge still match (Go's \\b would never)", func(t *testing.T) {
|
||||||
|
assert.True(t, park(t, "Åke|Société|Ørsted", "Åke called"))
|
||||||
|
assert.True(t, park(t, "Åke|Société|Ørsted", "la Société wrote"))
|
||||||
|
assert.True(t, park(t, "Åke|Société|Ørsted", "Ørsted wind"))
|
||||||
|
})
|
||||||
|
t.Run("identifiers with underscores and punctuation count as boundaries", func(t *testing.T) {
|
||||||
|
assert.True(t, park(t, "trucker", "repo trucker_ecommerce was cloned"))
|
||||||
|
assert.True(t, park(t, "trucker", "(Trucker)"))
|
||||||
|
})
|
||||||
|
t.Run("a name inside a longer word or with digits glued on is not a boundary", func(t *testing.T) {
|
||||||
|
assert.False(t, park(t, "acme", "acmecorp"))
|
||||||
|
assert.False(t, park(t, "acme", "acme2"))
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWrapNames_RefusesWhatWouldBreakOrBlockEverything(t *testing.T) {
|
||||||
|
for _, bad := range []string{
|
||||||
|
"x)|.*(", // breaks out of the group
|
||||||
|
"acme)|(b", // changes the structure
|
||||||
|
"a||b", // empty alternative matches everywhere
|
||||||
|
"acme|", // trailing empty alternative
|
||||||
|
"|acme", // leading empty alternative
|
||||||
|
"", // nothing
|
||||||
|
"c++ corp", // not a valid regexp
|
||||||
|
"([unclosed", // not a valid regexp
|
||||||
|
} {
|
||||||
|
t.Run(bad, func(t *testing.T) {
|
||||||
|
_, err := wing.WrapNames(bad)
|
||||||
|
assert.Error(t, err)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNewRouter_RefusesAPatternThatMatchesTheEmptyString(t *testing.T) {
|
||||||
|
for _, p := range []string{`.*`, `x*`, `^`, `\b`, `(a|)`} {
|
||||||
|
t.Run(p, func(t *testing.T) {
|
||||||
|
_, err := wing.NewRouter(routerBrain(t), "web-clip", p)
|
||||||
|
assert.Error(t, err, "it would park every note")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user