Coverage for main.py: 76%
143 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
6from typing import Any
7from fastapi import Depends, FastAPI, HTTPException, Header, Request
8from dotenv import load_dotenv
9from fastapi.responses import JSONResponse
10from langchain.chat_models import BaseChatModel, init_chat_model
11from langchain_core.messages import HumanMessage, SystemMessage
12from opentelemetry import trace
13from opentelemetry.exporter.otlp.proto.http.trace_exporter import OTLPSpanExporter
14from opentelemetry.instrumentation.fastapi import FastAPIInstrumentor
15from opentelemetry.sdk.resources import Resource, SERVICE_NAME
16from opentelemetry.sdk.trace import TracerProvider
17from opentelemetry.sdk.trace.export import BatchSpanProcessor
18from pydantic import BaseModel
20from client.cooking_assistant_gen_ai_services_api_internal_client.types import Unset
22from prometheus_fastapi_instrumentator import Instrumentator
23from client.cooking_assistant_gen_ai_services_api_internal_client.models.help_request_forwarded import (
24 HelpRequestForwarded,
25)
28# autogenerated classes must be mapped to local ones that are compatible with pydantic
29class LocalHelpResponse(BaseModel):
30 response: str
33# Load variables from .env for local testing
34load_dotenv()
36_otlp_base = os.getenv(
37 "OTEL_EXPORTER_OTLP_ENDPOINT", "http://alloy.monitoring.svc.cluster.local:4318"
38)
39_provider = TracerProvider(resource=Resource.create({SERVICE_NAME: "py-help-service"}))
40_provider.add_span_processor(
41 BatchSpanProcessor(OTLPSpanExporter(endpoint=f"{_otlp_base}/v1/traces"))
42)
43trace.set_tracer_provider(_provider)
44_tracer = trace.get_tracer("py-help-service")
46app = FastAPI(title="Cooking Assistant GenAI Service")
47Instrumentator().instrument(app).expose(app)
48FastAPIInstrumentor.instrument_app(app, excluded_urls="/health,/metrics")
51@app.exception_handler(HTTPException)
52async def http_exception_handler(request: Request, exc: HTTPException):
53 body = exc.detail if isinstance(exc.detail, dict) else {"message": exc.detail}
54 return JSONResponse(status_code=exc.status_code, content=body, headers=exc.headers)
57LANGUAGE_NAMES = {"EN": "English", "DE": "German", "HU": "Hungarian"}
59SECRET_KEY_STR = os.getenv("INTERNAL_AUTH_SECRET")
60if not SECRET_KEY_STR:
61 raise RuntimeError(
62 "CRITICAL: INTERNAL_AUTH_SECRET environment variable is missing!"
63 )
65SECRET_KEY_BYTES = SECRET_KEY_STR.encode("utf-8")
68async def verify_internal_hmac(
69 request: Request,
70 x_internal_timestamp: str = Header(None),
71 x_internal_signature: str = Header(None),
72):
73 """
74 Validates that the incoming request contains an authentic HMAC signature bound to the timestamp and the request payload.
75 Caution: Redundant versions of this method in py-help-service and py-recipe service.
76 """
77 if not x_internal_timestamp or not x_internal_signature:
78 raise HTTPException(
79 status_code=401,
80 detail={
81 "message": "Unauthorized: Missing security authentication headers."
82 },
83 )
85 # 1. Parse incoming timestamp string context securely
86 try:
87 request_time = int(x_internal_timestamp)
88 except ValueError:
89 raise HTTPException(
90 status_code=400,
91 detail={"message": "Invalid timestamp metadata formatting."},
92 )
94 # 2. Reject requests with more than 5 minutes of clock drift
95 current_time = int(time.time())
96 if abs(current_time - request_time) > 300:
97 raise HTTPException(
98 status_code=401, detail={"message": "Request token signature expired."}
99 )
101 # 3. Read the raw body bytes directly from the stream
102 body_bytes = await request.body()
104 # 4. Recalculate signature locally by hashing both pieces together
105 # Using a separator byte like b'.' prevents boundary shifting bugs
106 hmac_context = hmac.new(SECRET_KEY_BYTES, digestmod=hashlib.sha256)
107 hmac_context.update(x_internal_timestamp.encode("utf-8"))
108 hmac_context.update(b".")
109 hmac_context.update(body_bytes)
111 expected_signature = hmac_context.hexdigest()
113 # 5. Use constant-time comparison to completely prevent timing attacks
114 if not hmac.compare_digest(expected_signature, x_internal_signature):
115 raise HTTPException(
116 status_code=403,
117 detail={"message": "Forbidden: HMAC signature validation mismatch."},
118 )
121def get_llm():
122 """
123 Dynamically builds the correct LLM structure using environment variables.
124 """
125 provider = os.getenv("PROVIDER", "google_genai")
127 kwargs = {"timeout": 60}
129 if provider == "local":
130 kwargs["base_url"] = os.getenv(
131 "LOCAL_BASE_URL", "http://host.docker.internal:1234/v1"
132 )
133 kwargs["api_key"] = os.getenv("LOCAL_KEY", "not-needed")
134 model_name = os.getenv("LOCAL_MODEL", "local-model")
136 # We explicitly enforce the underlying OpenAI integration layout
137 provider_target = "openai"
139 elif provider == "openai":
140 logos_key = os.getenv("LOGOS_KEY")
141 if not logos_key:
142 raise RuntimeError("CRITICAL: LOGOS_KEY is missing!")
144 kwargs["base_url"] = os.getenv(
145 "LOGOS_BASE_URL", "https://logos.aet.cit.tum.de/v1"
146 )
147 kwargs["api_key"] = logos_key
148 model_name = os.getenv("LOGOS_MODEL", "openai/gpt-oss-120b")
150 provider_target = "openai"
152 else:
153 gemini_key = os.getenv("GEMINI_HELP_SERVICE_KEY")
154 if not gemini_key:
155 raise RuntimeError("CRITICAL: GEMINI_HELP_SERVICE_KEY is missing!")
157 kwargs["google_api_key"] = gemini_key
158 kwargs["thinking_level"] = (
159 "low" # inference time fluctuate a lot
160 # minimal doesn't seem a lot faster than low but seemingly produces more mistakes in json output formatting
161 # medium and high seem noticeably slower than low
162 )
163 model_name = os.getenv("GEMINI_MODEL", "gemini-3.1-flash-lite")
165 provider_target = "google_genai"
167 try:
168 return init_chat_model(
169 model=model_name, model_provider=provider_target, **kwargs
170 )
171 except Exception as e:
172 raise RuntimeError(f"Failed to boot LLM provider '{provider}': {e}")
175@app.get("/health")
176def health_check():
177 return {"status": "healthy"}
180@app.post("/ai/help", dependencies=[Depends(verify_internal_hmac)])
181async def generate_help(
182 request_data: dict[str, Any], llm: BaseChatModel = Depends(get_llm)
183):
184 try:
185 request = HelpRequestForwarded.from_dict(request_data)
186 except Exception as e:
187 raise HTTPException(
188 status_code=400, detail={"message": f"Invalid request format: {str(e)}"}
189 )
191 try:
192 context = ["You are a professional culinary assistant."]
194 language = "English"
195 if request.profile and request.profile.preferences:
196 prefs = request.profile.preferences
198 language = LANGUAGE_NAMES.get(
199 getattr(prefs.language, "value", None) or "EN", "English"
200 )
201 context.append(f"You serve a user speaking {language}.")
203 diet = prefs.diet or []
204 if diet:
205 context.append(
206 f"The user specifies the following diet: {', '.join(diet)}."
207 )
209 about_me = prefs.about_me or []
210 if about_me:
211 context.append(f"The user specifies: {', '.join(about_me)}")
213 allergies = prefs.allergies or []
214 if allergies:
215 context.append(
216 f"Important: The user is allergic to: {', '.join(allergies)}."
217 )
219 if request.recipe:
220 recipe_ctx = [
221 f"The user is currently looking at a recipe for '{request.recipe.title}'."
222 ]
224 ingredients = getattr(request.recipe, "ingredients", None)
225 if ingredients:
226 recipe_ctx.append("\nIngredients:")
227 for ing in ingredients:
228 # an absent quantity or unit is Unset, which would render as "UNSET"
229 parts = [ing.quantity, ing.unit, ing.name]
230 line = " ".join(str(p) for p in parts if not isinstance(p, Unset))
231 recipe_ctx.append(f"- {line}")
233 instructions = getattr(request.recipe, "instructions", None)
234 if instructions:
235 recipe_ctx.append("\nInstructions:")
236 for idx, step in enumerate(instructions, 1):
237 recipe_ctx.append(f"{idx}. {step}")
239 if request.recipe.nutrients and not isinstance(
240 request.recipe.nutrients, Unset
241 ):
242 nut = request.recipe.nutrients
243 recipe_ctx.append("\nNutritional Information (Total recipe):")
244 recipe_ctx.append(f"- Calories: {nut.calories} kcal")
245 recipe_ctx.append(f"- Protein: {nut.protein}g")
246 recipe_ctx.append(f"- Fat: {nut.fat}g")
247 recipe_ctx.append(f"- Carbohydrates: {nut.carbs}g")
249 context.append("\n".join(recipe_ctx))
251 # Combine into LLM prompt
252 system_prompt = SystemMessage(content=" ".join(context))
253 user_prompt = HumanMessage(content=request.prompt)
255 structured_llm = llm.with_structured_output(LocalHelpResponse)
257 with _tracer.start_as_current_span("llm.generate_help"):
258 result: LocalHelpResponse = await asyncio.wait_for(
259 structured_llm.ainvoke([system_prompt, user_prompt]), timeout=60
260 )
262 return result.model_dump()
264 except asyncio.TimeoutError:
265 raise HTTPException(
266 status_code=504, detail={"message": "LLM connection timed out."}
267 )
268 except Exception as e:
269 raise HTTPException(status_code=500, detail={"message": str(e)})