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
3 changes: 3 additions & 0 deletions .jules/bolt.md
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.
64 changes: 64 additions & 0 deletions backend/benchmark_jules.py
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

Comment on lines +15 to +27
Copy link

Copilot AI Mar 25, 2026

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.

Copilot uses AI. Check for mistakes.
# 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
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

This block of code, including the comment and the conditional checks for drape and elasticity, is now redundant. The backend/models.py file has been updated to include these keys in the SHOPIFY_INVENTORY data. Removing this temporary fix will make the benchmark script cleaner and ensure it tests against the actual data structure.

Suggested change
# 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"
garment = SHOPIFY_INVENTORY["BALMAIN_SS26_SLIM"].copy()


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()
45 changes: 31 additions & 14 deletions backend/jules_engine.py
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()
Expand All @@ -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
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caching is a core behavior change here, but there’s no test asserting that repeated calls with identical inputs avoid multiple model.generate_content(...) invocations. Consider adding a unit test that monkeypatches jules_engine.model.generate_content, calls get_jules_advice twice with the same event/garment properties, and asserts the model was called only once (and that the second response is served from cache).

Copilot uses AI. Check for mistakes.
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
Expand All @@ -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
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The fallback branch sets garment_data = garment, but the code below unconditionally calls garment_data.get(...). If garment is neither a dict-like object nor a Pydantic model, this will raise AttributeError. Consider either (a) removing the fallback and raising TypeError for unsupported types, or (b) converting unknown objects to a dict before calling .get() (e.g., via vars()), so the fallback is actually safe.

Suggested change
# 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"
)

Copilot uses AI. Check for mistakes.

# Use the cached helper to avoid redundant LLM calls
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')
)
Comment on lines +65 to +70
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

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.

Suggested change
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
)

8 changes: 6 additions & 2 deletions backend/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SHOPIFY_INVENTORY entries now include drape and elasticity, but the Garment Pydantic model doesn’t declare these fields. To keep the schema aligned (and avoid silently dropping these values when/if Garment is used), consider adding them as optional fields on Garment (or introducing a separate model for the inventory shape).

Copilot uses AI. Check for mistakes.
"LEVIS_510_STRETCH": {
"id": "LEVIS_510_STRETCH",
Expand All @@ -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"
}
}

Expand Down
32 changes: 19 additions & 13 deletions backend/tests/test_main.py
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):
Expand All @@ -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
Copy link

Copilot AI Mar 25, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test re-implements the auth token logic using the production SECRET_KEY and the current clock (time.time()), which makes the test tightly coupled to implementation details and potentially flaky. Since the goal here is to verify the Jules fallback behavior, consider monkeypatching backend.main.verify_auth to return True (or injecting a fixed timestamp) so the test is deterministic and not dependent on the secret/token algorithm.

Copilot uses AI. Check for mistakes.

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"]
Loading