Files
roux/docs/specs/2026-05-21-llm-recipe-import-design.md
T

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

  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

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:

---
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<input type="file" accept=".pdf,image/*">
  3. 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-cooklang manually, 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 .cook and original PDF are saved to the recipe directory.
  • Manual: Visit /import without 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_url config makes it easy to swap providers (Ollama, Google Gemini via Anthropic-compatible proxy, etc.).