Spaces:
Running
on
Zero
fix: resolve Decimal serialisation and UUID generation errors
Browse files- Add orjson for high-performance JSON serialisation with Decimal support
- Convert Decimal to string (not float) to preserve financial precision
- Implement client-side UUID v5 generation to remove uuid-ossp dependency
- Configure Supabase service role client with proper authentication
- Improve error handling in portfolio save flow with return value checks
This fixes three critical issues:
1. Redis cache Decimal serialisation errors
2. PostgreSQL uuid_generate_v5 function missing errors
3. Supabase RLS failures due to improper authentication configuration
Files modified:
- backend/utils/serialisation.py (new): Decimal-safe JSON utilities
- backend/utils/uuid_generator.py (new): Client-side UUID generation
- backend/caching/redis_cache.py: Use orjson for cache serialisation
- backend/database.py: Python UUID generation + Decimal handling
- app.py: Improved error handling and validation
- pyproject.toml: Add orjson dependency
- app.py +14 -4
- backend/caching/redis_cache.py +8 -1
- backend/database.py +41 -57
- backend/utils/__init__.py +1 -0
- backend/utils/serialisation.py +120 -0
- backend/utils/uuid_generator.py +151 -0
- pyproject.toml +1 -0
- uv.lock +2 -0
|
@@ -812,15 +812,22 @@ async def run_analysis_with_ui_update(
|
|
| 812 |
logger.error("No valid user_id available for portfolio creation")
|
| 813 |
raise ValueError("User ID not available")
|
| 814 |
|
| 815 |
-
await db.save_portfolio(
|
| 816 |
portfolio_id=portfolio_id,
|
| 817 |
user_id=user_id,
|
| 818 |
name=f"Analysis {datetime.now().strftime('%Y-%m-%d %H:%M')}",
|
| 819 |
risk_tolerance='moderate'
|
| 820 |
)
|
| 821 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 822 |
except Exception as e:
|
| 823 |
-
logger.
|
|
|
|
| 824 |
else:
|
| 825 |
logger.info(f"Demo mode: Portfolio {portfolio_id} created (ephemeral, not saved to database)")
|
| 826 |
|
|
@@ -876,9 +883,12 @@ async def run_analysis_with_ui_update(
|
|
| 876 |
if save_result:
|
| 877 |
logger.info(f"β Successfully saved analysis for portfolio {portfolio_id}")
|
| 878 |
else:
|
| 879 |
-
logger.
|
|
|
|
|
|
|
| 880 |
except Exception as e:
|
| 881 |
logger.error(f"β Exception saving analysis for portfolio {portfolio_id}: {e}", exc_info=True)
|
|
|
|
| 882 |
else:
|
| 883 |
logger.info(f"Demo mode: Analysis for portfolio {portfolio_id} not saved (ephemeral session)")
|
| 884 |
|
|
|
|
| 812 |
logger.error("No valid user_id available for portfolio creation")
|
| 813 |
raise ValueError("User ID not available")
|
| 814 |
|
| 815 |
+
portfolio_saved = await db.save_portfolio(
|
| 816 |
portfolio_id=portfolio_id,
|
| 817 |
user_id=user_id,
|
| 818 |
name=f"Analysis {datetime.now().strftime('%Y-%m-%d %H:%M')}",
|
| 819 |
risk_tolerance='moderate'
|
| 820 |
)
|
| 821 |
+
|
| 822 |
+
if portfolio_saved:
|
| 823 |
+
logger.info(f"β Portfolio {portfolio_id} created for user {user_id}")
|
| 824 |
+
else:
|
| 825 |
+
logger.error(f"β Failed to create portfolio {portfolio_id} in database")
|
| 826 |
+
raise ValueError(f"Portfolio creation failed for {portfolio_id}")
|
| 827 |
+
|
| 828 |
except Exception as e:
|
| 829 |
+
logger.error(f"β Failed to save portfolio: {e}")
|
| 830 |
+
raise ValueError(f"Database error: {e}") from e
|
| 831 |
else:
|
| 832 |
logger.info(f"Demo mode: Portfolio {portfolio_id} created (ephemeral, not saved to database)")
|
| 833 |
|
|
|
|
| 883 |
if save_result:
|
| 884 |
logger.info(f"β Successfully saved analysis for portfolio {portfolio_id}")
|
| 885 |
else:
|
| 886 |
+
logger.error(f"β Failed to save analysis for portfolio {portfolio_id} (returned False)")
|
| 887 |
+
# Note: We don't raise here because the analysis itself succeeded
|
| 888 |
+
# The user still gets their results even if database save fails
|
| 889 |
except Exception as e:
|
| 890 |
logger.error(f"β Exception saving analysis for portfolio {portfolio_id}: {e}", exc_info=True)
|
| 891 |
+
# Note: We don't raise here because the analysis itself succeeded
|
| 892 |
else:
|
| 893 |
logger.info(f"Demo mode: Analysis for portfolio {portfolio_id} not saved (ephemeral session)")
|
| 894 |
|
|
@@ -420,7 +420,14 @@ class HybridCache:
|
|
| 420 |
|
| 421 |
try:
|
| 422 |
if self.redis_client:
|
| 423 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 424 |
if ttl:
|
| 425 |
self.redis_client.setex(key, ttl, serialised)
|
| 426 |
else:
|
|
|
|
| 420 |
|
| 421 |
try:
|
| 422 |
if self.redis_client:
|
| 423 |
+
# Use custom serialiser if provided, otherwise use orjson with Decimal support
|
| 424 |
+
if serialiser:
|
| 425 |
+
serialised = serialiser(value)
|
| 426 |
+
else:
|
| 427 |
+
# Import here to avoid circular dependencies
|
| 428 |
+
from backend.utils.serialisation import dumps
|
| 429 |
+
serialised = dumps(value)
|
| 430 |
+
|
| 431 |
if ttl:
|
| 432 |
self.redis_client.setex(key, ttl, serialised)
|
| 433 |
else:
|
|
@@ -4,42 +4,17 @@ Handles Supabase PostgreSQL connections and operations.
|
|
| 4 |
"""
|
| 5 |
|
| 6 |
from typing import Optional, Dict, Any, List
|
| 7 |
-
from decimal import Decimal
|
| 8 |
-
from datetime import datetime, date
|
| 9 |
from supabase import create_client, Client
|
| 10 |
from backend.config import settings
|
|
|
|
|
|
|
| 11 |
import logging
|
| 12 |
|
| 13 |
logger = logging.getLogger(__name__)
|
| 14 |
|
| 15 |
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
Converts:
|
| 20 |
-
- Decimal objects to float (financial calculations)
|
| 21 |
-
- datetime/date objects to ISO format strings (timestamps)
|
| 22 |
-
|
| 23 |
-
This is needed because JSON serialization doesn't support these types,
|
| 24 |
-
but financial data often contains them.
|
| 25 |
-
|
| 26 |
-
Args:
|
| 27 |
-
obj: Any Python object (dict, list, Decimal, datetime, etc.)
|
| 28 |
-
|
| 29 |
-
Returns:
|
| 30 |
-
Same structure with non-serializable objects converted
|
| 31 |
-
"""
|
| 32 |
-
if isinstance(obj, Decimal):
|
| 33 |
-
return float(obj)
|
| 34 |
-
elif isinstance(obj, (datetime, date)):
|
| 35 |
-
return obj.isoformat()
|
| 36 |
-
elif isinstance(obj, dict):
|
| 37 |
-
return {k: decimal_to_float(v) for k, v in obj.items()}
|
| 38 |
-
elif isinstance(obj, list):
|
| 39 |
-
return [decimal_to_float(item) for item in obj]
|
| 40 |
-
elif isinstance(obj, tuple):
|
| 41 |
-
return tuple(decimal_to_float(item) for item in obj)
|
| 42 |
-
return obj
|
| 43 |
|
| 44 |
|
| 45 |
class Database:
|
|
@@ -59,19 +34,32 @@ class Database:
|
|
| 59 |
if settings.supabase_service_role_key:
|
| 60 |
api_key = settings.supabase_service_role_key
|
| 61 |
logger.info("Using Supabase service role key (bypasses RLS)")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
elif settings.supabase_key:
|
| 63 |
api_key = settings.supabase_key
|
| 64 |
-
logger.warning(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 65 |
else:
|
| 66 |
logger.error("No Supabase API key found (need either SUPABASE_SERVICE_ROLE_KEY or SUPABASE_KEY)")
|
| 67 |
return
|
| 68 |
-
|
| 69 |
-
# Simple client creation for backend service (no session management needed)
|
| 70 |
-
self.client = create_client(
|
| 71 |
-
settings.supabase_url,
|
| 72 |
-
api_key
|
| 73 |
-
)
|
| 74 |
-
logger.info("Supabase client initialised successfully")
|
| 75 |
except Exception as e:
|
| 76 |
logger.error(f"Failed to initialise Supabase client: {e}")
|
| 77 |
logger.info("Running in demo mode without database")
|
|
@@ -87,28 +75,24 @@ class Database:
|
|
| 87 |
return self.client is not None
|
| 88 |
|
| 89 |
def _string_to_uuid(self, string_id: str) -> str:
|
| 90 |
-
"""Convert string ID to deterministic UUID
|
|
|
|
|
|
|
|
|
|
|
|
|
| 91 |
|
| 92 |
Args:
|
| 93 |
-
string_id:
|
| 94 |
|
| 95 |
Returns:
|
| 96 |
-
UUID string
|
| 97 |
-
"""
|
| 98 |
-
if not self.is_connected():
|
| 99 |
-
return string_id
|
| 100 |
-
|
| 101 |
-
try:
|
| 102 |
-
result = self.client.rpc('string_to_portfolio_uuid', {
|
| 103 |
-
'input_string': string_id
|
| 104 |
-
}).execute()
|
| 105 |
|
| 106 |
-
|
| 107 |
-
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
|
| 111 |
-
return string_id
|
| 112 |
|
| 113 |
async def ensure_demo_user_exists(self) -> str:
|
| 114 |
"""Ensure demo user exists in database.
|
|
@@ -200,9 +184,9 @@ class Database:
|
|
| 200 |
try:
|
| 201 |
uuid_portfolio_id = self._string_to_uuid(portfolio_id)
|
| 202 |
|
| 203 |
-
# Convert all Decimal objects to
|
| 204 |
-
#
|
| 205 |
-
analysis_clean =
|
| 206 |
|
| 207 |
data = {
|
| 208 |
'portfolio_id': uuid_portfolio_id,
|
|
|
|
| 4 |
"""
|
| 5 |
|
| 6 |
from typing import Optional, Dict, Any, List
|
|
|
|
|
|
|
| 7 |
from supabase import create_client, Client
|
| 8 |
from backend.config import settings
|
| 9 |
+
from backend.utils.serialisation import decimal_to_json_safe
|
| 10 |
+
from backend.utils.uuid_generator import string_to_uuid
|
| 11 |
import logging
|
| 12 |
|
| 13 |
logger = logging.getLogger(__name__)
|
| 14 |
|
| 15 |
|
| 16 |
+
# Re-export for backwards compatibility
|
| 17 |
+
__all__ = ['Database', 'decimal_to_json_safe']
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
|
| 19 |
|
| 20 |
class Database:
|
|
|
|
| 34 |
if settings.supabase_service_role_key:
|
| 35 |
api_key = settings.supabase_service_role_key
|
| 36 |
logger.info("Using Supabase service role key (bypasses RLS)")
|
| 37 |
+
|
| 38 |
+
# Simple client creation for backend service operations
|
| 39 |
+
# Service role automatically bypasses RLS, no special config needed
|
| 40 |
+
self.client = create_client(
|
| 41 |
+
settings.supabase_url,
|
| 42 |
+
api_key
|
| 43 |
+
)
|
| 44 |
+
logger.info("Supabase service role client initialised successfully")
|
| 45 |
+
|
| 46 |
elif settings.supabase_key:
|
| 47 |
api_key = settings.supabase_key
|
| 48 |
+
logger.warning(
|
| 49 |
+
"Using anon key - database operations may fail due to RLS policies. "
|
| 50 |
+
"Add SUPABASE_SERVICE_ROLE_KEY environment variable for full functionality."
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
# Anon key client (RLS enforced, requires user context)
|
| 54 |
+
self.client = create_client(
|
| 55 |
+
settings.supabase_url,
|
| 56 |
+
api_key
|
| 57 |
+
)
|
| 58 |
+
logger.info("Supabase anon client initialised (RLS enforced)")
|
| 59 |
+
|
| 60 |
else:
|
| 61 |
logger.error("No Supabase API key found (need either SUPABASE_SERVICE_ROLE_KEY or SUPABASE_KEY)")
|
| 62 |
return
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 63 |
except Exception as e:
|
| 64 |
logger.error(f"Failed to initialise Supabase client: {e}")
|
| 65 |
logger.info("Running in demo mode without database")
|
|
|
|
| 75 |
return self.client is not None
|
| 76 |
|
| 77 |
def _string_to_uuid(self, string_id: str) -> str:
|
| 78 |
+
"""Convert string ID to deterministic UUID.
|
| 79 |
+
|
| 80 |
+
Uses Python UUID v5 generation for deterministic conversion.
|
| 81 |
+
This is faster than database-side generation and doesn't
|
| 82 |
+
require the uuid-ossp extension.
|
| 83 |
|
| 84 |
Args:
|
| 85 |
+
string_id: Human-readable ID (e.g., 'demo_20241119_105323')
|
| 86 |
|
| 87 |
Returns:
|
| 88 |
+
UUID string (deterministic, always same for same input)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 89 |
|
| 90 |
+
Example:
|
| 91 |
+
>>> db = Database()
|
| 92 |
+
>>> db._string_to_uuid('demo_20241119_105323')
|
| 93 |
+
'...' # Same UUID every time for this input
|
| 94 |
+
"""
|
| 95 |
+
return string_to_uuid(string_id)
|
| 96 |
|
| 97 |
async def ensure_demo_user_exists(self) -> str:
|
| 98 |
"""Ensure demo user exists in database.
|
|
|
|
| 184 |
try:
|
| 185 |
uuid_portfolio_id = self._string_to_uuid(portfolio_id)
|
| 186 |
|
| 187 |
+
# Convert all Decimal objects to string for JSON serialisation
|
| 188 |
+
# This preserves precision - float conversion loses pennies in large numbers
|
| 189 |
+
analysis_clean = decimal_to_json_safe(analysis_results)
|
| 190 |
|
| 191 |
data = {
|
| 192 |
'portfolio_id': uuid_portfolio_id,
|
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
"""Utility modules for the Portfolio Intelligence Platform."""
|
|
@@ -0,0 +1,120 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""JSON serialisation utilities with Decimal precision preservation.
|
| 2 |
+
|
| 3 |
+
This module provides safe JSON serialisation for financial data,
|
| 4 |
+
ensuring Decimal types are converted to strings (not floats) to
|
| 5 |
+
preserve full precision. Converting Decimal to float can cause
|
| 6 |
+
monetary errors in large values (e.g., Β£1,000,000,000.47 β Β£1,000,000,000.50).
|
| 7 |
+
"""
|
| 8 |
+
|
| 9 |
+
from typing import Any
|
| 10 |
+
from decimal import Decimal
|
| 11 |
+
from datetime import datetime, date
|
| 12 |
+
import orjson
|
| 13 |
+
import logging
|
| 14 |
+
|
| 15 |
+
logger = logging.getLogger(__name__)
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
def default_handler(obj: Any) -> Any:
|
| 19 |
+
"""Custom default handler for orjson serialisation.
|
| 20 |
+
|
| 21 |
+
Handles types that aren't natively JSON-serialisable:
|
| 22 |
+
- Decimal β string (preserves precision for financial data)
|
| 23 |
+
- datetime/date β ISO format string
|
| 24 |
+
|
| 25 |
+
Args:
|
| 26 |
+
obj: Object to serialise
|
| 27 |
+
|
| 28 |
+
Returns:
|
| 29 |
+
JSON-serialisable representation
|
| 30 |
+
|
| 31 |
+
Raises:
|
| 32 |
+
TypeError: If object type is not handled
|
| 33 |
+
|
| 34 |
+
Example:
|
| 35 |
+
>>> import orjson
|
| 36 |
+
>>> from decimal import Decimal
|
| 37 |
+
>>> orjson.dumps({"price": Decimal("19.99")}, default=default_handler)
|
| 38 |
+
b'{"price":"19.99"}'
|
| 39 |
+
"""
|
| 40 |
+
if isinstance(obj, Decimal):
|
| 41 |
+
# CRITICAL: Convert to string, NOT float
|
| 42 |
+
# Float loses precision in large numbers
|
| 43 |
+
return str(obj)
|
| 44 |
+
elif isinstance(obj, (datetime, date)):
|
| 45 |
+
return obj.isoformat()
|
| 46 |
+
raise TypeError(f"Type {type(obj)} not serialisable")
|
| 47 |
+
|
| 48 |
+
|
| 49 |
+
def dumps(obj: Any) -> bytes:
|
| 50 |
+
"""Serialise object to JSON bytes with Decimal support.
|
| 51 |
+
|
| 52 |
+
Args:
|
| 53 |
+
obj: Object to serialise
|
| 54 |
+
|
| 55 |
+
Returns:
|
| 56 |
+
JSON bytes with Decimal values as strings
|
| 57 |
+
|
| 58 |
+
Example:
|
| 59 |
+
>>> from decimal import Decimal
|
| 60 |
+
>>> dumps({"price": Decimal("19.99"), "quantity": 5})
|
| 61 |
+
b'{"price":"19.99","quantity":5}'
|
| 62 |
+
"""
|
| 63 |
+
return orjson.dumps(obj, default=default_handler)
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
def dumps_str(obj: Any) -> str:
|
| 67 |
+
"""Serialise object to JSON string with Decimal support.
|
| 68 |
+
|
| 69 |
+
Args:
|
| 70 |
+
obj: Object to serialise
|
| 71 |
+
|
| 72 |
+
Returns:
|
| 73 |
+
JSON string with Decimal values as strings
|
| 74 |
+
|
| 75 |
+
Example:
|
| 76 |
+
>>> from decimal import Decimal
|
| 77 |
+
>>> dumps_str({"price": Decimal("19.99")})
|
| 78 |
+
'{"price":"19.99"}'
|
| 79 |
+
"""
|
| 80 |
+
return dumps(obj).decode('utf-8')
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
def decimal_to_json_safe(obj: Any) -> Any:
|
| 84 |
+
"""Recursively convert non-JSON-serialisable objects to safe types.
|
| 85 |
+
|
| 86 |
+
This function provides an alternative approach when you need
|
| 87 |
+
Python dict/list structures rather than JSON bytes.
|
| 88 |
+
|
| 89 |
+
Converts:
|
| 90 |
+
- Decimal objects β string (preserves full precision)
|
| 91 |
+
- datetime/date objects β ISO format strings
|
| 92 |
+
|
| 93 |
+
This preserves precision for financial calculations, which is critical
|
| 94 |
+
as converting Decimal to float can lose pence in large numbers.
|
| 95 |
+
|
| 96 |
+
Args:
|
| 97 |
+
obj: Any Python object (dict, list, Decimal, datetime, etc.)
|
| 98 |
+
|
| 99 |
+
Returns:
|
| 100 |
+
Same structure with non-serialisable objects converted
|
| 101 |
+
|
| 102 |
+
Example:
|
| 103 |
+
>>> from decimal import Decimal
|
| 104 |
+
>>> decimal_to_json_safe({"price": Decimal('19.99')})
|
| 105 |
+
{'price': '19.99'}
|
| 106 |
+
>>> decimal_to_json_safe([Decimal('1.23'), Decimal('4.56')])
|
| 107 |
+
['1.23', '4.56']
|
| 108 |
+
"""
|
| 109 |
+
if isinstance(obj, Decimal):
|
| 110 |
+
# CRITICAL: String preserves precision, float does not
|
| 111 |
+
return str(obj)
|
| 112 |
+
elif isinstance(obj, (datetime, date)):
|
| 113 |
+
return obj.isoformat()
|
| 114 |
+
elif isinstance(obj, dict):
|
| 115 |
+
return {k: decimal_to_json_safe(v) for k, v in obj.items()}
|
| 116 |
+
elif isinstance(obj, list):
|
| 117 |
+
return [decimal_to_json_safe(item) for item in obj]
|
| 118 |
+
elif isinstance(obj, tuple):
|
| 119 |
+
return tuple(decimal_to_json_safe(item) for item in obj)
|
| 120 |
+
return obj
|
|
@@ -0,0 +1,151 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""UUID generation utilities for deterministic ID conversion.
|
| 2 |
+
|
| 3 |
+
This module provides client-side UUID generation to convert human-readable
|
| 4 |
+
identifiers (like 'portfolio-demo', 'user-123') into deterministic UUIDs.
|
| 5 |
+
This approach is faster and more portable than database-side generation.
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import uuid
|
| 9 |
+
from typing import Union
|
| 10 |
+
import logging
|
| 11 |
+
|
| 12 |
+
logger = logging.getLogger(__name__)
|
| 13 |
+
|
| 14 |
+
# Use null UUID as root namespace for application IDs
|
| 15 |
+
NULL_NAMESPACE = uuid.UUID('00000000-0000-0000-0000-000000000000')
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class DeterministicUUIDGenerator:
|
| 19 |
+
"""Generates deterministic UUIDs for consistent ID conversion.
|
| 20 |
+
|
| 21 |
+
This class provides a standardised way to convert human-readable
|
| 22 |
+
identifiers into deterministic UUIDs that remain consistent across
|
| 23 |
+
sessions using UUID v5 (SHA-1 based).
|
| 24 |
+
|
| 25 |
+
Args:
|
| 26 |
+
namespace: Base UUID for the namespace. Defaults to null UUID.
|
| 27 |
+
|
| 28 |
+
Example:
|
| 29 |
+
>>> generator = DeterministicUUIDGenerator()
|
| 30 |
+
>>> portfolio_id = generator.generate('portfolio-demo')
|
| 31 |
+
>>> same_id = generator.generate('portfolio-demo')
|
| 32 |
+
>>> portfolio_id == same_id
|
| 33 |
+
True
|
| 34 |
+
"""
|
| 35 |
+
|
| 36 |
+
def __init__(self, namespace: Union[str, uuid.UUID, None] = None) -> None:
|
| 37 |
+
"""Initialise the UUID generator with a namespace.
|
| 38 |
+
|
| 39 |
+
Args:
|
| 40 |
+
namespace: Base UUID for generation. If None, uses null UUID.
|
| 41 |
+
"""
|
| 42 |
+
if namespace is None:
|
| 43 |
+
self.namespace = NULL_NAMESPACE
|
| 44 |
+
elif isinstance(namespace, str):
|
| 45 |
+
self.namespace = uuid.UUID(namespace)
|
| 46 |
+
else:
|
| 47 |
+
self.namespace = namespace
|
| 48 |
+
|
| 49 |
+
logger.debug(f"UUID generator initialised with namespace: {self.namespace}")
|
| 50 |
+
|
| 51 |
+
def generate(self, identifier: str) -> uuid.UUID:
|
| 52 |
+
"""Generate deterministic UUID from identifier.
|
| 53 |
+
|
| 54 |
+
Uses UUID v5 (SHA-1 hashing) to create a deterministic UUID.
|
| 55 |
+
Same input always produces the same output.
|
| 56 |
+
|
| 57 |
+
Args:
|
| 58 |
+
identifier: Human-readable identifier string
|
| 59 |
+
|
| 60 |
+
Returns:
|
| 61 |
+
Deterministic UUID that will always be the same for this identifier
|
| 62 |
+
|
| 63 |
+
Example:
|
| 64 |
+
>>> gen = DeterministicUUIDGenerator()
|
| 65 |
+
>>> gen.generate('demo-portfolio')
|
| 66 |
+
UUID('...')
|
| 67 |
+
"""
|
| 68 |
+
if not identifier:
|
| 69 |
+
raise ValueError("Identifier cannot be empty")
|
| 70 |
+
|
| 71 |
+
return uuid.uuid5(namespace=self.namespace, name=identifier)
|
| 72 |
+
|
| 73 |
+
def generate_str(self, identifier: str) -> str:
|
| 74 |
+
"""Generate deterministic UUID as string.
|
| 75 |
+
|
| 76 |
+
Args:
|
| 77 |
+
identifier: Human-readable identifier string
|
| 78 |
+
|
| 79 |
+
Returns:
|
| 80 |
+
UUID as string with hyphens (e.g., '550e8400-e29b-41d4-a716-446655440000')
|
| 81 |
+
"""
|
| 82 |
+
return str(self.generate(identifier))
|
| 83 |
+
|
| 84 |
+
def generate_hex(self, identifier: str) -> str:
|
| 85 |
+
"""Generate deterministic UUID as hex string (no hyphens).
|
| 86 |
+
|
| 87 |
+
Args:
|
| 88 |
+
identifier: Human-readable identifier string
|
| 89 |
+
|
| 90 |
+
Returns:
|
| 91 |
+
UUID as hex string without hyphens (e.g., '550e8400e29b41d4a716446655440000')
|
| 92 |
+
"""
|
| 93 |
+
return self.generate(identifier).hex
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
# Global instance for application use
|
| 97 |
+
_generator = DeterministicUUIDGenerator()
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def string_to_uuid(identifier: str) -> str:
|
| 101 |
+
"""Convert string identifier to deterministic UUID string.
|
| 102 |
+
|
| 103 |
+
Convenience function using the global UUID generator.
|
| 104 |
+
|
| 105 |
+
Args:
|
| 106 |
+
identifier: Human-readable identifier (e.g., 'user-123', 'demo-portfolio')
|
| 107 |
+
|
| 108 |
+
Returns:
|
| 109 |
+
UUID string representation
|
| 110 |
+
|
| 111 |
+
Example:
|
| 112 |
+
>>> string_to_uuid('portfolio-demo')
|
| 113 |
+
'...'
|
| 114 |
+
"""
|
| 115 |
+
return _generator.generate_str(identifier)
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
def validate_or_generate_uuid(provided_uuid: Union[str, None], fallback_identifier: str) -> str:
|
| 119 |
+
"""Validate provided UUID or generate from identifier.
|
| 120 |
+
|
| 121 |
+
Args:
|
| 122 |
+
provided_uuid: UUID string from client (if provided)
|
| 123 |
+
fallback_identifier: Identifier to use if validation fails
|
| 124 |
+
|
| 125 |
+
Returns:
|
| 126 |
+
Valid UUID string
|
| 127 |
+
|
| 128 |
+
Example:
|
| 129 |
+
>>> validate_or_generate_uuid(None, 'user-123')
|
| 130 |
+
'...'
|
| 131 |
+
>>> validate_or_generate_uuid('invalid', 'user-123')
|
| 132 |
+
'...'
|
| 133 |
+
"""
|
| 134 |
+
if provided_uuid:
|
| 135 |
+
try:
|
| 136 |
+
# Validate it's a proper UUID
|
| 137 |
+
validated = uuid.UUID(provided_uuid)
|
| 138 |
+
# Verify it matches expected deterministic value
|
| 139 |
+
expected = _generator.generate(fallback_identifier)
|
| 140 |
+
if validated == expected:
|
| 141 |
+
return str(validated)
|
| 142 |
+
else:
|
| 143 |
+
logger.warning(
|
| 144 |
+
f"Provided UUID {provided_uuid} doesn't match expected "
|
| 145 |
+
f"deterministic UUID for {fallback_identifier}"
|
| 146 |
+
)
|
| 147 |
+
except (ValueError, AttributeError) as e:
|
| 148 |
+
logger.warning(f"Invalid UUID provided: {provided_uuid}, error: {e}")
|
| 149 |
+
|
| 150 |
+
# Generate server-side if validation fails or no UUID provided
|
| 151 |
+
return string_to_uuid(fallback_identifier)
|
|
@@ -26,6 +26,7 @@ dependencies = [
|
|
| 26 |
# Caching & Rate Limiting
|
| 27 |
"redis>=5.0.0",
|
| 28 |
"upstash-redis>=0.15.0",
|
|
|
|
| 29 |
# Quantitative Finance (P0 - Deterministic Models)
|
| 30 |
# Research validated: Package name is 'pyportfolioopt', import as 'pypfopt'
|
| 31 |
"pyportfolioopt>=1.5.6",
|
|
|
|
| 26 |
# Caching & Rate Limiting
|
| 27 |
"redis>=5.0.0",
|
| 28 |
"upstash-redis>=0.15.0",
|
| 29 |
+
"orjson>=3.10.0",
|
| 30 |
# Quantitative Finance (P0 - Deterministic Models)
|
| 31 |
# Research validated: Package name is 'pyportfolioopt', import as 'pypfopt'
|
| 32 |
"pyportfolioopt>=1.5.6",
|
|
@@ -3167,6 +3167,7 @@ dependencies = [
|
|
| 3167 |
{ name = "langgraph" },
|
| 3168 |
{ name = "mcp" },
|
| 3169 |
{ name = "numpy" },
|
|
|
|
| 3170 |
{ name = "pandas" },
|
| 3171 |
{ name = "psycopg2-binary" },
|
| 3172 |
{ name = "pydantic" },
|
|
@@ -3212,6 +3213,7 @@ requires-dist = [
|
|
| 3212 |
{ name = "langgraph", specifier = ">=0.1.0" },
|
| 3213 |
{ name = "mcp", specifier = ">=1.15.0" },
|
| 3214 |
{ name = "numpy", specifier = ">=2.0.0" },
|
|
|
|
| 3215 |
{ name = "pandas", specifier = ">=2.2.3" },
|
| 3216 |
{ name = "psycopg2-binary", specifier = ">=2.9.9" },
|
| 3217 |
{ name = "pydantic", specifier = ">=2.5.0" },
|
|
|
|
| 3167 |
{ name = "langgraph" },
|
| 3168 |
{ name = "mcp" },
|
| 3169 |
{ name = "numpy" },
|
| 3170 |
+
{ name = "orjson" },
|
| 3171 |
{ name = "pandas" },
|
| 3172 |
{ name = "psycopg2-binary" },
|
| 3173 |
{ name = "pydantic" },
|
|
|
|
| 3213 |
{ name = "langgraph", specifier = ">=0.1.0" },
|
| 3214 |
{ name = "mcp", specifier = ">=1.15.0" },
|
| 3215 |
{ name = "numpy", specifier = ">=2.0.0" },
|
| 3216 |
+
{ name = "orjson", specifier = ">=3.10.0" },
|
| 3217 |
{ name = "pandas", specifier = ">=2.2.3" },
|
| 3218 |
{ name = "psycopg2-binary", specifier = ">=2.9.9" },
|
| 3219 |
{ name = "pydantic", specifier = ">=2.5.0" },
|