Coverage for main.py: 72%
159 statements
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-17 15:45 +0000
« prev ^ index » next coverage.py v7.15.2, created at 2026-07-17 15:45 +0000
1import asyncio
2import hashlib
3import hmac
4import os
5import time
6import traceback
7from typing import Any, List
8from fastapi import Depends, FastAPI, HTTPException, Header, Request
9from dotenv import load_dotenv
10from fastapi.responses import JSONResponse
11from langchain.chat_models import init_chat_model
12from langchain_core.messages import HumanMessage, SystemMessage
13from opentelemetry import trace
14from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
15from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
16from opentelemetry.sdk.resources import Resource, SERVICE_NAME
17from opentelemetry.sdk.trace import TracerProvider
18from opentelemetry.sdk.trace.export import BatchSpanProcessor
19from pydantic import BaseModel
20from langchain_core.language_models.chat_models import BaseChatModel
22from prometheus_fastapi_instrumentator import Instrumentator
23from client.cooking_assistant_gen_ai_services_api_internal_client.models.recipe_request_forwarded import (
24 RecipeRequestForwarded,
25)
27from client.cooking_assistant_gen_ai_services_api_internal_client.models.recipe_input import (
28 RecipeInput,
29)
31from client.cooking_assistant_gen_ai_services_api_internal_client.models.nutrient_request_forwarded import (
32 NutrientRequestForwarded,
33)
35# Load variables from .env for local testing
36load_dotenv()
38_otlp_base = os.getenv(
39 "OTEL_EXPORTER_OTLP_ENDPOINT", "http://alloy.monitoring.svc.cluster.local:4318"
40)
41_provider = TracerProvider(
42 resource=Resource.create({SERVICE_NAME: "py-recipe-service"})
43)
44_provider.add_span_processor(
45 BatchSpanProcessor(OTLPSpanExporter(endpoint=f"{_otlp_base}/v1/traces"))
46)
47trace.set_tracer_provider(_provider)
48_tracer = trace.get_tracer("py-recipe-service")
50app = FastAPI(title="Cooking Assistant GenAI Service")
51Instrumentator().instrument(app).expose(app)
52FastAPIInstrumentor.instrument_app(app, excluded_urls="/health,/metrics")
55# autogenerated classes must be mapped to local ones that are compatible with pydantic
56class LocalRecipeIngredient(BaseModel):
57 # Optional unlike nutrients below: omitting them is sometimes the *correct* answer
58 # ("2 eggs" has no unit, "salt" has neither), so the llm is guided by prompt instead.
59 quantity: float | None = None
60 unit: str | None = None
61 name: str
64class LocalRecipeNutrients(BaseModel):
65 calories: int
66 protein: int
67 fat: int
68 carbs: int
71class LocalRecipeInput(BaseModel):
72 title: str
73 ingredients: List[LocalRecipeIngredient]
74 instructions: List[str]
75 portions: float
76 nutrients: LocalRecipeNutrients # required field here as llm can't handle optional fields reliably
79# local wrapper for recipes - llms are optimized for producing json whereas api spec expects array of json
80class RecipeListWrapper(BaseModel):
81 recipes: List[LocalRecipeInput]
84@app.exception_handler(HTTPException)
85async def http_exception_handler(request: Request, exc: HTTPException):
86 body = exc.detail if isinstance(exc.detail, dict) else {"message": exc.detail}
87 return JSONResponse(status_code=exc.status_code, content=body, headers=exc.headers)
90LANGUAGE_NAMES = {"EN": "English", "DE": "German", "HU": "Hungarian"}
92SECRET_KEY_STR = os.getenv("INTERNAL_AUTH_SECRET")
93if not SECRET_KEY_STR:
94 raise RuntimeError(
95 "CRITICAL: INTERNAL_AUTH_SECRET environment variable is missing!"
96 )
98SECRET_KEY_BYTES = SECRET_KEY_STR.encode("utf-8")
101async def verify_internal_hmac(
102 request: Request,
103 x_internal_timestamp: str = Header(None),
104 x_internal_signature: str = Header(None),
105):
106 """
107 Validates that the incoming request contains an authentic HMAC signature bound to the timestamp and the request payload.
108 Caution: Redundant versions of this method in py-help-service and py-recipe service.
109 """
110 if not x_internal_timestamp or not x_internal_signature:
111 raise HTTPException(
112 status_code=401,
113 detail={
114 "message": "Unauthorized: Missing security authentication headers."
115 },
116 )
118 # 1. Parse incoming timestamp string context securely
119 try:
120 request_time = int(x_internal_timestamp)
121 except ValueError:
122 raise HTTPException(
123 status_code=400,
124 detail={"message": "Invalid timestamp metadata formatting."},
125 )
127 # 2. Reject requests with more than 5 minutes of clock drift
128 current_time = int(time.time())
129 if abs(current_time - request_time) > 300:
130 raise HTTPException(
131 status_code=401, detail={"message": "Request token signature expired."}
132 )
134 # 3. Read the raw body bytes directly from the stream
135 body_bytes = await request.body()
137 # 4. Recalculate signature locally by hashing both pieces together
138 # Using a separator byte like b'.' prevents boundary shifting bugs
139 hmac_context = hmac.new(SECRET_KEY_BYTES, digestmod=hashlib.sha256)
140 hmac_context.update(x_internal_timestamp.encode("utf-8"))
141 hmac_context.update(b".")
142 hmac_context.update(body_bytes)
144 expected_signature = hmac_context.hexdigest()
146 # 5. Use constant-time comparison to completely prevent timing attacks
147 if not hmac.compare_digest(expected_signature, x_internal_signature):
148 raise HTTPException(
149 status_code=403,
150 detail={"message": "Forbidden: HMAC signature validation mismatch."},
151 )
154def get_llm():
155 """
156 Dynamically builds the correct LLM structure using environment variables.
157 """
158 provider = os.getenv("PROVIDER", "google_genai")
160 kwargs = {"timeout": 60}
162 if provider == "local":
163 kwargs["base_url"] = os.getenv(
164 "LOCAL_BASE_URL", "http://host.docker.internal:1234/v1"
165 )
166 kwargs["api_key"] = os.getenv("LOCAL_KEY", "not-needed")
167 kwargs["response_format"] = {"type": "json_object"}
168 model_name = os.getenv("LOCAL_MODEL", "local-model")
170 # We explicitly enforce the underlying OpenAI integration layout
171 provider_target = "openai"
173 elif provider == "openai":
174 logos_key = os.getenv("LOGOS_KEY")
175 if not logos_key:
176 raise RuntimeError("CRITICAL: LOGOS_KEY is missing!")
178 kwargs["base_url"] = os.getenv(
179 "LOGOS_BASE_URL", "https://logos.aet.cit.tum.de/v1"
180 )
181 kwargs["api_key"] = logos_key
182 kwargs["reasoning_effort"] = (
183 "low" # times out for medium and high, "minimal not supported by Harmony"
184 )
185 model_name = os.getenv("LOGOS_MODEL", "openai/gpt-oss-120b")
187 provider_target = "openai"
189 else:
190 gemini_key = os.getenv("GEMINI_RECIPE_SERVICE_KEY")
191 if not gemini_key:
192 raise RuntimeError("CRITICAL: GEMINI_RECIPE_SERVICE_KEY is missing!")
194 kwargs["google_api_key"] = gemini_key
195 kwargs["thinking_level"] = (
196 "low" # inference time fluctuate a lot
197 # minimal doesn't seem a lot faster than low but seemingly produces more mistakes in json output formatting
198 # medium and high seem noticeably slower than low
199 )
200 model_name = os.getenv("GEMINI_MODEL", "gemini-3.1-flash-lite")
202 provider_target = "google_genai"
204 try:
205 return init_chat_model(
206 model=model_name, model_provider=provider_target, **kwargs
207 )
208 except Exception as e:
209 raise RuntimeError(f"Failed to boot LLM provider '{provider}': {e}")
212@app.get("/health")
213def health_check():
214 return {"status": "healthy"}
217@app.post("/ai/recipes", dependencies=[Depends(verify_internal_hmac)])
218async def generate_recipes(
219 request_data: dict[str, Any], llm: BaseChatModel = Depends(get_llm)
220):
222 try:
223 request = RecipeRequestForwarded.from_dict(request_data)
224 except Exception as e:
225 raise HTTPException(
226 status_code=400, detail={"message": f"Invalid request format: {str(e)}"}
227 )
229 if not request.profile or not request.profile.preferences:
230 raise HTTPException(
231 status_code=400, detail={"message": "Missing required profile preferences."}
232 )
234 try:
235 # 1. Extract Profile Context
236 prefs = request.profile.preferences
237 diet = ", ".join(prefs.diet) if prefs.diet else "None"
238 allergies = ", ".join(prefs.allergies) if prefs.allergies else "None"
239 about = ", ".join(prefs.about_me) if prefs.about_me else "None"
240 language = LANGUAGE_NAMES.get(
241 getattr(prefs.language, "value", None) or "EN", "English"
242 )
244 # 2. Build the System Prompt
245 system_prompt = (
246 "You are a professional chef. Create a collection of distinct high-quality recipes.\n"
247 f"Constraint - Diet: {diet}\n"
248 f"Constraint - Allergies: {allergies} (DO NOT USE THESE)\n"
249 f"User Context: {about}\n\n"
250 f"Write all recipe content (title, ingredients, units and instructions) in {language}. "
251 f"Abbreviate units the way {language} conventionally abbreviates them, not the way English "
252 "does (a tablespoon is 'tbsp' in English but 'EL' in German).\n"
253 "Omit the unit for ingredients counted as whole items (2 eggs, 1 onion) rather than "
254 "inventing one (2 pieces egg). Always give a quantity when you give a unit.\n"
255 "Output the nutrients for the whole recipe in total, not per portion.\n"
256 f"Keep the JSON keys in English as specified."
257 )
259 structured_llm = llm.with_structured_output(RecipeListWrapper)
261 # 3. Invoke LLM
262 messages = [
263 SystemMessage(content=system_prompt),
264 HumanMessage(content=f"Generate 3 distinct recipes for: {request.prompt}"),
265 ]
267 with _tracer.start_as_current_span("llm.generate_recipes"):
268 response = await asyncio.wait_for(
269 structured_llm.ainvoke(messages), timeout=60
270 )
272 # return array of recipes as expeted from the api spec
273 final_recipes = []
274 for r in response.recipes:
275 # exclude_none: the spec marks an absent quantity/unit by omitting the key,
276 # and forbids additional properties — a null would be rejected as neither.
277 recipe_dict = r.model_dump(exclude_none=True)
278 final_recipes.append(RecipeInput.from_dict(recipe_dict))
280 return [r.to_dict() for r in final_recipes]
282 except asyncio.TimeoutError:
283 raise HTTPException(
284 status_code=504, detail={"message": "LLM connection timed out."}
285 )
286 except Exception as e:
287 traceback.print_exc()
288 raise HTTPException(status_code=500, detail={"message": str(e)})
291@app.post("/ai/nutrients", dependencies=[Depends(verify_internal_hmac)])
292async def generate_nutrients(
293 request_data: dict[str, Any], llm: BaseChatModel = Depends(get_llm)
294):
295 try:
296 request = NutrientRequestForwarded.from_dict(request_data)
297 except Exception as e:
298 raise HTTPException(
299 status_code=400, detail={"message": f"Invalid request format: {str(e)}"}
300 )
302 if not request.recipe:
303 raise HTTPException(
304 status_code=400, detail={"message": "Missing required recipe data."}
305 )
307 try:
308 recipe_dict = request.recipe.to_dict()
310 system_prompt = (
311 "You are an expert nutritional scientist. Calculate the macronutrients and total calories "
312 "for the provided recipe. Output the nutrients for the whole recipe in total, not per portion. "
313 "Evaluate ingredient quantities, units, and base portion sizes carefully. "
314 "An ingredient with a quantity but no unit is counted as whole items ('2 eggs') - assume a "
315 "typical size for one item. An ingredient with neither is added to taste ('salt') - assume a "
316 "negligible amount. "
317 "Ensure the output strictly mirrors the exact target JSON object fields."
318 )
320 structured_llm = llm.with_structured_output(LocalRecipeNutrients)
322 messages = [
323 SystemMessage(content=system_prompt),
324 HumanMessage(
325 content=f"Calculate exact macronutrients for this recipe payload: {recipe_dict}"
326 ),
327 ]
329 with _tracer.start_as_current_span("llm.generate_nutrients"):
330 response = await asyncio.wait_for(
331 structured_llm.ainvoke(messages), timeout=60
332 )
334 return response.model_dump()
336 except asyncio.TimeoutError:
337 raise HTTPException(
338 status_code=504, detail={"message": "LLM connection timed out."}
339 )
340 except Exception as e:
341 traceback.print_exc()
342 raise HTTPException(status_code=500, detail={"message": str(e)})