diff --git a/agent/README.md b/agent/README.md new file mode 100644 index 0000000..fd9f893 --- /dev/null +++ b/agent/README.md @@ -0,0 +1,150 @@ +# Recipe Standardization Agent + +An agent that reads recipe files in **multiple formats**, normalises them into a standard schema, and produces a single **iPad-friendly HTML document** you can open directly in Safari. + +--- + +## Features + +| Capability | Detail | +|---|---| +| **Supported input formats** | `.txt` (plain text), `.json`, `.md` / `.markdown`, `.csv` | +| **Parsed fields** | Title, servings, prep time, cook time, ingredients (amount / unit / item), numbered instructions, notes | +| **Output** | Single self-contained HTML file – no internet connection required on device | +| **iPad optimised** | Responsive layout, system fonts, print-safe, table of contents | + +--- + +## Quick Start + +```bash +# Parse all recipes in recipes/input/ and write recipes/output/recipes.html +python3 agent/recipe_agent.py + +# Custom paths +python3 agent/recipe_agent.py --input /path/to/my/recipes --output /path/to/output.html +``` + +Open `recipes/output/recipes.html` in **Safari on your iPad** (or any browser). + +--- + +## Project Layout + +``` +AgentBuilding/ +├── agent/ +│ └── recipe_agent.py # The agent (pure Python, no dependencies) +├── recipes/ +│ ├── input/ # Drop your recipe files here +│ │ ├── banana_bread.txt +│ │ ├── caesar_salad.csv +│ │ ├── chicken_tikka_masala.md +│ │ ├── chocolate_chip_cookies.json +│ │ └── spaghetti_carbonara.txt +│ └── output/ +│ └── recipes.html # Generated output (open on iPad) +└── README.md +``` + +--- + +## Input Format Examples + +### Plain Text (`.txt`) + +``` +My Recipe Title + +Servings: 4 +Prep Time: 15 minutes +Cook Time: 30 minutes + +Ingredients: +- 2 cups flour +- 1 tsp salt + +Instructions: +1. Mix the ingredients. +2. Bake at 350°F for 30 minutes. + +Notes: +Don't over-mix the batter. +``` + +Section headings are flexible — `INGREDIENTS:`, `What you need:`, `You will need:` are all recognised. Instruction headings like `INSTRUCTIONS:`, `Directions:`, `How to make it:` also work. + +### JSON (`.json`) + +```json +{ + "name": "My Recipe", + "servings": "4", + "prepTime": "10 min", + "cookTime": "25 min", + "ingredients": [ + { "qty": "2", "unit": "cups", "item": "flour" } + ], + "steps": [ + "Mix the ingredients.", + "Bake for 25 minutes." + ], + "notes": "Optional tip here." +} +``` + +### Markdown (`.md`) + +```markdown +# My Recipe + +**Servings:** 4 +**Prep Time:** 10 minutes +**Cook Time:** 25 minutes + +## Ingredients +- 2 cups flour +- 1 tsp salt + +## Instructions +1. Mix the ingredients. +2. Bake for 25 minutes. + +## Notes +> Optional tip here. +``` + +### CSV (`.csv`) + +```csv +Recipe Name,My Recipe +Servings,4 +Prep Time,10 minutes +Cook Time,25 minutes +Notes,Optional tip here. + +Section,Amount,Unit,Ingredient +Main,2,cups,flour +Main,1,tsp,salt + +Step,Instruction +1,Mix the ingredients. +2,Bake for 25 minutes. +``` + +--- + +## Requirements + +- Python 3.7 or later +- No third-party libraries required + +--- + +## Adding Your Own Recipes + +1. Place your recipe file(s) in `recipes/input/` +2. Run `python3 agent/recipe_agent.py` +3. Open `recipes/output/recipes.html` on your iPad + +The agent processes all supported files in the input directory alphabetically and combines them into one document. diff --git a/agent/__pycache__/recipe_agent.cpython-312.pyc b/agent/__pycache__/recipe_agent.cpython-312.pyc new file mode 100644 index 0000000..ef1f71d Binary files /dev/null and b/agent/__pycache__/recipe_agent.cpython-312.pyc differ diff --git a/agent/recipe_agent.py b/agent/recipe_agent.py new file mode 100644 index 0000000..c7c43fe --- /dev/null +++ b/agent/recipe_agent.py @@ -0,0 +1,752 @@ +#!/usr/bin/env python3 +""" +Recipe Standardization Agent +============================= +An agent that reads recipe files in various formats (TXT, JSON, Markdown, CSV), +parses and standardizes them into a common schema, and produces a single +iPad-friendly HTML document. + +Supported input formats: + - .txt plain-text recipes (flexible heading detection) + - .json JSON-structured recipes + - .md Markdown recipes + - .csv CSV-structured recipes (custom two-section format) + +Usage: + python recipe_agent.py [--input ] [--output ] + +Defaults: + --input recipes/input/ + --output recipes/output/recipes.html +""" + +import argparse +import csv +import json +import os +import re +import sys +from dataclasses import dataclass, field +from pathlib import Path +from typing import List, Optional + + +# --------------------------------------------------------------------------- +# Data model +# --------------------------------------------------------------------------- + +@dataclass +class Ingredient: + amount: str = "" + unit: str = "" + item: str = "" + + def display(self) -> str: + parts = [p for p in (self.amount, self.unit, self.item) if p] + return " ".join(parts) + + +@dataclass +class Recipe: + title: str = "Untitled Recipe" + servings: str = "" + prep_time: str = "" + cook_time: str = "" + ingredients: List[Ingredient] = field(default_factory=list) + instructions: List[str] = field(default_factory=list) + notes: str = "" + source_file: str = "" + + +# --------------------------------------------------------------------------- +# Parsers +# --------------------------------------------------------------------------- + +class RecipeParser: + """Base class – subclasses implement `can_parse` and `parse`.""" + + def can_parse(self, path: Path) -> bool: + raise NotImplementedError + + def parse(self, path: Path) -> Recipe: + raise NotImplementedError + + +class JsonParser(RecipeParser): + """Parses JSON-structured recipe files.""" + + def can_parse(self, path: Path) -> bool: + return path.suffix.lower() == ".json" + + def parse(self, path: Path) -> Recipe: + with open(path, encoding="utf-8") as f: + data = json.load(f) + + recipe = Recipe(source_file=path.name) + recipe.title = data.get("name") or data.get("title") or path.stem.replace("_", " ").title() + recipe.servings = str(data.get("servings") or data.get("yield") or "") + recipe.prep_time = str(data.get("prepTime") or data.get("prep_time") or "") + recipe.cook_time = str(data.get("cookTime") or data.get("cook_time") or "") + recipe.notes = data.get("notes") or "" + + raw_ingredients = data.get("ingredients") or [] + for item in raw_ingredients: + if isinstance(item, dict): + recipe.ingredients.append(Ingredient( + amount=str(item.get("qty") or item.get("amount") or ""), + unit=str(item.get("unit") or ""), + item=str(item.get("item") or item.get("name") or ""), + )) + elif isinstance(item, str): + recipe.ingredients.append(Ingredient(item=item)) + + instructions = data.get("steps") or data.get("instructions") or [] + recipe.instructions = [str(s) for s in instructions] + + return recipe + + +class MarkdownParser(RecipeParser): + """Parses Markdown-structured recipe files.""" + + _TIME_KEYS = re.compile(r"(prep|cook|total)\s*time", re.IGNORECASE) + _SERVING_KEYS = re.compile(r"(servings?|yield|makes)", re.IGNORECASE) + _HEADING = re.compile(r"^#{1,4}\s+(.*)", re.IGNORECASE) + _LIST_ITEM = re.compile(r"^\s*[-*+]\s+(.*)") + _NUMBERED = re.compile(r"^\s*\d+\.\s+(.*)") + _BOLD_META = re.compile(r"\*\*([^*]+)\*\*\s*:?\s*(.*)") + + def can_parse(self, path: Path) -> bool: + return path.suffix.lower() in (".md", ".markdown") + + def parse(self, path: Path) -> Recipe: + with open(path, encoding="utf-8") as f: + lines = f.readlines() + + recipe = Recipe(source_file=path.name) + section = "meta" + + for line in lines: + stripped = line.strip() + if not stripped: + continue + + # Top-level heading → recipe title + h_match = self._HEADING.match(stripped) + if h_match: + heading = h_match.group(1).strip() + lower = heading.lower() + if "ingredient" in lower: + section = "ingredients" + elif "instruction" in lower or "direction" in lower or "method" in lower or "step" in lower: + section = "instructions" + elif "note" in lower or "tip" in lower: + section = "notes" + elif section == "meta": + recipe.title = heading + continue + + # Bold metadata lines (e.g. **Servings:** 4) + bold_match = self._BOLD_META.match(stripped) + if bold_match: + key, value = bold_match.group(1).strip(), bold_match.group(2).strip() + value = value.lstrip(":").strip() + if self._SERVING_KEYS.search(key): + recipe.servings = value + elif "prep" in key.lower(): + recipe.prep_time = value + elif "cook" in key.lower() or "bake" in key.lower(): + recipe.cook_time = value + continue + + # List items + list_match = self._LIST_ITEM.match(stripped) + if list_match: + content = list_match.group(1).strip() + if section == "ingredients": + recipe.ingredients.append(_parse_ingredient_string(content)) + elif section == "notes": + recipe.notes += content + " " + continue + + # Numbered steps + num_match = self._NUMBERED.match(stripped) + if num_match: + content = num_match.group(1).strip() + # Strip bold sub-headings like **Marinate:** + content = re.sub(r"\*\*[^*]+\*\*:?\s*", "", content).strip() + if section == "instructions" and content: + recipe.instructions.append(content) + continue + + # Blockquote notes + if stripped.startswith(">"): + note_text = stripped.lstrip("> ").strip() + recipe.notes += note_text + " " + + recipe.notes = recipe.notes.strip() + return recipe + + +class CsvParser(RecipeParser): + """ + Parses a two-section CSV format: + - Header rows: Recipe Name, Servings, Prep Time, Cook Time, Notes + - Ingredient rows: Section, Amount, Unit, Ingredient + - Step rows: Step, Instruction + """ + + def can_parse(self, path: Path) -> bool: + return path.suffix.lower() == ".csv" + + def parse(self, path: Path) -> Recipe: + with open(path, encoding="utf-8", newline="") as f: + rows = list(csv.reader(f)) + + recipe = Recipe(source_file=path.name) + mode = "header" + + for row in rows: + if not any(cell.strip() for cell in row): + continue + + first = row[0].strip() + second = row[1].strip() if len(row) > 1 else "" + + # Detect section transitions + if first.lower() == "section" and second.lower() in ("amount", "qty"): + mode = "ingredients" + continue + if first.lower() == "step" and second.lower() == "instruction": + mode = "instructions" + continue + + if mode == "header": + key = first.lower() + value = second + if "recipe name" in key or "name" == key: + recipe.title = value + elif "servings" in key or "yield" in key: + recipe.servings = value + elif "prep" in key: + recipe.prep_time = value + elif "cook" in key or "bake" in key: + recipe.cook_time = value + elif "note" in key: + recipe.notes = value.strip('"') + + elif mode == "ingredients": + # Row format: Section, Amount, Unit, Ingredient + amount = row[1].strip() if len(row) > 1 else "" + unit = row[2].strip() if len(row) > 2 else "" + item = row[3].strip() if len(row) > 3 else "" + if item: + recipe.ingredients.append(Ingredient(amount=amount, unit=unit, item=item)) + + elif mode == "instructions": + instruction = row[1].strip() if len(row) > 1 else "" + if instruction: + recipe.instructions.append(instruction) + + return recipe + + +class PlainTextParser(RecipeParser): + """ + Parses plain-text recipe files with flexible heading detection. + Handles headings like INGREDIENTS:, INSTRUCTIONS:, What you need:, How to make it: etc. + """ + + _INGREDIENT_HEADINGS = re.compile( + r"^(ingredients?|what\s+you\s+need|you\s+will\s+need|shopping\s+list)\s*:?\s*$", + re.IGNORECASE, + ) + _INSTRUCTION_HEADINGS = re.compile( + r"^(instructions?|directions?|method|steps?|how\s+to\s+(make\s+)?it|preparation)\s*:?\s*$", + re.IGNORECASE, + ) + _NOTES_HEADINGS = re.compile( + r"^(notes?|tips?|chef['\u2019s]*\s+note)\s*:?\s*$", + re.IGNORECASE, + ) + _META_LINE = re.compile( + r"^(servings?|yield|makes|prep\s*(time)?|cook\s*(time)?|bak(e|ing)\s*(time)?|total\s*(time)?)\s*:\s*(.+)$", + re.IGNORECASE, + ) + _NUMBERED = re.compile(r"^\d+[.)]\s+(.*)") + _BULLET = re.compile(r"^[-*•]\s+(.*)") + _INLINE_NOTE = re.compile(r"^(tip|note)\s*:\s*(.*)", re.IGNORECASE) + + def can_parse(self, path: Path) -> bool: + return path.suffix.lower() in (".txt", ".text", "") + + def parse(self, path: Path) -> Recipe: + with open(path, encoding="utf-8") as f: + lines = [l.rstrip("\n") for l in f.readlines()] + + recipe = Recipe(source_file=path.name) + section = "title" + + for line in lines: + stripped = line.strip() + if not stripped: + continue + + # Section heading detection + if self._INGREDIENT_HEADINGS.match(stripped): + section = "ingredients" + continue + if self._INSTRUCTION_HEADINGS.match(stripped): + section = "instructions" + continue + if self._NOTES_HEADINGS.match(stripped): + section = "notes" + continue + + # Metadata lines (Servings: 4, Prep Time: 15 min, ...) + meta = self._META_LINE.match(stripped) + if meta: + key = meta.group(1).lower() + value = meta.group(meta.lastindex).strip() if meta.lastindex else "" + if not value: + # fallback: everything after the first colon + value = stripped.split(":", 1)[-1].strip() + if "serving" in key or "yield" in key or "makes" in key: + recipe.servings = value + elif "prep" in key: + recipe.prep_time = value + elif "cook" in key or "bak" in key: + recipe.cook_time = value + continue + + # Inline tip/note + note_match = self._INLINE_NOTE.match(stripped) + if note_match: + recipe.notes += note_match.group(2).strip() + " " + continue + + if section == "title": + recipe.title = stripped + section = "meta" # only the first non-empty line is the title + + elif section == "ingredients": + # Remove leading bullets + bullet = self._BULLET.match(stripped) + content = bullet.group(1).strip() if bullet else stripped + recipe.ingredients.append(_parse_ingredient_string(content)) + + elif section == "instructions": + num = self._NUMBERED.match(stripped) + if num: + recipe.instructions.append(num.group(1).strip()) + else: + # Treat non-empty lines as instruction sentences + recipe.instructions.append(stripped) + + elif section == "notes": + recipe.notes += stripped + " " + + recipe.notes = recipe.notes.strip() + return recipe + + +# --------------------------------------------------------------------------- +# Ingredient string parser +# --------------------------------------------------------------------------- + +_AMOUNT_PATTERN = re.compile( + # Amount: mixed numbers (e.g. "1 1/2"), plain fractions ("3/4"), integers, or vulgar fractions + r"^(\d+\s+\d+/\d+|\d+/\d+|\d+(?:\.\d+)?|[½¼¾⅓⅔⅛⅜⅝⅞])\s*" + # Unit: must match as a complete word (word boundary) to avoid "l" matching "large" + r"(cups?|tbsps?|tablespoons?|tsps?|teaspoons?|fl\.?\s*oz|oz|ounces?|lbs?|pounds?|" + r"grams?|kg|kilograms?|ml|liters?|litres?|cloves?|heads?|bunches?|stalks?|" + r"cans?|packages?|slices?|pieces?|pinch(?:es)?|dash(?:es)?|" + r"small|medium|large|whole|g)\b\s*" + r"(.*)", + re.IGNORECASE, +) + + +def _parse_ingredient_string(text: str) -> Ingredient: + """Best-effort parse of a free-form ingredient string into (amount, unit, item).""" + m = _AMOUNT_PATTERN.match(text.strip()) + if m: + amount = m.group(1).strip() + unit = (m.group(2) or "").strip() + item = (m.group(3) or "").strip().lstrip(",- ").strip() + return Ingredient(amount=amount, unit=unit, item=item or text) + return Ingredient(item=text) + + +# --------------------------------------------------------------------------- +# Agent orchestrator +# --------------------------------------------------------------------------- + +PARSERS: List[RecipeParser] = [ + JsonParser(), + MarkdownParser(), + CsvParser(), + PlainTextParser(), +] + + +def process_directory(input_dir: Path) -> List[Recipe]: + """Walk input_dir, select a parser for each file, and return parsed recipes.""" + recipes: List[Recipe] = [] + supported = {".txt", ".text", ".json", ".md", ".markdown", ".csv"} + + for path in sorted(input_dir.iterdir()): + if path.is_dir() or path.suffix.lower() not in supported: + continue + parser = next((p for p in PARSERS if p.can_parse(path)), None) + if parser is None: + print(f" [skip] no parser for {path.name}", file=sys.stderr) + continue + print(f" [parse] {path.name} ({parser.__class__.__name__})") + try: + recipe = parser.parse(path) + recipes.append(recipe) + except Exception as exc: # noqa: BLE001 + print(f" [error] {path.name}: {exc}", file=sys.stderr) + + return recipes + + +# --------------------------------------------------------------------------- +# HTML renderer (iPad-optimised) +# --------------------------------------------------------------------------- + +_CSS = """ +:root { + --bg: #fdf6ec; + --card: #ffffff; + --accent: #c0392b; + --accent-light: #f9e5e3; + --text: #2c2c2c; + --muted: #6b6b6b; + --border: #e2d9cf; + --shadow: 0 2px 12px rgba(0,0,0,.08); + --radius: 14px; +} + +* { box-sizing: border-box; margin: 0; padding: 0; } + +body { + font-family: -apple-system, "Helvetica Neue", Arial, sans-serif; + background: var(--bg); + color: var(--text); + line-height: 1.65; + padding: 1.5rem 1rem 3rem; +} + +header { + text-align: center; + padding: 2rem 1rem 1.5rem; +} +header h1 { + font-size: 2rem; + color: var(--accent); + letter-spacing: -.5px; +} +header p { + color: var(--muted); + font-size: .9rem; + margin-top: .3rem; +} + +/* Table of contents */ +.toc { + background: var(--card); + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 1.2rem 1.5rem; + max-width: 700px; + margin: 0 auto 2rem; + box-shadow: var(--shadow); +} +.toc h2 { + font-size: 1rem; + text-transform: uppercase; + letter-spacing: .08em; + color: var(--muted); + margin-bottom: .7rem; +} +.toc ol { padding-left: 1.4rem; } +.toc li { margin: .3rem 0; } +.toc a { color: var(--accent); text-decoration: none; font-weight: 500; } +.toc a:hover { text-decoration: underline; } + +/* Recipe cards */ +.recipes { max-width: 700px; margin: 0 auto; display: flex; flex-direction: column; gap: 2rem; } + +.card { + background: var(--card); + border-radius: var(--radius); + border: 1px solid var(--border); + box-shadow: var(--shadow); + overflow: hidden; +} + +.card-header { + background: var(--accent); + color: #fff; + padding: 1.2rem 1.5rem; +} +.card-header h2 { + font-size: 1.4rem; + font-weight: 700; +} +.card-header .source { + font-size: .75rem; + opacity: .75; + margin-top: .2rem; +} + +.meta-bar { + display: flex; + flex-wrap: wrap; + gap: .5rem 1.5rem; + padding: .9rem 1.5rem; + background: var(--accent-light); + border-bottom: 1px solid var(--border); +} +.meta-item { font-size: .85rem; color: var(--muted); } +.meta-item strong { color: var(--text); } + +.card-body { padding: 1.2rem 1.5rem; } + +.section-title { + font-size: .8rem; + font-weight: 700; + text-transform: uppercase; + letter-spacing: .1em; + color: var(--accent); + margin: 1.2rem 0 .5rem; + padding-bottom: .3rem; + border-bottom: 2px solid var(--accent-light); +} +.section-title:first-child { margin-top: 0; } + +.ingredients { list-style: none; } +.ingredients li { + padding: .4rem 0; + border-bottom: 1px solid var(--border); + font-size: .95rem; + display: flex; + align-items: baseline; + gap: .4rem; +} +.ingredients li:last-child { border-bottom: none; } +.ingredients .amount { font-weight: 600; min-width: 3rem; } +.ingredients .unit { color: var(--muted); min-width: 4rem; } + +.instructions { list-style: none; counter-reset: step; } +.instructions li { + counter-increment: step; + display: flex; + gap: .9rem; + padding: .5rem 0; + font-size: .95rem; + border-bottom: 1px solid var(--border); + align-items: flex-start; +} +.instructions li:last-child { border-bottom: none; } +.instructions li::before { + content: counter(step); + background: var(--accent); + color: #fff; + border-radius: 50%; + min-width: 1.6rem; + height: 1.6rem; + display: flex; + align-items: center; + justify-content: center; + font-size: .8rem; + font-weight: 700; + flex-shrink: 0; + margin-top: .1rem; +} + +.notes-box { + background: #fffbf0; + border-left: 4px solid #f0c040; + border-radius: 0 8px 8px 0; + padding: .7rem 1rem; + font-size: .9rem; + color: #5a4a00; + margin-top: .5rem; +} + +footer { + text-align: center; + color: var(--muted); + font-size: .8rem; + margin-top: 3rem; +} + +/* iPad / print optimisations */ +@media (min-width: 768px) { + body { padding: 2rem 2rem 4rem; } + .card-header h2 { font-size: 1.6rem; } +} +@media print { + body { background: #fff; } + .card { page-break-inside: avoid; box-shadow: none; border: 1px solid #ccc; } +} +""" + + +def _escape(text: str) -> str: + """Minimal HTML escaping.""" + return ( + text.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace('"', """) + ) + + +def render_html(recipes: List[Recipe], generated_at: str, year: str = "") -> str: + """Return the full HTML document as a string.""" + parts: List[str] = [] + + parts.append(f""" + + + + + My Recipe Book + + + + +
+

🍽 My Recipe Book

+

{len(recipes)} recipe{"s" if len(recipes) != 1 else ""}  ·  Generated {_escape(generated_at)}

+
+""") + + # Table of contents + parts.append('\n\n") + + # Recipe cards + parts.append('
\n') + for i, r in enumerate(recipes, 1): + anchor = f"recipe-{i}" + parts.append(f'
\n') + + # Card header + parts.append(f'
\n') + parts.append(f'

{_escape(r.title)}

\n') + if r.source_file: + parts.append(f'
Source: {_escape(r.source_file)}
\n') + parts.append(f'
\n') + + # Meta bar + meta_items = [] + if r.servings: + meta_items.append(f'Servings: {_escape(r.servings)}') + if r.prep_time: + meta_items.append(f'Prep: {_escape(r.prep_time)}') + if r.cook_time: + meta_items.append(f'Cook: {_escape(r.cook_time)}') + if meta_items: + parts.append('
\n ') + parts.append("\n ".join(meta_items)) + parts.append('\n
\n') + + parts.append('
\n') + + # Ingredients + if r.ingredients: + parts.append('
Ingredients
\n') + parts.append('
    \n') + for ing in r.ingredients: + amount_html = f'{_escape(ing.amount)}' if ing.amount else "" + unit_html = f'{_escape(ing.unit)}' if ing.unit else "" + item_html = _escape(ing.item) + parts.append(f'
  • {amount_html}{unit_html}{item_html}
  • \n') + parts.append('
\n') + + # Instructions + if r.instructions: + parts.append('
Instructions
\n') + parts.append('
    \n') + for step in r.instructions: + parts.append(f'
  1. {_escape(step)}
  2. \n') + parts.append('
\n') + + # Notes + if r.notes: + parts.append(f'
Notes
\n') + parts.append(f'
{_escape(r.notes)}
\n') + + parts.append('
\n') # card-body + parts.append('
\n\n') + + parts.append('
\n\n') # .recipes + + parts.append(f'
Recipe Book © {_escape(year or generated_at[-4:])}  ·  Open in Safari on iPad for best experience
\n') + parts.append('\n\n') + + return "".join(parts) + + +# --------------------------------------------------------------------------- +# CLI entry point +# --------------------------------------------------------------------------- + +def main() -> None: + parser = argparse.ArgumentParser( + description="Recipe Standardization Agent – converts mixed-format recipes to a single iPad-friendly HTML file." + ) + parser.add_argument( + "--input", + default="recipes/input", + help="Directory containing recipe files (default: recipes/input)", + ) + parser.add_argument( + "--output", + default="recipes/output/recipes.html", + help="Output HTML file path (default: recipes/output/recipes.html)", + ) + args = parser.parse_args() + + input_dir = Path(args.input) + output_path = Path(args.output) + + if not input_dir.is_dir(): + print(f"Error: input directory '{input_dir}' does not exist.", file=sys.stderr) + sys.exit(1) + + output_path.parent.mkdir(parents=True, exist_ok=True) + + print(f"Recipe Standardization Agent") + print(f" Input : {input_dir.resolve()}") + print(f" Output : {output_path.resolve()}") + print() + + recipes = process_directory(input_dir) + + if not recipes: + print("No recipes parsed – nothing to write.", file=sys.stderr) + sys.exit(1) + + print(f"\n {len(recipes)} recipe(s) standardised successfully.") + + from datetime import datetime, timezone + now = datetime.now(timezone.utc) + generated_at = now.strftime("%B %d, %Y") + year = now.strftime("%Y") + + html = render_html(recipes, generated_at, year) + output_path.write_text(html, encoding="utf-8") + print(f" Output written to: {output_path}") + + +if __name__ == "__main__": + main() diff --git a/recipes/input/banana_bread.txt b/recipes/input/banana_bread.txt new file mode 100644 index 0000000..ff2efcc --- /dev/null +++ b/recipes/input/banana_bread.txt @@ -0,0 +1,28 @@ +Banana Bread + +Yield: 1 loaf (8 slices) +Prep: 15 minutes +Bake: 60-65 minutes + +What you need: +3 very ripe bananas +1/3 cup melted butter +3/4 cup sugar +1 egg, beaten +1 tsp vanilla +1 tsp baking soda +Pinch of salt +1 1/2 cups all-purpose flour + +How to make it: +Preheat your oven to 350°F (175°C). Grease a 4x8-inch loaf pan. +In a mixing bowl, mash the ripe bananas with a fork until smooth. +Stir the melted butter into the mashed bananas. +Mix in the sugar, beaten egg, and vanilla extract. +Sprinkle the baking soda and salt over the mixture and stir. +Add the flour and mix until just combined — do not overmix. +Pour the batter into the prepared loaf pan. +Bake for 60–65 minutes, or until a toothpick inserted in the center comes out clean. +Let cool in the pan for a few minutes before turning out onto a wire rack. + +Tip: The riper and blacker the bananas, the sweeter and more flavorful the bread will be! diff --git a/recipes/input/caesar_salad.csv b/recipes/input/caesar_salad.csv new file mode 100644 index 0000000..ade60a5 --- /dev/null +++ b/recipes/input/caesar_salad.csv @@ -0,0 +1,26 @@ +Recipe Name,Caesar Salad +Servings,2 +Prep Time,20 minutes +Cook Time,0 minutes +Notes,"A classic Caesar salad. Use fresh Parmesan for best results." + +Section,Amount,Unit,Ingredient +Salad,1,head,romaine lettuce (washed and torn) +Salad,0.5,cup,Parmesan cheese (freshly grated) +Salad,1,cup,croutons +Dressing,2,,anchovy fillets (minced) +Dressing,1,,garlic clove (minced) +Dressing,1,tsp,Dijon mustard +Dressing,1,tsp,Worcestershire sauce +Dressing,2,tbsp,lemon juice +Dressing,0.5,cup,mayonnaise +Dressing,0.25,cup,Parmesan cheese (grated) +Dressing,,,Salt and black pepper to taste + +Step,Instruction +1,Whisk together anchovies and garlic in a large bowl until a paste forms. +2,Add mustard and Worcestershire sauce; whisk to combine. +3,Whisk in lemon juice then mayonnaise until smooth and creamy. +4,Stir in 1/4 cup Parmesan and season generously with salt and pepper. +5,Add romaine lettuce and toss well to coat every leaf with dressing. +6,Top with croutons and remaining Parmesan. Serve immediately. diff --git a/recipes/input/chicken_tikka_masala.md b/recipes/input/chicken_tikka_masala.md new file mode 100644 index 0000000..56b64b1 --- /dev/null +++ b/recipes/input/chicken_tikka_masala.md @@ -0,0 +1,43 @@ +# Chicken Tikka Masala + +**Servings:** 4 +**Prep Time:** 30 minutes (plus 2 hours marinating) +**Cook Time:** 40 minutes + +## Ingredients + +### Marinade +- 1 cup plain yogurt +- 2 tbsp lemon juice +- 2 tsp cumin +- 1 tsp cinnamon +- 2 tsp cayenne pepper +- 2 tsp black pepper +- 1 tbsp minced fresh ginger +- 1 tsp salt +- 3 boneless chicken breasts, cut into bite-sized pieces + +### Sauce +- 3 tbsp butter +- 1 clove garlic, minced +- 1 jalapeno pepper, finely chopped +- 2 tsp cumin +- 2 tsp paprika +- 1 tsp salt +- 1 can (8 oz) tomato sauce +- 1 cup heavy cream +- 1/4 cup fresh cilantro, chopped + +## Instructions + +1. **Marinate the chicken:** Combine yogurt, lemon juice, and marinade spices in a bowl. Add the chicken, cover, and refrigerate for at least 2 hours (overnight preferred). +2. **Grill or broil the chicken:** Thread marinated chicken onto skewers and grill or broil until slightly charred, about 5 minutes per side. Set aside. +3. **Make the sauce:** Melt butter in a large heavy skillet over medium heat. Sauté garlic and jalapeno for 1 minute. Add cumin, paprika, and salt; stir for 1 minute. +4. **Add tomatoes:** Pour in the tomato sauce and simmer for 15 minutes, stirring occasionally. +5. **Add cream:** Stir in heavy cream and simmer until the sauce thickens, about 10 minutes. +6. **Combine:** Add the grilled chicken to the sauce and simmer for 10 minutes. +7. **Serve:** Garnish with fresh cilantro and serve over basmati rice or with naan bread. + +## Notes + +> The longer you marinate the chicken, the more tender and flavorful it will be. Overnight marinating gives the best results. diff --git a/recipes/input/chocolate_chip_cookies.json b/recipes/input/chocolate_chip_cookies.json new file mode 100644 index 0000000..8fdb9f5 --- /dev/null +++ b/recipes/input/chocolate_chip_cookies.json @@ -0,0 +1,29 @@ +{ + "name": "Classic Chocolate Chip Cookies", + "servings": "36 cookies", + "prepTime": "15 min", + "cookTime": "11 min", + "ingredients": [ + { "qty": "2 1/4", "unit": "cups", "item": "all-purpose flour" }, + { "qty": "1", "unit": "tsp", "item": "baking soda" }, + { "qty": "1", "unit": "tsp", "item": "salt" }, + { "qty": "1", "unit": "cup", "item": "unsalted butter, softened" }, + { "qty": "3/4", "unit": "cup", "item": "granulated sugar" }, + { "qty": "3/4", "unit": "cup", "item": "packed brown sugar" }, + { "qty": "2", "unit": "", "item": "large eggs" }, + { "qty": "2", "unit": "tsp", "item": "vanilla extract" }, + { "qty": "2", "unit": "cups", "item": "chocolate chips" }, + { "qty": "1", "unit": "cup", "item": "chopped walnuts (optional)" } + ], + "steps": [ + "Preheat oven to 375°F (190°C). Line baking sheets with parchment paper.", + "Whisk together flour, baking soda, and salt in a bowl; set aside.", + "Beat butter and both sugars together until light and fluffy, about 3 minutes.", + "Add eggs one at a time, then mix in vanilla extract.", + "Gradually blend in the flour mixture until just combined.", + "Stir in chocolate chips and walnuts if using.", + "Drop rounded tablespoons of dough onto prepared baking sheets, spacing 2 inches apart.", + "Bake for 9–11 minutes or until golden brown. Cool on baking sheet for 2 minutes before transferring to wire rack." + ], + "notes": "For chewier cookies, refrigerate the dough for at least 1 hour before baking." +} diff --git a/recipes/input/spaghetti_carbonara.txt b/recipes/input/spaghetti_carbonara.txt new file mode 100644 index 0000000..7c97829 --- /dev/null +++ b/recipes/input/spaghetti_carbonara.txt @@ -0,0 +1,25 @@ +Spaghetti Carbonara + +Servings: 4 +Prep Time: 10 minutes +Cook Time: 20 minutes + +INGREDIENTS: +- 400g spaghetti +- 200g pancetta or guanciale, diced +- 4 large eggs +- 100g Pecorino Romano, finely grated +- 100g Parmigiano-Reggiano, finely grated +- 2 cloves garlic +- Salt and black pepper to taste + +INSTRUCTIONS: +1. Bring a large pot of salted water to a boil and cook spaghetti until al dente. +2. Meanwhile, cook the pancetta in a large skillet over medium heat until crispy, about 8 minutes. +3. In a bowl, whisk together the eggs and half the cheese. Season generously with black pepper. +4. Remove the skillet from heat. Add the drained pasta, tossing to coat in the pancetta fat. +5. Pour the egg mixture over the pasta, tossing quickly so the eggs don't scramble. Add pasta water a splash at a time to create a creamy sauce. +6. Serve immediately topped with remaining cheese and extra black pepper. + +NOTES: +Do not add cream — traditional carbonara gets its creaminess from the eggs and cheese. diff --git a/recipes/output/recipes.html b/recipes/output/recipes.html new file mode 100644 index 0000000..95c5024 --- /dev/null +++ b/recipes/output/recipes.html @@ -0,0 +1,408 @@ + + + + + + My Recipe Book + + + + +
+

🍽 My Recipe Book

+

5 recipes  ·  Generated April 30, 2026

+
+ + +
+
+
+

Banana Bread

+
Source: banana_bread.txt
+
+
+ Servings: 1 loaf (8 slices) + Prep: 15 minutes + Cook: 60-65 minutes +
+
+
Ingredients
+
    +
  • 3 very ripe bananas
  • +
  • 1/3cupmelted butter
  • +
  • 3/4cupsugar
  • +
  • 1 egg, beaten
  • +
  • 1tspvanilla
  • +
  • 1tspbaking soda
  • +
  • Pinch of salt
  • +
  • 1 1/2cupsall-purpose flour
  • +
+
Instructions
+
    +
  1. Preheat your oven to 350°F (175°C). Grease a 4x8-inch loaf pan.
  2. +
  3. In a mixing bowl, mash the ripe bananas with a fork until smooth.
  4. +
  5. Stir the melted butter into the mashed bananas.
  6. +
  7. Mix in the sugar, beaten egg, and vanilla extract.
  8. +
  9. Sprinkle the baking soda and salt over the mixture and stir.
  10. +
  11. Add the flour and mix until just combined — do not overmix.
  12. +
  13. Pour the batter into the prepared loaf pan.
  14. +
  15. Bake for 60–65 minutes, or until a toothpick inserted in the center comes out clean.
  16. +
  17. Let cool in the pan for a few minutes before turning out onto a wire rack.
  18. +
+
Notes
+
The riper and blacker the bananas, the sweeter and more flavorful the bread will be!
+
+
+ +
+
+

Caesar Salad

+
Source: caesar_salad.csv
+
+
+ Servings: 2 + Prep: 20 minutes + Cook: 0 minutes +
+
+
Ingredients
+
    +
  • 1headromaine lettuce (washed and torn)
  • +
  • 0.5cupParmesan cheese (freshly grated)
  • +
  • 1cupcroutons
  • +
  • 2anchovy fillets (minced)
  • +
  • 1garlic clove (minced)
  • +
  • 1tspDijon mustard
  • +
  • 1tspWorcestershire sauce
  • +
  • 2tbsplemon juice
  • +
  • 0.5cupmayonnaise
  • +
  • 0.25cupParmesan cheese (grated)
  • +
  • Salt and black pepper to taste
  • +
+
Instructions
+
    +
  1. Whisk together anchovies and garlic in a large bowl until a paste forms.
  2. +
  3. Add mustard and Worcestershire sauce; whisk to combine.
  4. +
  5. Whisk in lemon juice then mayonnaise until smooth and creamy.
  6. +
  7. Stir in 1/4 cup Parmesan and season generously with salt and pepper.
  8. +
  9. Add romaine lettuce and toss well to coat every leaf with dressing.
  10. +
  11. Top with croutons and remaining Parmesan. Serve immediately.
  12. +
+
Notes
+
A classic Caesar salad. Use fresh Parmesan for best results.
+
+
+ +
+
+

Chicken Tikka Masala

+
Source: chicken_tikka_masala.md
+
+
+ Servings: 4 + Prep: 30 minutes (plus 2 hours marinating) + Cook: 40 minutes +
+
+
Ingredients
+
    +
  • 1cupplain yogurt
  • +
  • 2tbsplemon juice
  • +
  • 2tspcumin
  • +
  • 1tspcinnamon
  • +
  • 2tspcayenne pepper
  • +
  • 2tspblack pepper
  • +
  • 1tbspminced fresh ginger
  • +
  • 1tspsalt
  • +
  • 3 boneless chicken breasts, cut into bite-sized pieces
  • +
  • 3tbspbutter
  • +
  • 1clovegarlic, minced
  • +
  • 1 jalapeno pepper, finely chopped
  • +
  • 2tspcumin
  • +
  • 2tsppaprika
  • +
  • 1tspsalt
  • +
  • 1can(8 oz) tomato sauce
  • +
  • 1cupheavy cream
  • +
  • 1/4cupfresh cilantro, chopped
  • +
+
Instructions
+
    +
  1. Combine yogurt, lemon juice, and marinade spices in a bowl. Add the chicken, cover, and refrigerate for at least 2 hours (overnight preferred).
  2. +
  3. Thread marinated chicken onto skewers and grill or broil until slightly charred, about 5 minutes per side. Set aside.
  4. +
  5. Melt butter in a large heavy skillet over medium heat. Sauté garlic and jalapeno for 1 minute. Add cumin, paprika, and salt; stir for 1 minute.
  6. +
  7. Pour in the tomato sauce and simmer for 15 minutes, stirring occasionally.
  8. +
  9. Stir in heavy cream and simmer until the sauce thickens, about 10 minutes.
  10. +
  11. Add the grilled chicken to the sauce and simmer for 10 minutes.
  12. +
  13. Garnish with fresh cilantro and serve over basmati rice or with naan bread.
  14. +
+
Notes
+
The longer you marinate the chicken, the more tender and flavorful it will be. Overnight marinating gives the best results.
+
+
+ +
+
+

Classic Chocolate Chip Cookies

+
Source: chocolate_chip_cookies.json
+
+
+ Servings: 36 cookies + Prep: 15 min + Cook: 11 min +
+
+
Ingredients
+
    +
  • 2 1/4cupsall-purpose flour
  • +
  • 1tspbaking soda
  • +
  • 1tspsalt
  • +
  • 1cupunsalted butter, softened
  • +
  • 3/4cupgranulated sugar
  • +
  • 3/4cuppacked brown sugar
  • +
  • 2large eggs
  • +
  • 2tspvanilla extract
  • +
  • 2cupschocolate chips
  • +
  • 1cupchopped walnuts (optional)
  • +
+
Instructions
+
    +
  1. Preheat oven to 375°F (190°C). Line baking sheets with parchment paper.
  2. +
  3. Whisk together flour, baking soda, and salt in a bowl; set aside.
  4. +
  5. Beat butter and both sugars together until light and fluffy, about 3 minutes.
  6. +
  7. Add eggs one at a time, then mix in vanilla extract.
  8. +
  9. Gradually blend in the flour mixture until just combined.
  10. +
  11. Stir in chocolate chips and walnuts if using.
  12. +
  13. Drop rounded tablespoons of dough onto prepared baking sheets, spacing 2 inches apart.
  14. +
  15. Bake for 9–11 minutes or until golden brown. Cool on baking sheet for 2 minutes before transferring to wire rack.
  16. +
+
Notes
+
For chewier cookies, refrigerate the dough for at least 1 hour before baking.
+
+
+ +
+
+

Spaghetti Carbonara

+
Source: spaghetti_carbonara.txt
+
+
+ Servings: 4 + Prep: 10 minutes + Cook: 20 minutes +
+
+
Ingredients
+
    +
  • 400gspaghetti
  • +
  • 200gpancetta or guanciale, diced
  • +
  • 4largeeggs
  • +
  • 100gPecorino Romano, finely grated
  • +
  • 100gParmigiano-Reggiano, finely grated
  • +
  • 2clovesgarlic
  • +
  • Salt and black pepper to taste
  • +
+
Instructions
+
    +
  1. Bring a large pot of salted water to a boil and cook spaghetti until al dente.
  2. +
  3. Meanwhile, cook the pancetta in a large skillet over medium heat until crispy, about 8 minutes.
  4. +
  5. In a bowl, whisk together the eggs and half the cheese. Season generously with black pepper.
  6. +
  7. Remove the skillet from heat. Add the drained pasta, tossing to coat in the pancetta fat.
  8. +
  9. Pour the egg mixture over the pasta, tossing quickly so the eggs don't scramble. Add pasta water a splash at a time to create a creamy sauce.
  10. +
  11. Serve immediately topped with remaining cheese and extra black pepper.
  12. +
+
Notes
+
Do not add cream — traditional carbonara gets its creaminess from the eggs and cheese.
+
+
+ +
+ + + +