| """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") |
|
|
|
|
| |
| |
| |
|
|
| 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 |
| |
| m = re.search(r"```(?:json)?\s*(\{.*?\})\s*```", text, re.DOTALL) |
| if m: |
| try: |
| return json.loads(m.group(1)) |
| except Exception: |
| pass |
| |
| 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 {} |
|
|
|
|
| |
| |
| |
|
|
| 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) |
|
|
| |
| 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] |
|
|
|
|
| |
| |
| |
|
|
| @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")], |
| ) |
|
|