Consolidate the application, culinary data model, read-only runtime, and registry-backed deployment.
117 lines
4.2 KiB
Python
Executable File
117 lines
4.2 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""Export canonical culinary recipes as clean Markdown knowledge documents."""
|
||
|
||
from __future__ import annotations
|
||
|
||
import argparse
|
||
import shutil
|
||
import tempfile
|
||
from pathlib import Path
|
||
|
||
import yaml
|
||
|
||
|
||
ROOT = Path(__file__).resolve().parent.parent
|
||
CULINARY_DIR = ROOT / "culinary"
|
||
DEFAULT_OUTPUT = ROOT / "generated/open-webui/recipes"
|
||
|
||
|
||
def load_entities(directory: Path) -> dict[str, dict]:
|
||
entities = {}
|
||
for path in sorted(directory.glob("*.yaml")):
|
||
data = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||
entity_id = data["id"]
|
||
if entity_id in entities:
|
||
raise ValueError(f"duplicate ID {entity_id!r} in {directory}")
|
||
entities[entity_id] = data
|
||
return entities
|
||
|
||
|
||
def amount_text(amount: dict | None) -> str:
|
||
if not amount:
|
||
return "—"
|
||
display = amount.get("display")
|
||
if display:
|
||
return display
|
||
quantity = amount["quantity"]
|
||
maximum = amount.get("quantity_max")
|
||
value = f"{quantity:g}" if isinstance(quantity, float) else str(quantity)
|
||
if maximum is not None:
|
||
upper = f"{maximum:g}" if isinstance(maximum, float) else str(maximum)
|
||
value = f"{value}–{upper}"
|
||
return f"{value} {amount['unit_id'].replace('_', ' ')}"
|
||
|
||
|
||
def render_recipe(recipe: dict, ingredients: dict[str, dict], recipes: dict[str, dict]) -> str:
|
||
lines = [f"# {recipe['title']}", ""]
|
||
lines.extend(
|
||
[
|
||
f"Recipe ID: `{recipe['id']}`",
|
||
f"Categories: {', '.join(recipe['categories']) or 'None'}",
|
||
f"Tags: {', '.join(recipe['tags']) or 'None'}",
|
||
f"Yield: {amount_text(recipe['yield']['amount'])}",
|
||
]
|
||
)
|
||
if recipe["yield"].get("servings") is not None:
|
||
lines.append(f"Servings: {recipe['yield']['servings']:g}")
|
||
if recipe["yield"].get("description"):
|
||
lines.append(f"Yield description: {recipe['yield']['description']}")
|
||
lines.append("")
|
||
|
||
for component in recipe["components"]:
|
||
lines.extend([f"## {component['name']}", "", "| Item | Amount | Formula percentage |", "| --- | ---: | ---: |"])
|
||
for item in component["items"]:
|
||
reference = item["reference"]
|
||
if "ingredient_id" in reference:
|
||
item_id = reference["ingredient_id"]
|
||
label = ingredients[item_id]["name"]
|
||
else:
|
||
item_id = reference["recipe_id"]
|
||
label = f"{recipes[item_id]['title']} (sub-recipe)"
|
||
percentage = f"{item['percentage']:g}%" if "percentage" in item else "—"
|
||
lines.append(f"| {label} | {amount_text(item['amount'])} | {percentage} |")
|
||
if component.get("notes"):
|
||
lines.extend(["", *[f"- {note}" for note in component["notes"]]])
|
||
lines.append("")
|
||
|
||
lines.extend(["## Preparation", ""])
|
||
for step in sorted(recipe["steps"], key=lambda value: value["order"]):
|
||
lines.append(f"{step['order']}. {step['instruction']}")
|
||
if recipe.get("notes"):
|
||
lines.extend(["", "## Notes", "", *[f"- {note}" for note in recipe["notes"]]])
|
||
return "\n".join(lines).rstrip() + "\n"
|
||
|
||
|
||
def parse_args():
|
||
parser = argparse.ArgumentParser(description=__doc__)
|
||
parser.add_argument("output", nargs="?", type=Path, default=DEFAULT_OUTPUT)
|
||
return parser.parse_args()
|
||
|
||
|
||
def main() -> int:
|
||
args = parse_args()
|
||
output = args.output.resolve()
|
||
if output in {Path("/"), ROOT}:
|
||
raise ValueError(f"refusing unsafe output directory: {output}")
|
||
ingredients = load_entities(CULINARY_DIR / "ingredients")
|
||
recipes = load_entities(CULINARY_DIR / "recipes")
|
||
output.parent.mkdir(parents=True, exist_ok=True)
|
||
staging = Path(tempfile.mkdtemp(prefix="recipe-corpus-", dir=output.parent))
|
||
try:
|
||
for recipe_id, recipe in recipes.items():
|
||
(staging / f"{recipe_id}.md").write_text(
|
||
render_recipe(recipe, ingredients, recipes), encoding="utf-8"
|
||
)
|
||
if output.exists():
|
||
shutil.rmtree(output)
|
||
staging.replace(output)
|
||
except Exception:
|
||
shutil.rmtree(staging, ignore_errors=True)
|
||
raise
|
||
print(f"Exported {len(recipes)} canonical recipes to {output}")
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main())
|