"""Tests for LLMRecipeGenerator — prompt builders and allergy filtering.""" from __future__ import annotations import os from contextlib import contextmanager from dataclasses import dataclass from unittest.mock import MagicMock, patch import pytest from app.models.schemas.recipe import RecipeRequest from app.services.recipe.element_classifier import IngredientProfile def _make_store(): """Create a minimal in-memory Store.""" from app.db.store import Store import sqlite3 conn = sqlite3.connect(":memory:") conn.row_factory = sqlite3.Row store = Store.__new__(Store) store.conn = conn return store def test_build_level3_prompt_contains_element_scaffold(): """Level 3 prompt includes element coverage, pantry items, and constraints.""" from app.services.recipe.llm_recipe import LLMRecipeGenerator store = _make_store() gen = LLMRecipeGenerator(store) req = RecipeRequest( pantry_items=["butter", "mushrooms"], level=3, constraints=["vegetarian"], ) profiles = [ IngredientProfile(name="butter", elements=["Richness"]), IngredientProfile(name="mushrooms", elements=["Depth"]), ] gaps = ["Brightness", "Aroma"] prompt = gen.build_level3_prompt(req, profiles, gaps) assert "Richness" in prompt assert "Depth" in prompt assert "Brightness" in prompt assert "butter" in prompt assert "vegetarian" in prompt def test_build_level4_prompt_contains_pantry_and_constraints(): """Level 4 prompt is concise and includes key context.""" from app.services.recipe.llm_recipe import LLMRecipeGenerator store = _make_store() gen = LLMRecipeGenerator(store) req = RecipeRequest( pantry_items=["pasta", "eggs", "mystery ingredient"], level=4, constraints=["no gluten"], allergies=["gluten"], wildcard_confirmed=True, ) prompt = gen.build_level4_prompt(req) assert "mystery" in prompt.lower() assert "gluten" in prompt.lower() assert len(prompt) < 1500 def test_allergy_items_excluded_from_prompt(): """Allergy items are listed as forbidden AND filtered from pantry shown to LLM.""" from app.services.recipe.llm_recipe import LLMRecipeGenerator store = _make_store() gen = LLMRecipeGenerator(store) req = RecipeRequest( pantry_items=["olive oil", "peanuts", "garlic"], level=3, constraints=[], allergies=["peanuts"], ) profiles = [ IngredientProfile(name="olive oil", elements=["Richness"]), IngredientProfile(name="peanuts", elements=["Texture"]), IngredientProfile(name="garlic", elements=["Aroma"]), ] gaps: list[str] = [] prompt = gen.build_level3_prompt(req, profiles, gaps) # Check peanuts are in the exclusion section but NOT in the pantry section lines = prompt.split("\n") pantry_line = next((l for l in lines if l.startswith("Pantry")), "") exclusion_line = next( (l for l in lines if "must not" in l.lower()), "", ) assert "peanuts" not in pantry_line.lower() assert "peanuts" in exclusion_line.lower() assert "olive oil" in prompt.lower() def test_generate_returns_result_when_llm_responds(monkeypatch): """generate() returns RecipeResult with title when LLM returns a valid response.""" from app.services.recipe.llm_recipe import LLMRecipeGenerator from app.models.schemas.recipe import RecipeResult store = _make_store() gen = LLMRecipeGenerator(store) canned_response = ( "Title: Mushroom Butter Pasta\n" "Ingredients: butter, mushrooms, pasta\n" "Directions: Cook pasta. Sauté mushrooms in butter. Combine.\n" "Notes: Add parmesan to taste.\n" ) monkeypatch.setattr(gen, "_call_llm", lambda prompt: canned_response) req = RecipeRequest( pantry_items=["butter", "mushrooms", "pasta"], level=3, constraints=["vegetarian"], ) profiles = [ IngredientProfile(name="butter", elements=["Richness"]), IngredientProfile(name="mushrooms", elements=["Depth"]), ] gaps = ["Brightness"] result = gen.generate(req, profiles, gaps) assert isinstance(result, RecipeResult) assert len(result.suggestions) == 1 suggestion = result.suggestions[0] assert suggestion.title == "Mushroom Butter Pasta" # All LLM ingredients (butter, mushrooms, pasta) are in the pantry, so none are missing assert suggestion.missing_ingredients == [] assert len(suggestion.directions) > 0 assert "parmesan" in suggestion.notes.lower() assert result.element_gaps == ["Brightness"] # --------------------------------------------------------------------------- # CFOrchClient integration tests # --------------------------------------------------------------------------- @dataclass class _FakeAllocation: allocation_id: str = "alloc-test-1" service: str = "vllm" node_id: str = "node-1" gpu_id: int = 0 model: str | None = "Ouro-2.6B-Thinking" url: str = "http://test:8000" started: bool = True warm: bool = True def test_recipe_gen_uses_cf_orch_when_env_set(monkeypatch): """When CF_ORCH_URL is set, _call_llm uses alloc.url+/v1 as the OpenAI base_url.""" from app.services.recipe.llm_recipe import LLMRecipeGenerator store = _make_store() gen = LLMRecipeGenerator(store) fake_alloc = _FakeAllocation() @contextmanager def _fake_llm_context(): yield fake_alloc captured = {} # Fake OpenAI that records the base_url it was constructed with class _FakeOpenAI: def __init__(self, *, base_url, api_key): captured["base_url"] = base_url msg = MagicMock() msg.content = "Title: Test\nIngredients: a\nDirections: do it.\nNotes: none." choice = MagicMock() choice.message = msg completion = MagicMock() completion.choices = [choice] self.chat = MagicMock() self.chat.completions = MagicMock() self.chat.completions.create = MagicMock(return_value=completion) # Patch _get_llm_context directly so no real HTTP call is made monkeypatch.setattr(gen, "_get_llm_context", _fake_llm_context) with patch("app.services.recipe.llm_recipe.OpenAI", _FakeOpenAI): gen._call_llm("make me a recipe") assert captured.get("base_url") == "http://test:8000/v1" def test_recipe_gen_falls_back_without_cf_orch(monkeypatch): """When CF_ORCH_URL is not set, _call_llm falls back to LLMRouter.""" from app.services.recipe.llm_recipe import LLMRecipeGenerator store = _make_store() gen = LLMRecipeGenerator(store) monkeypatch.delenv("CF_ORCH_URL", raising=False) router_called = {} def _fake_complete(prompt, **_kwargs): router_called["prompt"] = prompt return "Title: Direct\nIngredients: x\nDirections: go.\nNotes: ok." fake_router = MagicMock() fake_router.complete.side_effect = _fake_complete # LLMRouter is imported locally inside _call_llm, so patch it at its source module. # new_callable=MagicMock makes the class itself a MagicMock; set return_value so # that LLMRouter() (instantiation) yields fake_router rather than a new MagicMock. with patch("circuitforge_core.llm.router.LLMRouter", new_callable=MagicMock) as mock_router_cls: mock_router_cls.return_value = fake_router gen._call_llm("direct path prompt") assert router_called.get("prompt") == "direct path prompt"