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
+37 -9
View File
@@ -92,7 +92,7 @@ Columns ending in `_ct` hold AES-256-GCM ciphertext (via `secretbox`).
| Table | Holds |
| --- | --- |
| `household_settings` | One row: timezone, dinner_time (17:30), reminder_time (16:30), review_time (09:00), plan_draft day/time (Fri 09:00), suggestions day/time (Sun 08:00), chat_autosend day/time (Sat 08:00), onboarding_completed_at, updates_seen_at. |
| `recipes` | A real recipe page: source_url (unique), site_name, title, total/prep/cook minutes, yield, raw_jsonld, fetched_at. |
| `recipes` | A real recipe page: source_url (unique), site_name, title, total/prep/cook minutes (the page's JSON-LD), yield, raw_jsonld, fetched_at; and its grounding: photo_id, method, difficulty, minutes_total (normalized prep to table). |
| `recipe_ingredients` | recipe_id, position, raw_text, name, qty, unit, note. |
| `recipe_steps` | recipe_id, position, text. |
| `photos` | source_url, local_path, width, height, sha256, fetched_at. |
@@ -105,7 +105,7 @@ Columns ending in `_ct` hold AES-256-GCM ciphertext (via `secretbox`).
| `shopping_lists` | week_start (unique), plan_id, title, status (`current` / `archived`), sent_to_chat_at, frozen_at. |
| `shopping_groups` / `shopping_items` | One group per dinner/day; item name, qty, needed (bool), position. |
| `ai_settings` | One row: provider, base_url, model, temperature, system_prompt, api_key_ct, api_key_last4, key_verified_at. |
| `recipe_search_settings` | One row: provider (`searxng` / `brave`), base_url, api_key_ct. |
| `recipe_search_settings` | One row: provider (`searxng` / `brave`), base_url (the SearXNG instance), api_key_ct and api_key_last4 (Brave). |
| `integrations_google` | One row: refresh_token_ct, calendar_id, calendar_name, connected_at, last_sync_error. |
| `chat_settings` | One row: webhook_url_ct, space_label, last_test_at, last_test_status. |
| `notification_prefs` | event (`plan_ready` / `list_ready` / `day_of_reminder` / `new_suggestions` / `rotation_review`), push, chat. |
@@ -145,6 +145,13 @@ automatic** (D-021).
with a `photos` row, with
`Cache-Control: private, max-age=31536000, immutable`; anything else is
404.
- `GET`/`PATCH /api/v1/settings/recipe-search` — provider, base_url and
the Brave key (`api_key`, write-only: responses carry only
`api_key_set` and `api_key_last4`; an empty string removes it). The
result must be usable: SearXNG needs a base_url, Brave a key.
`POST /api/v1/settings/recipe-search/test` searches "chicken tikka
masala" and returns `{query, count}`, 409 `not_configured`, or 502
`search_failed` with the provider's error.
- `/health` — liveness.
- Everything else serves the embedded PWA with SPA fallback.
@@ -246,7 +253,7 @@ automatic** (D-021).
- Recipe search (`recipes.SearchProvider`: `Search(ctx, query, n)` →
`[]Candidate{URL, Title, Snippet}`, n 1–20) appends " recipe" to a query
without that word. SearXNG is called at
`{MEALQUEST_SEARXNG_URL}/search?q=…&format=json&categories=general` —
`{recipe_search_settings.base_url}/search?q=…&format=json&categories=general` —
directly, not through the Fetcher, since this configured endpoint may be
on the internal network; Brave calls the Brave Web Search API with the
key decrypted from `recipe_search_settings.api_key_ct`
@@ -280,13 +287,34 @@ automatic** (D-021).
hard over 10 steps or 90 minutes, easy at 5 steps or fewer and 45 minutes
or less, else medium, a crockpot's minutes not counting.
- Photos are cached locally (`photos`) and processed with
`golang.org/x/image`. `recipes.PhotoCache.CachePhoto` downloads through
`golang.org/x/image`. `recipes.PhotoCache.DownloadPhoto` downloads through
the Fetcher's `FetchImage` (JPEG, PNG, WebP or GIF content types, 8 MB
cap, not cached), decodes the image (a GIF's first frame), refuses a
decoded image under 300 px on its short side, scales it so its long side is 1200 px and
400 px, and stores each as JPEG quality 82 at
`$MEALQUEST_DATA_DIR/media/photos/{id}-{size}.jpg`. Images are
deduplicated by the SHA-256 of the downloaded bytes.
cap, not cached), decodes the image (a GIF's first frame) and refuses a
decoded image under 300 px on its short side, recording nothing.
`CachePhoto` then records it in the caller's transaction: it scales the
image so its long side is 1200 px and 400 px, and stores each as JPEG
quality 82 at `$MEALQUEST_DATA_DIR/media/photos/{id}-{size}.jpg`. Images
are deduplicated by the SHA-256 of the downloaded bytes; the writer's
single connection serialises transactions, so one image is never
recorded twice. Files written for a transaction that rolls back are
removed by the caller (`RemovePhotoFiles`).
- Grounding (`recipes.Grounder`) ties these together.
`Ground(ctx, dishName, query)` searches for the top 5 candidates; if one
is already a stored `source_url`, the first such is returned without
fetching anything. Otherwise it ranks them and tries each ranked recipe
best first: download its photo, run `normalize_recipe`, then store the
photo, recipe, ingredients and steps in **one transaction** (an existing
`source_url` is updated in place). It returns `Grounded{recipe_id,
photo_id, method, minutes_total, difficulty, source_url, site_name}`. A
candidate whose photo fails or whose model output is unusable
(`llm.ErrBadOutput`) is passed over; when none grounds, the error is a
typed `*recipes.ErrUngroundable` listing every reason (no candidates,
Rank's per-candidate reasons, photo or normalize failures). Failures
that are not the candidates' — search, model provider, database — are
returned as they are. `GroundURL(ctx, url)` does the same for one pasted
URL. `GroundMany` grounds items 3 at a time and returns one result per
item, so one failure never affects its siblings. A recipe is usable only
with ingredients, an image and, if given, a positive total time.
## Scheduler