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
+61 -46
View File
@@ -19,7 +19,6 @@ import (
"path/filepath"
"slices"
"strconv"
"sync"
"github.com/oklog/ulid/v2"
"golang.org/x/image/draw"
@@ -69,16 +68,12 @@ var photoDecoders = map[string]func([]byte) (image.Image, error){
// PhotoCache downloads photos through a Fetcher and stores each at every
// PhotoSizes length as {id}-{size}.jpg under PhotoDir, with a photos row.
// Photos are deduplicated by the SHA-256 of the downloaded bytes. A
// PhotoCache is safe for concurrent use; photos are processed one at a time.
// PhotoCache is safe for concurrent use.
type PhotoCache struct {
fetcher *Fetcher
store *store.Store
// dir is the absolute photo directory.
dir string
// mu serialises the dedupe lookup, processing and insert, so one image
// is never stored twice.
mu sync.Mutex
}
// NewPhotoCache returns a PhotoCache storing photos under dataDir, creating
@@ -100,68 +95,88 @@ func NewPhotoCache(fetcher *Fetcher, st *store.Store, dataDir string) (*PhotoCac
return &PhotoCache{fetcher: fetcher, store: st, dir: dir}, nil
}
// CachePhoto downloads the image at rawURL and returns its photos row. An
// image whose bytes are already cached returns the existing row. Otherwise
// it is decoded, refused if its shorter side is under PhotoMinShortSide,
// scaled so its long side is each of PhotoSizes, encoded as JPEG and
// recorded. The row's width and height are the original image's, and its
// local_path is the largest size's file relative to the data directory.
func (c *PhotoCache) CachePhoto(ctx context.Context, rawURL string) (db.Photo, error) {
// DownloadedPhoto is an image DownloadPhoto has downloaded and decoded,
// ready for CachePhoto.
type DownloadedPhoto struct {
// url is the image's final URL, after redirects.
url string
sha256 string
img image.Image
}
// DownloadPhoto downloads the image at rawURL and decodes it, refusing one
// whose shorter side is under PhotoMinShortSide. It records nothing, so a
// caller can try another image before opening a transaction.
func (c *PhotoCache) DownloadPhoto(ctx context.Context, rawURL string) (DownloadedPhoto, error) {
page, err := c.fetcher.FetchImage(ctx, rawURL)
if err != nil {
return db.Photo{}, err
return DownloadedPhoto{}, err
}
digest := sha256.Sum256(page.Body)
sum := hex.EncodeToString(digest[:])
c.mu.Lock()
defer c.mu.Unlock()
existing, err := db.New(c.store.Reader).GetPhotoBySHA256(ctx, sum)
if err == nil {
return existing, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return db.Photo{}, fmt.Errorf("recipes: look up photo: %w", err)
}
img, err := decodePhoto(page.Body)
if err != nil {
return db.Photo{}, fmt.Errorf("%w: %s", err, page.URL)
return DownloadedPhoto{}, fmt.Errorf("%w: %s", err, page.URL)
}
digest := sha256.Sum256(page.Body)
return DownloadedPhoto{url: page.URL, sha256: hex.EncodeToString(digest[:]), img: img}, nil
}
// CachePhoto records p in tx, the caller's transaction, so its photos row
// commits or rolls back with the rows that refer to it. An image whose
// bytes are already recorded returns that row and created false. Otherwise
// p is scaled so its long side is each of PhotoSizes, encoded as JPEG and
// recorded, and created is true: if tx then does not commit, the caller
// removes the files with RemovePhotoFiles. The row's width and height are
// the original image's, and its local_path is the largest size's file
// relative to the data directory.
//
// The store's writer has one connection, so transactions run one at a
// time and an image is never recorded twice.
func (c *PhotoCache) CachePhoto(ctx context.Context, tx *sql.Tx, p DownloadedPhoto) (photo db.Photo, created bool, err error) {
q := db.New(tx)
existing, err := q.GetPhotoBySHA256(ctx, p.sha256)
if err == nil {
return existing, false, nil
}
if !errors.Is(err, sql.ErrNoRows) {
return db.Photo{}, false, fmt.Errorf("recipes: look up photo: %w", err)
}
id := store.NewID()
var written []string
removeWritten := func() error {
var errs []error
for _, p := range written {
errs = append(errs, os.Remove(p))
}
return errors.Join(errs...)
}
for _, size := range PhotoSizes {
p := c.path(id, size)
if err := writeJPEG(p, scaleLongSide(img, size)); err != nil {
return db.Photo{}, errors.Join(err, removeWritten())
if err := writeJPEG(c.path(id, size), scaleLongSide(p.img, size)); err != nil {
return db.Photo{}, false, errors.Join(err, c.RemovePhotoFiles(id))
}
written = append(written, p)
}
now := store.Now().String()
bounds := img.Bounds()
photo, err := db.New(c.store.Writer).CreatePhoto(ctx, db.CreatePhotoParams{
bounds := p.img.Bounds()
photo, err = q.CreatePhoto(ctx, db.CreatePhotoParams{
ID: id,
SourceUrl: page.URL,
SourceUrl: p.url,
LocalPath: path.Join(PhotoDir, photoFileName(id, PhotoSizes[0])),
Width: int64(bounds.Dx()),
Height: int64(bounds.Dy()),
Sha256: sum,
Sha256: p.sha256,
FetchedAt: now,
CreatedAt: now,
UpdatedAt: now,
})
if err != nil {
return db.Photo{}, errors.Join(fmt.Errorf("recipes: record photo: %w", err), removeWritten())
return db.Photo{}, false, errors.Join(fmt.Errorf("recipes: record photo: %w", err), c.RemovePhotoFiles(id))
}
return photo, nil
return photo, true, nil
}
// RemovePhotoFiles removes the files of photo id at every PhotoSizes
// length; a file already absent is not an error.
func (c *PhotoCache) RemovePhotoFiles(id string) error {
var errs []error
for _, size := range PhotoSizes {
if err := os.Remove(c.path(id, size)); err != nil && !errors.Is(err, fs.ErrNotExist) {
errs = append(errs, err)
}
}
return errors.Join(errs...)
}
// OpenPhoto opens the stored file of photo id at size. An id that is not a