Rank stored candidates on their stored recipe without refetching
CI / ci (push) Successful in 1m48s
CI / ci (pull_request) Successful in 1m48s

Ground no longer fetches the page of a candidate whose source URL is
already stored: the stored recipe, its photo, ingredients and steps are
scored as a fetched page would be, so ranking still decides between it
and new candidates, and a stored page that is now unavailable can still
be reused. Rank's fetch-and-extract is split out behind a loader so
Rank and Ground share one scoring path.

The reuse test removes the stored page and checks that only the new
candidate is fetched; the ranking regression test lists the unrelated
stored recipe first so search order cannot pass it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 15:35:37 -04:00
co-authored by Claude Opus 5.5
parent 871a566800
commit 24d91b5bf6
3 changed files with 136 additions and 32 deletions
+21 -8
View File
@@ -57,6 +57,14 @@ type Ranked struct {
// unranked candidate's URL and reason. A cancelled ctx returns only its
// error.
func Rank(ctx context.Context, f PageFetcher, dish string, cands []Candidate) ([]Ranked, error) {
return rank(ctx, dish, cands, func(ctx context.Context, c Candidate) (Recipe, error) {
return fetchRecipe(ctx, f, c.URL)
})
}
// rank is Rank with each candidate's recipe got by load rather than always
// fetched.
func rank(ctx context.Context, dish string, cands []Candidate, load func(context.Context, Candidate) (Recipe, error)) ([]Ranked, error) {
if len(cands) > RankDepth {
cands = cands[:RankDepth]
}
@@ -70,7 +78,7 @@ func Rank(ctx context.Context, f PageFetcher, dish string, cands []Candidate) ([
wg.Add(1)
go func() {
defer wg.Done()
r, err := rankOne(ctx, f, dish, c)
r, err := rankOne(ctx, load, dish, c)
outcomes[i] = outcome{r, err}
}()
}
@@ -98,14 +106,10 @@ func Rank(ctx context.Context, f PageFetcher, dish string, cands []Candidate) ([
return ranked, nil
}
// rankOne fetches, extracts and scores one candidate, or says why it is
// rankOne loads and scores one candidate's recipe, or says why it is
// rejected.
func rankOne(ctx context.Context, f PageFetcher, dish string, c Candidate) (Ranked, error) {
page, err := f.Fetch(ctx, c.URL)
if err != nil {
return Ranked{}, err
}
r, err := Extract(page.Body, page.URL)
func rankOne(ctx context.Context, load func(context.Context, Candidate) (Recipe, error), dish string, c Candidate) (Ranked, error) {
r, err := load(ctx, c)
if err != nil {
return Ranked{}, err
}
@@ -115,6 +119,15 @@ func rankOne(ctx context.Context, f PageFetcher, dish string, c Candidate) (Rank
return Ranked{Candidate: c, Recipe: r, Score: harness.Similarity(r.Title, dish) + completeness(r)}, nil
}
// fetchRecipe fetches the page at rawURL and extracts its recipe.
func fetchRecipe(ctx context.Context, f PageFetcher, rawURL string) (Recipe, error) {
page, err := f.Fetch(ctx, rawURL)
if err != nil {
return Recipe{}, err
}
return Extract(page.Body, page.URL)
}
// usable says why r cannot ground a dish: it has no ingredients or no
// image, or a total time that is not positive.
func usable(r Recipe) error {