Grounding pipeline and recipe-search settings API
CI / ci (push) Successful in 1m49s
CI / ci (pull_request) Successful in 1m48s

recipes.Grounder ties search, fetch/extract, Rank, the photo cache and
normalize_recipe together. Ground(ctx, dishName, query) reuses a stored
source_url among the top candidates without fetching, otherwise tries the
ranked recipes best first and stores the photo, recipe, ingredients and
steps in one transaction, returning Grounded{recipe_id, photo_id, method,
minutes_total, difficulty, source_url, site_name} or a typed
*ErrUngroundable listing every reason. GroundURL grounds a pasted URL the
same way; GroundMany grounds items 3 at a time with per-item results.

PhotoCache is split into DownloadPhoto (fetch, decode, size check, no
writes) and CachePhoto(ctx, tx, photo), which records the row in the
caller's transaction; RemovePhotoFiles cleans up after a rollback.

Migration 0007 adds photo_id, method, difficulty and minutes_total to
recipes, and api_key_last4 to recipe_search_settings. SearXNG is now
called at recipe_search_settings.base_url.

GET/PATCH /api/v1/settings/recipe-search (Brave key sealed with
secretbox, only api_key_set and api_key_last4 returned) and POST
/api/v1/settings/recipe-search/test (searches "chicken tikka masala",
returns the count, 409 not_configured or 502 search_failed).

Maestro task 53afd59c-6c35-4af7-904c-6c167d1ad69d.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-05 15:06:39 -04:00
co-authored by Claude Opus 5.5
parent 2a19e89e5a
commit ceedc45309
27 changed files with 1821 additions and 143 deletions
+21 -9
View File
@@ -45,12 +45,13 @@ type Ranked struct {
}
// 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.
// returns those whose recipe is usable — it has ingredients and an image,
// and any total time it gives is positive — highest Score first; equal
// scores keep search order.
//
// Every candidate that is not ranked — its fetch failed, its page holds no
// valid recipe, or the recipe lacks ingredients or an image — is reported,
// never dropped silently: when some are ranked the error is
// valid recipe, or the recipe is not usable — is reported, never dropped
// silently: when some are ranked the error is
// ErrCandidatesUnranked, returned together with the ranked ones; when none
// are it is ErrNoUsableRecipe with no results. Either is joined with each
// unranked candidate's URL and reason. A cancelled ctx returns only its
@@ -108,15 +109,26 @@ func rankOne(ctx context.Context, f PageFetcher, dish string, c Candidate) (Rank
if err != nil {
return Ranked{}, err
}
if len(r.Ingredients) == 0 {
return Ranked{}, ErrNoIngredients
}
if r.ImageURL == "" {
return Ranked{}, ErrNoImage
if err := usable(r); err != nil {
return Ranked{}, err
}
return Ranked{Candidate: c, Recipe: r, Score: harness.Similarity(r.Title, dish) + completeness(r)}, nil
}
// 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 {
switch {
case len(r.Ingredients) == 0:
return ErrNoIngredients
case r.ImageURL == "":
return ErrNoImage
case r.TotalMinutes != nil && *r.TotalMinutes <= 0:
return ErrTotalTimeNotPositive
}
return nil
}
// completeness is a quarter for each of ingredients, image, steps and total
// minutes the recipe has.
func completeness(r Recipe) float64 {