kiwi/app/services/recipe/llm_recipe.py
pyr0ball 33a5cdec37 feat: cloud auth bypass, VRAM leasing, barcode EXIF fix, pipeline improvements
- cloud_session.py: CLOUD_AUTH_BYPASS_IPS with CIDR support; X-Real-IP for
  Docker bridge NAT-aware client IP resolution; local-dev DB path under
  CLOUD_DATA_ROOT for bypass sessions
- compose.cloud.yml: thread CLOUD_AUTH_BYPASS_IPS from shell env; document
  Docker bridge CIDR requirement in .env.example
- nginx.cloud.conf + nginx.conf: client_max_body_size 20m for barcode uploads
- barcode_scanner.py: EXIF orientation correction (PIL ImageOps.exif_transpose)
  before cv2 decode; rotation coverage extended to [90, 180, 270, 45, 135]
  to catch sideways barcodes the 270° case was missing
- llm_recipe.py: CF-core VRAM lease acquire/release wrapping LLMRouter calls
- tasks/runner.py + config.py: COORDINATOR_URL + recipe_llm VRAM budget (4GB)
- recipes.py: per-request Store creation inside asyncio.to_thread worker to
  avoid SQLite check_same_thread violations
- download_datasets.py: HF_PARQUET_FILES strategy for repos without dataset
  builders (lishuyang/recipepairs direct parquet download)
- derive_substitutions.py: use recipepairs_recipes.parquet for ingredient
  lookup; numpy array detection; JSON category parsing
- test_build_flavorgraph_index.py: rewritten for CSV-based index format
- pyproject.toml: add Pillow>=10.0 for EXIF rotation support
2026-04-01 16:06:23 -07:00

271 lines
9.5 KiB
Python

"""LLM-driven recipe generator for Levels 3 and 4."""
from __future__ import annotations
import logging
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from app.db.store import Store
from app.models.schemas.recipe import RecipeRequest, RecipeResult, RecipeSuggestion
from app.services.recipe.element_classifier import IngredientProfile
from app.services.recipe.style_adapter import StyleAdapter
logger = logging.getLogger(__name__)
def _filter_allergies(pantry_items: list[str], allergies: list[str]) -> list[str]:
"""Return pantry items with allergy matches removed (bidirectional substring)."""
if not allergies:
return list(pantry_items)
return [
item for item in pantry_items
if not any(
allergy.lower() in item.lower() or item.lower() in allergy.lower()
for allergy in allergies
)
]
class LLMRecipeGenerator:
def __init__(self, store: "Store") -> None:
self._store = store
self._style_adapter = StyleAdapter()
def build_level3_prompt(
self,
req: RecipeRequest,
profiles: list[IngredientProfile],
gaps: list[str],
) -> str:
"""Build a structured element-scaffold prompt for Level 3."""
allergy_list = req.allergies
safe_pantry = _filter_allergies(req.pantry_items, allergy_list)
covered_elements: list[str] = []
for profile in profiles:
for element in profile.elements:
if element not in covered_elements:
covered_elements.append(element)
lines: list[str] = [
"You are a creative chef. Generate a recipe using the ingredients below.",
"",
f"Pantry items: {', '.join(safe_pantry)}",
]
if req.constraints:
lines.append(f"Dietary constraints: {', '.join(req.constraints)}")
if allergy_list:
lines.append(f"IMPORTANT — must NOT contain: {', '.join(allergy_list)}")
lines.append("")
lines.append(f"Covered culinary elements: {', '.join(covered_elements) or 'none'}")
if gaps:
lines.append(
f"Missing elements to address: {', '.join(gaps)}. "
"Incorporate ingredients or techniques to fill these gaps."
)
if req.style_id:
template = self._style_adapter.get(req.style_id)
if template:
lines.append(f"Cuisine style: {template.name}")
if template.aromatics:
lines.append(f"Preferred aromatics: {', '.join(template.aromatics[:4])}")
lines += [
"",
"Reply in this format:",
"Title: <recipe name>",
"Ingredients: <comma-separated list>",
"Directions: <numbered steps>",
"Notes: <optional tips>",
]
return "\n".join(lines)
def build_level4_prompt(
self,
req: RecipeRequest,
) -> str:
"""Build a minimal wildcard prompt for Level 4."""
allergy_list = req.allergies
safe_pantry = _filter_allergies(req.pantry_items, allergy_list)
lines: list[str] = [
"Surprise me with a creative, unexpected recipe.",
f"Ingredients available: {', '.join(safe_pantry)}",
]
if req.constraints:
lines.append(f"Constraints: {', '.join(req.constraints)}")
if allergy_list:
lines.append(f"Must NOT contain: {', '.join(allergy_list)}")
lines += [
"Treat any mystery ingredient as a wildcard — use your imagination.",
"Title: <name> | Ingredients: <list> | Directions: <steps>",
]
return "\n".join(lines)
def _acquire_vram_lease(self) -> str | None:
"""Request a VRAM lease from the CF-core coordinator. Best-effort — returns None if unavailable."""
try:
import httpx
from app.core.config import settings
from app.tasks.runner import VRAM_BUDGETS
budget_mb = int(VRAM_BUDGETS.get("recipe_llm", 4.0) * 1024)
coordinator = settings.COORDINATOR_URL
nodes_resp = httpx.get(f"{coordinator}/api/nodes", timeout=2.0)
if nodes_resp.status_code != 200:
return None
nodes = nodes_resp.json().get("nodes", [])
if not nodes:
return None
best_node = best_gpu = best_free = None
for node in nodes:
for gpu in node.get("gpus", []):
free = gpu.get("vram_free_mb", 0)
if best_free is None or free > best_free:
best_node = node["node_id"]
best_gpu = gpu["gpu_id"]
best_free = free
if best_node is None:
return None
lease_resp = httpx.post(
f"{coordinator}/api/leases",
json={
"node_id": best_node,
"gpu_id": best_gpu,
"mb": budget_mb,
"service": "kiwi",
"priority": 5,
},
timeout=3.0,
)
if lease_resp.status_code == 200:
lease_id = lease_resp.json()["lease"]["lease_id"]
logger.debug("Acquired VRAM lease %s for recipe_llm (%d MB)", lease_id, budget_mb)
return lease_id
except Exception as exc:
logger.debug("VRAM lease acquire failed (non-fatal): %s", exc)
return None
def _release_vram_lease(self, lease_id: str) -> None:
"""Release a VRAM lease. Best-effort."""
try:
import httpx
from app.core.config import settings
httpx.delete(f"{settings.COORDINATOR_URL}/api/leases/{lease_id}", timeout=3.0)
logger.debug("Released VRAM lease %s", lease_id)
except Exception as exc:
logger.debug("VRAM lease release failed (non-fatal): %s", exc)
def _call_llm(self, prompt: str) -> str:
"""Call the LLM router with a VRAM lease held for the duration."""
lease_id = self._acquire_vram_lease()
try:
from circuitforge_core.llm.router import LLMRouter
router = LLMRouter()
return router.complete(prompt)
except Exception as exc:
logger.error("LLM call failed: %s", exc)
return ""
finally:
if lease_id:
self._release_vram_lease(lease_id)
def _parse_response(self, response: str) -> dict[str, str | list[str]]:
"""Parse LLM response text into structured recipe fields."""
result: dict[str, str | list[str]] = {
"title": "",
"ingredients": [],
"directions": "",
"notes": "",
}
current_key: str | None = None
buffer: list[str] = []
def _flush(key: str | None, buf: list[str]) -> None:
if key is None or not buf:
return
text = " ".join(buf).strip()
if key == "ingredients":
result["ingredients"] = [i.strip() for i in text.split(",") if i.strip()]
else:
result[key] = text
for line in response.splitlines():
lower = line.lower().strip()
if lower.startswith("title:"):
_flush(current_key, buffer)
current_key, buffer = "title", [line.split(":", 1)[1].strip()]
elif lower.startswith("ingredients:"):
_flush(current_key, buffer)
current_key, buffer = "ingredients", [line.split(":", 1)[1].strip()]
elif lower.startswith("directions:"):
_flush(current_key, buffer)
current_key, buffer = "directions", [line.split(":", 1)[1].strip()]
elif lower.startswith("notes:"):
_flush(current_key, buffer)
current_key, buffer = "notes", [line.split(":", 1)[1].strip()]
elif current_key and line.strip():
buffer.append(line.strip())
_flush(current_key, buffer)
return result
def generate(
self,
req: RecipeRequest,
profiles: list[IngredientProfile],
gaps: list[str],
) -> RecipeResult:
"""Generate a recipe via LLM and return a RecipeResult."""
if req.level == 4:
prompt = self.build_level4_prompt(req)
else:
prompt = self.build_level3_prompt(req, profiles, gaps)
response = self._call_llm(prompt)
if not response:
return RecipeResult(suggestions=[], element_gaps=gaps)
parsed = self._parse_response(response)
raw_directions = parsed.get("directions", "")
directions_list: list[str] = (
[s.strip() for s in raw_directions.split(".") if s.strip()]
if isinstance(raw_directions, str)
else list(raw_directions)
)
raw_notes = parsed.get("notes", "")
notes_str: str = raw_notes if isinstance(raw_notes, str) else ""
suggestion = RecipeSuggestion(
id=0,
title=parsed.get("title") or "LLM Recipe",
match_count=len(req.pantry_items),
element_coverage={},
missing_ingredients=list(parsed.get("ingredients", [])),
directions=directions_list,
notes=notes_str,
level=req.level,
is_wildcard=(req.level == 4),
)
return RecipeResult(
suggestions=[suggestion],
element_gaps=gaps,
)