diff --git a/services/py-help-service/main.py b/services/py-help-service/main.py index 7abcdfe..02232a1 100644 --- a/services/py-help-service/main.py +++ b/services/py-help-service/main.py @@ -7,15 +7,21 @@ from fastapi import Depends, FastAPI, HTTPException, Header, Request from dotenv import load_dotenv from fastapi.responses import JSONResponse -from langchain.chat_models import init_chat_model +from langchain.chat_models import BaseChatModel, init_chat_model from langchain_core.messages import HumanMessage, SystemMessage +from pydantic import BaseModel + +from client.cooking_assistant_gen_ai_services_api_internal_client.types import Unset from client.cooking_assistant_gen_ai_services_api_internal_client.models.help_request_forwarded import ( HelpRequestForwarded, ) -from client.cooking_assistant_gen_ai_services_api_internal_client.models.help_response import ( - HelpResponse, -) + + +# autogenerated classes must be mapped to local ones that are compatible with pydantic +class LocalHelpResponse(BaseModel): + response: str + # Load variables from .env for local testing load_dotenv() @@ -118,7 +124,11 @@ def get_llm(): raise RuntimeError("CRITICAL: GEMINI_HELP_SERVICE_KEY is missing!") kwargs["google_api_key"] = gemini_key - kwargs["response_format"] = {"type": "application/json"} + kwargs["thinking_level"] = ( + "low" # inference time fluctuate a lot + # minimal doesn't seem a lot faster than low but seemingly produces more mistakes in json output formatting + # medium and high seem noticeably slower than low + ) model_name = os.getenv("GEMINI_MODEL", "gemini-3.1-flash-lite") try: @@ -133,7 +143,9 @@ def health_check(): @app.post("/ai/help", dependencies=[Depends(verify_internal_hmac)]) -async def generate_help(request_data: dict[str, Any], llm=Depends(get_llm)): +async def generate_help( + request_data: dict[str, Any], llm: BaseChatModel = Depends(get_llm) +): try: request = HelpRequestForwarded.from_dict(request_data) except Exception as e: @@ -170,20 +182,47 @@ async def generate_help(request_data: dict[str, Any], llm=Depends(get_llm)): ) if request.recipe: - context.append( + recipe_ctx = [ f"The user is currently looking at a recipe for '{request.recipe.title}'." + ] + + ingredients = getattr(request.recipe, "ingredients", None) + if ingredients: + recipe_ctx.append("\nIngredients:") + for ing in ingredients: + recipe_ctx.append(f"- {ing}") + + instructions = getattr(request.recipe, "instructions", None) or getattr( + request.recipe, "steps", None ) + if instructions: + recipe_ctx.append("\nInstructions:") + for idx, step in enumerate(instructions, 1): + recipe_ctx.append(f"{idx}. {step}") + + if request.recipe.nutrients and not isinstance( + request.recipe.nutrients, Unset + ): + nut = request.recipe.nutrients + recipe_ctx.append("\nNutritional Information (Total recipe):") + recipe_ctx.append(f"- Calories: {nut.calories} kcal") + recipe_ctx.append(f"- Protein: {nut.protein}g") + recipe_ctx.append(f"- Fat: {nut.fat}g") + recipe_ctx.append(f"- Carbohydrates: {nut.carbs}g") + + context.append("\n".join(recipe_ctx)) # Combine into LLM prompt system_prompt = SystemMessage(content=" ".join(context)) user_prompt = HumanMessage(content=request.prompt) - result = await asyncio.wait_for( - llm.ainvoke([system_prompt, user_prompt]), timeout=60 + structured_llm = llm.with_structured_output(LocalHelpResponse) + + result: LocalHelpResponse = await asyncio.wait_for( + structured_llm.ainvoke([system_prompt, user_prompt]), timeout=60 ) - help_response = HelpResponse(response=result.content) - return help_response.to_dict() + return result.model_dump() except asyncio.TimeoutError: raise HTTPException( diff --git a/services/py-help-service/requirements.txt b/services/py-help-service/requirements.txt index 68d4b58..bc1d1e5 100644 --- a/services/py-help-service/requirements.txt +++ b/services/py-help-service/requirements.txt @@ -1,10 +1,10 @@ fastapi==0.111.0 uvicorn==0.30.1 langchain==1.3.11 -langchain-openai==1.3.3 -langchain-google-genai==2.1.12 langchain-core==1.4.7 -pydantic==2.7.4 +langchain-openai==1.3.3 +langchain-google-genai==4.2.6 +pydantic==2.12.5 python-dotenv==1.0.1 attrs==23.2.0 python-dateutil==2.9.0.post0 diff --git a/services/py-help-service/tests/test_help_service.py b/services/py-help-service/tests/test_help_service.py index 3da89a4..42aadfd 100644 --- a/services/py-help-service/tests/test_help_service.py +++ b/services/py-help-service/tests/test_help_service.py @@ -3,118 +3,127 @@ import hmac import hashlib import json -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, MagicMock from fastapi.testclient import TestClient -from main import app, get_llm, SECRET_KEY_STR +from main import app, get_llm, SECRET_KEY_STR, LocalHelpResponse client = TestClient(app) + def create_auth_headers(payload: dict): - """Helper to generate valid HMAC headers for testing.""" - timestamp = str(int(time.time())) - body_bytes = json.dumps(payload, separators=(',', ':')).encode('utf-8') - - hmac_context = hmac.new(SECRET_KEY_STR.encode('utf-8'), digestmod=hashlib.sha256) - hmac_context.update(timestamp.encode('utf-8')) - hmac_context.update(b'.') - hmac_context.update(body_bytes) - - return { - "X-Internal-Timestamp": timestamp, - "X-Internal-Signature": hmac_context.hexdigest() - } + """Helper to generate valid HMAC headers for testing.""" + timestamp = str(int(time.time())) + body_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + + hmac_context = hmac.new(SECRET_KEY_STR.encode("utf-8"), digestmod=hashlib.sha256) + hmac_context.update(timestamp.encode("utf-8")) + hmac_context.update(b".") + hmac_context.update(body_bytes) + + return { + "X-Internal-Timestamp": timestamp, + "X-Internal-Signature": hmac_context.hexdigest(), + } + def test_health_check(): - response = client.get("/health") - assert response.status_code == 200 - assert response.json() == {"status": "healthy"} + response = client.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "healthy"} @pytest.fixture def mock_llm(): - mock = AsyncMock() - app.dependency_overrides[get_llm] = lambda: mock - yield mock - app.dependency_overrides.clear() + mock_base = MagicMock() + mock_structured_runnable = AsyncMock() + + # llm.with_structured_output(LocalHelpResponse) returns the runnable + mock_base.with_structured_output.return_value = mock_structured_runnable + app.dependency_overrides[get_llm] = lambda: mock_base + yield mock_base + app.dependency_overrides.clear() def test_generate_help_success(mock_llm): - mock_llm.ainvoke.return_value.content = "Add a pinch of salt." - - payload = { - "profile": { - "username": "testuser", - "preferences": { - "diet": ["vegan"], - "allergies": [], - "about_me": [], - "language": "EN" - } - }, - "recipe": { - "title": "Tomato Soup", - "ingredients": [{"quantity": 1, "unit": "cup", "name": "Tomato"}], - "instructions": ["Boil tomatoes."], - "portions": 2.0 - }, - "prompt": "How do I fix bland soup?" - } - - headers = create_auth_headers(payload) - response = client.post("/ai/help", json=payload, headers=headers) - - assert response.status_code == 200 - assert response.json()["response"] == "Add a pinch of salt." - mock_llm.ainvoke.assert_called_once() + mock_structured_runnable = mock_llm.with_structured_output.return_value + mock_structured_runnable.ainvoke.return_value = LocalHelpResponse( + response="Add a pinch of salt." + ) + + payload = { + "profile": { + "username": "testuser", + "preferences": { + "diet": ["vegan"], + "allergies": [], + "about_me": [], + "language": "EN", + }, + }, + "recipe": { + "title": "Tomato Soup", + "ingredients": [{"quantity": 1, "unit": "cup", "name": "Tomato"}], + "instructions": ["Boil tomatoes."], + "portions": 2.0, + }, + "prompt": "How do I fix bland soup?", + } + + headers = create_auth_headers(payload) + response = client.post("/ai/help", json=payload, headers=headers) + + assert response.status_code == 200 + assert response.json()["response"] == "Add a pinch of salt." + + mock_structured_runnable.ainvoke.assert_called_once() + def test_generate_help_invalid_payload(mock_llm): - - # preferences field missing - payload = { - "profile": { - "username": "testuser" - }, - "recipe": { - "title": "Tomato Soup", - "ingredients": [{"quantity": 1, "unit": "cup", "name": "Tomato"}], - "instructions": ["Boil tomatoes."], - "portions": 2.0 - }, - "prompt": "How do I fix bland soup?" - } - - headers = create_auth_headers(payload) - response = client.post("/ai/help", json=payload, headers=headers) - - assert response.status_code == 400 - - response_json = response.json() - if "detail" in response_json: - assert "Invalid request format" in str(response_json["detail"]) - else: - assert "Invalid request format" in response_json["message"] + + # preferences field missing + payload = { + "profile": {"username": "testuser"}, + "recipe": { + "title": "Tomato Soup", + "ingredients": [{"quantity": 1, "unit": "cup", "name": "Tomato"}], + "instructions": ["Boil tomatoes."], + "portions": 2.0, + }, + "prompt": "How do I fix bland soup?", + } + + headers = create_auth_headers(payload) + response = client.post("/ai/help", json=payload, headers=headers) + + assert response.status_code == 400 + + response_json = response.json() + if "detail" in response_json: + assert "Invalid request format" in str(response_json["detail"]) + else: + assert "Invalid request format" in response_json["message"] def test_generate_help_unauthorized(): - # no header - response = client.post("/ai/help", json={"prompt": "test"}) - assert response.status_code == 401 + # no header + response = client.post("/ai/help", json={"prompt": "test"}) + assert response.status_code == 401 def test_generate_help_invalid_signature(): - payload = {"prompt": "test"} - headers = { - "X-Internal-Timestamp": str(int(time.time())), - "X-Internal-Signature": "wrong-signature" - } - - response = client.post("/ai/help", json=payload, headers=headers) - assert response.status_code == 403 - - response_json = response.json() - if "detail" in response_json: - assert "message" in response_json["detail"] - assert "Forbidden" in response_json["detail"]["message"] - else: - assert "message" in response_json - assert "Forbidden" in response_json["message"] \ No newline at end of file + payload = {"prompt": "test"} + headers = { + "X-Internal-Timestamp": str(int(time.time())), + "X-Internal-Signature": "wrong-signature", + } + + response = client.post("/ai/help", json=payload, headers=headers) + assert response.status_code == 403 + + response_json = response.json() + if "detail" in response_json: + assert "message" in response_json["detail"] + assert "Forbidden" in response_json["detail"]["message"] + else: + assert "message" in response_json + assert "Forbidden" in response_json["message"] diff --git a/services/py-recipe-service/main.py b/services/py-recipe-service/main.py index 57f8b24..d1b20f6 100644 --- a/services/py-recipe-service/main.py +++ b/services/py-recipe-service/main.py @@ -2,26 +2,58 @@ import hashlib import hmac import os -import json import time import traceback -from typing import Any +from typing import Any, List from fastapi import Depends, FastAPI, HTTPException, Header, Request from dotenv import load_dotenv from fastapi.responses import JSONResponse from langchain.chat_models import init_chat_model from langchain_core.messages import HumanMessage, SystemMessage +from pydantic import BaseModel +from langchain_core.language_models.chat_models import BaseChatModel from client.cooking_assistant_gen_ai_services_api_internal_client.models.recipe_request_forwarded import ( RecipeRequestForwarded, ) +from client.cooking_assistant_gen_ai_services_api_internal_client.models.recipe_input import ( + RecipeInput, +) + # Load variables from .env for local testing load_dotenv() app = FastAPI(title="Cooking Assistant GenAI Service") +# autogenerated classes must be mapped to local ones that are compatible with pydantic +class LocalRecipeIngredient(BaseModel): + quantity: float + unit: str + name: str + + +class LocalRecipeNutrients(BaseModel): + calories: int + protein: int + fat: int + carbs: int + + +class LocalRecipeInput(BaseModel): + title: str + ingredients: List[LocalRecipeIngredient] + instructions: List[str] + portions: float + nutrients: LocalRecipeNutrients # required field here as llm can't handle optional fields reliably + + +# local wrapper for recipes - llms are optimized for producing json whereas api spec expects array of json +class RecipeListWrapper(BaseModel): + recipes: List[LocalRecipeInput] + + @app.exception_handler(HTTPException) async def http_exception_handler(request: Request, exc: HTTPException): body = exc.detail if isinstance(exc.detail, dict) else {"message": exc.detail} @@ -109,7 +141,9 @@ def get_llm(): "LOGOS_BASE_URL", "https://logos.aet.cit.tum.de/v1" ) kwargs["api_key"] = logos_key - kwargs["response_format"] = {"type": "json_object"} + kwargs["reasoning_effort"] = ( + "low" # times out for medium and high, "minimal not supported by Harmony" + ) model_name = os.getenv("LOGOS_MODEL", "openai/gpt-oss-120b") else: @@ -118,7 +152,11 @@ def get_llm(): raise RuntimeError("CRITICAL: GEMINI_RECIPE_SERVICE_KEY is missing!") kwargs["google_api_key"] = gemini_key - kwargs["response_format"] = {"type": "application/json"} + kwargs["thinking_level"] = ( + "low" # inference time fluctuate a lot + # minimal doesn't seem a lot faster than low but seemingly produces more mistakes in json output formatting + # medium and high seem noticeably slower than low + ) model_name = os.getenv("GEMINI_MODEL", "gemini-3.1-flash-lite") try: @@ -133,7 +171,9 @@ def health_check(): @app.post("/ai/recipes", dependencies=[Depends(verify_internal_hmac)]) -async def generate_recipes(request_data: dict[str, Any], llm=Depends(get_llm)): +async def generate_recipes( + request_data: dict[str, Any], llm: BaseChatModel = Depends(get_llm) +): try: request = RecipeRequestForwarded.from_dict(request_data) @@ -157,51 +197,34 @@ async def generate_recipes(request_data: dict[str, Any], llm=Depends(get_llm)): getattr(prefs.language, "value", None) or "EN", "English" ) - # 2. Build the System Prompt with strict JSON requirements + # 2. Build the System Prompt system_prompt = ( "You are a professional chef. Create a collection of distinct high-quality recipes.\n" f"Constraint - Diet: {diet}\n" f"Constraint - Allergies: {allergies} (DO NOT USE THESE)\n" f"User Context: {about}\n\n" - "Respond ONLY with a JSON object matching this schema:\n" - "{\n" - ' "recipes": [\n' - " {\n" - ' "title": "string",\n' - ' "ingredients": [{"quantity": number, "unit": "string", "name": "string"}],\n' - ' "instructions": ["string"],\n' - ' "portions": number,\n' - ' "nutrients": {"calories": int, "protein": int, "fat": int, "carbs": int}\n' - " }\n" - " ]\n" - "}" f"Write all recipe content (title, ingredients, units and instructions) in {language}. " f"Keep the JSON keys in English as specified." ) + structured_llm = llm.with_structured_output(RecipeListWrapper) + # 3. Invoke LLM messages = [ SystemMessage(content=system_prompt), HumanMessage(content=f"Generate 3 distinct recipes for: {request.prompt}"), ] - response = await asyncio.wait_for(llm.ainvoke(messages), timeout=60) - - # 4. Parse and Validate - # Clean markdown formatting if present - clean_json = response.content.strip() - - if clean_json.startswith("```json"): - clean_json = clean_json.removeprefix("```json").removesuffix("```").strip() - elif clean_json.startswith("```"): - clean_json = clean_json.removeprefix("```").removesuffix("```").strip() - - data = json.loads(clean_json) + response = await asyncio.wait_for(structured_llm.ainvoke(messages), timeout=60) - if isinstance(data, dict) and "recipes" in data: - return data["recipes"] + # return array of recipes as expeted from the api spec + final_recipes = [] + for r in response.recipes: + # exclude_none=True ensures that if nutrients is None, it won't be included in the dict keys at all + recipe_dict = r.model_dump() + final_recipes.append(RecipeInput.from_dict(recipe_dict)) - return data + return [r.to_dict() for r in final_recipes] except asyncio.TimeoutError: raise HTTPException( diff --git a/services/py-recipe-service/requirements.txt b/services/py-recipe-service/requirements.txt index 68d4b58..bc1d1e5 100644 --- a/services/py-recipe-service/requirements.txt +++ b/services/py-recipe-service/requirements.txt @@ -1,10 +1,10 @@ fastapi==0.111.0 uvicorn==0.30.1 langchain==1.3.11 -langchain-openai==1.3.3 -langchain-google-genai==2.1.12 langchain-core==1.4.7 -pydantic==2.7.4 +langchain-openai==1.3.3 +langchain-google-genai==4.2.6 +pydantic==2.12.5 python-dotenv==1.0.1 attrs==23.2.0 python-dateutil==2.9.0.post0 diff --git a/services/py-recipe-service/tests/test_recipe_service.py b/services/py-recipe-service/tests/test_recipe_service.py index 245dfce..68648df 100644 --- a/services/py-recipe-service/tests/test_recipe_service.py +++ b/services/py-recipe-service/tests/test_recipe_service.py @@ -3,99 +3,120 @@ import hmac import hashlib import json -from unittest.mock import AsyncMock +from unittest.mock import AsyncMock, MagicMock from fastapi.testclient import TestClient -from main import app, get_llm, SECRET_KEY_STR +from main import app, get_llm, SECRET_KEY_STR, RecipeListWrapper, LocalRecipeInput client = TestClient(app) + def create_auth_headers(payload: dict): - """Helper to generate valid HMAC headers for testing.""" - timestamp = str(int(time.time())) - body_bytes = json.dumps(payload, separators=(',', ':')).encode('utf-8') - - hmac_context = hmac.new(SECRET_KEY_STR.encode('utf-8'), digestmod=hashlib.sha256) - hmac_context.update(timestamp.encode('utf-8')) - hmac_context.update(b'.') - hmac_context.update(body_bytes) - - return { - "X-Internal-Timestamp": timestamp, - "X-Internal-Signature": hmac_context.hexdigest() - } + """Helper to generate valid HMAC headers for testing.""" + timestamp = str(int(time.time())) + body_bytes = json.dumps(payload, separators=(",", ":")).encode("utf-8") + + hmac_context = hmac.new(SECRET_KEY_STR.encode("utf-8"), digestmod=hashlib.sha256) + hmac_context.update(timestamp.encode("utf-8")) + hmac_context.update(b".") + hmac_context.update(body_bytes) + + return { + "X-Internal-Timestamp": timestamp, + "X-Internal-Signature": hmac_context.hexdigest(), + } + def test_health_check(): - response = client.get("/health") - assert response.status_code == 200 - assert response.json() == {"status": "healthy"} + response = client.get("/health") + assert response.status_code == 200 + assert response.json() == {"status": "healthy"} @pytest.fixture def mock_llm(): - mock = AsyncMock() - app.dependency_overrides[get_llm] = lambda: mock - yield mock - app.dependency_overrides.clear() + mock_base = MagicMock() + mock_structured_runnable = AsyncMock() + + # llm.with_structured_output(...) returns the runnable + mock_base.with_structured_output.return_value = mock_structured_runnable + + app.dependency_overrides[get_llm] = lambda: mock_base + yield mock_base + app.dependency_overrides.clear() def test_generate_recipes_success(mock_llm): - mock_llm.ainvoke.return_value.content = '{"recipes": [{"title": "Pancake", "ingredients": [{"quantity": 1, "unit": "cup", "name": "Flour"}], "instructions": ["Cook"], "portions": 1.0}]}' - - payload = { - "profile": { - "username": "testuser", - "preferences": { - "diet": ["vegan"], - "allergies": [], - "about_me": [], - "language": "EN" - } - }, - "prompt": "Pancakes" - } - - headers = create_auth_headers(payload) - response = client.post("/ai/recipes", json=payload, headers=headers) - - assert response.status_code == 200 - assert response.json() == [{"title": "Pancake", "ingredients": [{"quantity": 1, "unit": "cup", "name": "Flour"}], "instructions": ["Cook"], "portions": 1.0}] - mock_llm.ainvoke.assert_called_once() + mock_recipe = LocalRecipeInput( + title="Pancake", + ingredients=[{"quantity": 1.0, "unit": "cup", "name": "Flour"}], + instructions=["Cook"], + portions=1.0, + nutrients={"calories": 200, "protein": 5, "fat": 3, "carbs": 35}, + ) + + mock_structured_runnable = mock_llm.with_structured_output.return_value + mock_structured_runnable.ainvoke.return_value = RecipeListWrapper( + recipes=[mock_recipe] + ) + + payload = { + "profile": { + "username": "testuser", + "preferences": { + "diet": ["vegan"], + "allergies": [], + "about_me": [], + "language": "EN", + }, + }, + "prompt": "Pancakes", + } + + headers = create_auth_headers(payload) + response = client.post("/ai/recipes", json=payload, headers=headers) + + assert response.status_code == 200 + assert response.json() == [ + { + "title": "Pancake", + "ingredients": [{"quantity": 1.0, "unit": "cup", "name": "Flour"}], + "instructions": ["Cook"], + "portions": 1.0, + "nutrients": {"calories": 200, "protein": 5, "fat": 3, "carbs": 35}, + } + ] + mock_structured_runnable.ainvoke.assert_called_once() def test_generate_recipes_invalid_payload(mock_llm): - - # preferences field missing - payload = { - "profile": { - "username": "testuser" - }, - "prompt": "Pancakes" - } - - headers = create_auth_headers(payload) - response = client.post("/ai/recipes", json=payload, headers=headers) - - assert response.status_code == 400 - - response_json = response.json() - if "detail" in response_json: - assert len(response_json["detail"]) > 0 - else: - assert "message" in response_json + + # preferences field missing + payload = {"profile": {"username": "testuser"}, "prompt": "Pancakes"} + + headers = create_auth_headers(payload) + response = client.post("/ai/recipes", json=payload, headers=headers) + + assert response.status_code == 400 + + response_json = response.json() + if "detail" in response_json: + assert len(response_json["detail"]) > 0 + else: + assert "message" in response_json def test_generate_recipes_unauthorized(): - # no header - response = client.post("/ai/recipes", json={"prompt": "test"}) - assert response.status_code == 401 + # no header + response = client.post("/ai/recipes", json={"prompt": "test"}) + assert response.status_code == 401 def test_generate_recipes_invalid_signature(): - payload = {"prompt": "test"} - headers = { - "X-Internal-Timestamp": str(int(time.time())), - "X-Internal-Signature": "wrong-signature" - } - response = client.post("/ai/recipes", json=payload, headers=headers) - assert response.status_code == 403 \ No newline at end of file + payload = {"prompt": "test"} + headers = { + "X-Internal-Timestamp": str(int(time.time())), + "X-Internal-Signature": "wrong-signature", + } + response = client.post("/ai/recipes", json=payload, headers=headers) + assert response.status_code == 403