Maestro task: 53afd59c-6c35-4af7-904c-6c167d1ad69d (Feature 10 - Recipe Grounding and Photos).
Done-condition
recipes.Ground(ctx, dishName, query) runs search -> fetch/extract top candidates -> rank -> CachePhoto -> normalize_recipe -> persists recipe, ingredients, steps and photo in one transaction and returns Grounded{recipe_id, photo_id, method, minutes_total, difficulty, source_url, site_name} or a typed ErrUngroundable with reasons; GroundURL(url) for a pasted URL; GroundMany concurrent (limit 3) with per-item failure isolation; a stored source_url is reused without refetching. GET/PATCH /api/v1/settings/recipe-search (provider, base_url, Brave key via secretbox with last4) and POST /api/v1/settings/recipe-search/test (searches 'chicken tikka masala', returns count or error). Tests with fake search/fetch/LLM cover the happy path, ungroundable dish, one failing sibling not affecting others, URL grounding, stored-recipe reuse and that the Brave key never appears in responses. Go quality floor passes.
Evidence
New grounding pipeline + settings API; migration 0007 adds recipes.photo_id/method/difficulty/minutes_total and search settings api_key_last4; CachePhoto split into DownloadPhoto + transactional CachePhoto with rollback cleanup.
gofmt/vet/go test -race/build pass; sqlc-check no drift.
Note: Grounder is not yet wired into cmd/server (nothing calls it yet); routes were exercised through the real router with real SQLite/secretbox and a local SearXNG-style server.
Maestro task: 53afd59c-6c35-4af7-904c-6c167d1ad69d (Feature 10 - Recipe Grounding and Photos).
## Done-condition
recipes.Ground(ctx, dishName, query) runs search -> fetch/extract top candidates -> rank -> CachePhoto -> normalize_recipe -> persists recipe, ingredients, steps and photo in one transaction and returns Grounded{recipe_id, photo_id, method, minutes_total, difficulty, source_url, site_name} or a typed ErrUngroundable with reasons; GroundURL(url) for a pasted URL; GroundMany concurrent (limit 3) with per-item failure isolation; a stored source_url is reused without refetching. GET/PATCH /api/v1/settings/recipe-search (provider, base_url, Brave key via secretbox with last4) and POST /api/v1/settings/recipe-search/test (searches 'chicken tikka masala', returns count or error). Tests with fake search/fetch/LLM cover the happy path, ungroundable dish, one failing sibling not affecting others, URL grounding, stored-recipe reuse and that the Brave key never appears in responses. Go quality floor passes.
## Evidence
- New grounding pipeline + settings API; migration 0007 adds recipes.photo_id/method/difficulty/minutes_total and search settings api_key_last4; CachePhoto split into DownloadPhoto + transactional CachePhoto with rollback cleanup.
- gofmt/vet/go test -race/build pass; sqlc-check no drift.
- Note: Grounder is not yet wired into cmd/server (nothing calls it yet); routes were exercised through the real router with real SQLite/secretbox and a local SearXNG-style server.
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>
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>
Ground returned the first search result that was already stored before
fetching or ranking anything, so an unrelated stored recipe could win
over a relevant new one. Ground now ranks every candidate and walks the
ranking best first, reusing a stored recipe only at its ranked place.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
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>
The architecture of record still described the first stored search
result being returned before ranking. It now says ranking decides among
stored and new candidates, a stored source_url being scored on its stored
recipe without refetching its page.
State the exact per-candidate order in the docs and in ground: the photo
download and validation (DownloadPhoto) precedes normalize_recipe, and
CachePhoto records the photo in the same transaction as the recipe.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Maestro task: 53afd59c-6c35-4af7-904c-6c167d1ad69d (Feature 10 - Recipe Grounding and Photos).
Done-condition
recipes.Ground(ctx, dishName, query) runs search -> fetch/extract top candidates -> rank -> CachePhoto -> normalize_recipe -> persists recipe, ingredients, steps and photo in one transaction and returns Grounded{recipe_id, photo_id, method, minutes_total, difficulty, source_url, site_name} or a typed ErrUngroundable with reasons; GroundURL(url) for a pasted URL; GroundMany concurrent (limit 3) with per-item failure isolation; a stored source_url is reused without refetching. GET/PATCH /api/v1/settings/recipe-search (provider, base_url, Brave key via secretbox with last4) and POST /api/v1/settings/recipe-search/test (searches 'chicken tikka masala', returns count or error). Tests with fake search/fetch/LLM cover the happy path, ungroundable dish, one failing sibling not affecting others, URL grounding, stored-recipe reuse and that the Brave key never appears in responses. Go quality floor passes.
Evidence
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>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>Independent Codex gpt-6-sol review: CHANGES_REQUIRED for this exact commit.
Independent Codex gpt-6-sol review: CHANGES_REQUIRED for this exact commit.
Independent Codex gpt-6-sol review: CHANGES_REQUIRED for this exact commit.
Independent Codex gpt-6-sol review: APPROVED for this exact commit.