-
Notifications
You must be signed in to change notification settings - Fork 0
⚡ Bolt: Cache AI recommendations in Jules Engine #25
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| ## 2025-05-15 - [LRU Cache for AI Recommendations] | ||
| **Learning:** LLM API calls are a major bottleneck (several seconds per request). Implementing `functools.lru_cache` provides massive performance gains for identical requests. However, Pydantic models and dictionaries are not hashable and cannot be used directly as cache keys. | ||
| **Action:** When implementing `lru_cache` for functions involving complex objects, use an internal helper function that accepts primitive, hashable types (strings, ints) to form the cache key. |
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,64 @@ | ||||||||||||||||
| import time | ||||||||||||||||
| import sys | ||||||||||||||||
| import os | ||||||||||||||||
| from unittest.mock import MagicMock | ||||||||||||||||
|
|
||||||||||||||||
| # Ensure we can import from the backend directory | ||||||||||||||||
| sys.path.append(os.path.dirname(os.path.abspath(__file__))) | ||||||||||||||||
|
|
||||||||||||||||
| import jules_engine | ||||||||||||||||
| from models import UserScan, SHOPIFY_INVENTORY | ||||||||||||||||
|
|
||||||||||||||||
| def benchmark(): | ||||||||||||||||
| print("--- ⚡ JULES ENGINE PERFORMANCE BENCHMARK ---") | ||||||||||||||||
|
|
||||||||||||||||
| # Mock the generative model to avoid real API calls and simulate latency | ||||||||||||||||
| original_model = jules_engine.model | ||||||||||||||||
| mock_model = MagicMock() | ||||||||||||||||
|
|
||||||||||||||||
| def mock_generate_content(prompt): | ||||||||||||||||
| time.sleep(0.5) # Simulate 500ms LLM latency | ||||||||||||||||
| mock_response = MagicMock() | ||||||||||||||||
| mock_response.text = "Mocked Luxury Advice: Elegant and Fluid." | ||||||||||||||||
| return mock_response | ||||||||||||||||
|
|
||||||||||||||||
| mock_model.generate_content.side_effect = mock_generate_content | ||||||||||||||||
| jules_engine.model = mock_model | ||||||||||||||||
|
|
||||||||||||||||
| # Prepare test data | ||||||||||||||||
| user_data = UserScan( | ||||||||||||||||
| user_id="test_user", | ||||||||||||||||
| token="test_token", | ||||||||||||||||
| waist=70.0, | ||||||||||||||||
| event_type="Gala" | ||||||||||||||||
| ) | ||||||||||||||||
|
|
||||||||||||||||
| # Ensure garment has required keys (temporarily for this test if not fixed yet) | ||||||||||||||||
| garment = SHOPIFY_INVENTORY["BALMAIN_SS26_SLIM"].copy() | ||||||||||||||||
| if 'drape' not in garment: | ||||||||||||||||
| garment['drape'] = "Architectural" | ||||||||||||||||
| if 'elasticity' not in garment: | ||||||||||||||||
| garment['elasticity'] = "High-Recovery" | ||||||||||||||||
|
Comment on lines
+36
to
+41
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This block of code, including the comment and the conditional checks for
Suggested change
|
||||||||||||||||
|
|
||||||||||||||||
| print("\n1. Initial call (Cold Cache):") | ||||||||||||||||
| start = time.perf_counter() | ||||||||||||||||
| advice1 = jules_engine.get_jules_advice(user_data, garment) | ||||||||||||||||
| end = time.perf_counter() | ||||||||||||||||
| print(f"Time: {(end - start) * 1000:.2f}ms") | ||||||||||||||||
|
|
||||||||||||||||
| print("\n2. Second call with same data (Should be cached if implemented):") | ||||||||||||||||
| start = time.perf_counter() | ||||||||||||||||
| advice2 = jules_engine.get_jules_advice(user_data, garment) | ||||||||||||||||
| end = time.perf_counter() | ||||||||||||||||
| print(f"Time: {(end - start) * 1000:.2f}ms") | ||||||||||||||||
|
|
||||||||||||||||
| # Restore original model | ||||||||||||||||
| jules_engine.model = original_model | ||||||||||||||||
|
|
||||||||||||||||
| if advice1 == advice2: | ||||||||||||||||
| print("\n[SUCCESS] Responses match.") | ||||||||||||||||
| else: | ||||||||||||||||
| print("\n[ERROR] Responses do not match.") | ||||||||||||||||
|
|
||||||||||||||||
| if __name__ == "__main__": | ||||||||||||||||
| benchmark() | ||||||||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -1,6 +1,7 @@ | ||||||||||||||||||||||||||||||||||||||||||||||
| import os | ||||||||||||||||||||||||||||||||||||||||||||||
| import google.generativeai as genai | ||||||||||||||||||||||||||||||||||||||||||||||
| from dotenv import load_dotenv | ||||||||||||||||||||||||||||||||||||||||||||||
| from functools import lru_cache | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| # Load .env from the same directory or current directory | ||||||||||||||||||||||||||||||||||||||||||||||
| load_dotenv() | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -17,26 +18,18 @@ | |||||||||||||||||||||||||||||||||||||||||||||
| genai.configure(api_key=api_key) | ||||||||||||||||||||||||||||||||||||||||||||||
| model = genai.GenerativeModel('gemini-1.5-flash') | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| def get_jules_advice(user_data, garment): | ||||||||||||||||||||||||||||||||||||||||||||||
| @lru_cache(maxsize=128) | ||||||||||||||||||||||||||||||||||||||||||||||
| def _get_jules_advice_cached(event_type: str, garment_name: str, drape: str, elasticity: str): | ||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+21
to
23
|
||||||||||||||||||||||||||||||||||||||||||||||
| Generates an emotional styling tip without mentioning body numbers or sizes. | ||||||||||||||||||||||||||||||||||||||||||||||
| Internal cached function using only hashable types for the cache key. | ||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||
| # garment is a dict (from GARMENT_DB) or Garment object. | ||||||||||||||||||||||||||||||||||||||||||||||
| # The prompt usage implies dict access: garment['name'] | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| # Handle both dict and Pydantic model | ||||||||||||||||||||||||||||||||||||||||||||||
| if hasattr(garment, 'dict'): | ||||||||||||||||||||||||||||||||||||||||||||||
| garment_data = garment.dict() | ||||||||||||||||||||||||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||||||||||||||||||||||||
| garment_data = garment | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| prompt = f""" | ||||||||||||||||||||||||||||||||||||||||||||||
| You are 'Jules', a high-end fashion consultant at Galeries Lafayette. | ||||||||||||||||||||||||||||||||||||||||||||||
| A client is interested in the '{garment_data['name']}' for a {user_data.event_type}. | ||||||||||||||||||||||||||||||||||||||||||||||
| A client is interested in the '{garment_name}' for a {event_type}. | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| Technical Context: | ||||||||||||||||||||||||||||||||||||||||||||||
| - Fabric Drape: {garment_data['drape']} | ||||||||||||||||||||||||||||||||||||||||||||||
| - Fabric Elasticity: {garment_data['elasticity']} | ||||||||||||||||||||||||||||||||||||||||||||||
| - Fabric Drape: {drape} | ||||||||||||||||||||||||||||||||||||||||||||||
| - Fabric Elasticity: {elasticity} | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| Task: | ||||||||||||||||||||||||||||||||||||||||||||||
| Explain why this garment is the perfect choice for their silhouette based | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
@@ -51,3 +44,27 @@ def get_jules_advice(user_data, garment): | |||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| response = model.generate_content(prompt) | ||||||||||||||||||||||||||||||||||||||||||||||
| return response.text | ||||||||||||||||||||||||||||||||||||||||||||||
|
|
||||||||||||||||||||||||||||||||||||||||||||||
| def get_jules_advice(user_data, garment): | ||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||
| Generates an emotional styling tip without mentioning body numbers or sizes. | ||||||||||||||||||||||||||||||||||||||||||||||
| Leverages caching for performance. | ||||||||||||||||||||||||||||||||||||||||||||||
| """ | ||||||||||||||||||||||||||||||||||||||||||||||
| # Handle both dict and Pydantic model | ||||||||||||||||||||||||||||||||||||||||||||||
| if hasattr(garment, 'get'): | ||||||||||||||||||||||||||||||||||||||||||||||
| # It's a dict | ||||||||||||||||||||||||||||||||||||||||||||||
| garment_data = garment | ||||||||||||||||||||||||||||||||||||||||||||||
| elif hasattr(garment, 'dict'): | ||||||||||||||||||||||||||||||||||||||||||||||
| # It's a Pydantic model | ||||||||||||||||||||||||||||||||||||||||||||||
| garment_data = garment.dict() | ||||||||||||||||||||||||||||||||||||||||||||||
| else: | ||||||||||||||||||||||||||||||||||||||||||||||
| # Fallback | ||||||||||||||||||||||||||||||||||||||||||||||
| garment_data = garment | ||||||||||||||||||||||||||||||||||||||||||||||
|
Comment on lines
+55
to
+62
|
||||||||||||||||||||||||||||||||||||||||||||||
| # It's a dict | |
| garment_data = garment | |
| elif hasattr(garment, 'dict'): | |
| # It's a Pydantic model | |
| garment_data = garment.dict() | |
| else: | |
| # Fallback | |
| garment_data = garment | |
| # It's a dict or dict-like object | |
| garment_data = garment | |
| elif hasattr(garment, 'dict'): | |
| # It's a Pydantic model | |
| garment_data = garment.dict() | |
| elif hasattr(garment, '__dict__'): | |
| # Generic object: use its __dict__ representation | |
| garment_data = vars(garment) | |
| else: | |
| # Unsupported type: fail fast with a clear error instead of AttributeError on .get() | |
| raise TypeError( | |
| "garment must be a mapping with 'get', a Pydantic-like model with 'dict', " | |
| "or an object with a '__dict__' attribute" | |
| ) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The use of default values in garment_data.get(...) can lead to incorrect AI recommendations being cached. If a garment is missing name, drape, or elasticity, the function will proceed with generic defaults ('Luxury Item', 'Fluid', 'Comfortable'). This will cause the LLM to generate advice based on potentially incorrect information, and this incorrect advice will then be stored in the cache. Subsequent requests for other garments that are also missing these keys will receive the same incorrect, cached response.
To ensure data integrity and the correctness of the AI advice, it's better to handle missing keys more explicitly by raising an error. The existing try...except block in main.py will gracefully handle this and provide a fallback, preventing bad data from being cached.
| return _get_jules_advice_cached( | |
| user_data.event_type, | |
| garment_data.get('name', 'Luxury Item'), | |
| garment_data.get('drape', 'Fluid'), | |
| garment_data.get('elasticity', 'Comfortable') | |
| ) | |
| # Use the cached helper to avoid redundant LLM calls | |
| garment_name = garment_data.get('name') | |
| garment_drape = garment_data.get('drape') | |
| garment_elasticity = garment_data.get('elasticity') | |
| if not all((garment_name, garment_drape, garment_elasticity)): | |
| # Avoid caching and generating advice with incomplete data. | |
| # The calling function in main.py already handles exceptions gracefully. | |
| raise ValueError(f"Garment {garment_data.get('id', 'Unknown')} is missing required attributes for AI advice.") | |
| return _get_jules_advice_cached( | |
| user_data.event_type, | |
| garment_name, | |
| garment_drape, | |
| garment_elasticity | |
| ) |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -25,7 +25,9 @@ class Garment(BaseModel): | |
| "stretch_factor": 1.15, | ||
| "stock": 12, | ||
| "price": "1.290 €", | ||
| "variant_id": "gid://shopify/ProductVariant/445566" | ||
| "variant_id": "gid://shopify/ProductVariant/445566", | ||
| "drape": "Architectural", | ||
| "elasticity": "Medium-High Recovery" | ||
| }, | ||
|
Comment on lines
+28
to
31
|
||
| "LEVIS_510_STRETCH": { | ||
| "id": "LEVIS_510_STRETCH", | ||
|
|
@@ -34,7 +36,9 @@ class Garment(BaseModel): | |
| "stretch_factor": 1.10, | ||
| "stock": 45, | ||
| "price": "110 €", | ||
| "variant_id": "gid://shopify/ProductVariant/778899" | ||
| "variant_id": "gid://shopify/ProductVariant/778899", | ||
| "drape": "Classic Slim", | ||
| "elasticity": "Moderate Comfort" | ||
| } | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,14 +1,17 @@ | ||
| import pytest | ||
| import hmac | ||
| import hashlib | ||
| import time | ||
| from fastapi.testclient import TestClient | ||
| from backend.main import app | ||
| from backend.main import app, SECRET_KEY | ||
|
|
||
| client = TestClient(app) | ||
|
|
||
| def test_recommend_garment_engine_failure(monkeypatch): | ||
| def test_recommend_garment_engine_fallback(monkeypatch): | ||
| """ | ||
| Test that the /api/recommend endpoint correctly handles failures | ||
| from the Jules AI engine (get_jules_advice) and returns a 503 | ||
| Service Unavailable with a gracefully structured JSON error. | ||
| from the Jules AI engine (get_jules_advice) and returns a 200 | ||
| with a fallback recommendation string. | ||
| """ | ||
| # 1. Mock the get_jules_advice function to raise an exception | ||
| def mock_get_jules_advice(*args, **kwargs): | ||
|
|
@@ -17,22 +20,25 @@ def mock_get_jules_advice(*args, **kwargs): | |
| # Use monkeypatch to replace the real function with our mock | ||
| monkeypatch.setattr("backend.main.get_jules_advice", mock_get_jules_advice) | ||
|
|
||
| # 2. Prepare the request payload | ||
| # 2. Prepare the request payload with valid auth | ||
| user_id = "LAFAYETTE_USER" | ||
| ts = str(int(time.time())) | ||
| sig = hmac.new(SECRET_KEY.encode(), f"{user_id}:{ts}".encode(), hashlib.sha256).hexdigest() | ||
| token = f"{ts}.{sig}" | ||
|
Comment on lines
+23
to
+27
|
||
|
|
||
| payload = { | ||
| "height": 175.0, | ||
| "weight": 68.0, | ||
| "user_id": user_id, | ||
| "token": token, | ||
| "waist": 70.0, | ||
| "event_type": "Gala" | ||
| } | ||
|
|
||
| # 3. Send the POST request to the endpoint | ||
| response = client.post("/api/recommend", json=payload) | ||
|
|
||
| # 4. Assertions | ||
| assert response.status_code == 503 | ||
| assert response.status_code == 200 | ||
|
|
||
| data = response.json() | ||
| assert data == { | ||
| "status": "error", | ||
| "code": 503, | ||
| "message": "Jules AI Engine is currently recalibrating or unavailable. Please try again." | ||
| } | ||
| # It should contain the fallback styling advice | ||
| assert "Divineo confirmado con" in data["styling_advice"] | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The benchmark’s “cold cache” timing can be incorrect if the process has already populated the LRU cache (e.g., if the script is run in an interactive session or reused in the same process). Clear the cache before the first timing (and optionally after restoring the model) to ensure the first call is truly cold and the second call is truly warm.