diff --git a/.gitignore b/.gitignore index 0474c18..c813bbe 100644 --- a/.gitignore +++ b/.gitignore @@ -47,4 +47,14 @@ coverage/ pids/ *.pid *.seed -*.pid.lock \ No newline at end of file +*.pid.lock + +# Python +__pycache__/ +*.pyc +*.pyo +*.pyd +.pytest_cache/ +.venv/ +venv/ +env/ diff --git a/.jules/bolt.md b/.jules/bolt.md new file mode 100644 index 0000000..30ea938 --- /dev/null +++ b/.jules/bolt.md @@ -0,0 +1,4 @@ +## 2025-05-15 - [Add LRU caching to AI recommendation engine] +**Learning:** LLM API calls are a significant performance bottleneck in the recommendation pipeline. Since fashion advice for specific garment/event combinations is often static, caching these results provides a massive performance boost (from ~1s to <0.1ms). Using primitive, hashable types for cache keys is essential when working with complex objects like Pydantic models. + +**Action:** Always wrap expensive, repeatable AI logic with `functools.lru_cache` using primitive keys to maximize hits and minimize latency. diff --git a/backend/benchmark_cache.py b/backend/benchmark_cache.py new file mode 100644 index 0000000..d63bb7f --- /dev/null +++ b/backend/benchmark_cache.py @@ -0,0 +1,50 @@ +import time +from unittest.mock import MagicMock +import jules_engine + +def benchmark(): + print("--- ⚡ BOLT CACHE BENCHMARK (MOCKED LLM) ---") + + # Mock the LLM call to simulate a real, slow API response + original_model = jules_engine.model + jules_engine.model = MagicMock() + + def slow_generate_content(prompt): + time.sleep(1.0) # Simulate 1s network latency + mock_response = MagicMock() + mock_response.text = f"Mocked advice for: {prompt[:50]}..." + return mock_response + + jules_engine.model.generate_content.side_effect = slow_generate_content + + # Clear cache for a clean run + jules_engine._get_cached_advice.cache_clear() + + test_args = ("Gala", "Balmain Slim-Fit Jeans", "Architectural and structured", "Minimal with memory retention") + + print(f"Executing first call (uncached)...") + start_time = time.time() + advice1 = jules_engine._get_cached_advice(*test_args) + end_time = time.time() + uncached_duration = end_time - start_time + print(f"Uncached duration: {uncached_duration:.4f} seconds") + + print(f"\nExecuting second call (cached)...") + start_time = time.time() + advice2 = jules_engine._get_cached_advice(*test_args) + end_time = time.time() + cached_duration = end_time - start_time + print(f"Cached duration: {cached_duration:.4f} seconds") + + # Restore original model + jules_engine.model = original_model + + if advice1 == advice2: + print("\n[SUCCESS] Cache returned identical result.") + speedup = uncached_duration / cached_duration if cached_duration > 0 else float('inf') + print(f"Performance Gain: {speedup:.1f}x faster") + else: + print("\n[ERROR] Cache mismatch!") + +if __name__ == "__main__": + benchmark() diff --git a/backend/jules_engine.py b/backend/jules_engine.py index bb38696..e56d37a 100644 --- a/backend/jules_engine.py +++ b/backend/jules_engine.py @@ -1,4 +1,5 @@ import os +from functools import lru_cache import google.generativeai as genai from dotenv import load_dotenv @@ -17,26 +18,19 @@ 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_cached_advice(event_type, garment_name, drape, elasticity): """ - Generates an emotional styling tip without mentioning body numbers or sizes. + Internal cached function to generate styling tips. + Uses primitive types as keys for the LRU cache. """ - # 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 +45,22 @@ 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. + Utilizes LRU caching to avoid redundant expensive LLM calls. + """ + # Handle both dict and Pydantic model for garment + if hasattr(garment, 'dict'): + garment_data = garment.dict() + else: + garment_data = garment + + # Extract primitive types for the cache key + event_type = getattr(user_data, 'event_type', 'Casual') + garment_name = garment_data.get('name', 'Unknown Garment') + drape = garment_data.get('drape', 'Unknown') + elasticity = garment_data.get('elasticity', 'Unknown') + + return _get_cached_advice(event_type, garment_name, drape, elasticity) diff --git a/backend/models.py b/backend/models.py index a730f85..0abc822 100644 --- a/backend/models.py +++ b/backend/models.py @@ -15,6 +15,8 @@ class Garment(BaseModel): stock: int price: str variant_id: str + drape: str + elasticity: str # 👗 Catálogo Shopify (Divineo Bunker) SHOPIFY_INVENTORY = { @@ -25,7 +27,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 and structured", + "elasticity": "Minimal with memory retention" }, "LEVIS_510_STRETCH": { "id": "LEVIS_510_STRETCH", @@ -34,7 +38,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": "Fluid and adaptive", + "elasticity": "High performance stretch" } } diff --git a/backend/tests/test_main.py b/backend/tests/test_main.py index 8d756a9..3b402bd 100644 --- a/backend/tests/test_main.py +++ b/backend/tests/test_main.py @@ -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): """ 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) by returning a 200 OK + 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,28 @@ 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 valid authentication + user_id = "test_user_123" + ts = int(time.time()) + sig = hmac.new(SECRET_KEY.encode(), f"{user_id}:{ts}".encode(), hashlib.sha256).hexdigest() + token = f"{ts}.{sig}" + + # 3. Prepare the request payload matching UserScan model payload = { - "height": 175.0, - "weight": 68.0, + "user_id": user_id, + "token": token, + "waist": 74.75, # 74.75 / (65 * 1.15) = 1.0 (Perfect fit for BALMAIN_SS26_SLIM) "event_type": "Gala" } - # 3. Send the POST request to the endpoint + # 4. Send the POST request to the endpoint + # Note: the endpoint also takes a garment_id as a query param, defaults to "BALMAIN_SS26_SLIM" response = client.post("/api/recommend", json=payload) - # 4. Assertions - assert response.status_code == 503 + # 5. Assertions + 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." - } + assert data["status"] == "SUCCESS" + assert "styling_advice" in data + assert "Balmain Slim-Fit Jeans" in data["styling_advice"]