Files
roux/scripts/convert-to-cooklang
T

206 lines
6.3 KiB
Python
Executable File

#!/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()