# 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 ↓ 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/ ``` ## Config File A YAML config file, defaulting to `./roux-config.yaml` in the working directory, overridable via `--config-file` CLI flag. ```yaml # 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` ```haskell 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 [--file | --text "" | --stdin] ``` Exactly one input mode required. ### Input modes - `--text ""` — inline recipe text - `--stdin` — read recipe text from stdin - `--file ` — PDF or image file ### Behavior 1. Reads the config file (YAML) for `api_key`, optional `base_url`, optional `model`. 2. Builds the Anthropic Messages API request: - System prompt: contents of `prompts/system-prompt.md` - User message: the Cooklang converter instructions from `prompts/cooklang-converter.md` followed by the recipe content - See "Prompt composition" section below for details. 3. For `--text`/`--stdin`: sends a `{"type": "text", "text": "..."}` content block 4. 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": "..."}}` 5. Validates the response is valid Cooklang (basic sanity: starts with `---` front matter marker). On failure, exits with error on stderr. 6. Outputs **only** the Cooklang text on stdout. 7. 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 API `system` parameter - **`prompts/cooklang-converter.md`** — prepended to the user message as context, followed by the recipe content wrapped in the template from `prompts/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` ```haskell 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) ``` 1. Write the uploaded source file to the recipe directory (for PDFs/images — linked as source from the Cooklang metadata). 2. Call `scripts/convert-to-cooklang` as a subprocess. 3. Capture stdout (Cooklang text) and stderr (error messages). 4. Derive filename from the recipe title in the generated Cooklang front matter. 5. Write the `.cook` file. 6. 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: ```yaml --- 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: 1. **URL** — existing text input, unchanged 2. **File upload** — `` 3. **Paste text** — `