Integration fix: Feature 12 feature-review findings F1-F8 #95
@@ -219,7 +219,10 @@ automatic** (D-021).
|
||||
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`
|
||||
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
|
||||
`pending` is kept; grounding fills only those left empty. At boot, after
|
||||
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
|
||||
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
|
||||
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
|
||||
notifications (`/onboarding/notifications`: what MealQuest notifies about,
|
||||
the iOS install hint, "Turn on notifications" and "Maybe later"). Either
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"os"
|
||||
"testing"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// 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) {
|
||||
tests := []struct {
|
||||
name, want string
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
#!/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
|
||||
`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.
|
||||
"""
|
||||
|
||||
import json
|
||||
import re
|
||||
import subprocess
|
||||
import sys
|
||||
import unicodedata
|
||||
|
||||
PWA_TABLE = "../../../web/src/dinners/letterFolds.json"
|
||||
|
||||
EXTRA = {
|
||||
"ß": "ss", "ẞ": "ss", "æ": "ae", "Æ": "ae", "œ": "oe", "Œ": "oe",
|
||||
"þ": "th", "Þ": "th", "ð": "d", "Ð": "d", "ı": "i", "ʼn": "n",
|
||||
@@ -110,6 +116,9 @@ def main():
|
||||
with open("folds_table.go", "w", encoding="utf-8") as f:
|
||||
f.write("\n".join(out) + "\n")
|
||||
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__":
|
||||
|
||||
+17
@@ -67,6 +67,18 @@
|
||||
{
|
||||
"name": "7-layer dip",
|
||||
"want": "7 layer dip"
|
||||
},
|
||||
{
|
||||
"name": "Ƈhili",
|
||||
"want": "chili"
|
||||
},
|
||||
{
|
||||
"name": "Ꞔarbanzo Stew",
|
||||
"want": "carbanzo stew"
|
||||
},
|
||||
{
|
||||
"name": "ΜΟΥΣΑΚΑΣ",
|
||||
"want": "μουσακασ"
|
||||
}
|
||||
],
|
||||
"similar": [
|
||||
@@ -114,6 +126,11 @@
|
||||
"a": "The best",
|
||||
"b": "Easy recipe",
|
||||
"want": false
|
||||
},
|
||||
{
|
||||
"a": "Ƈhili",
|
||||
"b": "Chili",
|
||||
"want": true
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -11,9 +11,11 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"math"
|
||||
"net/url"
|
||||
"slices"
|
||||
"strings"
|
||||
"sync"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
|
||||
@@ -130,6 +132,11 @@ type Service struct {
|
||||
grounder *recipes.Grounder
|
||||
logger *slog.Logger
|
||||
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
|
||||
@@ -146,7 +153,7 @@ func NewService(st *store.Store, grounder *recipes.Grounder, runner *jobs.Runner
|
||||
case logger == nil:
|
||||
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...)
|
||||
if err != nil {
|
||||
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
|
||||
// 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
|
||||
// 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) {
|
||||
if updatedBy == "" {
|
||||
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
|
||||
}
|
||||
if regrounding {
|
||||
s.stopGroundings(row.ID, row.GroundingRevision)
|
||||
jobID, err := s.enqueueGrounding(ctx, row.ID, row.GroundingRevision)
|
||||
if err != nil {
|
||||
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
|
||||
// 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) {
|
||||
if updatedBy == "" {
|
||||
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 {
|
||||
return Dinner{}, fmt.Errorf("dinners: archive %s: %w", id, err)
|
||||
}
|
||||
s.stopGroundings(row.ID, math.MaxInt64)
|
||||
return fromRow(row), nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
// 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
|
||||
// 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) {
|
||||
// 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)
|
||||
if err != nil {
|
||||
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
|
||||
if row.RecipeUrl.Valid {
|
||||
gr, err = s.grounder.GroundURL(ctx, row.RecipeUrl.String)
|
||||
gr, err = s.grounder.GroundURL(groundCtx, row.RecipeUrl.String)
|
||||
} 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 {
|
||||
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)
|
||||
}
|
||||
if n == 0 {
|
||||
cur, err := db.New(s.st.Reader).GetDinner(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 s.currentGrounding(ctx, row.ID)
|
||||
}
|
||||
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
|
||||
// still pending whose KindGroundDinner job is no longer queued or running:
|
||||
// a previous process stopped before enqueuing the job or before its
|
||||
|
||||
@@ -6,9 +6,11 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log/slog"
|
||||
"slices"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
"time"
|
||||
|
||||
"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 {
|
||||
t.Helper()
|
||||
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)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
@@ -63,6 +72,13 @@ func newUnstartedEnv(t *testing.T) *env {
|
||||
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 {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -290,19 +306,16 @@ func TestManualFixWhilePendingIsKept(t *testing.T) {
|
||||
|
||||
// TestRenameWhilePendingGroundsNewName: a Regular renamed while its job is
|
||||
// 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
|
||||
// name.
|
||||
// the old name would be found or not: the rename stops that job where it
|
||||
// is, and the job the rename enqueues grounds the new name.
|
||||
func TestRenameWhilePendingGroundsNewName(t *testing.T) {
|
||||
t.Parallel()
|
||||
tests := []struct {
|
||||
name string
|
||||
oldName string
|
||||
replies []llm.GenerateResult
|
||||
}{
|
||||
{"old name found", "Chicken curry", []llm.GenerateResult{
|
||||
recipestest.NormalizedReply("stovetop", 40), recipestest.NormalizedReply("oven", 150),
|
||||
}},
|
||||
{"old name not found", "Unknown dish", []llm.GenerateResult{recipestest.NormalizedReply("oven", 150)}},
|
||||
{"old name found", "Chicken curry"},
|
||||
{"old name not found", "Unknown dish"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
@@ -310,9 +323,7 @@ func TestRenameWhilePendingGroundsNewName(t *testing.T) {
|
||||
e := newEnv(t)
|
||||
e.search.Results["Beef stew"] = []recipes.Candidate{{URL: stew}}
|
||||
e.search.Gate = make(chan struct{})
|
||||
for _, r := range tt.replies {
|
||||
e.model.QueueGenerate(r)
|
||||
}
|
||||
e.model.QueueGenerate(recipestest.NormalizedReply("oven", 150))
|
||||
|
||||
c, err := e.svc.CreateRegular(t.Context(), dinners.NewRegular{Name: ptr(tt.oldName)}, member)
|
||||
if err != nil {
|
||||
@@ -327,12 +338,13 @@ func TestRenameWhilePendingGroundsNewName(t *testing.T) {
|
||||
if d.GroundingStatus != dinners.GroundingPending {
|
||||
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)
|
||||
|
||||
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)
|
||||
if row.GroundingStatus != dinners.GroundingGrounded {
|
||||
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"}) {
|
||||
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
|
||||
// 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) {
|
||||
t.Parallel()
|
||||
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 {
|
||||
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)
|
||||
if job := e.waitJob(t, *c.GroundingJobID); job.Status != jobs.StatusSucceeded {
|
||||
t.Fatalf("job = %+v", job)
|
||||
if job.Status != jobs.StatusSucceeded || string(job.Result) != `{"dinner_id":"`+c.ID+`","grounding_status":"pending"}` {
|
||||
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 {
|
||||
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
|
||||
// cannot be enqueued is returned, with its id, failed and no job id.
|
||||
func TestCreateEnqueueFailureReturnsFailedDinner(t *testing.T) {
|
||||
|
||||
@@ -66,9 +66,15 @@ func (s *Search) Queries() []string {
|
||||
type Pages struct {
|
||||
Bodies map[string][]byte
|
||||
Gate chan struct{}
|
||||
|
||||
mu sync.Mutex
|
||||
fetched []string
|
||||
}
|
||||
|
||||
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 {
|
||||
select {
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
// a 404.
|
||||
type Images struct {
|
||||
|
||||
@@ -5,3 +5,12 @@
|
||||
-- earlier job — queued or running — can no longer write the dinner.
|
||||
|
||||
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
@@ -3,37 +3,17 @@
|
||||
// API would refuse a name as a duplicate. The server still decides.
|
||||
// internal/ai/harness/testdata/dedupe_cases.json is run against both.
|
||||
|
||||
import folds from './letterFolds.json'
|
||||
|
||||
// SIMILAR_THRESHOLD is harness.SimilarThreshold.
|
||||
const SIMILAR_THRESHOLD = 0.85
|
||||
|
||||
// fillerWords is harness's fillerWords.
|
||||
const fillerWords = new Set(['easy', 'best', 'homemade', 'quick', 'simple', 'the', 'a', 'recipe', 'classic'])
|
||||
|
||||
// letterFolds are the letters harness folds that canonical decomposition
|
||||
// leaves whole: its ligatures and extra letters, and the common stroked
|
||||
// Latin letters. Letters with accents fold by decomposing and dropping the
|
||||
// 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',
|
||||
}
|
||||
// letterFolds is harness's letterFolds: gen_folds.py writes both tables,
|
||||
// and harness's TestLetterFoldsMatchPWA fails when they differ.
|
||||
const letterFolds: Record<string, string> = folds
|
||||
|
||||
const combiningMark = /\p{Mn}/u
|
||||
const wordChar = /[\p{L}\p{Nd}]/u
|
||||
@@ -44,12 +24,16 @@ const wordChar = /[\p{L}\p{Nd}]/u
|
||||
// "chicken tikka masala".
|
||||
export function normalizeName(name: string): string {
|
||||
let out = ''
|
||||
for (const ch of name.toLowerCase().normalize('NFD')) {
|
||||
if (combiningMark.test(ch) || ch === "'" || ch === '’') continue
|
||||
const fold = letterFolds[ch]
|
||||
if (fold !== undefined) out += fold
|
||||
else if (wordChar.test(ch)) out += ch
|
||||
else out += ' '
|
||||
// Lowercased letter by letter, as Go's strings.ToLower does, so no
|
||||
// letter is lowered by its context (a final Σ is σ, not ς).
|
||||
for (const letter of name) {
|
||||
for (const ch of letter.toLowerCase()) {
|
||||
if (combiningMark.test(ch) || ch === "'" || ch === '’') continue
|
||||
const fold = letterFolds[ch]
|
||||
if (fold !== undefined) out += fold
|
||||
else if (wordChar.test(ch)) out += ch
|
||||
else out += ' '
|
||||
}
|
||||
}
|
||||
return out
|
||||
.split(' ')
|
||||
|
||||
Reference in New Issue
Block a user