Fancy Fox publishes one AI-assisted seasonal
recipe idea each day. What began as a linear prompt eventually became a small
state machine: gather context, generate a recipe, validate structured output,
render static pages, and promote the result.
The interesting engineering work was not the happy path. It was deciding which
steps should fail the run, which should degrade gracefully, and which browser
dependencies should be recreated on demand.
The pipeline as a state machine
Each run moves through explicit steps instead of one long function:
date + location
-> weather context
-> recent-recipe context
-> structured recipe
-> validation
-> image
-> static site build
-> optional social promotion
This structure matters because the consequences are different. A malformed
recipe should stop publication. A missing optional promotion should not erase a
valid recipe. A browser failure should report that promotion failed rather than
claiming success.
Validate at the boundary
Model output is untrusted input. The pipeline parses it into a Pydantic model
before it reaches templates or storage:
class RecipeIngredient(BaseModel):
short_name: str
full_name: str
quantity: str
prep: str | None = None
class Recipe(BaseModel):
id: str
name: str
description: str
recipeCategory: str
recipeCuisine: str
ingredients: list[RecipeIngredient]
prepTime: int
cookTime: int
totalTime: int
Even small placeholder values deserve normalization. A generated string such as
"none" is not equivalent to Python's None, and leaving it untouched can
leak nonsense into rendered recipe text or structured data.
EMPTY_PREP_VALUES = {"-", "n/a", "none", "null", "not applicable"}
@validator("prep", pre=True)
def normalize_empty_prep(cls, value):
if isinstance(value, str) and value.strip().casefold() in EMPTY_PREP_VALUES:
return None
return value
The principle is broader than recipes: normalize at the boundary, then let the
rest of the program work with one representation.
Let optional work return honestly
Our promotion flow once treated “there is no special recipe today” as an error.
That made a normal condition look like an outage. The simplest fix was to let
the step return silently when no eligible item exists.
The opposite bug was more damaging: a promotion path could log “promoted” even
after the browser action failed. We changed the contract so the caller only
announces success when the platform-specific function returns a positive result.
posted = await promote_recipe(recipe)
if posted:
logger.info("Promoted %s", recipe.id)
else:
logger.warning("Promotion skipped or failed for %s", recipe.id)
Side effects should return enough information for their caller to tell the
truth.
Treat browser state as disposable
Social platforms occasionally require a real browser session. Assuming that a
human will keep Chromium open indefinitely is fragile, especially on a personal
workstation.
Our browser manager now follows a simple lifecycle:
- Connect to an existing controlled browser if it is healthy.
- Otherwise launch a fresh instance with the saved application profile.
- Open the required platform and run the action.
- Close resources owned by the run.
That turns “the browser was closed” from a mysterious production failure into a
normal startup condition.
Make operational logs survive the terminal
Uvicorn output is useful until the terminal scrollback disappears. We attach a
rotating file handler during app startup so application logs, tracebacks, and
server logs share one local diagnostic stream:
handler = RotatingFileHandler(
"logs/fancy_fox.log",
maxBytes=10 * 1024 * 1024,
backupCount=1,
)
Ten megabytes plus one backup is enough to investigate recent runs without
allowing a daily service to fill the disk.
Static output still benefits from an application server
FastAPI is the development and orchestration surface, while the public website
is rendered as static HTML. That gives recipe pages stable URLs, complete
metadata, crawlable links, and very little runtime work for a visitor.
The public archive now contains more than 1,500 pages, but the sitemap remains a
curated set of 50 URLs. Public availability, indexability, and active promotion
are separate decisions.
What we would do next
The generation pipeline is no longer the main constraint. Distribution and
feedback are. The next useful loop connects a recipe impression to a save,
return visit, or app open, then uses that signal to improve the featured set.
You can inspect the public result at Fancy Fox or
save recipes in the iOS
app.
We also published a CC0 sample of 30 recipes and matching images on
GitHub for
developers and food-data researchers.
Disclosure: This article is from the team building Fancy Fox. Recipe concepts
and imagery on the service are AI-assisted.
Top comments (0)