diff --git a/scripts/convert-to-cooklang b/scripts/convert-to-cooklang new file mode 100755 index 0000000..8f0eab6 --- /dev/null +++ b/scripts/convert-to-cooklang @@ -0,0 +1,205 @@ +#!/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()