Fix review findings R1–R3 on Regular grounding and name folding
CI / ci (push) Successful in 2m36s
CI / ci (pull_request) Successful in 2m22s

R1: Migration 0010 now writes revision 0 into every ground_dinner job
payload that has none. Jobs queued before the upgrade carry only
dinner_id, so the boot cleanup, which matches a pending dinner to its job
by revision, failed those dinners as interrupted while their jobs were
still queued. The migration runs before the job runner starts. A new test
migrates a database to 0009, adds a pending Regular with a legacy queued
job, upgrades and boots, and checks the dinner stays pending and is then
grounded.

R2: Archiving a dinner, or renaming a pending one, now cancels the
grounding of any ground_dinner job running for it at an older revision,
so no further search, page fetch, photo download or model call is made.
The job is registered before it reads the dinner, so an archive or rename
committed after that read always finds it. A stopped job succeeds and
reports the dinner's current grounding status. The archive and rename
tests now check that the job ends while its search is still held, with
no page fetched and no model call for the stale name.

R3: gen_folds.py writes web/src/dinners/letterFolds.json from the same
table as folds_table.go, and names.ts folds through it instead of a
hand-copied subset. names.ts also lowercases letter by letter, as Go's
strings.ToLower does, rather than by context. TestLetterFoldsMatchPWA
fails when the two tables differ. The shared cases add "Ƈhili", "Ꞔarbanzo
Stew" and "ΜΟΥΣΑΚΑΣ" (final sigma).

Maestro task 5037ff86-181d-448e-927b-c2fd2258a25d.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-06 19:40:34 -04:00
co-authored by Claude Opus 5.5
parent 837c9924d1
commit dff58d491f
11 changed files with 1454 additions and 62 deletions
+7 -2
View File
@@ -219,7 +219,10 @@ automatic** (D-021).
A job for a dinner archived, no longer `pending` or at another revision A job for a dinner archived, no longer `pending` or at another revision
does no work, and a job only writes a dinner still unarchived, `pending` does no work, and a job only writes a dinner still unarchived, `pending`
and at its revision, so a fix-up made meanwhile wins and an earlier job and at its revision, so a fix-up made meanwhile wins and an earlier job
for an old name — queued or running — never decides the outcome. A for an old name — queued or running — never decides the outcome.
Archiving or renaming a dinner also stops its running job where it is,
so no further search, page fetch, photo download or model call is made
for it. A
method, minutes_total or difficulty a member saved while the dinner was method, minutes_total or difficulty a member saved while the dinner was
`pending` is kept; grounding fills only those left empty. At boot, after `pending` is kept; grounding fills only those left empty. At boot, after
the job runner fails the jobs a previous process left running, every the job runner fails the jobs a previous process left running, every
@@ -502,7 +505,9 @@ steps: the welcome (`/onboarding`), the Regulars (`/onboarding/regulars`,
mock 1h, adding and archiving through the dinners API optimistically; Next mock 1h, adding and archiving through the dinners API optimistically; Next
waits for three saved Regulars, and a common pick is not offered once a waits for three saved Regulars, and a common pick is not offered once a
Regular has a name `harness.Similar` to it — the PWA's copy of the name Regular has a name `harness.Similar` to it — the PWA's copy of the name
rules in `web/src/dinners/names.ts` runs the same cases, rules in `web/src/dinners/names.ts` folds letters with
`web/src/dinners/letterFolds.json`, which `gen_folds.py` writes from the
same table as the server's, and runs the same cases,
`internal/ai/harness/testdata/dedupe_cases.json`, as the server's) and `internal/ai/harness/testdata/dedupe_cases.json`, as the server's) and
notifications (`/onboarding/notifications`: what MealQuest notifies about, notifications (`/onboarding/notifications`: what MealQuest notifies about,
the iOS install hint, "Turn on notifications" and "Maybe later"). Either the iOS install hint, "Turn on notifications" and "Maybe later"). Either
+25
View File
@@ -5,6 +5,7 @@ import (
"os" "os"
"testing" "testing"
"unicode" "unicode"
"unicode/utf8"
) )
// TestSharedDedupeCases runs testdata/dedupe_cases.json, which the PWA's // TestSharedDedupeCases runs testdata/dedupe_cases.json, which the PWA's
@@ -40,6 +41,30 @@ func TestSharedDedupeCases(t *testing.T) {
} }
} }
// TestLetterFoldsMatchPWA checks that the PWA's letter-fold table, which
// gen_folds.py writes beside folds_table.go, is letterFolds exactly; run
// go generate when it fails.
func TestLetterFoldsMatchPWA(t *testing.T) {
raw, err := os.ReadFile("../../../web/src/dinners/letterFolds.json")
if err != nil {
t.Fatal(err)
}
var pwa map[string]string
if err := json.Unmarshal(raw, &pwa); err != nil {
t.Fatal(err)
}
for r, want := range letterFolds {
if got, ok := pwa[string(r)]; !ok || got != want {
t.Errorf("PWA folds %U to %q (present %v), want %q", r, got, ok, want)
}
}
for k := range pwa {
if utf8.RuneCountInString(k) != 1 || letterFolds[[]rune(k)[0]] == "" {
t.Errorf("PWA folds %q, which letterFolds does not", k)
}
}
}
func TestNormalize(t *testing.T) { func TestNormalize(t *testing.T) {
tests := []struct { tests := []struct {
name, want string name, want string
+10 -1
View File
@@ -1,5 +1,8 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Generate folds_table.go, the letter-fold table used by harness.Normalize. """Generate folds_table.go, the letter-fold table used by harness.Normalize,
and its copy web/src/dinners/letterFolds.json, used by the PWA's
normalizeName (web/src/dinners/names.ts). TestLetterFoldsMatchPWA fails when
the two differ.
Run from this directory with `go generate ./internal/ai/harness` (or Run from this directory with `go generate ./internal/ai/harness` (or
`python3 gen_folds.py`; gofmt must be on PATH). It uses only the Python `python3 gen_folds.py`; gofmt must be on PATH). It uses only the Python
@@ -28,11 +31,14 @@ Hangul syllables are left out: their decomposition is into conjoining jamo,
not combining marks. not combining marks.
""" """
import json
import re import re
import subprocess import subprocess
import sys import sys
import unicodedata import unicodedata
PWA_TABLE = "../../../web/src/dinners/letterFolds.json"
EXTRA = { EXTRA = {
"ß": "ss", "ẞ": "ss", "æ": "ae", "Æ": "ae", "œ": "oe", "Œ": "oe", "ß": "ss", "ẞ": "ss", "æ": "ae", "Æ": "ae", "œ": "oe", "Œ": "oe",
"þ": "th", "Þ": "th", "ð": "d", "Ð": "d", "ı": "i", "ʼn": "n", "þ": "th", "Þ": "th", "ð": "d", "Ð": "d", "ı": "i", "ʼn": "n",
@@ -110,6 +116,9 @@ def main():
with open("folds_table.go", "w", encoding="utf-8") as f: with open("folds_table.go", "w", encoding="utf-8") as f:
f.write("\n".join(out) + "\n") f.write("\n".join(out) + "\n")
subprocess.run(["gofmt", "-w", "folds_table.go"], check=True) subprocess.run(["gofmt", "-w", "folds_table.go"], check=True)
with open(PWA_TABLE, "w", encoding="ascii") as f:
json.dump({chr(cp): folded for cp, folded in entries}, f, indent=2)
f.write("\n")
if __name__ == "__main__": if __name__ == "__main__":
+17
View File
@@ -67,6 +67,18 @@
{ {
"name": "7-layer dip", "name": "7-layer dip",
"want": "7 layer dip" "want": "7 layer dip"
},
{
"name": "Ƈhili",
"want": "chili"
},
{
"name": "Ꞔarbanzo Stew",
"want": "carbanzo stew"
},
{
"name": "ΜΟΥΣΑΚΑΣ",
"want": "μουσακασ"
} }
], ],
"similar": [ "similar": [
@@ -114,6 +126,11 @@
"a": "The best", "a": "The best",
"b": "Easy recipe", "b": "Easy recipe",
"want": false "want": false
},
{
"a": "Ƈhili",
"b": "Chili",
"want": true
} }
] ]
} }
+13 -3
View File
@@ -11,9 +11,11 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"math"
"net/url" "net/url"
"slices" "slices"
"strings" "strings"
"sync"
"unicode" "unicode"
"unicode/utf8" "unicode/utf8"
@@ -130,6 +132,11 @@ type Service struct {
grounder *recipes.Grounder grounder *recipes.Grounder
logger *slog.Logger logger *slog.Logger
groundJob *jobs.Kind[GroundJob] groundJob *jobs.Kind[GroundJob]
// groundingsMu guards groundings: the KindGroundDinner jobs running,
// by dinner id, so archiving or renaming a dinner can stop them.
groundingsMu sync.Mutex
groundings map[string]map[*runningGrounding]struct{}
} }
// NewService returns a service over st that grounds dinners with grounder // NewService returns a service over st that grounds dinners with grounder
@@ -146,7 +153,7 @@ func NewService(st *store.Store, grounder *recipes.Grounder, runner *jobs.Runner
case logger == nil: case logger == nil:
return nil, errors.New("dinners: nil logger") return nil, errors.New("dinners: nil logger")
} }
s := &Service{st: st, grounder: grounder, logger: logger} s := &Service{st: st, grounder: grounder, logger: logger, groundings: make(map[string]map[*runningGrounding]struct{})}
kind, err := jobs.Register(runner, KindGroundDinner, s.runGroundJob, Reasons...) kind, err := jobs.Register(runner, KindGroundDinner, s.runGroundJob, Reasons...)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -296,7 +303,7 @@ func (s *Service) List(ctx context.Context, status string) ([]Dinner, error) {
// clears its recipe_url, raises its grounding_revision and enqueues a fresh // clears its recipe_url, raises its grounding_revision and enqueues a fresh
// KindGroundDinner job that grounds the new name: a job queued or running // KindGroundDinner job that grounds the new name: a job queued or running
// for the old name or link carries the old revision, so it can no longer // for the old name or link carries the old revision, so it can no longer
// write the dinner. // write the dinner, and one running is stopped.
func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy string) (Dinner, error) { func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy string) (Dinner, error) {
if updatedBy == "" { if updatedBy == "" {
return Dinner{}, errors.New("dinners: update: no member to record as updated_by") return Dinner{}, errors.New("dinners: update: no member to record as updated_by")
@@ -368,6 +375,7 @@ func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy stri
return Dinner{}, err return Dinner{}, err
} }
if regrounding { if regrounding {
s.stopGroundings(row.ID, row.GroundingRevision)
jobID, err := s.enqueueGrounding(ctx, row.ID, row.GroundingRevision) jobID, err := s.enqueueGrounding(ctx, row.ID, row.GroundingRevision)
if err != nil { if err != nil {
return Dinner{}, err return Dinner{}, err
@@ -383,7 +391,8 @@ func (s *Service) Update(ctx context.Context, id string, p Patch, updatedBy stri
} }
// Archive sets dinner id's status to archived, recording updatedBy. The row // Archive sets dinner id's status to archived, recording updatedBy. The row
// is kept, so its name still counts for Side quest dedupe. // is kept, so its name still counts for Side quest dedupe. A
// KindGroundDinner job running for it is stopped.
func (s *Service) Archive(ctx context.Context, id, updatedBy string) (Dinner, error) { func (s *Service) Archive(ctx context.Context, id, updatedBy string) (Dinner, error) {
if updatedBy == "" { if updatedBy == "" {
return Dinner{}, errors.New("dinners: archive: no member to record as updated_by") return Dinner{}, errors.New("dinners: archive: no member to record as updated_by")
@@ -399,6 +408,7 @@ func (s *Service) Archive(ctx context.Context, id, updatedBy string) (Dinner, er
if err != nil { if err != nil {
return Dinner{}, fmt.Errorf("dinners: archive %s: %w", id, err) return Dinner{}, fmt.Errorf("dinners: archive %s: %w", id, err)
} }
s.stopGroundings(row.ID, math.MaxInt64)
return fromRow(row), nil return fromRow(row), nil
} }
+69 -8
View File
@@ -104,8 +104,14 @@ func (r groundResult) PublicJSON() ([]byte, error) { return json.Marshal(r) }
// either result is recorded only while the dinner is still unarchived, // either result is recorded only while the dinner is still unarchived,
// pending and at p.Revision: a dinner grounded by hand meanwhile is left as // pending and at p.Revision: a dinner grounded by hand meanwhile is left as
// it is, and one renamed or archived meanwhile is left pending — for the // it is, and one renamed or archived meanwhile is left pending — for the
// job its rename enqueued, or for none. // job its rename enqueued, or for none. Archiving or renaming the dinner
// stops the grounding where it is, so no further search, fetch, photo
// download or model call is made for it.
func (s *Service) runGroundJob(ctx context.Context, p GroundJob, _ func(groundResult) error) (groundResult, error) { func (s *Service) runGroundJob(ctx context.Context, p GroundJob, _ func(groundResult) error) (groundResult, error) {
// Tracked before the dinner is read: an archive or rename committed
// after the read finds this job to stop.
groundCtx, done := s.trackGrounding(ctx, p.DinnerID, p.Revision)
defer done()
row, err := db.New(s.st.Reader).GetDinner(ctx, p.DinnerID) row, err := db.New(s.st.Reader).GetDinner(ctx, p.DinnerID)
if err != nil { if err != nil {
return groundResult{}, fmt.Errorf("dinners: ground: read %s: %w", p.DinnerID, err) return groundResult{}, fmt.Errorf("dinners: ground: read %s: %w", p.DinnerID, err)
@@ -116,9 +122,12 @@ func (s *Service) runGroundJob(ctx context.Context, p GroundJob, _ func(groundRe
var gr recipes.Grounded var gr recipes.Grounded
if row.RecipeUrl.Valid { if row.RecipeUrl.Valid {
gr, err = s.grounder.GroundURL(ctx, row.RecipeUrl.String) gr, err = s.grounder.GroundURL(groundCtx, row.RecipeUrl.String)
} else { } else {
gr, err = s.grounder.Ground(ctx, row.Name, row.Name) gr, err = s.grounder.Ground(groundCtx, row.Name, row.Name)
}
if err != nil && ctx.Err() == nil && errors.Is(context.Cause(groundCtx), errGroundingStopped) {
return s.currentGrounding(ctx, row.ID)
} }
if err != nil { if err != nil {
reason := reasonFor(err, row.RecipeUrl.Valid) reason := reasonFor(err, row.RecipeUrl.Valid)
@@ -157,15 +166,67 @@ func (s *Service) runGroundJob(ctx context.Context, p GroundJob, _ func(groundRe
return groundResult{}, fmt.Errorf("dinners: ground: record %s: %w", row.ID, err) return groundResult{}, fmt.Errorf("dinners: ground: record %s: %w", row.ID, err)
} }
if n == 0 { if n == 0 {
cur, err := db.New(s.st.Reader).GetDinner(ctx, row.ID) return s.currentGrounding(ctx, row.ID)
if err != nil {
return groundResult{}, fmt.Errorf("dinners: ground: read %s: %w", row.ID, err)
}
return groundResult{DinnerID: cur.ID, GroundingStatus: cur.GroundingStatus}, nil
} }
return groundResult{DinnerID: row.ID, GroundingStatus: GroundingGrounded}, nil return groundResult{DinnerID: row.ID, GroundingStatus: GroundingGrounded}, nil
} }
// currentGrounding is the KindGroundDinner result of a job that did not
// ground dinner id: the grounding status the dinner has now.
func (s *Service) currentGrounding(ctx context.Context, id string) (groundResult, error) {
cur, err := db.New(s.st.Reader).GetDinner(ctx, id)
if err != nil {
return groundResult{}, fmt.Errorf("dinners: ground: read %s: %w", id, err)
}
return groundResult{DinnerID: cur.ID, GroundingStatus: cur.GroundingStatus}, nil
}
// errGroundingStopped is the cause a running KindGroundDinner job's
// grounding is cancelled with when its dinner is archived or renamed.
var errGroundingStopped = errors.New("dinners: grounding stopped: dinner archived or renamed")
// runningGrounding is a KindGroundDinner job running for a dinner at
// revision, with the cancel of its grounding.
type runningGrounding struct {
revision int64
cancel context.CancelCauseFunc
}
// trackGrounding returns a context for grounding dinnerID at revision that
// stopGroundings cancels, and the func to call when the job ends.
func (s *Service) trackGrounding(ctx context.Context, dinnerID string, revision int64) (context.Context, func()) {
groundCtx, cancel := context.WithCancelCause(ctx)
r := &runningGrounding{revision: revision, cancel: cancel}
s.groundingsMu.Lock()
if s.groundings[dinnerID] == nil {
s.groundings[dinnerID] = make(map[*runningGrounding]struct{})
}
s.groundings[dinnerID][r] = struct{}{}
s.groundingsMu.Unlock()
return groundCtx, func() {
s.groundingsMu.Lock()
delete(s.groundings[dinnerID], r)
if len(s.groundings[dinnerID]) == 0 {
delete(s.groundings, dinnerID)
}
s.groundingsMu.Unlock()
cancel(nil)
}
}
// stopGroundings cancels, with errGroundingStopped, the grounding of every
// KindGroundDinner job running for dinnerID at a revision below below. Call
// it once the change that makes those jobs stale is committed.
func (s *Service) stopGroundings(dinnerID string, below int64) {
s.groundingsMu.Lock()
defer s.groundingsMu.Unlock()
for r := range s.groundings[dinnerID] {
if r.revision < below {
r.cancel(errGroundingStopped)
}
}
}
// FailInterruptedGroundings fails, with ReasonInterrupted, every dinner // FailInterruptedGroundings fails, with ReasonInterrupted, every dinner
// still pending whose KindGroundDinner job is no longer queued or running: // still pending whose KindGroundDinner job is no longer queued or running:
// a previous process stopped before enqueuing the job or before its // a previous process stopped before enqueuing the job or before its
+109 -17
View File
@@ -6,9 +6,11 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"io" "io"
"io/fs"
"log/slog" "log/slog"
"slices" "slices"
"testing" "testing"
"testing/fstest"
"time" "time"
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm" "code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/llm"
@@ -54,6 +56,13 @@ func newEnv(t *testing.T) *env {
func newUnstartedEnv(t *testing.T) *env { func newUnstartedEnv(t *testing.T) *env {
t.Helper() t.Helper()
dataDir := t.TempDir() dataDir := t.TempDir()
return newUnstartedEnvOn(t, openStore(t, dataDir), dataDir)
}
// openStore opens the store in dataDir, closing it when the test ends. It
// is not migrated.
func openStore(t *testing.T, dataDir string) *store.Store {
t.Helper()
st, err := store.Open(t.Context(), dataDir) st, err := store.Open(t.Context(), dataDir)
if err != nil { if err != nil {
t.Fatal(err) t.Fatal(err)
@@ -63,6 +72,13 @@ func newUnstartedEnv(t *testing.T) *env {
t.Error(err) t.Error(err)
} }
}) })
return st
}
// newUnstartedEnvOn is newUnstartedEnv over st, the store in dataDir,
// which it migrates to the latest version.
func newUnstartedEnvOn(t *testing.T, st *store.Store, dataDir string) *env {
t.Helper()
if err := store.Migrate(t.Context(), st.Writer, migrations.FS); err != nil { if err := store.Migrate(t.Context(), st.Writer, migrations.FS); err != nil {
t.Fatal(err) t.Fatal(err)
} }
@@ -290,19 +306,16 @@ func TestManualFixWhilePendingIsKept(t *testing.T) {
// TestRenameWhilePendingGroundsNewName: a Regular renamed while its job is // TestRenameWhilePendingGroundsNewName: a Regular renamed while its job is
// still grounding the old name never gets the old name's result, whether // still grounding the old name never gets the old name's result, whether
// that job succeeds or fails; the job the rename enqueues grounds the new // the old name would be found or not: the rename stops that job where it
// name. // is, and the job the rename enqueues grounds the new name.
func TestRenameWhilePendingGroundsNewName(t *testing.T) { func TestRenameWhilePendingGroundsNewName(t *testing.T) {
t.Parallel() t.Parallel()
tests := []struct { tests := []struct {
name string name string
oldName string oldName string
replies []llm.GenerateResult
}{ }{
{"old name found", "Chicken curry", []llm.GenerateResult{ {"old name found", "Chicken curry"},
recipestest.NormalizedReply("stovetop", 40), recipestest.NormalizedReply("oven", 150), {"old name not found", "Unknown dish"},
}},
{"old name not found", "Unknown dish", []llm.GenerateResult{recipestest.NormalizedReply("oven", 150)}},
} }
for _, tt := range tests { for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) { t.Run(tt.name, func(t *testing.T) {
@@ -310,9 +323,7 @@ func TestRenameWhilePendingGroundsNewName(t *testing.T) {
e := newEnv(t) e := newEnv(t)
e.search.Results["Beef stew"] = []recipes.Candidate{{URL: stew}} e.search.Results["Beef stew"] = []recipes.Candidate{{URL: stew}}
e.search.Gate = make(chan struct{}) e.search.Gate = make(chan struct{})
for _, r := range tt.replies { e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
e.model.QueueGenerate(r)
}
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr(tt.oldName)}, member) c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr(tt.oldName)}, member)
if err != nil { if err != nil {
@@ -327,12 +338,13 @@ func TestRenameWhilePendingGroundsNewName(t *testing.T) {
if d.GroundingStatus != dinners.GroundingPending { if d.GroundingStatus != dinners.GroundingPending {
t.Fatalf("renamed = %+v, want still pending", d) t.Fatalf("renamed = %+v, want still pending", d)
} }
// The old name's job ends while its search is still held.
first := e.waitJob(t, *c.GroundingJobID)
if first.Status != jobs.StatusSucceeded || string(first.Result) != `{"dinner_id":"`+c.ID+`","grounding_status":"pending"}` {
t.Fatalf("old name's job = %+v (result %s), want succeeded leaving the dinner pending", first, first.Result)
}
close(e.search.Gate) close(e.search.Gate)
first := e.waitJob(t, *c.GroundingJobID)
if string(first.Result) == `{"dinner_id":"`+c.ID+`","grounding_status":"grounded"}` {
t.Fatalf("old name's job reported grounded: %+v (result %s)", first, first.Result)
}
row := e.waitGrounding(t, c.ID) row := e.waitGrounding(t, c.ID)
if row.GroundingStatus != dinners.GroundingGrounded { if row.GroundingStatus != dinners.GroundingGrounded {
t.Fatalf("dinner = %+v, want grounded for the new name", row) t.Fatalf("dinner = %+v, want grounded for the new name", row)
@@ -347,6 +359,9 @@ func TestRenameWhilePendingGroundsNewName(t *testing.T) {
if got := e.search.Queries(); !slices.Equal(got, []string{tt.oldName, "Beef stew"}) { if got := e.search.Queries(); !slices.Equal(got, []string{tt.oldName, "Beef stew"}) {
t.Errorf("searched %q, want the old name then the new", got) t.Errorf("searched %q, want the old name then the new", got)
} }
if got := len(e.model.Requests()); got != 1 {
t.Errorf("model called %d times, want once, for the new name only", got)
}
}) })
} }
} }
@@ -503,7 +518,8 @@ func TestEditWhilePendingIsKept(t *testing.T) {
// TestArchiveWhilePendingStopsGrounding: a pending Regular archived before // TestArchiveWhilePendingStopsGrounding: a pending Regular archived before
// its job starts is not searched for, and one archived while its job runs // its job starts is not searched for, and one archived while its job runs
// is not written; neither ends up grounded. // is stopped there, with no page fetched or model called; neither ends up
// grounded.
func TestArchiveWhilePendingStopsGrounding(t *testing.T) { func TestArchiveWhilePendingStopsGrounding(t *testing.T) {
t.Parallel() t.Parallel()
t.Run("before the job starts", func(t *testing.T) { t.Run("before the job starts", func(t *testing.T) {
@@ -541,9 +557,17 @@ func TestArchiveWhilePendingStopsGrounding(t *testing.T) {
if _, err := e.svc.Archive(t.Context(), c.ID, member); err != nil { if _, err := e.svc.Archive(t.Context(), c.ID, member); err != nil {
t.Fatal(err) t.Fatal(err)
} }
// The job ends while its search is still held: archiving stopped it.
job := e.waitJob(t, *c.GroundingJobID)
close(e.search.Gate) close(e.search.Gate)
if job := e.waitJob(t, *c.GroundingJobID); job.Status != jobs.StatusSucceeded { if job.Status != jobs.StatusSucceeded || string(job.Result) != `{"dinner_id":"`+c.ID+`","grounding_status":"pending"}` {
t.Fatalf("job = %+v", job) t.Fatalf("job = %+v (result %s), want succeeded leaving the dinner pending", job, job.Result)
}
if got := e.pages.Fetched(); len(got) != 0 {
t.Errorf("fetched %q for an archived dinner", got)
}
if got := len(e.model.Requests()); got != 0 {
t.Errorf("model called %d times for an archived dinner", got)
} }
if row := e.dinner(t, c.ID); row.Status != dinners.StatusArchived || row.GroundingStatus == dinners.GroundingGrounded || row.RecipeID.Valid { if row := e.dinner(t, c.ID); row.Status != dinners.StatusArchived || row.GroundingStatus == dinners.GroundingGrounded || row.RecipeID.Valid {
t.Errorf("dinner = %+v, want archived and not grounded", row) t.Errorf("dinner = %+v, want archived and not grounded", row)
@@ -922,6 +946,74 @@ func TestRestartFailsInterruptedGrounding(t *testing.T) {
} }
} }
// TestUpgradeKeepsLegacyQueuedGrounding: a pending Regular whose
// ground_dinner job was queued before migration 0010, so its payload has
// no revision, is not failed as interrupted at the first boot after the
// upgrade; its job still grounds it.
func TestUpgradeKeepsLegacyQueuedGrounding(t *testing.T) {
t.Parallel()
ctx := t.Context()
dataDir := t.TempDir()
st := openStore(t, dataDir)
before := fstest.MapFS{}
files, err := fs.Glob(migrations.FS, "*.sql")
if err != nil {
t.Fatal(err)
}
for _, name := range files {
if name >= "0010" {
continue
}
body, err := fs.ReadFile(migrations.FS, name)
if err != nil {
t.Fatal(err)
}
before[name] = &fstest.MapFile{Data: body}
}
if err := store.Migrate(ctx, st.Writer, before); err != nil {
t.Fatal(err)
}
now := store.Now().String()
if _, err := st.Writer.ExecContext(ctx, `INSERT INTO dinners
(id, name, normalized_name, status, origin, is_favorite, grounding_status, updated_by, created_at, updated_at)
VALUES ('dinner-1', 'Chicken curry', 'chicken curry', 'regular', 'regular', 0, 'pending', ?, ?, ?)`,
member, now, now); err != nil {
t.Fatal(err)
}
if _, err := st.Writer.ExecContext(ctx, `INSERT INTO jobs (id, kind, status, payload, created_at, updated_at)
VALUES ('job-1', ?, 'queued', '{"dinner_id":"dinner-1"}', ?, ?)`,
dinners.KindGroundDinner, now, now); err != nil {
t.Fatal(err)
}
// Boot: migrate, start the runner, then fail interrupted groundings. The
// search is held, so the job cannot finish before the cleanup runs.
e := newUnstartedEnvOn(t, st, dataDir)
e.search.Gate = make(chan struct{})
e.model.QueueGenerate(recipestest.NormalizedReply("stovetop", 40))
if p := e.queuedJob(t, "job-1"); p != (dinners.GroundJob{DinnerID: "dinner-1", Revision: 0}) {
t.Errorf("migrated payload = %+v, want dinner-1 at revision 0", p)
}
e.start(t)
n, err := e.svc.FailInterruptedGroundings(ctx)
if err != nil {
t.Fatal(err)
}
if n != 0 {
t.Errorf("failed %d dinners, want 0", n)
}
if row := e.dinner(t, "dinner-1"); row.GroundingStatus != dinners.GroundingPending {
t.Fatalf("dinner = %s (%s), want still pending", row.GroundingStatus, row.GroundingError.String)
}
close(e.search.Gate)
if job := e.waitJob(t, "job-1"); job.Status != jobs.StatusSucceeded {
t.Fatalf("job = %+v", job)
}
if row := e.dinner(t, "dinner-1"); row.GroundingStatus != dinners.GroundingGrounded {
t.Errorf("dinner = %s, want grounded", row.GroundingStatus)
}
}
// TestCreateEnqueueFailureReturnsFailedDinner: a dinner saved when its job // TestCreateEnqueueFailureReturnsFailedDinner: a dinner saved when its job
// cannot be enqueued is returned, with its id, failed and no job id. // cannot be enqueued is returned, with its id, failed and no job id.
func TestCreateEnqueueFailureReturnsFailedDinner(t *testing.T) { func TestCreateEnqueueFailureReturnsFailedDinner(t *testing.T) {
@@ -66,9 +66,15 @@ func (s *Search) Queries() []string {
type Pages struct { type Pages struct {
Bodies map[string][]byte Bodies map[string][]byte
Gate chan struct{} Gate chan struct{}
mu sync.Mutex
fetched []string
} }
func (p *Pages) Fetch(ctx context.Context, rawURL string) (recipes.Page, error) { func (p *Pages) Fetch(ctx context.Context, rawURL string) (recipes.Page, error) {
p.mu.Lock()
p.fetched = append(p.fetched, rawURL)
p.mu.Unlock()
if p.Gate != nil { if p.Gate != nil {
select { select {
case <-p.Gate: case <-p.Gate:
@@ -83,6 +89,13 @@ func (p *Pages) Fetch(ctx context.Context, rawURL string) (recipes.Page, error)
return recipes.Page{URL: rawURL, ContentType: "text/html", Body: body}, nil return recipes.Page{URL: rawURL, ContentType: "text/html", Body: body}, nil
} }
// Fetched returns every URL fetched, in order.
func (p *Pages) Fetched() []string {
p.mu.Lock()
defer p.mu.Unlock()
return append([]string(nil), p.fetched...)
}
// Images is a recipes.ImageFetcher serving JPEGs by URL; any other URL is // Images is a recipes.ImageFetcher serving JPEGs by URL; any other URL is
// a 404. // a 404.
type Images struct { type Images struct {
@@ -5,3 +5,12 @@
-- earlier job — queued or running — can no longer write the dinner. -- earlier job — queued or running — can no longer write the dinner.
ALTER TABLE dinners ADD COLUMN grounding_revision INTEGER NOT NULL DEFAULT 0; ALTER TABLE dinners ADD COLUMN grounding_revision INTEGER NOT NULL DEFAULT 0;
-- Every dinner starts at revision 0, so a ground_dinner job enqueued
-- before this migration, whose payload has only dinner_id, is for revision
-- 0. Recording it lets the boot cleanup, which matches a pending dinner to
-- its job by revision, see a job still queued for its dinner.
UPDATE jobs
SET payload = json_set(payload, '$.revision', 0)
WHERE kind = 'ground_dinner'
AND json_type(payload, '$.revision') IS NULL;
File diff suppressed because it is too large Load Diff
+15 -31
View File
@@ -3,37 +3,17 @@
// API would refuse a name as a duplicate. The server still decides. // API would refuse a name as a duplicate. The server still decides.
// internal/ai/harness/testdata/dedupe_cases.json is run against both. // internal/ai/harness/testdata/dedupe_cases.json is run against both.
import folds from './letterFolds.json'
// SIMILAR_THRESHOLD is harness.SimilarThreshold. // SIMILAR_THRESHOLD is harness.SimilarThreshold.
const SIMILAR_THRESHOLD = 0.85 const SIMILAR_THRESHOLD = 0.85
// fillerWords is harness's fillerWords. // fillerWords is harness's fillerWords.
const fillerWords = new Set(['easy', 'best', 'homemade', 'quick', 'simple', 'the', 'a', 'recipe', 'classic']) const fillerWords = new Set(['easy', 'best', 'homemade', 'quick', 'simple', 'the', 'a', 'recipe', 'classic'])
// letterFolds are the letters harness folds that canonical decomposition // letterFolds is harness's letterFolds: gen_folds.py writes both tables,
// leaves whole: its ligatures and extra letters, and the common stroked // and harness's TestLetterFoldsMatchPWA fails when they differ.
// Latin letters. Letters with accents fold by decomposing and dropping the const letterFolds: Record<string, string> = folds
// marks, as harness's table does; rarer stroked letters are compared as
// they are.
const letterFolds: Record<string, string> = {
ß: 'ss',
æ: 'ae',
œ: 'oe',
þ: 'th',
ð: 'd',
ı: 'i',
ʼn: 'n',
ij: 'ij',
ø: 'o',
đ: 'd',
ħ: 'h',
ŀ: 'l',
ł: 'l',
ŧ: 't',
ƀ: 'b',
ƶ: 'z',
ǥ: 'g',
ɨ: 'i',
}
const combiningMark = /\p{Mn}/u const combiningMark = /\p{Mn}/u
const wordChar = /[\p{L}\p{Nd}]/u const wordChar = /[\p{L}\p{Nd}]/u
@@ -44,12 +24,16 @@ const wordChar = /[\p{L}\p{Nd}]/u
// "chicken tikka masala". // "chicken tikka masala".
export function normalizeName(name: string): string { export function normalizeName(name: string): string {
let out = '' let out = ''
for (const ch of name.toLowerCase().normalize('NFD')) { // Lowercased letter by letter, as Go's strings.ToLower does, so no
if (combiningMark.test(ch) || ch === "'" || ch === '’') continue // letter is lowered by its context (a final Σ is σ, not ς).
const fold = letterFolds[ch] for (const letter of name) {
if (fold !== undefined) out += fold for (const ch of letter.toLowerCase()) {
else if (wordChar.test(ch)) out += ch if (combiningMark.test(ch) || ch === "'" || ch === '’') continue
else out += ' ' const fold = letterFolds[ch]
if (fold !== undefined) out += fold
else if (wordChar.test(ch)) out += ch
else out += ' '
}
} }
return out return out
.split(' ') .split(' ')