Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 50 additions & 11 deletions services/py-help-service/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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:
Expand All @@ -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:
Expand Down Expand Up @@ -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(
Expand Down
6 changes: 3 additions & 3 deletions services/py-help-service/requirements.txt
Original file line number Diff line number Diff line change
@@ -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
195 changes: 102 additions & 93 deletions services/py-help-service/tests/test_help_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
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"]
Loading
Loading