8.6 KiB
LLM-Powered Recipe Import
Date: 2026-05-21 Status: Approved
Problem
The current /import route only handles recipes from URLs with structured
schema.org JSON-LD data. There is no way to import recipes from:
- Free-form text (e.g. a recipe emailed by a family member)
- PDF documents (e.g. scanned cookbook pages, saved recipe PDFs)
- Image files (e.g. a photo of a recipe card)
Many recipes exist in these unstructured formats, and manually transcribing them into Cooklang is tedious.
Goal
Add Anthropic API (or any Anthropic-compatible API) integration to convert
unstructured recipe content into Cooklang format. The existing URL scraping
path remains unchanged. All three input methods (URL, text, file upload) are
available on the same /import page.
Architecture
User submits URL / text / file on /import
↓
Server dispatches based on input type:
- URL → runImportPipeline (existing scrape-recipe)
- Text → runLLMPipeline via scripts/convert-to-cooklang --text "..."
- File → save file to recipe dir, then runLLMPipeline via --file <path>
↓
scripts/convert-to-cooklang sends content to Anthropic API
using existing cooklang-converter prompts
↓
script outputs Cooklang text on stdout
↓
Server writes .cook file (and keeps original file for source linking)
↓
Redirect to /recipes/<filename>
Config File
A YAML config file, defaulting to ./roux-config.yaml in the working
directory, overridable via --config-file CLI flag.
# roux-config.yaml
anthropic:
api_key: sk-ant-... # required for LLM features
base_url: https://api.anthropic.com # optional, defaults to https://api.anthropic.com
model: claude-sonnet-4-20250514 # optional, defaults to latest Claude Sonnet
If the config file doesn't exist or anthropic.api_key is missing, the
LLM-powered import features are unavailable (the form shows an error message)
but the URL scraper still works.
New module: Roux.Config
data RouxConfig = RouxConfig
{ rcAnthropic :: Maybe AnthropicConfig
}
data AnthropicConfig = AnthropicConfig
{ acApiKey :: Text
, acBaseUrl :: Maybe Text -- default: https://api.anthropic.com
, acModel :: Maybe Text -- default: latest Claude Sonnet
}
loadConfig :: FilePath -> IO RouxConfig
Loads and validates the YAML file. Missing file = RouxConfig Nothing.
Malformed file = runtime error at startup (fail fast).
Subprocess Script: scripts/convert-to-cooklang
Language: Python (consistent with scrape-recipe)
Interface:
scripts/convert-to-cooklang --config <path> [--file <path> | --text "<text>" | --stdin]
Exactly one input mode required.
Input modes
--text "<recipe text>"— inline recipe text--stdin— read recipe text from stdin--file <path>— PDF or image file
Behavior
- Reads the config file (YAML) for
api_key, optionalbase_url, optionalmodel. - Builds the Anthropic Messages API request:
- System prompt: contents of
prompts/system-prompt.md - User message: the Cooklang converter instructions from
prompts/cooklang-converter.mdfollowed by the recipe content - See "Prompt composition" section below for details.
- System prompt: contents of
- For
--text/--stdin: sends a{"type": "text", "text": "..."}content block - For
--file(PDF/images): reads the file, base64-encodes it, sends as:- PDF:
{"type": "document", "source": {"type": "base64", "media_type": "application/pdf", "data": "..."}} - JPEG:
{"type": "image", "source": {"type": "base64", "media_type": "image/jpeg", "data": "..."}} - PNG:
{"type": "image", "source": {"type": "base64", "media_type": "image/png", "data": "..."}}
- PDF:
- Validates the response is valid Cooklang (basic sanity: starts with
---front matter marker). On failure, exits with error on stderr. - Outputs only the Cooklang text on stdout.
- Exits with code 0 on success, non-zero on error (error details on stderr).
Prompt composition
The script reads two files relative to the project root:
prompts/system-prompt.md— set as the Anthropic Messages APIsystemparameterprompts/cooklang-converter.md— prepended to the user message as context, followed by the recipe content wrapped in the template fromprompts/user-prompt-template.md
This keeps the prompts separate from the script, making them easy to iterate on without touching code.
Dependencies
anthropic
PyYAML
Added to the Docker image or a requirements.txt.
Server-Side Changes
CLI: --config-file flag in app/Main.hs
roux-server \
--recipe-dir ./recipes \
--config-file ./roux-config.yaml
Default: ./roux-config.yaml in the working directory.
Expanded POST /import handler
The handler inspects the form submission and dispatches accordingly:
| User action | Dispatched to |
|---|---|
| Submits URL only | runImportPipeline (existing) |
| Uploads file | Saves file, then runLLMPipeline --file |
| Pastes text | runLLMPipeline --text |
| URL + file/text | File/text takes priority (URL ignored) |
New runLLMPipeline in Roux.Server
runLLMPipeline ::
FilePath -- recipe directory
-> AnthropicConfig -- API key, base_url, model
-> Maybe FilePath -- Just path = uploaded file to keep as source
-> Text -- recipe text (or empty if file)
-> IO (Either Html.ImportError FilePath)
- Write the uploaded source file to the recipe directory (for PDFs/images — linked as source from the Cooklang metadata).
- Call
scripts/convert-to-cooklangas a subprocess. - Capture stdout (Cooklang text) and stderr (error messages).
- Derive filename from the recipe title in the generated Cooklang front matter.
- Write the
.cookfile. - Return filename for redirect.
Source file linking
When a PDF or image is uploaded, the original file is saved alongside the
.cook file in the recipe directory. The Cooklang front matter includes:
---
title: ...
source: filename.pdf # relative path to the original source
---
This way, the recipe page links to the original document for reference.
Expanded import form (Html.hs)
The /import page gets three input sections:
- URL — existing text input, unchanged
- File upload —
<input type="file" accept=".pdf,image/*"> - Paste text —
<textarea>for free-form recipe text
A note at the top explains the three options. A small JS snippet clears the other inputs when one is filled (optional UX polish).
Error handling
Follows the same pattern as runImportPipeline:
| Step | Failure mode | User sees |
|---|---|---|
| Config missing | No API key configured | "LLM import is not configured. Add an API key to the config file." |
| Subprocess | Script error, API error | "Could not convert recipe: {stderr}" |
| Output validation | Output is not valid Cooklang | "The AI returned invalid Cooklang: {preview}" |
| File write | Permission denied, disk full | "Could not save recipe: {IO error}" |
Files Changed
New
| File | Purpose |
|---|---|
scripts/convert-to-cooklang |
Python script for Anthropic API conversion |
src/Roux/Config.hs |
Config file loading and types |
Modified
| File | Change |
|---|---|
app/Main.hs |
Add --config-file CLI flag |
src/Roux/Server.hs |
Thread config through app, add runLLMPipeline, expand import handler |
src/Roux/Html.hs |
Add file upload and textarea to import form |
src/Roux.hs |
Re-export Config types |
package.yaml |
Add yaml dependency |
Testing
- Unit test: Write a test recipe text to a temp file, run
convert-to-cooklangmanually, verify Cooklang output with valid front matter. - Manual: Visit
/import, paste recipe text, verify the recipe appears with correct metadata. - Manual: Upload a recipe PDF, verify the
.cookand original PDF are saved to the recipe directory. - Manual: Visit
/importwithout a config file, verify the UI shows a helpful message about LLM features being unavailable. - Manual: Enter an invalid URL, verify URL scraper still returns a useful error (regression test).
Future Considerations
- Image support — once Claude API supports images in the same Messages API
format, no script changes needed; just add
image/*to the accept attribute. Actually, Claude already supports this — so it's included from day one. - Prompt iteration — the prompts in
prompts/can be tuned independently of the code. - Multiple API providers — the
base_urlconfig makes it easy to swap providers (Ollama, Google Gemini via Anthropic-compatible proxy, etc.).