Cook_with_a_LLM / src /agents /recipe_planner.py
Fred1e4's picture
Complete Cook App (#5)
75c5414
Raw
History Blame Contribute Delete
6.61 kB
"""Recipe planner agent: propose dishes + generate step-by-step recipe.
Uses openbmb/MiniCPM4.1-8B (text-only) as the primary planner.
Falls back to the shared vision model (MiniCPM-V-4.6) when the planner
model is unavailable (e.g. insufficient RAM on the Space).
"""
from __future__ import annotations
import json
import logging
import re
import spaces
import torch
from src import config
from src.pipeline import DishOption, Recipe, RecipeStep
log = logging.getLogger(__name__)
_PROPOSE_PROMPT = (config.PROMPTS_DIR / "planner_propose.txt").read_text(encoding="utf-8")
_RECIPE_PROMPT = (config.PROMPTS_DIR / "planner_recipe.txt").read_text(encoding="utf-8")
# ---------------------------------------------------------------------------
# JSON extraction helpers
# ---------------------------------------------------------------------------
def _extract_json(text: str) -> dict:
"""Robustly extract the first JSON object from raw model output."""
text = text.strip()
try:
return json.loads(text)
except Exception:
pass
# Markdown code-block
m = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL)
if m:
try:
return json.loads(m.group(1))
except Exception:
pass
# First {...} block with minor auto-fixes
m = re.search(r"\{.*\}", text, re.DOTALL)
if m:
candidate = m.group(0)
candidate = candidate.replace("'", '"')
candidate = re.sub(r",\s*([}\]])", r"\1", candidate)
try:
return json.loads(candidate)
except Exception:
pass
log.warning("Could not extract JSON from output (first 300 chars): %.300s", text)
return {}
# ---------------------------------------------------------------------------
# Inference dispatcher
# ---------------------------------------------------------------------------
def _infer(prompt: str, max_new_tokens: int = 1024, temperature: float = 0.0) -> str:
"""Run text inference.
Primary: the dedicated MiniCPM4.1-8B planner Modal endpoint (transformers
4.x). Falls back to the local vision model (text-only) if the endpoint is
unavailable or returns nothing.
"""
try:
import modal
cls = modal.Cls.from_name(config.PLANNER_MODAL_APP, config.PLANNER_MODAL_CLS)
out = cls().infer.remote(prompt, max_new_tokens=max_new_tokens, temperature=temperature)
if out and out.strip():
return out
log.warning("Planner endpoint returned empty — falling back to vision model.")
except Exception as exc:
log.warning("Planner endpoint call failed: %s — falling back to vision model.", exc)
# Fallback: use the vision model in text-only mode
log.warning("Using vision model as text fallback.")
from src.agents.mise_en_place import model as vis_model, processor as vis_proc
messages = [{"role": "user", "content": [{"type": "text", "text": prompt}]}]
inputs = vis_proc.apply_chat_template(
messages,
add_generation_prompt=True,
tokenize=True,
return_dict=True,
return_tensors="pt",
enable_thinking=False,
)
device = vis_model.device
inputs = {k: v.to(device) if isinstance(v, torch.Tensor) else v for k, v in inputs.items()}
for k, v in inputs.items():
if isinstance(v, torch.Tensor) and torch.is_floating_point(v):
inputs[k] = v.to(dtype=torch.bfloat16)
with torch.no_grad():
generated_ids = vis_model.generate(**inputs, max_new_tokens=max_new_tokens, do_sample=False)
trimmed = [out[len(inp):] for inp, out in zip(inputs["input_ids"], generated_ids)]
return vis_proc.batch_decode(trimmed, skip_special_tokens=True, clean_up_tokenization_spaces=False)[0]
# ---------------------------------------------------------------------------
# Public agent functions
# ---------------------------------------------------------------------------
@spaces.GPU(duration=90)
def propose_dishes(ingredients: list[str]) -> list[DishOption]:
"""Given detected ingredients, return up to 3 dish proposals."""
try:
prompt = _PROPOSE_PROMPT.replace("{ingredients}", ", ".join(ingredients))
raw = _infer(prompt, max_new_tokens=512, temperature=0.7)
log.info("propose_dishes raw: %.500s", raw)
data = _extract_json(raw)
options = data.get("options", [])
return [
DishOption(name=str(o.get("name", "Dish")), why=str(o.get("why", "")))
for o in options[:3]
if o.get("name")
] or [DishOption(name="Simple Stir-fry", why="Quick and adaptable to most ingredients.")]
except Exception as exc:
log.warning("propose_dishes failed: %s", exc)
return [DishOption(name="Simple Stir-fry", why="Quick and adaptable to most ingredients.")]
@spaces.GPU(duration=120)
def plan_recipe(dish_name: str, ingredients: list[str]) -> Recipe:
"""Generate a full step-by-step recipe for the chosen dish."""
try:
prompt = (
_RECIPE_PROMPT
.replace("{dish_name}", dish_name)
.replace("{ingredients}", ", ".join(ingredients))
)
raw = _infer(prompt, max_new_tokens=1024, temperature=0.0)
log.info("plan_recipe raw: %.800s", raw)
data = _extract_json(raw)
raw_steps = data.get("steps", [])
steps = []
for i, s in enumerate(raw_steps, start=1):
if not s.get("instruction"):
continue
tip_val = s.get("tip")
steps.append(RecipeStep(
n=int(s.get("n", i)),
instruction=str(s["instruction"]),
duration=str(s.get("duration", "5 min")),
tip=str(tip_val) if tip_val and str(tip_val).lower() not in ("null", "none") else None,
visual=str(s.get("visual", "")),
))
return Recipe(
name=str(data.get("name", dish_name)),
cuisine=str(data.get("cuisine", "International")),
servings=int(data.get("servings", 2)),
total_time_minutes=int(data.get("total_time_minutes", 30)),
final_dish_visual=str(data.get("final_dish_visual", "")),
steps=steps or [RecipeStep(n=1, instruction="Prepare and cook ingredients to taste.", duration="20 min")],
)
except Exception as exc:
log.warning("plan_recipe failed: %s", exc)
return Recipe(
name=dish_name,
steps=[RecipeStep(n=1, instruction="Prepare and cook ingredients to taste.", duration="20 min")],
)