34 KiB
LLM-Powered Recipe Import Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Add an Anthropic API-powered recipe import path to /import that converts free-form text, PDFs, and images into Cooklang, alongside the existing URL scraper.
Architecture: A new Python subprocess script (scripts/convert-to-cooklang) calls the Anthropic Messages API using the existing Cooklang converter prompts. The server dispatches based on input type (URL → existing scraper, text/file → new script). A YAML config file holds API credentials.
Tech Stack: Python (anthropic SDK), Haskell (wai-extra for multipart parsing, yaml for config), existing Cooklang converter prompts.
File Map
| File | Action | Purpose |
|---|---|---|
scripts/convert-to-cooklang |
Create | Python script calling Anthropic API |
src/Roux/Config.hs |
Create | Config file types and loading |
app/Main.hs |
Modify | Add --config-file CLI flag |
src/Roux/Server.hs |
Modify | Thread config through app, add runLLMPipeline, expand import handler |
src/Roux/Html.hs |
Modify | Add file upload and textarea to import form, switch to multipart form |
src/Roux.hs |
Modify | Re-export Config types |
package.yaml |
Modify | Add yaml and wai-extra dependencies |
Task 1: Create src/Roux/Config.hs — config types and loading
Files:
- Create:
src/Roux/Config.hs
This module defines the config file types and a loader. We use the yaml package to parse YAML into Aeson's Value, then decode with Fleece (the project's existing JSON library) for consistency.
To distinguish "file not found" from "parse error", check doesFileExist first before attempting to decode.
- Step 1: Write the module
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE OverloadedStrings #-}
{- | Configuration for Roux, loaded from a YAML config file.
Default location: ./roux-config.yaml in the working directory.
Overridable via --config-file CLI flag.
-}
module Roux.Config (
RouxConfig (..),
AnthropicConfig (..),
loadConfig,
) where
import Data.Text (Text)
import qualified Data.Text as T
import Data.Yaml (decodeFileEither)
import System.Directory (doesFileExist)
import qualified Fleece.Aeson as Fleece
import qualified Fleece.Aeson.Decoder as FleeceD
import qualified Fleece.Core as FC
-- | Top-level config.
data RouxConfig = RouxConfig
{ rcAnthropic :: Maybe AnthropicConfig
}
deriving stock (Eq, Show)
-- | Anthropic API configuration.
data AnthropicConfig = AnthropicConfig
{ acApiKey :: !Text
, acBaseUrl :: !(Maybe Text) -- default: https://api.anthropic.com
, acModel :: !(Maybe Text) -- default: claude-sonnet-4-20250514
}
deriving stock (Eq, Show)
-- | Fleece schema for AnthropicConfig.
anthropicConfigSchema :: FC.Schema Fleece.Decoder AnthropicConfig
anthropicConfigSchema =
FC.record (FC.unqualifiedName "AnthropicConfig")
( FC.setRequired "api_key" (FC.projection FC.string)
( \f r -> f (acApiKey r)
)
>>. FC.setOptional "base_url" (FC.projection FC.string)
( \f r -> f (acBaseUrl r)
)
>>. FC.setOptional "model" (FC.projection FC.string)
( \f r -> f (acModel r)
)
)
-- | Fleece schema for RouxConfig.
rouxConfigSchema :: FC.Schema Fleece.Decoder RouxConfig
rouxConfigSchema =
FC.record (FC.unqualifiedName "RouxConfig")
( FC.setOptional "anthropic" anthropicConfigSchema
( \f r -> f (rcAnthropic r)
)
)
-- | Load config from a YAML file. Returns Nothing (not an error) if the
-- file doesn't exist — LLM features will be unavailable but the server
-- still runs. Exits with an error if the file exists but is malformed.
loadConfig :: FilePath -> IO RouxConfig
loadConfig path = do
exists <- doesFileExist path
if not exists
then pure (RouxConfig Nothing)
else do
result <- decodeFileEither path
case result of
Left parseErr ->
fail $ "Failed to parse " <> path <> ": " <> show parseErr
Right val ->
case FleeceD.fromValue rouxConfigSchema val of
Right cfg -> pure cfg
Left decodeErr ->
fail $
"Invalid config format in " <> path <> ": " <> show decodeErr
- [ ] **Step 2: Build to verify compilation**
Run: `./hs stack build`
Expected: compiles without errors.
- [ ] **Step 3: Run tests to verify nothing broke**
Run: `./hs stack test`
Expected: all tests pass.
- [ ] **Step 4: Commit**
```bash
git add src/Roux/Config.hs
git commit -m "feat: add Config module for YAML config loading"
Task 2: Create scripts/convert-to-cooklang — Python subprocess
Files:
- Create:
scripts/convert-to-cooklang
This script calls the Anthropic Messages API with the existing Cooklang converter prompts to convert unstructured recipe content into Cooklang format.
- Step 1: Write the Python script
#!/usr/bin/env python3
"""Convert unstructured recipe content to Cooklang format using an
Anthropic-compatible API (Messages API).
Accepts text input (--text or --stdin) or file input (--file for PDF/images).
Reads API configuration from a YAML config file (--config).
Outputs the Cooklang text on stdout.
Exits 0 on success, non-zero with error details on stderr on failure.
Usage:
scripts/convert-to-cooklang --config roux-config.yaml --text "Recipe text..."
scripts/convert-to-cooklang --config roux-config.yaml --stdin < recipe.txt
scripts/convert-to-cooklang --config roux-config.yaml --file recipe.pdf
"""
import argparse
import base64
import json
import mimetypes
import os
import sys
import yaml
from anthropic import Anthropic
# -- Config -------------------------------------------------------------------
def load_config(path: str) -> dict:
with open(path) as f:
cfg = yaml.safe_load(f)
anthropic_cfg = cfg.get("anthropic", {})
if not anthropic_cfg.get("api_key"):
print("Error: anthropic.api_key is required in config", file=sys.stderr)
sys.exit(1)
return anthropic_cfg
# -- Prompt loading -----------------------------------------------------------
def load_prompts() -> tuple[str, str, str]:
"""Load system prompt, converter instructions, and user template.
Resolved relative to the script's parent directory (project root).
"""
script_dir = os.path.dirname(os.path.abspath(__file__))
project_root = os.path.dirname(script_dir) # scripts/ -> project root
prompts_dir = os.path.join(project_root, "prompts")
def read(name: str) -> str:
with open(os.path.join(prompts_dir, name)) as f:
return f.read()
return read("system-prompt.md"), read("cooklang-converter.md"), read("user-prompt-template.md")
# -- Content preparation ------------------------------------------------------
def build_text_content(text: str) -> list[dict]:
return [{"type": "text", "text": text}]
def build_file_content(filepath: str) -> list[dict]:
"""Prepare a file (PDF or image) for the Anthropic Messages API.
Returns a list with one document/image content block.
"""
mime_type, _ = mimetypes.guess_type(filepath)
if mime_type is None:
# Default to octet-stream; let the API decide
mime_type = "application/octet-stream"
with open(filepath, "rb") as f:
data = base64.b64encode(f.read()).decode("utf-8")
is_pdf = mime_type == "application/pdf"
block_type = "document" if is_pdf else "image"
return [
{
"type": block_type,
"source": {
"type": "base64",
"media_type": mime_type,
"data": data,
},
}
]
# -- API call -----------------------------------------------------------------
def cooklang_from_anthropic(
api_key: str,
base_url: str | None,
model: str | None,
system_prompt: str,
user_message_content: list[dict],
) -> str:
"""Send the recipe content to the Anthropic API and return Cooklang text."""
client = Anthropic(api_key=api_key, base_url=base_url) if base_url else Anthropic(api_key=api_key)
resolved_model = model or "claude-sonnet-4-20250514"
response = client.messages.create(
model=resolved_model,
max_tokens=4096,
system=[{"type": "text", "text": system_prompt}],
messages=[
{
"role": "user",
"content": user_message_content,
}
],
)
# Extract text from response content blocks
parts: list[str] = []
for block in response.content:
if block.type == "text":
parts.append(block.text)
cooklang = "\n".join(parts).strip()
# Basic validation: must start with front matter marker
if not cooklang.startswith("---"):
print(
"Error: AI response does not appear to be valid Cooklang "
"(missing front matter). Response preview: "
+ cooklang[:200],
file=sys.stderr,
)
sys.exit(1)
return cooklang
# -- Main ---------------------------------------------------------------------
def main() -> None:
parser = argparse.ArgumentParser(
description="Convert unstructured recipe content to Cooklang using an Anthropic-compatible API."
)
parser.add_argument(
"--config",
required=True,
help="Path to YAML config file (must contain anthropic.api_key)",
)
input_group = parser.add_mutually_exclusive_group(required=True)
input_group.add_argument("--text", help="Inline recipe text")
input_group.add_argument("--stdin", action="store_true", help="Read recipe text from stdin")
input_group.add_argument("--file", help="Path to recipe PDF or image file")
args = parser.parse_args()
# Load config
config = load_config(args.config)
api_key = config["api_key"]
base_url = config.get("base_url")
model = config.get("model")
# Load prompts
system_prompt, converter_instructions, user_template = load_prompts()
# Prepare user message content
if args.file:
# File input (PDF or image): send file content + converter instructions
file_content = build_file_content(args.file)
user_message_content = (
[{"type": "text", "text": converter_instructions}]
+ file_content
)
elif args.stdin:
raw_text = sys.stdin.read()
user_message_content = build_text_content(
user_template.replace("{{FULL_RECIPE_TEXT}}", raw_text)
.replace("{{TITLE}}", "Untitled")
.replace("{{URL}}", "")
)
else:
# --text
user_message_content = build_text_content(
user_template.replace("{{FULL_RECIPE_TEXT}}", args.text)
.replace("{{TITLE}}", "Untitled")
.replace("{{URL}}", "")
)
# Call API
try:
cooklang = cooklang_from_anthropic(
api_key=api_key,
base_url=base_url,
model=model,
system_prompt=system_prompt,
user_message_content=user_message_content,
)
except Exception as e:
print(f"Error calling Anthropic API: {e}", file=sys.stderr)
sys.exit(1)
# Output Cooklang text
print(cooklang)
if __name__ == "__main__":
main()
- Step 2: Make the script executable
chmod +x scripts/convert-to-cooklang
- Step 3: Test the script manually with text input
Create a test config file and a test recipe:
cd /work/personal/roux/roux-main
cat > /tmp/test-config.yaml << 'EOF'
anthropic:
api_key: "test-key-placeholder"
base_url: "https://api.anthropic.com"
model: "claude-sonnet-4-20250514"
EOF
echo "Testing syntax only (no real API call)" && python3 -c "import yaml; from pathlib import Path; Path('scripts/convert-to-cooklang').stat()"
Expected: Python imports the YAML module successfully, script exists and is executable.
- Step 4: Commit
git add scripts/convert-to-cooklang
git commit -m "feat: add convert-to-cooklang script for Anthropic API recipe conversion"
Task 3: Add --config-file CLI flag to app/Main.hs
Files:
-
Modify:
app/Main.hs -
Step 1: Add the config-file option
Update the Options type and parser:
data Options = Options
{ optHost :: String
, optPort :: Int
, optRecipeDir :: FilePath
, optConfigFile :: FilePath -- NEW
}
optionsParser :: Opts.Parser Options
optionsParser =
Options
<$> Opts.strOption
( Opts.long "host"
<> Opts.metavar "HOST"
<> Opts.help "Listen host"
<> Opts.value "0.0.0.0"
<> Opts.showDefault
)
<*> Opts.option
Opts.auto
( Opts.long "port"
<> Opts.short 'p'
<> Opts.metavar "PORT"
<> Opts.help "Listen port"
<> Opts.value 8080
<> Opts.showDefault
)
<*> Opts.strOption
( Opts.long "recipe-dir"
<> Opts.short 'r'
<> Opts.metavar "DIR"
<> Opts.help "Path to directory containing Cooklang recipe files"
<> Opts.value "recipes"
<> Opts.showDefault
)
<*> Opts.strOption -- NEW
( Opts.long "config-file" -- NEW
<> Opts.metavar "FILE" -- NEW
<> Opts.help "Path to YAML config file (default: ./roux-config.yaml)" -- NEW
<> Opts.value "roux-config.yaml" -- NEW
<> Opts.showDefault -- NEW
)
- Step 2: Load config and pass to
Roux.app
Change the main function to load the config and pass it to app:
main :: IO ()
main = do
hSetBuffering stdout NoBuffering
hSetBuffering stderr NoBuffering
opts <-
Opts.execParser $
Opts.info
(optionsParser Opts.<**> Opts.helper)
( Opts.fullDesc
<> Opts.progDesc "Roux recipe management server"
<> Opts.header "roux-server — personal/family recipe manager"
)
let settings =
Warp.setPort (optPort opts) $
Warp.setHost
(fromString (optHost opts))
Warp.defaultSettings
putStrLn $ "[roux] scanning recipes from " <> optRecipeDir opts
putStrLn $ "[roux] listening on " <> optHost opts <> ":" <> show (optPort opts)
-- Load config (may be missing — LLM features disabled gracefully)
config <- Roux.loadConfig (optConfigFile opts) -- NEW
waiApp <- Roux.app (optRecipeDir opts) config -- MODIFIED: pass config
Warp.runSettings settings waiApp
- Step 3: Build to verify compilation
Run: ./hs stack build
Expected: compiles without errors.
- Step 4: Run tests to verify nothing broke
Run: ./hs stack test
Expected: all tests pass.
- Step 5: Commit
git add app/Main.hs
git commit -m "feat: add --config-file CLI flag for YAML config"
Task 4: Thread config through Server.hs and add runLLMPipeline
Files:
-
Modify:
src/Roux/Server.hs -
Step 1: Add imports and update
appsignature
Add new imports at the top:
import Data.ByteString.Lazy (ByteString)
import qualified Data.ByteString.Lazy as LB
import qualified Data.Text.Encoding as TE
import qualified Roux.Config as Config
import Roux.Config (RouxConfig (..), AnthropicConfig (..))
import System.Directory (createDirectoryIfMissing)
Change the app function signature to accept RouxConfig:
app :: FilePath -> RouxConfig -> IO Wai.Application
app recipeDir config = do
-- ... existing startup code stays the same ...
pure (router absDir config state changeLog) -- MODIFIED: pass config
Update the router to accept and use config:
router :: FilePath -> RouxConfig -> IORef (Map FilePath Idx.RecipeInfo) -> ChangeLog -> Wai.Application
router recipeDir config state changeLog request respond =
case Wai.pathInfo request of
["events"] -> sseHandler changeLog request respond
["import"] -> importHandler recipeDir config state request respond
_ -> handleRequest state request respond
- Step 2: Update
importHandlerto handle file uploads via base64 fields
The form sends file data as base64-encoded hidden fields (set by client-side JS). The existing parseFormBody handles URL-encoded form data. We add file detection by looking for file-b64 and file-name parameters.
Replace the existing importHandler with the expanded version:
{- | Handle GET and POST requests to /import.
GET — show the import form.
POST — dispatch based on input: URL (existing scraper), text, or file (Anthropic).
Files are uploaded as base64-encoded content via hidden form fields
(file-b64, file-name, file-mime) set by client-side JavaScript.
-}
importHandler :: FilePath -> RouxConfig -> IORef (Map FilePath Idx.RecipeInfo) -> Wai.Application
importHandler recipeDir config _state request respond =
case Wai.requestMethod request of
"GET" ->
respond (htmlResponse (Html.importPage Nothing))
"POST" -> do
body <- readRequestBody request
let params = parseFormBody body
url = maybe "" id (lookup "url" params)
text = maybe "" id (lookup "text" params)
fileB64 = maybe "" id (lookup "file-b64" params)
fileName = maybe "" id (lookup "file-name" params)
fileMime = maybe "" id (lookup "file-mime" params)
hasFile = not (T.null fileB64)
putStrLn $ "[roux] import request: url=" <> T.unpack url
<> " text=" <> show (not (T.null text))
<> " file=" <> show hasFile
result <- try $ case (hasFile, not (T.null text), not (T.null url)) of
-- File takes priority, then text, then URL
(True, _, _) ->
runFileImport recipeDir config fileName fileMime fileB64
(_, True, _) ->
runTextImport recipeDir config text
(_, _, True) ->
runImportPipeline recipeDir url
_ ->
pure (Left (Html.ImportError "Provide a URL, recipe text, or file to import."))
case result of
Left (e :: SomeException) ->
respond $
htmlResponse $
Html.importResultPage $
Html.ImportError ("Unexpected error: " <> T.pack (show e))
Right (Left err) ->
respond $
htmlResponse $
Html.importResultPage err
Right (Right filename) ->
respond $
Wai.responseLBS
HTTP.status303
[("Location", "/recipes/" <> TE.encodeUtf8 (T.pack filename))]
""
_ -> respond notFound
- Step 3: Update
runFileImportfunction (accepts base64)
The file content arrives as base64. Decode it, save the original alongside the recipe, then call the LLM pipeline.
-- | Import a recipe from an uploaded file via the LLM pipeline.
-- The file content is base64-encoded in the form data.
runFileImport ::
FilePath -- ^ recipe directory
-> RouxConfig -- ^ server config (for Anthropic settings)
-> Text -- ^ original filename
-> Text -- ^ MIME type
-> Text -- ^ base64-encoded file content
-> IO (Either Html.ImportError FilePath)
runFileImport recipeDir config originalName mimeType b64Text = do
-- Decode base64
case decodeBase64 (TE.encodeUtf8 b64Text) of
Left err ->
pure $ Left $ Html.ImportError $
"Could not decode uploaded file: " <> T.pack err
Right content -> do
-- Ensure recipe dir exists
createDirectoryIfMissing True recipeDir
-- Save the original file
let sourceFilename = map (\c -> if isAlphaNum c || c == '.' || c == '-' || c == '_' then c else '_') (T.unpack originalName)
sourcePath = recipeDir </> sourceFilename
LB.writeFile sourcePath content
putStrLn $ "[roux] saved uploaded file to " <> sourcePath
-- Call the LLM pipeline with the saved file
case rcAnthropic config of
Nothing ->
pure $ Left $ Html.ImportError $
"LLM import is not configured. Add an anthropic.api_key to the config file."
Just anthropicConfig -> do
result <- runLLMPipeline recipeDir anthropicConfig (Just sourcePath) ""
case result of
Right filename -> pure $ Right filename
Left err -> pure $ Left err
- Step 5: Add
runTextImportfunction
-- | Import a recipe from pasted text via the LLM pipeline.
runTextImport ::
FilePath -> RouxConfig -> Text
-> IO (Either Html.ImportError FilePath)
runTextImport recipeDir config text = do
case rcAnthropic config of
Nothing ->
pure $ Left $ Html.ImportError $
"LLM import is not configured. Add an anthropic.api_key to the config file."
Just anthropicConfig ->
runLLMPipeline recipeDir anthropicConfig Nothing text
- Step 6: Add
runLLMPipelinefunction
-- | Run the full LLM import pipeline: call convert-to-cooklang → parse → save.
runLLMPipeline ::
FilePath -- ^ recipe directory
-> AnthropicConfig -- ^ API config
-> Maybe FilePath -- ^ Just path = uploaded source file to keep
-> Text -- ^ recipe text (or empty if file)
-> IO (Either Html.ImportError FilePath)
runLLMPipeline recipeDir anthropicConfig mSourceFile text = do
-- Step 1: locate the script
script <- findConvertScript
-- Step 2: write a temp config for the subprocess
let configPath = recipeDir </> ".convert-config.yaml"
writeConvertConfig configPath anthropicConfig
-- Step 3: build subprocess args
let (args, input) = case mSourceFile of
Just fp -> (["--config", configPath, "--file", fp], "")
Nothing -> (["--config", configPath, "--text", T.unpack text], "")
putStrLn $ "[roux] running " <> script <> " for LLM import"
(exitCode, stdoutBytes, stderrBytes) <-
readProcessBytes script args
-- Clean up temp config
_ <- try (removeFile configPath) :: IO (Either IOException ())
case exitCode of
ExitFailure _ -> do
let errMsg = TE.decodeUtf8 (LB.toStrict stderrBytes)
putStrLn $ "[roux] convert-to-cooklang failed: " <> T.unpack errMsg
return $
Left $
Html.ImportError $
"Could not convert recipe: " <> errMsg
ExitSuccess -> do
-- Step 4: parse the Cooklang output to extract the title
let cooklangText = TE.decodeUtf8 (LB.toStrict stdoutBytes)
case parseRecipeTitle cooklangText of
Nothing -> do
putStrLn "[roux] could not extract title from Cooklang output"
return $
Left $
Html.ImportError $
"Could not determine recipe title from AI output."
Just title -> do
let filename = CooklangPrint.filenameFromTitle title
filepath = recipeDir </> filename
-- Step 5: write the .cook file
putStrLn $ "[roux] writing imported recipe to " <> filepath
LB.writeFile filepath stdoutBytes
putStrLn $ "[roux] successfully imported recipe as " <> filename
return $ Right filename
- Step 7: Add helper functions for config generation and title parsing
-- | Locate the convert-to-cooklang script.
findConvertScript :: IO FilePath
findConvertScript = do
mPath <- findExecutable "convert-to-cooklang"
case mPath of
Just p -> pure p
Nothing -> pure "scripts/convert-to-cooklang"
-- | Write a temporary YAML config for the subprocess.
writeConvertConfig :: FilePath -> AnthropicConfig -> IO ()
writeConvertConfig path ac = do
let pairs =
[ "anthropic:"
, " api_key: " <> T.unpack (acApiKey ac)
] ++ case acBaseUrl ac of
Just url -> [" base_url: " <> T.unpack url]
Nothing -> []
++ case acModel ac of
Just m -> [" model: " <> T.unpack m]
Nothing -> []
writeFile path (unlines pairs)
-- | Parse the recipe title from Cooklang front matter.
-- Looks for "title: ..." in the YAML front matter block.
parseRecipeTitle :: Text -> Maybe Text
parseRecipeTitle cooklang =
case T.breakOn "---" cooklang of
(_, afterFirst) ->
let (_separator, inside) = T.breakOn "---" (T.drop 3 afterFirst)
frontMatter = T.take (T.length inside) (T.drop 3 afterFirst)
in case T.breakOn "title:" frontMatter of
(_, afterTitle) ->
let titleLine = T.takeWhile (/= '\n') (T.drop 6 afterTitle)
cleaned = T.strip titleLine
in if T.null cleaned then Nothing else Just cleaned
- Step 8: Add missing imports to Server.hs
Make sure these imports are present (add any that are missing):
import Control.Exception (IOException, SomeException, catch, finally, try)
import Data.Char (isAlphaNum)
import Data.List (isSuffixOf)
import Data.Maybe (fromMaybe, isJust, listToMaybe)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import qualified Data.ByteString.Lazy as LB
import System.Directory (createDirectoryIfMissing, doesFileExist, findExecutable, makeAbsolute, removeFile)
import System.FilePath (takeBaseName, takeExtension, (</>))
import qualified Roux.CooklangPrint as CooklangPrint
import qualified Roux.Html as Html
- Step 9: Build to verify compilation
Run: ./hs stack build
Expected: compiles without errors.
- Step 10: Run tests to verify nothing broke
Run: ./hs stack test
Expected: all tests pass.
- Step 11: Commit
git add src/Roux/Server.hs
git commit -m "feat: add LLM import pipeline with file upload and text input"
Task 5: Expand import form in Html.hs
Files:
-
Modify:
src/Roux/Html.hs -
Step 1: Update the import form to include file upload, textarea, and JS base64 conversion
The form stays URL-encoded (no enctype override). A small JS snippet converts the selected file to base64 and places it in hidden form fields before submission.
Replace the existing importPage function:
-- | Render the import form page, optionally with a validation error.
importPage :: Maybe Text -> ByteString
importPage merror =
page "Roux — Import Recipe" $ do
H.nav ! A.class_ "roux-navbar" $ do
H.ul $ H.li $ H.a ! A.href "/" $ "← Back"
H.ul $ H.li $ H.strong "Import Recipe"
case merror of
Just err -> H.p ! A.style "color: var(--roux-accent);" $ H.toHtml err
Nothing -> pure ()
H.p ! A.style "font-size: 0.85rem; opacity: 0.7; margin-bottom: 1.5rem;" $
"Paste a recipe URL, upload a PDF or image, or paste recipe text directly."
H.form ! A.method "POST" ! A.action "/import" ! A.id "import-form" $
-- URL input
H.label ! A.for "url" $ "Recipe URL"
>> H.input
! A.type_ "url"
! A.id "url"
! A.name "url"
! A.placeholder "https://cooking.nytimes.com/recipes/..."
! A.style "width: 100%; margin-bottom: 1.5rem;"
-- File upload
>> H.label ! A.for "file-input" $ "Or upload a PDF or image"
>> H.input
! A.type_ "file"
! A.id "file-input"
! A.accept ".pdf,image/*"
! A.style "width: 100%; margin-bottom: 1.5rem;"
-- Hidden fields for base64 file data (set by JS)
>> H.input ! A.type_ "hidden" ! A.id "file-b64" ! A.name "file-b64" ! A.value ""
>> H.input ! A.type_ "hidden" ! A.id "file-name" ! A.name "file-name" ! A.value ""
>> H.input ! A.type_ "hidden" ! A.id "file-mime" ! A.name "file-mime" ! A.value ""
-- Text area
>> H.label ! A.for "text" $ "Or paste recipe text"
>> H.textarea
! A.id "text"
! A.name "text"
! A.rows "8"
! A.placeholder "Paste recipe text here..."
! A.style "width: 100%; margin-bottom: 1.5rem; font-family: monospace; font-size: 0.85rem;"
-- Submit
>> H.button ! A.type_ "submit" $ "Import Recipe"
-- Inline JS for base64 file conversion
>> H.script ! A.type_ "text/javascript" $ H.preEscapedText fileUploadJs
-- | Inline JavaScript for converting file uploads to base64 before form submission.
fileUploadJs :: Text
fileUploadJs =
T.unlines
[ "(function(){"
, "'use strict';"
, "var form = document.getElementById('import-form');"
, "var fileInput = document.getElementById('file-input');"
, "var b64Field = document.getElementById('file-b64');"
, "var nameField = document.getElementById('file-name');"
, "var mimeField = document.getElementById('file-mime');"
, ""
, "form.addEventListener('submit', function(e) {"
, " if (fileInput.files.length > 0) {"
, " e.preventDefault();"
, " var file = fileInput.files[0];"
, " var reader = new FileReader();"
, " reader.onload = function() {"
, " var b64 = reader.result.split(',')[1];"
, " b64Field.value = b64;"
, " nameField.value = file.name;"
, " mimeField.value = file.type;"
, " form.submit();"
, " };"
, " reader.readAsDataURL(file);"
, " }"
, "});"
, "})();"
]
- Step 2: Build to verify compilation
Run: ./hs stack build
Expected: compiles without errors.
- Step 3: Run tests to verify nothing broke
Run: ./hs stack test
Expected: all tests pass.
- Step 4: Commit
git add src/Roux/Html.hs
git commit -m "feat: expand import form with file upload and textarea"
Task 6: Wire up dependencies and re-exports
Files:
-
Modify:
package.yaml,src/Roux.hs -
Step 1: Add dependencies to
package.yaml
In package.yaml, add yaml and base64-bytestring to library dependencies:
- text
- time
- wai
- warp
- yaml
- base64-bytestring
- Step 2: Regenerate
.cabalfile
Run: ./hs hpack
Expected: no errors.
- Step 3: Add Config re-exports to
Roux.hs
module Roux (
app,
loadConfig,
module X,
) where
import Data.CookLang as X
import Roux.Config (loadConfig)
import Roux.CooklangPrint as X
import Roux.SchemaOrg as X
import Roux.Server (app)
- Step 4: Build to verify compilation
Run: ./hs stack build
Expected: compiles without errors.
- Step 5: Run tests to verify nothing broke
Run: ./hs stack test
Expected: all tests pass.
- Step 6: Commit
git add package.yaml roux-server.cabal src/Roux.hs
git commit -m "chore: add yaml and base64-bytestring dependencies, re-export Config"
Task 7: End-to-end smoke test
Files: (none, manual testing)
- Step 1: Create a test config
cd /work/personal/roux/roux-main
cat > /tmp/roux-test-config.yaml << 'EOF'
anthropic:
api_key: "sk-ant-your-key-here"
model: "claude-sonnet-4-20250514"
EOF
- Step 2: Start the server with the config
cd /work/personal/roux/roux-main
mkdir -p /tmp/roux-test-recipes
./hs stack exec roux-server -- \
--recipe-dir /tmp/roux-test-recipes \
--config-file /tmp/roux-test-config.yaml &
sleep 2
- Step 3: Verify the import form loads
curl -s http://localhost:8080/import | grep -c "Import Recipe"
Expected: returns 1 (page contains heading).
- Step 4: Test text import
curl -s -X POST http://localhost:8080/import \
--data-urlencode "text=Spaghetti Carbonara
4 servings
400g spaghetti
200g guanciale
4 eggs
100g pecorino
Cook the pasta. Fry the guanciale. Mix eggs and cheese. Combine." \
-o /dev/null -w "%{http_code}\n" -L
Expected: HTTP 200 or 303 (redirect to new recipe page, or error if no real API key).
- Step 5: Test URL import still works (regression)
curl -s -X POST http://localhost:8080/import \
-d "url=https://example.com" \
-o /dev/null -w "%{http_code}\n"
Expected: HTTP 200 with an import error (scraper won't find schema.org data on example.com, but the pipeline runs).
- Step 6: Test with missing config (LLM features disabled)
cd /work/personal/roux/roux-main
./hs stack exec roux-server -- \
--recipe-dir /tmp/roux-test-recipes \
--config-file /tmp/roux-test-config-nonexistent.yaml &
sleep 2
curl -s -X POST http://localhost:8080/import \
--data-urlencode "text=test recipe" | grep -c "not configured"
Expected: contains "not configured".
- Step 7: Kill the servers
kill %1 %2 2>/dev/null; wait 2>/dev/null