Jump to content

Digest

From Yusupov's House
Infobox
nameDigest
urlhttps://digest.yusupov.cloud
developerMichel Vuijlsteke
released2025
genreAI-generated recipe application
languagePython
frameworkDjango 5.2
licenseProprietary

Digest is a web application hosted at digest.yusupov.cloud that generates and publishes AI-created recipes inspired by current news articles. Each day, the system fetches articles from the New York Times RSS feed, selects two at random, and uses them as creative prompts for a large language model to invent a realistic, seasonally appropriate recipe. A companion image-generation pipeline produces editorial-quality food photography for each recipe using a composition diversity system that enforces visual variety across generations. The site's tagline is "Daily recipes inspired by the news."

Technology stack

The application is built on Django 5.2 with SQLite as its database.[1] It is deployed behind Nginx with Gunicorn on an Ubuntu server running two synchronous workers on port 8001. Additional dependencies include Pillow for image processing and optimisation, the OpenAI Python client for language-model and image-generation calls, Beautiful Soup for scraping recipe inspiration from external sites, python-markdown for rendering recipe inspiration text, and python-dotenv for environment configuration. The front end uses Bootstrap 5.3.3 with Bootstrap Icons, and typography is set in Roboto (body), Roboto Serif (headings), and Roboto Slab (accents), loaded from Google Fonts.

Data model

Recipe

The central model is Recipe, which stores:

  • name, slug (auto-generated), intro (short paragraph), inspiration (120–200-word Markdown text linking to the source articles), and visual_description (a single sentence describing the plated dish for use as an image-generation prompt).
  • hero_image (uploaded to recipes/hero/), base_servings (default 4), prep_time_minutes, cook_time_minutes, is_featured (boolean), and automatic created_at / updated_at timestamps.
  • A many-to-many relationship to RecipeType (one of ten canonical types: Main, Salad, One-pan, Starter, Cold dish, Soup, Side dish, Dessert, Breakfast, or Snack).

Structured ingredients

Ingredients are organised into optional titled groups (e.g. "Dough", "Sauce") via IngredientGroup. Each Ingredient within a group has:

  • A foreign key to IngredientName (a canonical master list of deduplicated ingredient names, each with a slug and an optional foreign key to IngredientCategory).
  • A quantity (decimal), a foreign key to Unit (with name_singular and name_plural, e.g. "cup"/"cups", "clove"/"cloves", "g"/"g"), an optional note (e.g. "finely diced", "zested then juiced"), and an order field.

² IngredientCategory supports arbitrary hierarchy via a self-referencing parent foreign key, enabling structures such as Fruit → Citrus → Lemon.

Instructions

Recipe steps are stored as Direction rows, optionally grouped into titled InstructionGroup sections. Both models carry an order field for sequencing.

Image prompt history

Every AI image generation attempt is recorded in RecipeImagePrompt, which stores the full prompt text, the visual description, five composition dimension values (camera angle, framing, setting, lighting style, mood), a was_centered boolean, and a creation timestamp. This history is queried by the composition diversity system to enforce variety across successive hero images.

Recipe generation pipeline

Recipe generation is driven by Django management commands and a service layer in recipes/services/.

Inspiration sourcing

Two management commands gather external inspiration:

  • fetch_nyt_inspiration downloads the current New York Times front page image (saved to static/img/nyt/) and fetches articles from the NYT homepage RSS feed. It also manages a weighted variation-rotation state file (data/nyt_variation_state.json) that tracks which recipe types, cooking techniques, proteins, bases, and connection styles have been used recently.
  • fetch_recipe_inspiration scrapes the NYT Cooking and Dagelijkse Kost (VRT) websites for reference recipes, extracting titles, descriptions, and ingredient lists from embedded JSON-LD Recipe markup. Results are written to data/inspiration.json.

A PowerShell script (scripts/schedule_inspiration_refresh.ps1) automates the inspiration refresh on a three-day cycle via Windows Task Scheduler.

Variation rotation

To prevent repetitive output, the system maintains weighted "draw bags" for five dimensions of recipe variety:

Dimension Pool size Examples
Recipe type 10 Main (weight 7), One-pan (3), Soup (3), Salad (2), …
Technique 13 pan-searing (3), roasting (3), stir-frying (2), sous vide (2), …
Protein 9 chicken (3), beef (3), fish (2), shellfish (2), mushrooms (2), …
Base 8 rice (3), pasta (3), potatoes (3), flatbread (2), noodles (2), …
Connection style 10 geographical (10), seasonal (4), historical (3), narrative (3), …

Each dimension uses a weighted bag: items are added with multiplicity proportional to their weight, and one is drawn per run without replacement. When the bag empties it is refilled. Immediate repeats of the same pick are avoided when alternatives exist. State is persisted to JSON between runs.

Prompt construction

The gen_recipe_from_nyt command orchestrates the full pipeline:

  1. Two articles are selected from the RSS feed, filtered against a usage log (data/inspiration_usage.json) to avoid reuse.
  2. Variation picks are drawn for recipe type, technique, protein, base, and connection style.
  3. A structured prompt is assembled, instructing the model to act as a professional recipe developer, invent one realistic recipe that a home cook in Belgium can make using seasonally appropriate ingredients, stay within 75 minutes total time, and use the drawn picks. The prompt includes both article summaries and one extra recipe reference from the inspiration pool (with a 2:1 preference for VRT over NYT sources).
  4. An ingredient diversity constraint is injected: the system queries the last seven recipes' ingredients and flags any non-staple ingredient that appears in three or more of them. Flagged ingredients are added to the prompt as a hard exclusion list ("MUST NOT be used in this recipe"). A curated set of approximately 40 pantry staples — salt, oil, butter, garlic, onion, flour, water, lemon, parsley, and similar basics — is exempt from this check. The constraint is self-correcting: once a previously overused ingredient drops below the threshold it is automatically unblocked.
  5. The prompt is appended with strict JSON schema instructions specifying the output format: name, intro, inspiration, visual description, servings, times, typed ingredient groups with quantities and units, titled instruction groups, and a recipe type from the canonical list.

Text generation

The OpenAIRecipeGenerator service class sends the prompt to the OpenAI Responses API. The default model is GPT-5, configurable via environment variable. The system prompt instructs the model to behave as a seasoned chef with Michelin-starred and home-cooking experience, and enforces detailed culinary realism rules: precise quantities ("325g not 300g"), specified heat levels and sensory cues, timing ranges, prep details in ingredient notes, assertive seasoning with salt quantities, and instructions that read like a tested recipe.[2]

The API call requests JSON object output format. If the model or SDK does not support the text format parameter, the call falls back to unformatted output. Similarly, if the model does not support the temperature parameter (as is the case with GPT-5), the call automatically retries without it.

Validation

After generation, the recipe JSON is passed through a self-critique step: a second, cheaper API call instructs the model to review the recipe for culinary realism — checking that quantities make sense for the serving count, that cook times are realistic for the described technique, that no ingredients mentioned in the instructions are missing from the ingredient list, and that prep notes match the steps. The model returns a corrected JSON object or the original if no issues are found. If the critique call fails, the original is used unchanged.

Inspiration rewriting

After generation, the pipeline inspects the inspiration field. If it is shorter than 120 words, contains the forbidden words "article" or "news," or lacks Markdown anchor links to both source articles, a rewrite call asks the model to produce a 120–200-word vivid text weaving concrete ideas from both sources, ending with properly formatted attribution links.

Persistence

The save_recipe_from_data function maps the generated JSON to Django models within a single transaction:

  • Creates the Recipe row with all scalar fields.
  • Resolves recipe types against the canonical allowed list (by slug); creates missing RecipeType rows only for canonical names.
  • Creates IngredientGroup, Ingredient, IngredientName (get-or-create by slug), and Unit (get-or-create by singular name) rows.
  • Normalises instruction groups: merges untitled single-step groups into one, handles multiple JSON shapes (list of strings, list of dicts with step or text keys, plain text blobs), and filters artifacts shorter than three characters.
  • Triggers hero image generation (non-fatal by default; controlled by the OPENAI_IMAGE_REQUIRED setting).

Text sanitisation

All generated text fields are passed through a sanitisation function before database persistence. This addresses a known issue where certain language models (notably GPT-5) emit ASCII control characters in place of Unicode punctuation — for example, U+0019 followed by a digit where an en-dash should appear, or U+0019 followed by s where a right single quotation mark is intended. The sanitisation function applies pattern-based corrections for these control character sequences, normalises Unicode dashes and quotation marks to their ASCII equivalents, and strips any remaining C0 control characters. The function is applied to the recipe name, intro, inspiration, visual description, all ingredient names and notes, and all instruction steps and group titles.

Hero image generation

Image generation is a three-layer system: a low-level OpenAI client, a composition diversity engine, and a high-level orchestrator.

Image API client

OpenAIImageGenerator calls the OpenAI image generation endpoint (model gpt-image-1.5 by default, configurable via OPENAI_IMAGE_MODEL) and returns raw PNG bytes decoded from the base64 API response. The default output size is 1536×1024 (landscape), matching typical food photography framing.[3]

Composition diversity

The FoodPhotoPromptGenerator enforces visual variety across successive hero images by selecting compositions from the Cartesian product of five dimensions:

Dimension Count Examples
Camera angle 8 overhead flat lay, 45° oblique, eye-level, low side, macro detail, handheld documentary, three-quarter view, tilted dutch angle
Framing 11 wide with negative space, tight crop, off-center, partial out-of-frame, action/utensil-in-motion, ingredient close-up, shallow depth-of-field, symmetrical centered, diagonal composition, …
Setting 11 white studio, stainless pro kitchen, home counter, picnic/outdoor, dark restaurant table, marble surface, wooden cutting board, concrete backdrop, vintage tile, slate serving board, …
Lighting 12 hard sun, soft window, dramatic low-key, warm tungsten, backlit steam, golden hour, bounce fill, rim lighting, diffused overhead, candlelight, …
Mood 12 modern editorial, rustic farmhouse, minimalist, street-food, celebratory, cozy homestyle, vibrant colorful, moody atmospheric, fresh healthy, indulgent decadent, …

This yields approximately 13,000 possible combinations. The selection algorithm:

  1. Generates all combinations and shuffles them using a cryptographically secure random source (secrets.SystemRandom).
  2. Filters candidates against: angle+setting pairs used in the last 30 days (banned), recent compositions within the history window (must differ in at least 3 of 5 dimensions), and a centering constraint (no centered composition if the previous image was centered).
  3. Falls back to progressively relaxed criteria if no perfect match exists.

Prompt construction

The selected composition is assembled into a detailed image prompt that specifies:

  • The dish name and a one-sentence visual description.
  • A style directive requesting "high-end editorial food photography for a cookbook or food magazine," with natural imperfections — "slight char marks, a drip of sauce, steam rising, herbs slightly wilted from heat."
  • The five composition dimensions.
  • Food styling details: realistic portion sizes, contextual props (linen napkin, scattered herbs, olive oil drizzle), visible textures (crispy skin, glossy glaze, charred edges), and a natural, non-oversaturated colour palette.
  • Hard constraints: photorealistic only (no illustrations), no text or watermarks, no human faces or hands, and avoidance of rustic wood unless specified.

Persistence and attachment

Each generation creates a RecipeImagePrompt record storing all composition metadata. The generated PNG is attached to the recipe's hero_image field via Django's file storage. On-demand regeneration is available through the upload-image view, which also accepts manual image uploads (validated for JPEG, PNG, or WebP content type, maximum 20 MB, downscaled to 2048px and converted to JPEG at 85% quality).

Public interface

Home page

The home page displays a featured recipe (hero image with title, intro, and call-to-action) and a grid of the seven most recent recipes as cards with 1:1 hero images. The layout is responsive: seven columns on desktop, three on tablet, two on mobile.

Recipe detail

Each recipe page (/r/<slug>/) features:

  • A hero section with the image (left, 1:1 aspect ratio, rounded shadow) and title, intro, recipe type badges (linked to type pages), and time estimates (prep, cook, total) with ingredient count.
  • A sticky sidebar with the ingredient list, organised by groups, with a servings control (−/+ buttons and direct input). Ingredient quantities are scaled client-side using the formula scaled_qty = base_qty × (servings / base_servings), with unit pluralisation logic handling irregular forms (cup/cups, clove/cloves).
  • Numbered instructions below, optionally grouped by titled sections.
  • For authenticated users: edit, delete, and regenerate-image controls, plus a collapsible display of the raw image generation prompt.

The page emits Schema.org Recipe JSON-LD markup (name, image, prep/cook times in ISO 8601 duration format, yield, ingredient list, and HowToStep instructions), Open Graph meta tags, and a canonical URL.

Browsing

The site provides several browsing views, all with file-based caching:

  • Meals (/types/): lists recipe types with the latest example recipe for each, linking to paginated type detail pages (12 recipes per page).
  • Ingredients (/ingredients/): a master ingredient index with recipe usage counts, linking to paginated ingredient detail pages (12 recipes per page).
  • Categories (/categories/): a hierarchical ingredient category tree with subcategories and ingredient counts, linking to paginated category detail pages (24 items per page).
  • Calendar (/year/): a year-view calendar displaying all twelve months in a responsive grid. Days on which at least one recipe was published (across all years) are rendered as hyperlinks to a day detail page (/year/MM/DD/), which lists all recipes for that month-and-day combination regardless of year, using the same card layout and pagination as the type detail pages.
  • About (/about/): a static information page.

Theme

The site supports light and dark colour themes, toggled via a moon/sun icon button in the navigation bar and persisted in localStorage. Dark mode uses a #121212 background, #e6e6e6 text, and yellow (rgb(255, 193, 7)) accent links, with high-contrast card and list-group styling.

Administration

The Django admin interface is customised with:

  • RecipeAdmin: list display with name, servings, times, creation date, and featured flag; search across name, intro, and inspiration; inline editing of instruction groups and directions; horizontal filter for recipe types.
  • IngredientNameAdmin: list with category and recipe count; bulk actions for assigning or removing categories, and for merging duplicate ingredient names (which transfers all ingredient references to the merge target).
  • IngredientCategoryAdmin: hierarchical display with direct and recursive ingredient counts; inline subcategory editing; a custom overview view showing category statistics; and an action for moving ingredients between subcategories.

Custom admin templates provide forms for category assignment (assign_category.html), category overview (category_overview.html), ingredient merging (merge_ingredients.html), and ingredient moving (move_ingredients.html).

SEO and caching

Four sitemap classes are registered at /sitemap.xml:

Sitemap Items Priority Frequency
Recipes All recipes 0.9 daily
Recipe types All types 0.6 weekly
Ingredients All ingredient names 0.5 weekly
Static pages home, about, types index, ingredients index 0.4 weekly

A robots.txt view allows all crawlers and includes the sitemap URL. Django's file-based cache is used throughout, with cache timeouts ranging from 5 minutes (recipe detail for anonymous users) to 1 hour (ingredient and category indexes). Cache is invalidated wholesale via post_save, post_delete, and m2m_changed signals on the Recipe model.

Deployment

The application is deployed on an Ubuntu VPS behind Nginx, which serves static files and media directly and proxies application requests to Gunicorn on port 8001. The Gunicorn configuration specifies two synchronous workers, a 180-second request timeout (to accommodate long-running OpenAI image generation calls), and request-count-based worker recycling (1000 requests with ±100 jitter). Nginx is configured with proxy_read_timeout 300s for the same reason. TLS is provided by Let's Encrypt via Certbot.

Middleware

A custom ClacksOverheadMiddleware adds the X-Clacks-Overhead: GNU Terry Pratchett header to all responses, as a tribute to Terry Pratchett.

Management commands

Command Purpose
gen_recipe Generate a single recipe from a free-text topic
gen_recipe_from_file Create a recipe from a JSON file matching the output schema
gen_recipe_from_nyt Full pipeline: fetch NYT articles, build prompt with variation rotation, generate recipe, optionally save to database
fix_garbled_text One-time cleanup of control-character artifacts in existing recipes
fetch_nyt_inspiration Download NYT front page and fetch RSS articles; manage variation state
fetch_recipe_inspiration Scrape NYT Cooking and Dagelijkse Kost for reference recipes
create_ingredient_categories Seed the hierarchical ingredient category tree
auto_categorize_ingredients Pattern-matching assignment of categories to uncategorised ingredients
find_duplicate_ingredients Detect and merge duplicate ingredient names
normalize_instructions Post-migration cleanup for instruction group schema
seed_recipes Populate the database with sample recipes

See also

References

  1. requirements.txt in the project repository lists Django ≥5.2, Pillow ≥10, openai ≥1.40, python-dotenv ≥1.0, gunicorn ≥21, requests ≥2.31, beautifulsoup4 ≥4.12, and markdown ≥3.5.
  2. The system prompt in recipes/services/openai_recipes.py specifies: "You are a seasoned chef and food editor with 20 years of Michelin-starred and home-cooking experience."
  3. The default size is set in recipes/services/openai_images.py and can be overridden via the OPENAI_IMAGE_SIZE setting.