fix: three bugs the whole Go suite passed over, found by looking
tools/drive serves the app with sign-in off so a browser can see the real pages, the way sheets/tools/drive does. It earned its place immediately. A document starting at "##" with no "#" above it pads the heading trail, and joining that unfiltered rendered "› Revenue" — a separator with nothing before it. Every fixture started at "#", so nothing caught it. The chunk was sealed as Indexable(), which is heading + body, and then indexed as the body column while the heading also had its own column. The heading therefore counted twice in BM25 and opened every snippet: "Revenue June revenue was...". Now the body is sealed alone. rastrillo's rst-form-bar is position:sticky, bottom:0 — right for a long settings page, wrong under a textarea, where it floated the submit button over the field it belongs to. Screenshots confirm both themes resolve, all three stylesheets load, and the page-header rule computes solid rather than vanishing.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SiWVaR69Vb2Lzc9wgfit1v
8 files changed,
+196
−8
internal/memoria/chunk/chunk.go+15 −1internal/memoria/chunk/chunk_test.go+25 −0internal/memoria/static/memoria.css+5 −0internal/memoria/store.go+5 −1internal/memoriatest/app_test.go+23 −0internal/memoriatest/gates_test.go+0 −6tools/drive/main.go+95 −0tools/drive/multipart.go+28 −0
diff --git a/internal/memoria/chunk/chunk.go b/internal/memoria/chunk/chunk.go| index b3f5706..aed6335 100644 |
| --- a/internal/memoria/chunk/chunk.go |
| +++ b/internal/memoria/chunk/chunk.go |
| @@ -41,7 +41,21 @@ type Chunk struct { |
| } |
| // Heading returns the trail joined for display and for indexing. |
| -func (c Chunk) Heading() string { return strings.Join(c.Trail, " › ") } |
| +// |
| +// Empty segments are dropped. The trail is padded when a document skips a |
| +// level — one starting at "##" with no "#" above it — and an unfiltered |
| +// join then renders "› Revenue" with a leading separator and nothing |
| +// before it. Found by looking at the page; no test saw it, because every |
| +// fixture started at "#". |
| +func (c Chunk) Heading() string { |
| + parts := make([]string, 0, len(c.Trail)) |
| + for _, t := range c.Trail { |
| + if strings.TrimSpace(t) != "" { |
| + parts = append(parts, t) |
| + } |
| + } |
| + return strings.Join(parts, " › ") |
| +} |
| // Indexable is what the index tokenises: the heading trail followed by |
| // the body. The trail is repeated into every chunk of a section |
diff --git a/internal/memoria/chunk/chunk_test.go b/internal/memoria/chunk/chunk_test.go| index 629e811..734c596 100644 |
| --- a/internal/memoria/chunk/chunk_test.go |
| +++ b/internal/memoria/chunk/chunk_test.go |
| @@ -72,3 +72,28 @@ func TestEmptyInputYieldsNoChunks(t *testing.T) { |
| } |
| } |
| } |
| + |
| +// A document that starts at "##" with no "#" above it pads the trail, and |
| +// an unfiltered join renders "› Revenue" — a separator with nothing before |
| +// it. Every earlier fixture started at "#", so nothing caught this until |
| +// the page was looked at. |
| +func TestHeadingDropsEmptyTrailSegments(t *testing.T) { |
| + got := Split("## Revenue\n\n" + strings.Repeat("word ", 60)) |
| + if len(got) != 1 { |
| + t.Fatalf("want 1 chunk, got %d", len(got)) |
| + } |
| + if h := got[0].Heading(); h != "Revenue" { |
| + t.Errorf("Heading() = %q, want %q", h, "Revenue") |
| + } |
| + if strings.HasPrefix(got[0].Heading(), "›") || strings.Contains(got[0].Heading(), " › ") { |
| + t.Errorf("heading has a dangling separator: %q", got[0].Heading()) |
| + } |
| +} |
| + |
| +// Deeper skips too: "###" with nothing above it. |
| +func TestDeepSkipDoesNotDangle(t *testing.T) { |
| + got := Split("### Deep\n\n" + strings.Repeat("word ", 60)) |
| + if len(got) != 1 || got[0].Heading() != "Deep" { |
| + t.Fatalf("Heading() = %q, want %q", got[0].Heading(), "Deep") |
| + } |
| +} |
diff --git a/internal/memoria/static/memoria.css b/internal/memoria/static/memoria.css| index 8df56d8..6bcbdac 100644 |
| --- a/internal/memoria/static/memoria.css |
| +++ b/internal/memoria/static/memoria.css |
| @@ -44,6 +44,11 @@ |
| } |
| [rst-section] { border-top: 1px solid var(--rst-line); margin-top: 2.5rem; padding-top: 1rem; } |
| + |
| +/* rastrillo's form bar is position:sticky; bottom:0, which is right for a |
| + * long settings page and wrong here — it floated the submit button over |
| + * the textarea it belongs under. Ours is an ordinary row. */ |
| +[rst-stack] [rst-form-bar] { position: static; margin-top: 1rem; } |
| [rst-stack] { display: flex; flex-direction: column; gap: 0.35rem; } |
| [rst-stack] label { font-weight: 560; margin-top: 0.6rem; } |
| [rst-stack] input[type="text"], |
diff --git a/internal/memoria/store.go b/internal/memoria/store.go| index 13ea18b..a234343 100644 |
| --- a/internal/memoria/store.go |
| +++ b/internal/memoria/store.go |
| @@ -126,7 +126,11 @@ func (s *Store) Ingest(ctx context.Context, source, title, body string) (int64, |
| rows := make([]Chunk, 0, len(chunks)) |
| for _, c := range chunks { |
| - st, err := s.sealer.Seal(seal.PurposeChunk, []byte(c.Indexable())) |
| + // Seal the body ALONE, not Indexable(): the heading has its |
| + // own indexed column, and sealing heading+body made the |
| + // heading count twice in BM25 and appear at the front of |
| + // every snippet. Found by reading a rendered page. |
| + st, err := s.sealer.Seal(seal.PurposeChunk, []byte(c.Text)) |
| if err != nil { |
| return err |
| } |
diff --git a/internal/memoriatest/app_test.go b/internal/memoriatest/app_test.go| index 8c66f52..bf0c86e 100644 |
| --- a/internal/memoriatest/app_test.go |
| +++ b/internal/memoriatest/app_test.go |
| @@ -114,3 +114,26 @@ func TestFooterStatesNoEgress(t *testing.T) { |
| t.Errorf("the page does not state the no-egress property:\n%s", body) |
| } |
| } |
| + |
| +// The heading has its own indexed column, so it must not also be inside |
| +// the body column: it would count twice in BM25 and appear at the front |
| +// of every snippet. Caught by reading a rendered page, not by a test — |
| +// hence this one. |
| +func TestSnippetDoesNotRepeatTheHeading(t *testing.T) { |
| + h := newApp(t) |
| + store(t, h, "Board pack", "## Revenue\n\nJune revenue was 1,350,000 across all regions and every product line we sell today.") |
| + body := get(t, h, "/?q=june+revenue").Body.String() |
| + |
| + // Find the snippet paragraph and check it does not open with the |
| + // heading text. |
| + i := strings.Index(body, "rst-result-snippet") |
| + if i < 0 { |
| + t.Fatalf("no snippet rendered:\n%s", body) |
| + } |
| + snippet := body[i:min(i+300, len(body))] |
| + // The heading is "Revenue"; the passage itself begins "June revenue". |
| + if strings.Contains(snippet, "Revenue</mark> <mark>June") || strings.Contains(snippet, "Revenue June revenue") { |
| + t.Errorf("the snippet repeats the heading before the body:\n%s", snippet) |
| + } |
| +} |
| + |
diff --git a/internal/memoriatest/gates_test.go b/internal/memoriatest/gates_test.go| index 078ff57..e0cc950 100644 |
| --- a/internal/memoriatest/gates_test.go |
| +++ b/internal/memoriatest/gates_test.go |
| @@ -253,9 +253,3 @@ func bytesContainsFold(hay []byte, needle string) bool { |
| return strings.Contains(strings.ToLower(string(hay)), strings.ToLower(needle)) |
| } |
| -func min(a, b int) int { |
| - if a < b { |
| - return a |
| - } |
| - return b |
| -} |
diff --git a/tools/drive/main.go b/tools/drive/main.go| new file mode 100644 |
| index 0000000..4be5916 |
| --- /dev/null |
| +++ b/tools/drive/main.go |
| @@ -0,0 +1,95 @@ |
| +// Command drive serves memoria with no sign-in and seeds it, so a real |
| +// browser can look at the real pages. |
| +// |
| +// Why this exists: a Go test renders a template into a buffer and proves |
| +// the markup is there. It cannot see that a stylesheet failed to load, |
| +// that an attribute the app writes is styled by nobody, or that an app |
| +// rule quietly overrode a framework component. Sheets learned this the |
| +// hard way — a menu bar that worked perfectly from the keyboard and not |
| +// at all with a mouse — and its tools/drive is the committed version of |
| +// the same recipe. |
| +// |
| +// It builds the app with auth left nil, which makes requireSession a |
| +// pass-through (see app.go). That is a test seam, not a product one: |
| +// cmd/memoria refuses to start without sign-in configured, and |
| +// TestConfigRefusesIncompleteEnvironment holds it to that. |
| +package main |
| + |
| +import ( |
| + "context" |
| + "encoding/base64" |
| + "flag" |
| + "fmt" |
| + "log/slog" |
| + "net/http" |
| + "os" |
| + "path/filepath" |
| + |
| + "github.com/carlosframework/rastrillo/db" |
| + |
| + memoria "amadan.net/oficina/memoria/internal/memoria" |
| + "amadan.net/oficina/memoria/internal/memoria/seal" |
| +) |
| + |
| +func main() { |
| + addr := flag.String("addr", "127.0.0.1:8824", "address to serve on") |
| + flag.Parse() |
| + |
| + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelWarn})) |
| + dir, err := os.MkdirTemp("/home/paulca/memoriadrive", "drive") |
| + if err != nil { |
| + panic(err) |
| + } |
| + d, err := db.Open(filepath.Join(dir, "drive.db"), logger) |
| + if err != nil { |
| + panic(err) |
| + } |
| + defer d.Close() |
| + |
| + dek := make([]byte, 32) |
| + for i := range dek { |
| + dek[i] = byte(i * 3) |
| + } |
| + sealer, err := seal.New(dek) |
| + if err != nil { |
| + panic(err) |
| + } |
| + _ = base64.StdEncoding |
| + |
| + mux, _, err := memoria.App(d, "http://"+*addr, sealer, logger) |
| + if err != nil { |
| + panic(err) |
| + } |
| + |
| + // Seed enough to make every surface non-empty: results, a heading |
| + // trail, a highlight, a document list with more than one row. |
| + store := memoria.NewStore(d, sealer, nil) |
| + _ = store |
| + seedViaHTTP(mux, *addr) |
| + |
| + fmt.Printf("drive serving on http://%s (no sign-in)\n", *addr) |
| + if err := http.ListenAndServe(*addr, mux); err != nil { |
| + panic(err) |
| + } |
| +} |
| + |
| +// seedViaHTTP posts documents through the real handler, so the seeded |
| +// state is exactly what a person would produce. |
| +func seedViaHTTP(h http.Handler, addr string) { |
| + docs := []struct{ title, body string }{ |
| + {"Q3 board pack", "## Revenue\n\nJune revenue was 1,350,000 across all regions, up from 1,180,000 in the same month last year. The increase is almost entirely the enterprise tier.\n\n## Costs\n\nHosting costs fell after the migration, and support headcount was flat.\n\n## Risks\n\nOne customer is 22% of recurring revenue. That concentration is the single largest risk on this page and it has not improved since Q2."}, |
| + {"Sealing notes", "## The one rule\n\nA sheet's contents are sealed at rest and the bucket never holds a plaintext cell. The instance holds the key while it runs, so a compromised live host can read a sheet it has open — that is a deliberate softening and the README says so.\n\n## What it does not cover\n\nEmbeddings are content: a vector inverts back to text well enough that an unsealed index is a plaintext cell in a costume."}, |
| + {"Meeting, 12 August", "## Present\n\nEveryone.\n\n## Decisions\n\nWe will not build a suite index app. It answers a different question from the one anyone asked, and per-item access control is not expressible by the app that would have to publish it.\n\n## Actions\n\nPaul to decide whether the central host is a directory or an authenticator."}, |
| + } |
| + for _, doc := range docs { |
| + body, ctype := multipartBody(doc.title, doc.body) |
| + req, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, "http://"+addr+"/documents", body) |
| + req.Header.Set("Content-Type", ctype) |
| + req.Header.Set("Origin", "http://"+addr) |
| + rec := &recorder{header: http.Header{}} |
| + h.ServeHTTP(rec, req) |
| + if rec.code != http.StatusSeeOther { |
| + fmt.Fprintf(os.Stderr, "seed %q: status %d\n", doc.title, rec.code) |
| + } |
| + } |
| +} |
diff --git a/tools/drive/multipart.go b/tools/drive/multipart.go| new file mode 100644 |
| index 0000000..f91e904 |
| --- /dev/null |
| +++ b/tools/drive/multipart.go |
| @@ -0,0 +1,28 @@ |
| +package main |
| + |
| +import ( |
| + "bytes" |
| + "io" |
| + "mime/multipart" |
| + "net/http" |
| +) |
| + |
| +func multipartBody(title, body string) (io.Reader, string) { |
| + var buf bytes.Buffer |
| + mw := multipart.NewWriter(&buf) |
| + mw.WriteField("title", title) |
| + mw.WriteField("body", body) |
| + mw.Close() |
| + return &buf, mw.FormDataContentType() |
| +} |
| + |
| +// recorder is a minimal ResponseWriter — httptest is a test-only package |
| +// and this is a command. |
| +type recorder struct { |
| + code int |
| + header http.Header |
| +} |
| + |
| +func (r *recorder) Header() http.Header { return r.header } |
| +func (r *recorder) Write(b []byte) (int, error) { return len(b), nil } |
| +func (r *recorder) WriteHeader(c int) { r.code = c } |