fix(notes): søgningen svarede med det forkerte først, og et træk gjorde ingenting
Release / Build and publish image (push) Failing after 9m55s
Release / Build and publish image (push) Failing after 9m55s
**Søgningen sorterede efter dato.** Usynligt med ti noter og ubrugeligt med tolv hundrede: de blev alle importeret samme aften, så `updated_at` siger ingenting, og loftet skar listen af ved halvtreds vilkårlige rækker. At søge "Tesla" svarede med en note om nginx først og noten, der hedder "Tesla Model Y", bagefter — hvilket læses som en søgning, der ikke virker, fordi det fra brugerens plads er præcis dét. Nu bm25, med titlen vægtet ti gange kroppen: et ord i titlen er, hvad noten *handler om*, mens det samme ord én gang i en lang krop er en henvisning. `fold` vejer som kroppen, ellers ville et dansk match slå et præcist. Sammenføjet frem for `rowid IN (…)`, fordi rangeringen hører til matchet, og en underforespørgsel smider den væk. Fladen sorterer ikke om, når der er søgt — det ville kaste netop det væk, der gør en søgning i tolv hundrede noter brugbar. **Et træk af en note hen på et projekt gjorde ingenting.** `ondragover` tog imod både opgaver og noter, så rækken lyste op — og `ondrop` sendte kun opgaver videre, mens noten faldt ned i grenen, der omarrangerer projekter, hvor den ikke betyder noget. Den værst mulige form for i stykker: den så ud, som om den ville virke, helt indtil man slap. Det står i overleveringen som "virker i koden, men ikke fastholdt i prøver, fordi prøven blev upålidelig". Den er skrevet igen, og den ser på resultatet — ligger noten i projektet bagefter — frem for på om rækken blev fremhævet, hvilket var netop det, den gamle kunne se og som blev ved med at være sandt, mens funktionen var i stykker. **Og datoen i listen var altid `updated_at`.** For tolv hundrede importerede noter er det i aftes, så en note fra 2017 stod som "i går", og den dato, man sorterede efter, var ingen steder at se. Rækken viser nu den dato, listen er sorteret efter, og har begge i sit værktøjstip. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5
parent
4aa8bd6a40
commit
3a02bc8304
+36
-7
@@ -35,6 +35,14 @@ type NoteLink struct {
|
||||
const noteColumns = `id, project_id, title, body, created_by, pinned,
|
||||
created_at, updated_at, deleted_at`
|
||||
|
||||
// The same columns, said with the table in front of them.
|
||||
//
|
||||
// Needed only by the search, which joins `notes_fts` — and that table carries its
|
||||
// own `id` and `project_id` (UNINDEXED, so the row can be found from a match).
|
||||
// Unqualified, SQLite cannot tell which one is meant and refuses the query.
|
||||
const noteColumnsQualified = `notes.id, notes.project_id, notes.title, notes.body,
|
||||
notes.created_by, notes.pinned, notes.created_at, notes.updated_at, notes.deleted_at`
|
||||
|
||||
// Reference syntax, and deliberately the same characters the quick-add parser
|
||||
// already uses. A `#Firma` in a note means the same project as a `#Firma` typed
|
||||
// into the task box — not a second kind of tag that happens to look alike. That
|
||||
@@ -238,12 +246,27 @@ func (db *DB) SearchNotes(ctx context.Context, query string, limit int) ([]Note,
|
||||
if expr == "" {
|
||||
return nil, nil
|
||||
}
|
||||
// By rowid, the way tasks do it: `id` is UNINDEXED in the table, so selecting
|
||||
// on it would work and read the whole index to do it.
|
||||
return db.notesWhere(ctx, `
|
||||
WHERE deleted_at IS NULL
|
||||
AND rowid IN (SELECT rowid FROM notes_fts WHERE notes_fts MATCH ?)
|
||||
ORDER BY updated_at DESC
|
||||
// Ordered by how well it matched, not by when it was last touched.
|
||||
//
|
||||
// `ORDER BY updated_at DESC` was invisible while there were ten notes and
|
||||
// useless at twelve hundred: every one of them was imported the same evening,
|
||||
// so `updated_at` says nothing, and the limit then cut the list at fifty
|
||||
// arbitrary rows. Searching "Tesla" answered with a note about nginx first and
|
||||
// the note actually called "Tesla Model Y" second — which reads as a search
|
||||
// that does not work, because from where the person is sitting it is one.
|
||||
//
|
||||
// bm25 with the title weighted ten times the body. A word in the title is what
|
||||
// the note is *about*; the same word once in a long body is a mention. `fold`
|
||||
// carries the same text transliterated, so it must weigh the same as the body
|
||||
// or a Danish match would outrank an exact one.
|
||||
//
|
||||
// Joined rather than `rowid IN (…)`: the rank belongs to the match, and a
|
||||
// subquery throws it away.
|
||||
return db.notesJoin(ctx, noteColumnsQualified, `
|
||||
JOIN notes_fts ON notes_fts.rowid = notes.rowid
|
||||
WHERE notes.deleted_at IS NULL
|
||||
AND notes_fts MATCH ?
|
||||
ORDER BY bm25(notes_fts, 10.0, 1.0, 1.0)
|
||||
LIMIT ?`, expr, limit)
|
||||
}
|
||||
|
||||
@@ -295,7 +318,13 @@ func (db *DB) RestoreNote(ctx context.Context, id string) error {
|
||||
}
|
||||
|
||||
func (db *DB) notesWhere(ctx context.Context, where string, args ...any) ([]Note, error) {
|
||||
rows, err := db.QueryContext(ctx, `SELECT `+noteColumns+` FROM notes `+where, args...)
|
||||
return db.notesJoin(ctx, noteColumns, where, args...)
|
||||
}
|
||||
|
||||
// notesJoin is notesWhere with the column list spelled out, for the one caller
|
||||
// that joins another table and must therefore say which `id` it means.
|
||||
func (db *DB) notesJoin(ctx context.Context, columns, where string, args ...any) ([]Note, error) {
|
||||
rows, err := db.QueryContext(ctx, `SELECT `+columns+` FROM notes `+where, args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -333,3 +333,39 @@ func TestATagFindsItsProjectWhateverTheCase(t *testing.T) {
|
||||
t.Error("a note title did not match itself")
|
||||
}
|
||||
}
|
||||
|
||||
// En søgning skal svare med det, der passer bedst, øverst.
|
||||
//
|
||||
// Den sorterede efter `updated_at`, hvilket var usynligt med ti noter og
|
||||
// ubrugeligt med tolv hundrede: de blev alle importeret samme aften, så datoen
|
||||
// siger ingenting, og loftet skar så listen af ved halvtreds tilfældige rækker.
|
||||
// At søge "Tesla" svarede med en note om nginx først og noten, der hedder "Tesla
|
||||
// Model Y", bagefter — hvilket læses som en søgning, der ikke virker, fordi det
|
||||
// fra brugerens plads er præcis dét.
|
||||
func TestSearchAnswersWithTheBestMatchFirst(t *testing.T) {
|
||||
db, userID := sealedStore(t)
|
||||
ctx := context.Background()
|
||||
|
||||
// Skrevet i den rækkefølge, der ville give det forkerte svar: den, der kun
|
||||
// nævner ordet i kroppen, gemmes sidst og har derfor den nyeste dato.
|
||||
for _, body := range []string{
|
||||
"# Tesla Model Y\n\nBilen selv.\n",
|
||||
"# Ladestandere\n\nNoter om Tesla og andre mærker.\n",
|
||||
"# nginx proxy manager backup\n\nEn konfiguration. Tesla nævnes i forbifarten.\n",
|
||||
} {
|
||||
if err := db.SaveNote(ctx, &Note{CreatedBy: userID, Body: body}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
found, err := db.SearchNotes(ctx, "Tesla", 10)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(found) != 3 {
|
||||
t.Fatalf("fandt %d noter, ventede 3", len(found))
|
||||
}
|
||||
if found[0].Title != "Tesla Model Y" {
|
||||
t.Errorf("øverst stod %q; den note, der hedder det, man søgte efter, skal stå først", found[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user