recipes.SearchProvider.Search(ctx, query, n) returns []Candidate{URL,
Title, Snippet}, appending " recipe" when the query lacks the word.
SearXNG calls {MEALQUEST_SEARXNG_URL}/search?q=…&format=json&categories=general
directly (the configured endpoint may be internal); Brave calls the Web
Search API with the key decrypted from recipe_search_settings. Redirects
are not followed; 401/403, 429, other non-2xx and malformed bodies map to
distinct errors.
recipes.Rank fetches and extracts the top 5 candidates, rejects those
without ingredients or an image, and scores the rest by
harness.Similarity of the recipe title to the dish name plus
completeness (ingredients, image, steps, minutes). harness.Similarity
exposes the existing Jaccard score used by Similar.
Maestro task 10c25cb5-3774-419c-87e1-3f87a3d1391d.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
117 lines
3.3 KiB
Go
117 lines
3.3 KiB
Go
package recipes
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"fmt"
|
|
"sort"
|
|
"sync"
|
|
|
|
"code.cubecraftlabs.com/Maniacs_Mansion/Meal-Quest/internal/ai/harness"
|
|
)
|
|
|
|
// RankDepth is how many of the top search candidates Rank fetches and
|
|
// extracts.
|
|
const RankDepth = 5
|
|
|
|
var (
|
|
// ErrNoIngredients rejects a candidate whose recipe lists no
|
|
// ingredients.
|
|
ErrNoIngredients = errors.New("recipes: recipe has no ingredients")
|
|
// ErrNoImage rejects a candidate whose recipe has no image.
|
|
ErrNoImage = errors.New("recipes: recipe has no image")
|
|
// ErrNoUsableRecipe is returned when Rank rejects every candidate; it
|
|
// wraps each candidate's reason.
|
|
ErrNoUsableRecipe = errors.New("recipes: no candidate holds a usable recipe")
|
|
)
|
|
|
|
// PageFetcher fetches a candidate's page; *Fetcher satisfies it.
|
|
type PageFetcher interface {
|
|
Fetch(ctx context.Context, rawURL string) (Page, error)
|
|
}
|
|
|
|
// Ranked is a candidate whose page held a usable recipe, with its score.
|
|
type Ranked struct {
|
|
Candidate Candidate
|
|
Recipe Recipe
|
|
// Score is the token similarity of the recipe's title to the dish name
|
|
// (0 to 1, harness.Similarity) plus a quarter for each of ingredients,
|
|
// image, steps and total minutes present (0 to 1).
|
|
Score float64
|
|
}
|
|
|
|
// Rank fetches and extracts the first RankDepth candidates concurrently and
|
|
// returns those whose recipe has ingredients and an image, highest Score
|
|
// first; equal scores keep search order. When every candidate is rejected
|
|
// the error is ErrNoUsableRecipe joined with each rejection's reason.
|
|
func Rank(ctx context.Context, f PageFetcher, dish string, cands []Candidate) ([]Ranked, error) {
|
|
if len(cands) > RankDepth {
|
|
cands = cands[:RankDepth]
|
|
}
|
|
type outcome struct {
|
|
ranked Ranked
|
|
err error
|
|
}
|
|
outcomes := make([]outcome, len(cands))
|
|
var wg sync.WaitGroup
|
|
for i, c := range cands {
|
|
wg.Add(1)
|
|
go func() {
|
|
defer wg.Done()
|
|
r, err := rankOne(ctx, f, dish, c)
|
|
outcomes[i] = outcome{r, err}
|
|
}()
|
|
}
|
|
wg.Wait()
|
|
if err := ctx.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
var ranked []Ranked
|
|
reasons := []error{ErrNoUsableRecipe}
|
|
for i, o := range outcomes {
|
|
if o.err != nil {
|
|
reasons = append(reasons, fmt.Errorf("%s: %w", cands[i].URL, o.err))
|
|
continue
|
|
}
|
|
ranked = append(ranked, o.ranked)
|
|
}
|
|
if len(ranked) == 0 {
|
|
return nil, errors.Join(reasons...)
|
|
}
|
|
sort.SliceStable(ranked, func(a, b int) bool { return ranked[a].Score > ranked[b].Score })
|
|
return ranked, nil
|
|
}
|
|
|
|
// rankOne fetches, extracts and scores one candidate, 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)
|
|
if err != nil {
|
|
return Ranked{}, err
|
|
}
|
|
if len(r.Ingredients) == 0 {
|
|
return Ranked{}, ErrNoIngredients
|
|
}
|
|
if r.ImageURL == "" {
|
|
return Ranked{}, ErrNoImage
|
|
}
|
|
return Ranked{Candidate: c, Recipe: r, Score: harness.Similarity(r.Title, dish) + completeness(r)}, nil
|
|
}
|
|
|
|
// completeness is a quarter for each of ingredients, image, steps and total
|
|
// minutes the recipe has.
|
|
func completeness(r Recipe) float64 {
|
|
score := 0.0
|
|
for _, present := range []bool{len(r.Ingredients) > 0, r.ImageURL != "", len(r.Steps) > 0, r.TotalMinutes != nil} {
|
|
if present {
|
|
score += 0.25
|
|
}
|
|
}
|
|
return score
|
|
}
|