feat(services/bsl): add llm_planner — LLM-assisted full-week meal plan generation (Paid/BYOK)
This commit is contained in:
parent
5f094eb37a
commit
062b5d16a1
1 changed files with 87 additions and 0 deletions
87
app/services/meal_plan/llm_planner.py
Normal file
87
app/services/meal_plan/llm_planner.py
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
# app/services/meal_plan/llm_planner.py
|
||||
# BSL 1.1 — LLM feature
|
||||
"""LLM-assisted full-week meal plan generation.
|
||||
|
||||
Returns suggestions for human review — never writes to the DB directly.
|
||||
The API endpoint presents the suggestions and waits for user approval
|
||||
before calling store.upsert_slot().
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_PLAN_SYSTEM = """\
|
||||
You are a practical meal planning assistant. Given a pantry inventory and
|
||||
dietary preferences, suggest a week of dinners (or other configured meals).
|
||||
|
||||
Prioritise ingredients that are expiring soon. Prefer variety across the week.
|
||||
Respect all dietary restrictions.
|
||||
|
||||
Respond with a JSON array only — no prose, no markdown fences.
|
||||
Each item: {"day": 0-6, "meal_type": "dinner", "recipe_id": <int or null>, "suggestion": "<recipe name>"}
|
||||
|
||||
day 0 = Monday, day 6 = Sunday.
|
||||
If you cannot match a known recipe_id, set recipe_id to null and provide a suggestion name.
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PlanSuggestion:
|
||||
day: int # 0 = Monday
|
||||
meal_type: str
|
||||
recipe_id: int | None
|
||||
suggestion: str # human-readable name
|
||||
|
||||
|
||||
def generate_plan(
|
||||
pantry_items: list[str],
|
||||
meal_types: list[str],
|
||||
dietary_notes: str,
|
||||
router,
|
||||
) -> list[PlanSuggestion]:
|
||||
"""Return a list of PlanSuggestion for user review.
|
||||
|
||||
Never writes to DB — caller must upsert slots after user approves.
|
||||
Returns an empty list if router is None or response is unparseable.
|
||||
"""
|
||||
if router is None:
|
||||
return []
|
||||
|
||||
pantry_text = "\n".join(f"- {item}" for item in pantry_items[:50])
|
||||
meal_text = ", ".join(meal_types)
|
||||
user_msg = (
|
||||
f"Meal types: {meal_text}\n"
|
||||
f"Dietary notes: {dietary_notes or 'none'}\n\n"
|
||||
f"Pantry (partial):\n{pantry_text}"
|
||||
)
|
||||
|
||||
try:
|
||||
response = router.complete(
|
||||
system=_PLAN_SYSTEM,
|
||||
user=user_msg,
|
||||
max_tokens=512,
|
||||
temperature=0.7,
|
||||
)
|
||||
items = json.loads(response.strip())
|
||||
suggestions = []
|
||||
for item in items:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
day = item.get("day")
|
||||
meal_type = item.get("meal_type", "dinner")
|
||||
if not isinstance(day, int) or day < 0 or day > 6:
|
||||
continue
|
||||
suggestions.append(PlanSuggestion(
|
||||
day=day,
|
||||
meal_type=meal_type,
|
||||
recipe_id=item.get("recipe_id"),
|
||||
suggestion=str(item.get("suggestion", "")),
|
||||
))
|
||||
return suggestions
|
||||
except Exception as exc:
|
||||
logger.debug("LLM plan generation failed: %s", exc)
|
||||
return []
|
||||
Loading…
Reference in a new issue