# app/services/meal_plan/llm_timing.py # BSL 1.1 — LLM feature """Estimate cook times for recipes missing corpus prep/cook time fields. Used only when tier allows `meal_plan_llm_timing`. Falls back gracefully when no LLM backend is available. Routing: pass a router from get_meal_plan_router() in llm_router.py. Cloud: cf-text via cf-orch (3B GGUF, ~2GB VRAM). Local: LLMRouter (ollama / vllm / openai-compat per llm.yaml). """ from __future__ import annotations import logging logger = logging.getLogger(__name__) _TIMING_PROMPT = """\ You are a practical cook. Given a recipe name and its ingredients, estimate: 1. prep_time: minutes of active prep work (chopping, mixing, etc.) 2. cook_time: minutes of cooking (oven, stovetop, etc.) Respond with ONLY two integers on separate lines: prep_time cook_time If you cannot estimate, respond with: 0 0 """ def estimate_timing(recipe_name: str, ingredients: list[str], router) -> tuple[int | None, int | None]: """Return (prep_minutes, cook_minutes) for a recipe using LLMRouter. Returns (None, None) if the router is unavailable or the response is unparseable. Never raises. Args: recipe_name: Name of the recipe. ingredients: List of raw ingredient strings from the corpus. router: An LLMRouter instance (from circuitforge_core.llm). """ if router is None: return None, None ingredient_list = "\n".join(f"- {i}" for i in (ingredients or [])[:15]) prompt = f"Recipe: {recipe_name}\n\nIngredients:\n{ingredient_list}" try: response = router.complete( system=_TIMING_PROMPT, user=prompt, max_tokens=16, temperature=0.0, ) lines = response.strip().splitlines() prep = int(lines[0].strip()) if lines else 0 cook = int(lines[1].strip()) if len(lines) > 1 else 0 if prep == 0 and cook == 0: return None, None return prep or None, cook or None except Exception as exc: logger.debug("LLM timing estimation failed for %r: %s", recipe_name, exc) return None, None