diff --git a/README.md b/README.md index d14bc31..869e6d1 100644 --- a/README.md +++ b/README.md @@ -114,8 +114,8 @@ claimwise/ │ └── requirements.txt # Dependencies │ └── 📚 docs/ # Documentation - ├── api/ # API documentation - └── deployment/ # Deployment guides + ├── api/ # Architectural & RAG reviews + └── deployment/ # Deployment guides (TODO) ``` --- diff --git a/backend/requirements.txt b/backend/requirements.txt index aaa9dd0..7eb4c67 100644 Binary files a/backend/requirements.txt and b/backend/requirements.txt differ diff --git a/backend/src/auth.py b/backend/src/auth.py index 14a1342..e511f5e 100644 --- a/backend/src/auth.py +++ b/backend/src/auth.py @@ -1,9 +1,8 @@ -import os +import os from supabase.client import create_client, Client from fastapi import Depends, HTTPException, status from fastapi.security import OAuth2PasswordBearer from jose import JWTError, jwt -from functools import lru_cache import logging # intialzing supbasae client @@ -13,7 +12,7 @@ raise ValueError("SUPABASE_URL and SUPABASE_KEY environment variables must be set") supabase: Client = create_client(supabase_url, supabase_key) -#Oauth 2 scheme for token +# Oauth 2 scheme for token oauth2_scheme = OAuth2PasswordBearer(tokenUrl="token") # Supabase JWT secret for token verification @@ -23,13 +22,13 @@ ALGORITHM = ["HS256"] # Supabase Legacy JWT uses HS256 for signing -@lru_cache(maxsize=128) + def decode_token(token: str) -> str: """ Decode and verify a Supabase-issued JWT token. Supports tokens from email/password and social authentication (Google, GitHub). - Args: + Args: token (str): The JWT token to decode and verify. Returns: @@ -42,19 +41,28 @@ def decode_token(token: str) -> str: logger = logging.getLogger(__name__) logger.debug("Decoding token") jwt_secret: str = SUPABASE_JWT_SECRET # type: ignore - payload = jwt.decode(token, jwt_secret, algorithms=ALGORITHM, options={"verify_aud": True}, audience="authenticated") + payload = jwt.decode( + token, + jwt_secret, + algorithms=ALGORITHM, + options={"verify_aud": True}, + audience="authenticated", + ) logger.debug("Decoded payload keys: %s", list(payload.keys())) user_id = payload.get("sub") if user_id is None: logger.warning("[Auth] Missing 'sub' claim in JWT payload") raise JWTError("Invalid authentication credentials: missing 'sub' claim") - + logger.debug("[Auth] User ID extracted from token: %s", user_id) return str(user_id) except JWTError as e: - logging.getLogger(__name__).exception("[Auth] JWTError during token decode: %s", str(e)) + logging.getLogger(__name__).exception( + "[Auth] JWTError during token decode: %s", str(e) + ) raise - + + async def get_current_user(token: str = Depends(oauth2_scheme)) -> str: """ Verify JWT token and extract user ID. @@ -72,7 +80,7 @@ async def get_current_user(token: str = Depends(oauth2_scheme)) -> str: try: logger = logging.getLogger(__name__) logger.debug("[Auth] get_current_user called") - + user_id = decode_token(token) logger.debug("[Auth] User authenticated: %s", user_id) return user_id @@ -93,13 +101,14 @@ async def get_current_user(token: str = Depends(oauth2_scheme)) -> str: headers={"WWW-Authenticate": "Bearer"}, ) + async def refresh_token(refresh_token: str): """ Refresh an access token using a refresh token. - + Args: refresh_token (str): Refresh token from client. - + Returns: dict: New access and refresh tokens. """ @@ -107,12 +116,13 @@ async def refresh_token(refresh_token: str): response = supabase.auth.refresh_session(refresh_token) session = getattr(response, "session", None) if session is None: - raise HTTPException(status_code=400, detail="No session returned from Supabase.") + raise HTTPException( + status_code=400, detail="No session returned from Supabase." + ) return { "access_token": session.access_token, "refresh_token": session.refresh_token, - "token_type": "bearer" + "token_type": "bearer", } except Exception as e: raise HTTPException(status_code=400, detail=f"Refresh failed: {str(e)}") - \ No newline at end of file diff --git a/backend/src/embeddings.py b/backend/src/embeddings.py index 9562319..7ef3a40 100644 --- a/backend/src/embeddings.py +++ b/backend/src/embeddings.py @@ -2,6 +2,7 @@ Enhanced embedding system for ClaimWise backend. Provides efficient text embedding with caching, batching, and multiple provider support. """ + import logging import hashlib from typing import List, Dict, Any, Optional, Tuple @@ -11,120 +12,138 @@ logger = logging.getLogger(__name__) + class EmbeddingProvider: """Base class for embedding providers""" - + def __init__(self, name: str): self.name = name - + async def embed_texts(self, texts: List[str]) -> List[List[float]]: """Generate embeddings for a list of texts""" raise NotImplementedError - + def get_embedding_dimension(self) -> int: """Get the dimension of embeddings produced by this provider""" raise NotImplementedError + class OpenAIEmbeddingProvider(EmbeddingProvider): """OpenAI embedding provider""" - + def __init__(self, api_key: str, model: str = "text-embedding-ada-002"): super().__init__("openai") self.api_key = api_key self.model = model self.dimension = 1536 # ada-002 dimension - + async def embed_texts(self, texts: List[str]) -> List[List[float]]: """Generate embeddings using OpenAI API""" + import asyncio + try: import openai + client = openai.OpenAI(api_key=self.api_key) - + # OpenAI allows up to 2048 texts per request batch_size = min(2048, len(texts)) embeddings = [] - + for i in range(0, len(texts), batch_size): - batch = texts[i:i + batch_size] - - response = client.embeddings.create( - input=batch, - model=self.model + batch = texts[i : i + batch_size] + + response = await asyncio.wait_for( + asyncio.to_thread( + lambda b=batch: client.embeddings.create( + input=b, model=self.model + ) + ), + timeout=15.0, ) - + batch_embeddings = [item.embedding for item in response.data] embeddings.extend(batch_embeddings) - + return embeddings - + + except asyncio.TimeoutError: + raise ExternalAPIError( + message="OpenAI embedding timed out after 15s", + service="openai_embeddings", + ) except Exception as e: raise ExternalAPIError( message=f"OpenAI embedding failed: {str(e)}", service="openai_embeddings", - original_exception=e + original_exception=e, ) - + def get_embedding_dimension(self) -> int: return self.dimension + class GeminiEmbeddingProvider(EmbeddingProvider): """Gemini embedding provider using google-generativeai""" - + def __init__(self, api_key: str, model_name: str = "models/embedding-001"): super().__init__("gemini") self.api_key = api_key self.model_name = model_name self.dimension = 768 self._genai: Any = None - + def _load_client(self): if self._genai is None: try: import google.generativeai as genai + genai.configure(api_key=self.api_key) # type: ignore self._genai = genai except ImportError: raise ProcessingError( message="google-generativeai library not installed", - operation="load_embedding_model" + operation="load_embedding_model", ) async def embed_texts(self, texts: List[str]) -> List[List[float]]: + import asyncio + self._load_client() try: - # Gemini supports batch embedding - result = self._genai.embed_content( # type: ignore - model=self.model_name, - content=texts, - task_type="retrieval_document" + result = await asyncio.to_thread( + lambda: self._genai.embed_content( # type: ignore + model=self.model_name, content=texts, task_type="retrieval_document" + ) ) - # Ensure we return a list of lists as expected by the interface - embeddings = result['embedding'] + embeddings = result["embedding"] return embeddings # type: ignore except Exception as e: raise ExternalAPIError( message=f"Gemini embedding failed: {str(e)}", operation="generate_embeddings", - original_exception=e + original_exception=e, ) - + def get_embedding_dimension(self) -> int: return self.dimension + class SentenceTransformerProvider(EmbeddingProvider): """Local sentence transformer embedding provider""" - + def __init__(self, model_name: str = "all-MiniLM-L6-v2"): super().__init__("sentence_transformer") self.model_name = model_name self._model = None self.dimension = 384 # Default for all-MiniLM-L6-v2 - + def _load_model(self): """Lazy load the sentence transformer model""" if self._model is None: try: from sentence_transformers import SentenceTransformer + self._model = SentenceTransformer(self.model_name) logger.info(f"Loaded sentence transformer model: {self.model_name}") except ImportError as e: @@ -134,53 +153,67 @@ def _load_model(self): original_exception=e, recovery_suggestions=[ "Install sentence-transformers: pip install sentence-transformers" - ] + ], ) except Exception as e: raise ProcessingError( message=f"Failed to load sentence transformer model: {str(e)}", operation="load_embedding_model", - original_exception=e + original_exception=e, ) - + async def embed_texts(self, texts: List[str]) -> List[List[float]]: - """Generate embeddings using sentence transformers""" + """Generate embeddings using sentence transformers (non-blocking via to_thread)""" + import asyncio + self._load_model() - + if self._model is None: raise ProcessingError( message="Sentence transformer model failed to load", - operation="generate_embeddings" + operation="generate_embeddings", ) - + try: - embeddings = self._model.encode(texts, convert_to_tensor=False) + embeddings = await asyncio.wait_for( + asyncio.to_thread( + lambda: self._model.encode(texts, convert_to_tensor=False) + ), + timeout=10.0, + ) return embeddings.tolist() + except asyncio.TimeoutError: + raise ProcessingError( + message="Sentence transformer embedding timed out after 10s", + operation="generate_embeddings", + ) except Exception as e: raise ProcessingError( message=f"Sentence transformer embedding failed: {str(e)}", operation="generate_embeddings", - original_exception=e + original_exception=e, ) - + def get_embedding_dimension(self) -> int: return self.dimension + class HuggingFaceEmbeddingProvider(EmbeddingProvider): """Hugging Face embedding provider""" - + def __init__(self, model_name: str = "sentence-transformers/all-MiniLM-L6-v2"): super().__init__("huggingface") self.model_name = model_name self._tokenizer = None self._model = None self.dimension = 384 - + def _load_model(self): """Lazy load the Hugging Face model""" if self._model is None: try: from transformers import AutoTokenizer, AutoModel + self._tokenizer = AutoTokenizer.from_pretrained(self.model_name) self._model = AutoModel.from_pretrained(self.model_name) logger.info(f"Loaded Hugging Face model: {self.model_name}") @@ -190,62 +223,66 @@ def _load_model(self): operation="load_embedding_model", recovery_suggestions=[ "Install transformers: pip install transformers torch" - ] + ], ) - + async def embed_texts(self, texts: List[str]) -> List[List[float]]: - """Generate embeddings using Hugging Face transformers""" + """Generate embeddings using Hugging Face models (non-blocking via to_thread)""" + import asyncio + self._load_model() - - if self._tokenizer is None or self._model is None: + + if self._model is None: raise ProcessingError( - message="Hugging Face model or tokenizer failed to load", - operation="generate_embeddings" + message="Hugging Face model failed to load", + operation="generate_embeddings", ) - + try: import torch - - # Tokenize and encode + encoded_input = self._tokenizer( - texts, - padding=True, - truncation=True, - return_tensors='pt' + texts, padding=True, truncation=True, return_tensors="pt" ) - - with torch.no_grad(): - model_output = self._model(**encoded_input) - # Use mean pooling - embeddings = model_output.last_hidden_state.mean(dim=1) - - return embeddings.tolist() - + + embeddings = await asyncio.to_thread(lambda: self._encode_hf(encoded_input)) + + return embeddings + except Exception as e: raise ProcessingError( message=f"Hugging Face embedding failed: {str(e)}", operation="generate_embeddings", - original_exception=e + original_exception=e, ) - + + def _encode_hf(self, encoded_input) -> List[List[float]]: + import torch + + with torch.no_grad(): + model_output = self._model(**encoded_input) + embeddings = model_output.last_hidden_state.mean(dim=1) + return embeddings.tolist() + def get_embedding_dimension(self) -> int: return self.dimension + class EmbeddingManager: """Manages multiple embedding providers and caching""" - + def __init__(self): self.providers: Dict[str, EmbeddingProvider] = {} self.default_provider: Optional[str] = None self.cache = get_embedding_cache() - + # Initialize default providers self._initialize_providers() - + def _initialize_providers(self): """Initialize available embedding providers""" import os - + # Try to initialize OpenAI provider openai_key = os.getenv("OPENAI_API_KEY") if openai_key: @@ -255,7 +292,7 @@ def _initialize_providers(self): logger.info("Initialized OpenAI embedding provider") except Exception as e: logger.warning(f"Failed to initialize OpenAI provider: {e}") - + # Try to initialize Gemini provider gemini_key = os.getenv("GEMINI_API_KEY") if gemini_key: @@ -266,7 +303,7 @@ def _initialize_providers(self): logger.info("Initialized Gemini embedding provider") except Exception as e: logger.warning(f"Failed to initialize Gemini provider: {e}") - + # Try to initialize Sentence Transformer provider try: self.providers["sentence_transformer"] = SentenceTransformerProvider() @@ -275,7 +312,7 @@ def _initialize_providers(self): logger.info("Initialized Sentence Transformer provider") except Exception as e: logger.warning(f"Failed to initialize Sentence Transformer provider: {e}") - + # Try to initialize Hugging Face provider try: self.providers["huggingface"] = HuggingFaceEmbeddingProvider() @@ -284,17 +321,17 @@ def _initialize_providers(self): logger.info("Initialized Hugging Face provider") except Exception as e: logger.warning(f"Failed to initialize Hugging Face provider: {e}") - + if not self.providers: logger.error("No embedding providers available!") - + def add_provider(self, name: str, provider: EmbeddingProvider): """Add a custom embedding provider""" self.providers[name] = provider if self.default_provider is None: self.default_provider = name logger.info(f"Added custom embedding provider: {name}") - + def _generate_cache_key(self, texts: List[str], provider_name: str) -> str: """Generate a cache key for the given texts and provider""" # Create a hash of the texts and provider @@ -303,19 +340,16 @@ def _generate_cache_key(self, texts: List[str], provider_name: str) -> str: f"{provider_name}:{text_content}".encode() ).hexdigest()[:16] return f"embed:{provider_name}:{content_hash}" - + @handle_exceptions(logger) async def embed_texts( - self, - texts: List[str], - provider: Optional[str] = None, - use_cache: bool = True + self, texts: List[str], provider: Optional[str] = None, use_cache: bool = True ) -> List[List[float]]: """Generate embeddings for a list of texts""" - + if not texts: return [] - + # Use default provider if none specified provider_name = provider or self.default_provider if not provider_name or provider_name not in self.providers: @@ -326,10 +360,10 @@ async def embed_texts( details={"available_providers": available}, recovery_suggestions=[ f"Use one of the available providers: {', '.join(available)}", - "Check your API keys and dependencies" - ] + "Check your API keys and dependencies", + ], ) - + # Check cache first cache_key = self._generate_cache_key(texts, provider_name) if use_cache: @@ -337,13 +371,13 @@ async def embed_texts( if cached_result is not None: logger.debug(f"Cache hit for embedding batch of {len(texts)} texts") return cached_result - + # Generate embeddings provider_instance = self.providers[provider_name] - + try: embeddings = await provider_instance.embed_texts(texts) - + # Validate results if len(embeddings) != len(texts): raise ProcessingError( @@ -351,17 +385,19 @@ async def embed_texts( operation="embed_texts", details={ "input_count": len(texts), - "output_count": len(embeddings) - } + "output_count": len(embeddings), + }, ) - + # Cache the results if use_cache: self.cache.set(cache_key, embeddings, ttl=7200) # Cache for 2 hours - - logger.debug(f"Generated {len(embeddings)} embeddings using {provider_name}") + + logger.debug( + f"Generated {len(embeddings)} embeddings using {provider_name}" + ) return embeddings - + except Exception as e: if isinstance(e, (ExternalAPIError, ProcessingError)): raise @@ -369,123 +405,93 @@ async def embed_texts( raise ProcessingError( message=f"Embedding generation failed with {provider_name}", operation="embed_texts", - original_exception=e + original_exception=e, ) - + async def embed_single_text( - self, - text: str, - provider: Optional[str] = None, - use_cache: bool = True + self, text: str, provider: Optional[str] = None, use_cache: bool = True ) -> List[float]: """Generate embedding for a single text""" embeddings = await self.embed_texts([text], provider, use_cache) return embeddings[0] if embeddings else [] - + def get_embedding_dimension(self, provider: Optional[str] = None) -> int: """Get the embedding dimension for the specified provider""" provider_name = provider or self.default_provider if provider_name and provider_name in self.providers: return self.providers[provider_name].get_embedding_dimension() return 384 # Default dimension - + def get_available_providers(self) -> List[str]: """Get list of available embedding providers""" return list(self.providers.keys()) - + def calculate_similarity( - self, - embedding1: List[float], - embedding2: List[float] + self, embedding1: List[float], embedding2: List[float] ) -> float: """Calculate cosine similarity between two embeddings""" try: # Convert to numpy arrays vec1 = np.array(embedding1) vec2 = np.array(embedding2) - + # Calculate cosine similarity dot_product = np.dot(vec1, vec2) norm1 = np.linalg.norm(vec1) norm2 = np.linalg.norm(vec2) - + if norm1 == 0 or norm2 == 0: return 0.0 - + return dot_product / (norm1 * norm2) - + except Exception as e: logger.error(f"Error calculating similarity: {e}") return 0.0 - + def find_most_similar( - self, - query_embedding: List[float], + self, + query_embedding: List[float], candidate_embeddings: List[List[float]], - top_k: int = 5 + top_k: int = 5, ) -> List[Tuple[int, float]]: """Find the most similar embeddings to the query""" try: similarities = [] - + for i, candidate in enumerate(candidate_embeddings): similarity = self.calculate_similarity(query_embedding, candidate) similarities.append((i, similarity)) - + # Sort by similarity (descending) similarities.sort(key=lambda x: x[1], reverse=True) - + return similarities[:top_k] - + except Exception as e: logger.error(f"Error finding similar embeddings: {e}") return [] + # Global embedding manager instance embedding_manager = EmbeddingManager() + # Convenience functions async def embed_texts( - texts: List[str], - provider: Optional[str] = None, - use_cache: bool = True + texts: List[str], provider: Optional[str] = None, use_cache: bool = True ) -> List[List[float]]: """Generate embeddings for a list of texts""" return await embedding_manager.embed_texts(texts, provider, use_cache) + async def embed_text( - text: str, - provider: Optional[str] = None, - use_cache: bool = True + text: str, provider: Optional[str] = None, use_cache: bool = True ) -> List[float]: """Generate embedding for a single text""" return await embedding_manager.embed_single_text(text, provider, use_cache) + def calculate_similarity(embedding1: List[float], embedding2: List[float]) -> float: """Calculate cosine similarity between two embeddings""" return embedding_manager.calculate_similarity(embedding1, embedding2) - -# Cached wrapper functions -@cached("embeddings", ttl=7200) -def embed_texts_with_cache(texts: List[str], provider: Optional[str] = None) -> List[List[float]]: - """Cached version of embed_texts for synchronous use""" - import asyncio - - try: - loop = asyncio.get_event_loop() - return loop.run_until_complete(embed_texts(texts, provider, use_cache=True)) - except RuntimeError: - # No running loop, create new one - return asyncio.run(embed_texts(texts, provider, use_cache=True)) - -@cached("embeddings", ttl=7200) -def embed_text_with_cache(text: str, provider: Optional[str] = None) -> List[float]: - """Cached version of embed_text for synchronous use""" - import asyncio - - try: - loop = asyncio.get_event_loop() - return loop.run_until_complete(embed_text(text, provider, use_cache=True)) - except RuntimeError: - # No running loop, create new one - return asyncio.run(embed_text(text, provider, use_cache=True)) \ No newline at end of file diff --git a/backend/src/exceptions.py b/backend/src/exceptions.py index b3b5b02..a66258c 100644 --- a/backend/src/exceptions.py +++ b/backend/src/exceptions.py @@ -2,6 +2,7 @@ Enhanced error handling system for ClaimWise backend. Provides structured error categories, user-friendly messages, and recovery suggestions. """ + import logging import traceback from typing import Dict, List, Optional, Any, Union @@ -11,9 +12,10 @@ logger = logging.getLogger(__name__) + class ClaimWiseError(Exception): """Base exception class for ClaimWise application errors""" - + def __init__( self, message: str, @@ -21,7 +23,7 @@ def __init__( details: Optional[Dict[str, Any]] = None, recovery_suggestions: Optional[List[str]] = None, severity: str = "error", - original_exception: Optional[Exception] = None + original_exception: Optional[Exception] = None, ): self.message = message self.error_code = error_code @@ -30,7 +32,7 @@ def __init__( self.severity = severity self.original_exception = original_exception super().__init__(self.message) - + def to_dict(self) -> Dict[str, Any]: """Convert exception to dictionary for API responses""" return { @@ -40,142 +42,183 @@ def to_dict(self) -> Dict[str, Any]: "details": self.details, "recovery_suggestions": self.recovery_suggestions, "severity": self.severity, - "type": self.__class__.__name__ + "type": self.__class__.__name__, } } + class ValidationError(ClaimWiseError): """Raised when input validation fails""" - + def __init__( self, message: str, field: Optional[str] = None, value: Optional[Any] = None, - **kwargs + **kwargs, ): - details = kwargs.get('details', {}) + details = kwargs.get("details", {}) if field: - details['field'] = field + details["field"] = field if value is not None: - details['provided_value'] = str(value) - - kwargs['details'] = details - kwargs['error_code'] = kwargs.get('error_code', 'VALIDATION_ERROR') + details["provided_value"] = str(value) + + kwargs["details"] = details + kwargs["error_code"] = kwargs.get("error_code", "VALIDATION_ERROR") super().__init__(message, **kwargs) + class AuthenticationError(ClaimWiseError): """Raised when authentication fails""" - + def __init__(self, message: str = "Authentication required", **kwargs): - kwargs['error_code'] = kwargs.get('error_code', 'AUTHENTICATION_ERROR') - kwargs['recovery_suggestions'] = kwargs.get('recovery_suggestions', [ - "Please log in to continue", - "Check if your session has expired", - "Verify your credentials" - ]) + kwargs["error_code"] = kwargs.get("error_code", "AUTHENTICATION_ERROR") + kwargs["recovery_suggestions"] = kwargs.get( + "recovery_suggestions", + [ + "Please log in to continue", + "Check if your session has expired", + "Verify your credentials", + ], + ) super().__init__(message, **kwargs) + class AuthorizationError(ClaimWiseError): """Raised when user lacks required permissions""" - + def __init__(self, message: str = "Access denied", **kwargs): - kwargs['error_code'] = kwargs.get('error_code', 'AUTHORIZATION_ERROR') - kwargs['recovery_suggestions'] = kwargs.get('recovery_suggestions', [ - "Contact administrator for access", - "Check if you have the required permissions" - ]) + kwargs["error_code"] = kwargs.get("error_code", "AUTHORIZATION_ERROR") + kwargs["recovery_suggestions"] = kwargs.get( + "recovery_suggestions", + [ + "Contact administrator for access", + "Check if you have the required permissions", + ], + ) super().__init__(message, **kwargs) + class ProcessingError(ClaimWiseError): """Raised when document/data processing fails""" - + def __init__(self, message: str, operation: Optional[str] = None, **kwargs): - details = kwargs.get('details', {}) + details = kwargs.get("details", {}) if operation: - details['failed_operation'] = operation - - kwargs['details'] = details - kwargs['error_code'] = kwargs.get('error_code', 'PROCESSING_ERROR') - kwargs['recovery_suggestions'] = kwargs.get('recovery_suggestions', [ - "Try the operation again", - "Check if the input data is valid", - "Contact support if the problem persists" - ]) + details["failed_operation"] = operation + + kwargs["details"] = details + kwargs["error_code"] = kwargs.get("error_code", "PROCESSING_ERROR") + kwargs["recovery_suggestions"] = kwargs.get( + "recovery_suggestions", + [ + "Try the operation again", + "Check if the input data is valid", + "Contact support if the problem persists", + ], + ) super().__init__(message, **kwargs) + class FileHandlingError(ClaimWiseError): """Raised when file operations fail""" - + def __init__(self, message: str, filename: Optional[str] = None, **kwargs): - details = kwargs.get('details', {}) + details = kwargs.get("details", {}) if filename: - details['filename'] = filename - - kwargs['details'] = details - kwargs['error_code'] = kwargs.get('error_code', 'FILE_ERROR') - kwargs['recovery_suggestions'] = kwargs.get('recovery_suggestions', [ - "Check if the file is not corrupted", - "Ensure the file format is supported", - "Try uploading a different file" - ]) + details["filename"] = filename + + kwargs["details"] = details + kwargs["error_code"] = kwargs.get("error_code", "FILE_ERROR") + kwargs["recovery_suggestions"] = kwargs.get( + "recovery_suggestions", + [ + "Check if the file is not corrupted", + "Ensure the file format is supported", + "Try uploading a different file", + ], + ) super().__init__(message, **kwargs) + class DatabaseError(ClaimWiseError): """Raised when database operations fail""" - + def __init__(self, message: str, operation: Optional[str] = None, **kwargs): - details = kwargs.get('details', {}) + details = kwargs.get("details", {}) if operation: - details['database_operation'] = operation - - kwargs['details'] = details - kwargs['error_code'] = kwargs.get('error_code', 'DATABASE_ERROR') - kwargs['recovery_suggestions'] = kwargs.get('recovery_suggestions', [ - "Try the operation again in a moment", - "Check your internet connection", - "Contact support if the issue persists" - ]) + details["database_operation"] = operation + + kwargs["details"] = details + kwargs["error_code"] = kwargs.get("error_code", "DATABASE_ERROR") + kwargs["recovery_suggestions"] = kwargs.get( + "recovery_suggestions", + [ + "Try the operation again in a moment", + "Check your internet connection", + "Contact support if the issue persists", + ], + ) super().__init__(message, **kwargs) + class ExternalAPIError(ClaimWiseError): """Raised when external API calls fail""" - - def __init__(self, message: str, service: Optional[str] = None, status_code: Optional[int] = None, **kwargs): - details = kwargs.get('details', {}) + + def __init__( + self, + message: str, + service: Optional[str] = None, + status_code: Optional[int] = None, + **kwargs, + ): + details = kwargs.get("details", {}) if service: - details['service'] = service + details["service"] = service if status_code: - details['status_code'] = status_code - - kwargs['details'] = details - kwargs['error_code'] = kwargs.get('error_code', 'EXTERNAL_API_ERROR') - kwargs['recovery_suggestions'] = kwargs.get('recovery_suggestions', [ - "Try again in a few moments", - "Check if the service is available", - "Contact support if the issue continues" - ]) + details["status_code"] = status_code + + kwargs["details"] = details + kwargs["error_code"] = kwargs.get("error_code", "EXTERNAL_API_ERROR") + kwargs["recovery_suggestions"] = kwargs.get( + "recovery_suggestions", + [ + "Try again in a few moments", + "Check if the service is available", + "Contact support if the issue continues", + ], + ) super().__init__(message, **kwargs) + class RateLimitError(ClaimWiseError): """Raised when rate limits are exceeded""" - - def __init__(self, message: str = "Rate limit exceeded", retry_after: Optional[int] = None, **kwargs): - details = kwargs.get('details', {}) + + def __init__( + self, + message: str = "Rate limit exceeded", + retry_after: Optional[int] = None, + **kwargs, + ): + details = kwargs.get("details", {}) if retry_after: - details['retry_after_seconds'] = retry_after - - kwargs['details'] = details - kwargs['error_code'] = kwargs.get('error_code', 'RATE_LIMIT_ERROR') - kwargs['recovery_suggestions'] = kwargs.get('recovery_suggestions', [ - f"Please wait {retry_after or 60} seconds before trying again", - "Reduce the frequency of your requests" - ]) + details["retry_after_seconds"] = retry_after + + kwargs["details"] = details + kwargs["error_code"] = kwargs.get("error_code", "RATE_LIMIT_ERROR") + kwargs["recovery_suggestions"] = kwargs.get( + "recovery_suggestions", + [ + f"Please wait {retry_after or 60} seconds before trying again", + "Reduce the frequency of your requests", + ], + ) super().__init__(message, **kwargs) + def handle_exceptions(logger_instance: Optional[logging.Logger] = None): """Decorator for consistent exception handling""" - + def decorator(func): @wraps(func) async def async_wrapper(*args, **kwargs): @@ -191,14 +234,14 @@ async def async_wrapper(*args, **kwargs): if logger_instance: logger_instance.error(f"Unexpected error in {func.__name__}: {e}") logger_instance.debug(traceback.format_exc()) - + # Convert to ProcessingError raise ProcessingError( message=f"Unexpected error in {func.__name__}", operation=func.__name__, - original_exception=e + original_exception=e, ) - + @wraps(func) def sync_wrapper(*args, **kwargs): try: @@ -213,26 +256,28 @@ def sync_wrapper(*args, **kwargs): if logger_instance: logger_instance.error(f"Unexpected error in {func.__name__}: {e}") logger_instance.debug(traceback.format_exc()) - + # Convert to ProcessingError raise ProcessingError( message=f"Unexpected error in {func.__name__}", operation=func.__name__, - original_exception=e + original_exception=e, ) - + # Return appropriate wrapper based on whether function is async import asyncio + if asyncio.iscoroutinefunction(func): return async_wrapper else: return sync_wrapper - + return decorator + def convert_to_http_exception(error: ClaimWiseError) -> HTTPException: """Convert ClaimWise exceptions to FastAPI HTTPExceptions""" - + # Map error types to HTTP status codes status_code_map = { ValidationError: 400, @@ -243,32 +288,32 @@ def convert_to_http_exception(error: ClaimWiseError) -> HTTPException: DatabaseError: 500, ExternalAPIError: 502, ProcessingError: 500, - ClaimWiseError: 500 + ClaimWiseError: 500, } - + status_code = status_code_map.get(type(error), 500) - - return HTTPException( - status_code=status_code, - detail=error.to_dict() - ) -async def claimwise_exception_handler(request: Request, exc: ClaimWiseError) -> JSONResponse: + return HTTPException(status_code=status_code, detail=error.to_dict()) + + +async def claimwise_exception_handler( + request: Request, exc: ClaimWiseError +) -> JSONResponse: """Global exception handler for ClaimWise exceptions""" - + # Log the exception logger.error( f"ClaimWise exception in {request.method} {request.url}: {exc.message}", extra={ "error_code": exc.error_code, "details": exc.details, - "severity": exc.severity - } + "severity": exc.severity, + }, ) - + if exc.original_exception: logger.debug(f"Original exception: {exc.original_exception}") - + # Map error types to HTTP status codes status_code_map = { ValidationError: 400, @@ -279,45 +324,40 @@ async def claimwise_exception_handler(request: Request, exc: ClaimWiseError) -> DatabaseError: 500, ExternalAPIError: 502, ProcessingError: 500, - ClaimWiseError: 500 + ClaimWiseError: 500, } - + status_code = status_code_map.get(type(exc), 500) - - return JSONResponse( - status_code=status_code, - content=exc.to_dict() - ) + + return JSONResponse(status_code=status_code, content=exc.to_dict()) + # Predefined error instances for common scenarios INVALID_FILE_TYPE = ValidationError( message="Unsupported file type", recovery_suggestions=[ "Upload a PDF, DOC, or TXT file", - "Convert your file to a supported format" - ] + "Convert your file to a supported format", + ], ) FILE_TOO_LARGE = ValidationError( message="File size exceeds limit", - recovery_suggestions=[ - "Reduce the file size", - "Use a file smaller than 10MB" - ] + recovery_suggestions=["Reduce the file size", "Use a file smaller than 10MB"], ) POLICY_NOT_FOUND = ValidationError( message="Policy not found", recovery_suggestions=[ "Check if the policy ID is correct", - "Ensure you have access to this policy" - ] + "Ensure you have access to this policy", + ], ) SERVICE_UNAVAILABLE = ExternalAPIError( message="Service temporarily unavailable", recovery_suggestions=[ "Try again in a few minutes", - "Contact support if the issue persists" - ] + "Contact support if the issue persists", + ], ) diff --git a/backend/src/llm_groq.py b/backend/src/llm_groq.py index b37045f..215a6a6 100644 --- a/backend/src/llm_groq.py +++ b/backend/src/llm_groq.py @@ -117,7 +117,8 @@ def make_llm_request(prompt: str, max_retries: int = 3, delay: float = 1.0): model="llama-3.3-70b-versatile", messages=[{"role": "user", "content": prompt}], temperature=0.1, - max_tokens=4000 + max_tokens=4000, + timeout=30 ) logger.info("✅ Success with Primary Groq API") return response.choices[0].message.content @@ -132,7 +133,8 @@ def make_llm_request(prompt: str, max_retries: int = 3, delay: float = 1.0): model="llama-3.3-70b-versatile", messages=[{"role": "user", "content": prompt}], temperature=0.1, - max_tokens=4000 + max_tokens=4000, + timeout=30 ) logger.info("✅ Success with Secondary Groq API") return response.choices[0].message.content @@ -147,7 +149,8 @@ def make_llm_request(prompt: str, max_retries: int = 3, delay: float = 1.0): model="llama-3.3-70b-versatile", messages=[{"role": "user", "content": prompt}], temperature=0.1, - max_tokens=4000 + max_tokens=4000, + timeout=30 ) logger.info("✅ Success with Tertiary Groq API") return response.choices[0].message.content @@ -236,7 +239,6 @@ def generate_rule_based_analysis(prompt: str) -> str: "claim_readiness_score": 50 }}''' - raise Exception("Both Groq and Gemini APIs failed") def validate_and_clean_analysis(result: dict, original_text: str) -> dict: """ diff --git a/backend/src/main.py b/backend/src/main.py index dc542e7..aa5b372 100644 --- a/backend/src/main.py +++ b/backend/src/main.py @@ -1,156 +1,38 @@ -from fastapi import FastAPI, UploadFile, File, HTTPException, Form, Depends +from fastapi import FastAPI from dotenv import load_dotenv import os -# Load environment variables early so db.py can use them on import -load_dotenv() -STORAGE_BUCKET = os.getenv("SUPABASE_STORAGE_BUCKET", "project") -from src.db import supabase, supabase_storage -from src.llm_groq import analyze_policy, compare_policies, chat_with_policy, chat_with_multiple_policies, get_api_status -from src.auth import get_current_user, refresh_token, oauth2_scheme -from fastapi.middleware.cors import CORSMiddleware -import uuid -from datetime import datetime -import tempfile import logging -from supabase import create_client - -from src.models import UploadResponse, ChatRequest, MultiPolicyChatRequest, ChatResponse, PolicyAnalysisResponse, ComparisonResponse, CompareRequest -from typing import Union, Dict, List, Any -from fastapi import BackgroundTasks -from src.document_validator import validate_insurance_document -from src.rate_limiting import rate_limiter -from src.monitoring import monitor, performance_middleware, get_health_status, start_monitoring - - -APP_ENV = os.getenv("APP_ENV", os.getenv("ENVIRONMENT", "development")).lower() -MAX_UPLOAD_SIZE_MB = int(os.getenv("MAX_UPLOAD_SIZE_MB", "10")) -MAX_UPLOAD_BYTES = MAX_UPLOAD_SIZE_MB * 1024 * 1024 -ALLOWED_UPLOAD_EXTENSIONS = {"pdf"} -ALLOWED_UPLOAD_MIME_TYPES = {"application/pdf"} -ENABLE_DOCUMENT_VALIDATION = os.getenv("ENABLE_DOCUMENT_VALIDATION", "true").lower() == "true" -SIGNED_URL_TTL_SECONDS = int(os.getenv("SIGNED_URL_TTL_SECONDS", "3600")) -ALLOW_DEBUG_ROUTES = os.getenv("ALLOW_DEBUG_ROUTES", "false").lower() == "true" - - -def _configured_admin_user_ids() -> set[str]: - raw_ids = os.getenv("CLAIMWISE_ADMIN_USER_IDS", "") - return {admin_id.strip() for admin_id in raw_ids.split(",") if admin_id.strip()} - - -def _is_admin_user(user_id: str) -> bool: - if not user_id: - return False - - if user_id in _configured_admin_user_ids(): - return True - - try: - role_row = supabase.table("users").select("role, is_admin").eq("id", user_id).limit(1).execute() - if role_row and getattr(role_row, "data", None): - row = role_row.data[0] - role = str(row.get("role", "")).strip().lower() - is_admin = bool(row.get("is_admin", False)) - return is_admin or role == "admin" - except Exception: - logging.debug("Admin check via users table unavailable; using env allowlist only") - - return False - - -def _require_admin_user(user_id: str) -> None: - if not _is_admin_user(user_id): - raise HTTPException(status_code=403, detail="Admin access required.") - - -def _enforce_user_rate_limit(limit_name: str, user_id: str, endpoint: str) -> None: - result = rate_limiter.check_rate_limit(limit_name=limit_name, identifier=user_id, endpoint=endpoint) - if not result.allowed: - raise HTTPException( - status_code=429, - detail=f"Rate limit exceeded. Retry after {result.retry_after or 1} seconds.", - headers=result.to_headers(), - ) - - -def _require_debug_routes_enabled() -> None: - if APP_ENV == "production" and not ALLOW_DEBUG_ROUTES: - raise HTTPException(status_code=404, detail="Not found") - - -def _duplicate_conflict_detail(error_obj: Any) -> str: - default_message = "A resource already exists" - constraint_name = None - try: - if error_obj is None: - return default_message - - constraint_name = getattr(error_obj, "constraint_name", None) - if not constraint_name: - diag = getattr(error_obj, "diag", None) - constraint_name = getattr(diag, "constraint_name", None) if diag is not None else None - - if not constraint_name: - orig = getattr(error_obj, "orig", None) - if orig is not None: - diag = getattr(orig, "diag", None) - constraint_name = getattr(diag, "constraint_name", None) if diag is not None else None - except Exception: - constraint_name = None +load_dotenv() - if constraint_name: - return f"Conflict: duplicate value for constraint '{constraint_name}'" - return default_message +from src.db import supabase, supabase_storage +from fastapi.middleware.cors import CORSMiddleware +from src.monitoring import performance_middleware, start_monitoring +from src.exceptions import ClaimWiseError, claimwise_exception_handler +from src.routes.monitoring import router as monitoring_router +from src.routes.policies import router as policies_router +from src.routes.analysis import router as analysis_router +from src.routes.chat import router as chat_router +from src.routes.dashboard import router as dashboard_router +from src.routes.admin import router as admin_router +from src.routes.auth import router as auth_router -def log_activity(user_id: str, activity_type: str, title: str, description: str, details: Union[Dict, None] = None): - """ - Log user activity to the database for dynamic activity tracking. - """ - try: - activity_data = { - "id": str(uuid.uuid4()), - "user_id": user_id, - "type": activity_type, - "title": title, - "description": description, - "details": details or {}, - "status": "completed", - "created_at": datetime.utcnow().isoformat(), - } - - result = supabase.table("activities").insert(activity_data).execute() - logging.debug("Activity logged: %s - %s", activity_type, title) - return result.data[0] if result.data else None - except Exception as e: - logging.exception("Error logging activity: %s", e) - return None +logging.basicConfig(level=logging.INFO) app = FastAPI() +app.add_exception_handler(ClaimWiseError, claimwise_exception_handler) -# Basic logging configuration (adjust level in production via environment or hosting platform) -logging.basicConfig(level=logging.INFO) - -# CORS middleware to allow cross-origin requests frontend_url = os.getenv("FRONTEND_URL", "https://claimwise-fht9.vercel.app") - -# Use environment variable for production, localhost for development origins = [ "http://localhost:3000", "http://localhost:3001", "http://localhost:8000", ] - -# Add production frontend URL if set if frontend_url: origins.append(frontend_url) - logging.info(f"Production frontend URL: {frontend_url}") - -# Filter out empty strings and duplicates origins = list(set([url for url in origins if url])) -logging.info(f"CORS origins configured: {origins}") - app.add_middleware( CORSMiddleware, allow_origins=origins, @@ -161,1627 +43,15 @@ def log_activity(user_id: str, activity_type: str, title: str, description: str, app.middleware("http")(performance_middleware()) +app.include_router(monitoring_router) +app.include_router(policies_router) +app.include_router(analysis_router) +app.include_router(chat_router) +app.include_router(dashboard_router) +app.include_router(admin_router) +app.include_router(auth_router) + @app.on_event("startup") async def startup_monitoring() -> None: start_monitoring() - -@app.get("/") -def root(): - return {"message": "ClaimWise Backend"} - - -@app.get("/healthz") -def healthz(): - """Simple health endpoint for load balancers and platform health checks.""" - return {"status": "ok"} - - -@app.get("/monitoring/summary") -def monitoring_summary(user_id: str = Depends(get_current_user)): - _require_admin_user(user_id) - return monitor.get_performance_summary() - - -@app.get("/monitoring/endpoints") -def monitoring_endpoints(user_id: str = Depends(get_current_user)): - _require_admin_user(user_id) - return monitor.get_endpoint_stats() - - -@app.get("/monitoring/health") -async def monitoring_health(user_id: str = Depends(get_current_user)): - _require_admin_user(user_id) - return await get_health_status() - -@app.post("/upload-policy", response_model=UploadResponse) -async def upload_policy( - background_tasks: BackgroundTasks, - user_id: str = Depends(get_current_user), - policy_name: str = Form(None), - policy_number: str = Form(None), - file: UploadFile = File(None), - text_input: str = Form(None), - sync_indexing: bool = Form(False), # Dev-mode option for immediate indexing -): - """ - Upload a policy file or text, extract/process text, and store in Supabase. - - Args: - user_id (str): ID of the authenticated user. - policy_name (str): Name of the policy. - policy_number (str, optional): Policy number (if available). - file (UploadFile, optional): Uploaded file (PDF or image). - text_input (str, optional): Pasted policy text. - - Returns: - dict: Policy ID and processed text. - """ - logging.debug("file=%s, policy_name=%s, policy_number=%s, text_input=%s", file, policy_name, policy_number, text_input) - if file: - logging.debug("file.filename=%s, file.content_type=%s", file.filename, file.content_type) - try: - _enforce_user_rate_limit("upload", user_id, "/upload-policy") - - if file and text_input: - raise HTTPException(status_code=400, detail="Provide either a file or text, not both.") - elif not file and not text_input: - raise HTTPException(status_code=400, detail="Provide a file or text.") - - extracted_text = None - file_bytes = None - if file: - if not file.filename: - raise HTTPException(status_code=400, detail="File must have a valid filename") - - file_type = file.filename.rsplit('.', 1)[-1].lower() if '.' in file.filename else "" - if file_type not in ALLOWED_UPLOAD_EXTENSIONS: - raise HTTPException(status_code=400, detail="Only PDF files are supported.") - - content_type = (file.content_type or "").lower().strip() - if content_type not in ALLOWED_UPLOAD_MIME_TYPES: - raise HTTPException(status_code=400, detail="Unsupported file content type.") - - declared_size = file.headers.get("content-length") if file.headers else None - if declared_size and declared_size.isdigit() and int(declared_size) > MAX_UPLOAD_BYTES: - raise HTTPException(status_code=413, detail=f"File is too large. Max allowed size is {MAX_UPLOAD_SIZE_MB}MB.") - - logging.debug("Reading file bytes...") - file_bytes = await file.read() - logging.debug("Read %d bytes from file", len(file_bytes)) - if len(file_bytes) > MAX_UPLOAD_BYTES: - raise HTTPException(status_code=413, detail=f"File is too large. Max allowed size is {MAX_UPLOAD_SIZE_MB}MB.") - - logging.debug("Processing %s file via Gemini Files API...", file_type) - - # Save to a temporary file so the Gemini SDK can upload it - temp_file_path = None - try: - with tempfile.NamedTemporaryFile(delete=False, suffix=f"_{file.filename}") as temp_file: - temp_file.write(file_bytes) - temp_file_path = temp_file.name - - # Extract text locally using PyPDF2 ONLY (no API usage for extraction) - try: - from src.gemini_files import extract_text - except Exception as import_err: - logging.exception("Failed to import gemini_files: %s", import_err) - raise HTTPException(status_code=500, detail="Server misconfiguration: PDF extraction module not available") - - # Always use local file for extraction - NO API calls - extracted_text = extract_text(temp_file_path) - logging.debug("Local PDF extraction completed. Text length: %d", len(extracted_text) if extracted_text else 0) - logging.debug("Extracted text length: %d", len(extracted_text) if extracted_text else 0) - - if not extracted_text: - raise HTTPException(status_code=400, detail="No text extracted from file.") - - finally: - # Ensure temporary file is cleaned up - try: - if temp_file_path and os.path.exists(temp_file_path): - os.unlink(temp_file_path) - except Exception: - pass - else: - extracted_text = text_input - - if ENABLE_DOCUMENT_VALIDATION and extracted_text: - source_name = (file.filename if file and file.filename else "text_input") - validation_report = validate_insurance_document(extracted_text, source_name) - if not validation_report.is_valid: - raise HTTPException( - status_code=400, - detail="Uploaded content does not appear to be a valid insurance policy document.", - ) - - # Upload to Supabase storage (if we have a file) - logging.debug("Uploading to Supabase storage...") - file_url = None - if file and file_bytes: - storage_path = f"policies/{user_id}/{file.filename}" - try: - # Try to upload, if duplicate exists, generate unique name - try: - supabase_storage.storage.from_(STORAGE_BUCKET).upload(storage_path, file_bytes) - file_url = storage_path - logging.debug("File uploaded to private storage path: %s", storage_path) - except Exception as upload_error: - error_str = str(upload_error) - if "Duplicate" in error_str or "already exists" in error_str: - # Generate unique filename with timestamp - import time - timestamp = str(int(time.time())) - if file.filename: - filename_parts = file.filename.rsplit('.', 1) - if len(filename_parts) == 2: - unique_filename = f"{filename_parts[0]}_{timestamp}.{filename_parts[1]}" - else: - unique_filename = f"{file.filename}_{timestamp}" - else: - unique_filename = f"uploaded_file_{timestamp}" - - storage_path = f"policies/{user_id}/{unique_filename}" - logging.debug("File exists, trying with unique name: %s", unique_filename) - supabase_storage.storage.from_(STORAGE_BUCKET).upload(storage_path, file_bytes) - file_url = storage_path - logging.debug("File uploaded with unique name to private path: %s", storage_path) - else: - raise upload_error - except Exception as storage_error: - logging.exception("Storage Error: %s", storage_error) - raise HTTPException(status_code=500, detail="File storage failed.") - - logging.debug("Saving to database...") - - data = { - "user_id": user_id, - "policy_name": policy_name, - "policy_number": policy_number, - "extracted_text": extracted_text, - "uploaded_file_url": file_url - } - try: - # Use service-role client for writes if available - svc = supabase_storage or supabase - - # Verify that a corresponding user profile exists in the database to - # avoid foreign key constraint failures (gives a clearer error to client) - try: - user_check = svc.table("users").select("id").eq("id", user_id).execute() - if not (user_check and getattr(user_check, "data", None)): - logging.error("User id %s not found in users table before policy insert", user_id) - raise HTTPException(status_code=403, detail="User profile not found. Please re-authenticate.") - except HTTPException: - raise - except Exception: - # If the check itself failed (permissions/missing table), log and continue - logging.warning("Could not verify user existence prior to insert; continuing and relying on DB constraints") - - response = svc.table("policies").insert(data).execute() - - # Handle client-level errors returned by the Supabase client - resp_err = getattr(response, "error", None) - if resp_err: - logging.error("Supabase insert error: %s", resp_err) - err_msg = getattr(resp_err, "message", str(resp_err)) - err_code = getattr(resp_err, "code", "") - # Detect foreign key violation (user missing) - if "foreign key" in str(err_msg).lower() or err_code == "23503": - raise HTTPException(status_code=403, detail="Invalid user or authentication state. Please re-authenticate.") - if err_code == "23505" or "duplicate key" in str(err_msg).lower() or "already exists" in str(err_msg).lower(): - raise HTTPException(status_code=409, detail=_duplicate_conflict_detail(resp_err)) - else: - raise HTTPException(status_code=500, detail="Failed to save policy.") - - if not (response and getattr(response, "data", None)): - raise HTTPException(status_code=500, detail="Failed to save policy.") - - policy_id = response.data[0]['id'] - - # Choose indexing mode based on sync_indexing parameter - indexing_mode = "synchronous" if sync_indexing else "background" - - if sync_indexing: - # Synchronous indexing for dev-mode (immediate feedback) - try: - from src.rag import index_documents - indexed_chunks = index_documents(extracted_text, policy_id) - logging.info("Synchronous indexing completed: %d chunks indexed", len(indexed_chunks)) - except Exception as e: - logging.exception("Synchronous indexing failed: %s", e) - else: - # schedule indexing in background (non-blocking) - try: - from src.rag import index_documents - background_tasks.add_task(index_documents, extracted_text, policy_id) - except Exception as e: - logging.exception("Failed to schedule background indexing: %s", e) - - # Log activity - log_activity( - user_id=user_id, - activity_type="upload", - title=f"Uploaded {policy_name or 'Policy'}", - description=f"{policy_name or 'Policy document'} successfully uploaded and processed", - details={ - "policy_id": policy_id, - "file_type": "file" if file else "text", - "file_name": file.filename if file else None, - "text_length": len(extracted_text) - } - ) - - return UploadResponse( - policy_id=policy_id, - extracted_text=extracted_text, - status="indexing_started", - indexing_mode=indexing_mode - ) - except HTTPException: - # Re-raise HTTPExceptions we intentionally raised above - raise - except Exception as db_error: - # Log full exception server-side but do not leak DB internals to clients - logging.exception("Database save error: %s", db_error) - err_str = str(db_error).lower() - if "foreign key" in err_str or "violates foreign key" in err_str or "23503" in err_str: - raise HTTPException(status_code=403, detail="Invalid user or authentication state. Please re-authenticate.") - if "23505" in err_str or "duplicate key" in err_str or "already exists" in err_str: - raise HTTPException(status_code=409, detail=_duplicate_conflict_detail(db_error)) - raise HTTPException(status_code=500, detail="Database save failed.") - except HTTPException: - raise - except Exception as e: - logging.exception("Unexpected Error while processing upload_policy: %s", e) - raise HTTPException(status_code=500, detail="Error processing input.") - -from fastapi import Form - -@app.get("/debug/user-policies") -def debug_user_policies(user_id: str = Depends(get_current_user)): - """Debug endpoint to check user's policies""" - try: - _require_debug_routes_enabled() - _require_admin_user(user_id) - policies = supabase.table("policies").select("id", "policy_name", "user_id", "created_at").eq("user_id", user_id).execute().data - return { - "user_id": user_id, - "policy_count": len(policies), - "policies": policies[:5] # Show first 5 - } - except Exception as e: - logging.exception("debug_user_policies failed: %s", e) - raise HTTPException(status_code=500, detail="Debug endpoint failed.") - -@app.post("/analyze-policy") -def analyze(policy_id: str = Form(...), user_id: str = Depends(get_current_user)): - """ - Analyze a policy using LLM based on its extracted text. - - Args: - policy_id (str): ID of the policy to analyze. - user_id (str): ID of the authenticated user. - - Returns: - dict: LLM analysis result. - """ - try: - _enforce_user_rate_limit("analysis", user_id, "/analyze-policy") - result = supabase.table("policies").select("extracted_text", "policy_number", "policy_name").eq("id", policy_id).eq("user_id", user_id).execute() - if not result.data: - raise HTTPException(status_code=404, detail="Policy not found for this user.") - - policy = result.data[0] - analysis = analyze_policy(policy['extracted_text']) - - # Update policy with analysis result in validation_metadata - metadata = policy.get('validation_metadata') or {} - if not isinstance(metadata, dict): - metadata = {} - metadata['analysis_result'] = analysis - - # Calculate validation score based on analysis - # Lower score = more risks, Higher score = fewer risks - gaps_count = len(analysis.get('gaps_and_risks', [])) - exclusions_count = len(analysis.get('exclusions', [])) - total_risks = gaps_count + exclusions_count - - # Score formula: Start at 100%, deduct for each risk (max deduction 90%) - # This ensures at least 10% score even with many risks - risk_impact = min(total_risks * 0.5, 90) # Each risk = 0.5% deduction, capped at 90% - validation_score = max((100 - risk_impact) / 100, 0.1) # Score 0-1, min 0.1 - - supabase.table("policies").update({ - "validation_metadata": metadata, - "validation_score": validation_score - }).eq("id", policy_id).execute() - - # Log the analysis activity - log_activity( - user_id=user_id, - activity_type="analysis", - title="Policy Analysis Completed", - description=f"{policy.get('policy_name', 'Policy')} analyzed successfully", - details={ - "policy_id": policy_id, - "policy_type": analysis.get('policy_type', 'Unknown'), - "provider": analysis.get('provider', 'Unknown'), - "analysis_score": analysis.get('claim_readiness_score', 0) - } - ) - - return {"analysis": analysis} - except IndexError: - raise HTTPException(status_code=404, detail="Policy not found for this user.") - except Exception as e: - logging.exception("Analyze policy failed: %s", e) - raise HTTPException(status_code=500, detail="Error analyzing policy.") - -@app.delete("/policies/{policy_id}") -def delete_policy( - policy_id: str, - user_id: str = Depends(get_current_user), - token: str = Depends(oauth2_scheme) -): - """ - Delete a policy and its associated data. - """ - logging.info(f"Attempting to delete policy: {policy_id} for user: {user_id}") - try: - # Create a new client authenticated with the user's token to respect RLS - # This is safer than using the service role and works even if service role is missing - url = os.getenv("SUPABASE_URL", "") - key = os.getenv("SUPABASE_KEY", "") - auth_client = create_client(url, key) - auth_client.postgrest.auth(token) - - # Verify policy ownership (and existence) - # Using auth_client ensures we only see policies the user is allowed to see - policy = auth_client.table("policies").select("id", "uploaded_file_url").eq("id", policy_id).execute().data - - if not policy: - logging.warning(f"Policy {policy_id} not found for user {user_id}") - raise HTTPException(status_code=404, detail="Policy not found or access denied.") - - # Delete associated document chunks first (to avoid foreign key issues) - try: - auth_client.table("document_chunks").delete().eq("policy_id", policy_id).execute() - logging.info(f"Deleted document chunks for policy: {policy_id}") - except Exception as e: - logging.warning(f"Could not delete document chunks for policy {policy_id}: {e}") - # Continue anyway, as the policy delete might still work if there's a cascade or no FK - - # Delete from database - auth_client.table("policies").delete().eq("id", policy_id).execute() - - # Log activity (using service role or global client is fine for logging) - log_activity( - user_id=user_id, - activity_type="delete", - title="Policy Deleted", - description=f"Policy {policy_id} deleted", - details={"policy_id": policy_id} - ) - - return {"message": "Policy deleted successfully"} - - except HTTPException: - raise - except Exception as e: - logging.exception(f"Error deleting policy: {e}") - raise HTTPException(status_code=500, detail="Error deleting policy.") - -@app.post("/compare-policies") -def compare(request: CompareRequest, user_id: str = Depends(get_current_user)): - """ - Compare multiple policies using LLM and store the result. - Accepts 2-10 policy IDs and performs pairwise comparison. - - Args: - request (CompareRequest): JSON body with policy_ids array - user_id (str): ID of the authenticated user. - - Returns: - dict: Comparison result. - """ - try: - _enforce_user_rate_limit("analysis", user_id, "/compare-policies") - policy_ids = request.policy_ids - logging.debug(f"Starting comparison for user {user_id}, policies: {policy_ids}") - - # For now, compare first two policies (can be extended to compare all) - policy_1_id = policy_ids[0] - policy_2_id = policy_ids[1] - - res1 = supabase.table("policies").select("extracted_text", "policy_number", "policy_name").eq("id", policy_1_id).eq("user_id", user_id).execute() - res2 = supabase.table("policies").select("extracted_text", "policy_number", "policy_name").eq("id", policy_2_id).eq("user_id", user_id).execute() - - if not res1.data or not res2.data: - raise HTTPException(status_code=404, detail="One or both policies not found for this user.") - - pol1 = res1.data[0] - pol2 = res2.data[0] - - logging.debug("Retrieved policies successfully") - - comparison_result_text = compare_policies(pol1['extracted_text'], pol2['extracted_text'], pol1.get('policy_number'), pol2.get('policy_number')) - logging.debug("LLM comparison completed, storing result...") - - # Store comparison result - comparison_data = { - "user_id": user_id, - "policy_1_id": policy_1_id, - "policy_2_id": policy_2_id, - "comparison_result": comparison_result_text - } - - result = supabase.table("comparisons").insert(comparison_data).execute() - logging.debug("Comparison stored successfully: %s", result) - - # Log comparison activity - log_activity( - user_id=user_id, - activity_type="comparison", - title="Policy Comparison Completed", - description=f"Compared {pol1.get('policy_name', 'Policy 1')} vs {pol2.get('policy_name', 'Policy 2')}", - details={ - "policy_1_id": policy_1_id, - "policy_2_id": policy_2_id, - "policy_1_name": pol1.get('policy_name', 'Policy 1'), - "policy_2_name": pol2.get('policy_name', 'Policy 2'), - "total_policies": len(policy_ids) - } - ) - - if result.data: - comparison_id = result.data[0].get('id', 'unknown') - logging.debug("Comparison record created with ID: %s", comparison_id) - else: - logging.warning("Warning - comparison insert returned no data") - - return {"comparison": comparison_result_text, "comparison_id": result.data[0].get('id') if result.data else None} - - except IndexError: - policy_ids_str = str(request.policy_ids) if 'request' in locals() else 'unknown' - logging.debug(f"Policy not found - policy_ids: {policy_ids_str}, user_id: {user_id}") - raise HTTPException(status_code=404, detail="One or both policies not found for this user.") - except HTTPException: - raise - except Exception as e: - logging.exception("Error in compare-policies: %s", str(e)) - raise HTTPException(status_code=500, detail="Error comparing policies.") - -@app.get("/policies") -def get_user_policies(user_id: str = Depends(get_current_user)): - """ - Get all policies for the authenticated user. - - Args: - user_id (str): ID of the authenticated user. - - Returns: - dict: List of user policies with essential fields. - """ - try: - logging.debug(f"Fetching policies for user {user_id}") - - result = supabase.table("policies").select( - "id, policy_name, policy_number, created_at, validation_score, validation_metadata, uploaded_file_url" - ).eq("user_id", user_id).order("created_at", desc=True).execute() - - policies = result.data if result.data else [] - logging.debug(f"Found {len(policies)} policies for user {user_id}") - - return { - "policies": policies, - "total": len(policies), - "success": True - } - - except Exception as e: - logging.exception(f"Error fetching policies for user {user_id}: {e}") - raise HTTPException(status_code=500, detail="Error fetching policies.") - -@app.get("/debug/gemini-config") -def debug_gemini_config(user_id: str = Depends(get_current_user)): - """ - Check Gemini API configuration and model access. - """ - try: - _require_debug_routes_enabled() - _require_admin_user(user_id) - from src.llm import make_llm_request - import os - - # Get API key status (don't expose the actual key) - api_key = os.getenv("GEMINI_API_KEY") - key_status = { - "has_api_key": bool(api_key), - "key_length": len(api_key) if api_key else 0, - "key_prefix": api_key[:8] + "..." if api_key and len(api_key) > 8 else None - } - - # Test with a simple prompt - test_prompt = "Respond with exactly: 'Gemini Pro API working correctly'" - response = make_llm_request(test_prompt) - - # Ensure response is a string for Pylance - response_str = str(response) if response is not None else "" - - return { - "status": "success", - "model": "gemini-3-flash", - "api_key_status": key_status, - "test_response": response_str[:100] + "..." if len(response_str) > 100 else response_str, - "response_length": len(response_str), - "message": "Gemini Pro API is working correctly with your student account!" - } - - except Exception as e: - logging.exception("debug_gemini_config failed: %s", e) - error_str = str(e) - - return { - "status": "error", - "error": "Gemini API diagnostic check failed", - "is_quota_error": "429" in error_str or "quota" in error_str.lower(), - "is_auth_error": "401" in error_str or "unauthorized" in error_str.lower(), - "is_model_error": "model" in error_str.lower(), - "suggestions": [ - "Verify your GEMINI_API_KEY environment variable is set correctly", - "Check if your student account has access to gemini-1.5-pro model", - "Try switching to gemini-1.5-flash if pro access is limited", - "Ensure your API key hasn't expired" - ] - } - -@app.get("/debug/api-status") -def debug_api_status(user_id: str = Depends(get_current_user)): - """ - Check API status for both Groq and Gemini. - """ - try: - _require_debug_routes_enabled() - _require_admin_user(user_id) - from src.llm_groq import get_api_status - return get_api_status() - except Exception as e: - logging.exception("debug_api_status failed: %s", e) - return { - "status": "error", - "error": "Failed to check API status", - "message": "Failed to check API status" - } - -@app.get("/debug/policies") -def debug_list_policies(user_id: str = Depends(get_current_user)): - """ - List all policies with their current names for debugging. - """ - try: - _require_debug_routes_enabled() - _require_admin_user(user_id) - policies = supabase.table("policies").select("id, policy_name, policy_number, created_at, extracted_text").eq("user_id", user_id).execute().data - - return { - "total_policies": len(policies), - "policies": [ - { - "id": policy["id"], - "current_name": policy.get("policy_name"), - "policy_number": policy.get("policy_number"), - "created_at": policy.get("created_at"), - "text_length": len(policy.get("extracted_text", "")), - "is_test_data": "test insurance policy" in (policy.get("extracted_text", "")).lower() - } - for policy in policies - ] - } - - except Exception as e: - logging.exception("debug_list_policies failed: %s", e) - raise HTTPException(status_code=500, detail="Error fetching policies.") - -@app.post("/debug/update-policy-name/{policy_id}") -def debug_update_policy_name(policy_id: str, new_name: str, user_id: str = Depends(get_current_user)): - """ - Update a specific policy name for debugging. - """ - try: - _require_debug_routes_enabled() - _require_admin_user(user_id) - # Verify the policy belongs to the user - result = supabase.table("policies").select("id, policy_name").eq("id", policy_id).eq("user_id", user_id).execute() - - if not result.data: - raise HTTPException(status_code=404, detail="Policy not found") - - old_name = result.data[0].get("policy_name") - - # Update the policy name - result = supabase.table("policies").update({ - "policy_name": new_name - }).eq("id", policy_id).eq("user_id", user_id).execute() - - return { - "message": f"Policy name updated successfully", - "policy_id": policy_id, - "old_name": old_name, - "new_name": new_name - } - - except Exception as e: - logging.exception("debug_update_policy_name failed: %s", e) - raise HTTPException(status_code=500, detail="Error updating policy name.") - -@app.get("/debug/policy-content/{policy_id}") -def debug_policy_content(policy_id: str, user_id: str = Depends(get_current_user)): - """ - Debug endpoint to check policy content for chat troubleshooting. - """ - try: - _require_debug_routes_enabled() - _require_admin_user(user_id) - result = supabase.table("policies").select("*").eq("id", policy_id).eq("user_id", user_id).execute() - if not result.data: - raise HTTPException(status_code=404, detail="Policy not found") - - policy = result.data[0] - - extracted_text = policy.get('extracted_text', '') - text_length = len(extracted_text) - - # Check if it's test data - is_test_data = "test insurance policy for automated testing" in extracted_text.lower() - - # Check if text is substantial enough for chat - has_sufficient_content = text_length > 200 and not is_test_data - - return { - "policy_id": policy_id, - "policy_name": policy.get('policy_name'), - "text_length": text_length, - "is_test_data": is_test_data, - "has_sufficient_content": has_sufficient_content, - "text_preview": extracted_text[:200] + ("..." if text_length > 200 else ""), - "recommendations": [ - "Upload actual policy documents instead of test files" if is_test_data else None, - "Ensure extraction extracted the document properly" if text_length < 100 else None, - "Policy content looks good for chat" if has_sufficient_content else None - ] - } - - except IndexError: - raise HTTPException(status_code=404, detail="Policy not found") - except Exception as e: - logging.exception("debug_policy_content failed: %s", e) - raise HTTPException(status_code=500, detail="Debug endpoint failed.") - -@app.post("/chat", response_model=ChatResponse) -def chat(request: ChatRequest, user_id: str = Depends(get_current_user)): - """ - Answer a question based on a specific policy text. - - Args: - policy_id (str): ID of the policy to query. - question (str): User’s question. - user_id (str): ID of the authenticated user. - - Returns: - dict: Chat response. - """ - try: - _enforce_user_rate_limit("chat", user_id, "/chat") - policy_id = request.policy_id - question = request.question - - result = supabase.table("policies").select("extracted_text", "policy_number", "policy_name").eq("id", policy_id).eq("user_id", user_id).execute() - if not result.data: - raise HTTPException(status_code=404, detail="Policy not found for this user.") - - policy = result.data[0] - - extracted_text = policy.get('extracted_text', '') - - # If little or no extracted text, short-circuit - if not extracted_text or len(extracted_text) < 50: - return ChatResponse(answer="This policy appears to have no extracted text. Please try re-uploading the policy document.", citations=[]) - - # Use retrieve_top_k from rag to get context chunks (RAG) - try: - from src.rag import retrieve_top_k - except Exception as e: - logging.exception("Failed to import rag.retrieve_top_k: %s", e) - return ChatResponse(answer="Server misconfiguration: retrieval not available", citations=[]) - - # Retrieve top chunks for the question - top = [] - try: - top = retrieve_top_k(question, k=5, policy_id=policy_id) - except Exception as e: - logging.exception("retrieve_top_k failed: %s", e) - - # Build prompt with retrieved chunks as context with simple citations - prompt_parts = [] - citations = [] - for idx, content, score in top: - # idx is chunk id in DB; include short excerpt as citation - excerpt = (content[:400] + '...') if content and len(content) > 400 else (content or '') - prompt_parts.append(excerpt) - citations.append({"id": idx, "excerpt": excerpt, "score": score}) - - # If no retrieval results, fall back to using full extracted_text (but keep short) - if not prompt_parts: - context_str = extracted_text[:2000] - else: - context_str = "\n\n".join(prompt_parts) - - final_prompt = f"You are an insurance expert. Use the provided context to answer the question. Context:\n{context_str}\n\nQuestion: {question}\n\nAnswer succinctly and cite chunks by id when referenced." - - # Call optimized LLM (prefers Groq, falls back to Gemini) if available - answer = None - try: - from src.llm import make_llm_request - answer = make_llm_request(final_prompt) - except Exception as e: - logging.exception("Gemini generation failed, trying fallback: %s", e) - try: - from src.llm_groq import chat_with_policy as fallback_chat - answer = fallback_chat(extracted_text, question, policy.get('policy_number')) - except Exception as e2: - logging.exception("Fallback chat failed: %s", e2) - answer = None - - # Validate answer before returning - if not answer: - answer = "I could not generate a response. Please try again or rephrase your question." - - # Log chat activity - log_activity( - user_id=user_id, - activity_type="chat", - title=f"Asked about {policy.get('policy_name', 'Policy')}", - description=f"AI assistant answered question about policy coverage", - details={ - "policy_id": policy_id, - "question": question[:100] + "..." if len(question) > 100 else question, - "chat_type": "single_policy" - } - ) - - # Log successful chat - supabase.table("chat_logs").insert({ - "user_id": user_id, - "policy_id": policy_id, - "question": question, - "answer": str(answer) if answer is not None else "" - }).execute() - - return ChatResponse(answer=str(answer) if answer is not None else "", citations=citations) - except IndexError: - raise HTTPException(status_code=404, detail="Policy not found for this user.") - except Exception as e: - logging.exception("Chat error for policy %s: %s", getattr(request, 'policy_id', 'unknown'), str(e)) - raise HTTPException(status_code=500, detail="Error processing chat.") - -@app.post("/chat-multiple", response_model=ChatResponse) -def chat_multiple_policies(request: MultiPolicyChatRequest, user_id: str = Depends(get_current_user)): - """ - Answer a question based on all user's policies. - - Args: - question (str): User's question. - user_id (str): ID of the authenticated user. - - Returns: - dict: Chat response from multiple policies. - """ - try: - _enforce_user_rate_limit("chat", user_id, "/chat-multiple") - question = request.question - # Get all policies for the user - policies = supabase.table("policies").select("id", "extracted_text", "policy_number").eq("user_id", user_id).execute().data - - if not policies: - raise HTTPException(status_code=404, detail="No policies found for this user.") - - # Prepare policies data for the LLM - policies_data = [] - for policy in policies: - policies_data.append({ - 'id': policy['id'], - 'extracted_text': policy['extracted_text'], - 'policy_number': policy.get('policy_number', f"Policy {policy['id']}") - }) - - # Get comprehensive answer across all policies - answer = chat_with_multiple_policies(policies_data, question) - - # Validate answer before using - if not answer: - answer = "I could not generate a comprehensive response across your policies. Please try again." - - answer_str = str(answer) if answer is not None else "" - - # Log the multi-policy chat - supabase.table("chat_logs").insert({ - "user_id": user_id, - "policy_id": None, # NULL for multi-policy chats - "question": question, - "answer": answer_str, - "chat_type": "multiple_policies" - }).execute() - - # Log activity - log_activity( - user_id=user_id, - activity_type="chat", - title="Asked across all policies", - description=f"AI assistant answered multi-policy question", - details={ - "question": question[:100] + "..." if len(question) > 100 else question, - "policies_count": len(policies), - "chat_type": "multiple_policies" - } - ) - - # Return ChatResponse with answer and empty citations for multi-policy queries - return ChatResponse(answer=answer_str, citations=[]) - - except HTTPException: - raise - except Exception as e: - logging.exception("chat_multiple_policies failed: %s", e) - raise HTTPException(status_code=500, detail="Error processing multi-policy chat.") - -@app.get("/history") -def get_comprehensive_history( - page: int = 1, - page_size: int = 50, - user_id: str = Depends(get_current_user), -): - """ - Retrieve comprehensive activity history including uploads, analyses, chats, and comparisons. - - Args: - user_id (str): ID of the authenticated user. - - Returns: - dict: Comprehensive activity history with detailed metadata. - """ - try: - _enforce_user_rate_limit("user_general", user_id, "/history") - page = max(1, page) - page_size = max(1, min(page_size, 100)) - offset = (page - 1) * page_size - logging.debug("Fetching history for user_id: %s", user_id) - - # Get policy uploads - try: - policies = supabase.table("policies").select( - "id", "policy_name", "policy_number", "created_at", "uploaded_file_url" - ).eq("user_id", user_id).order("created_at", desc=True).execute().data - logging.debug("Found %d policies", len(policies) if policies else 0) - except Exception as e: - logging.exception("Error fetching policies: %s", e) - policies = [] - - # Get chat logs - total_chat_logs = 0 - try: - chat_logs = supabase.table("chat_logs").select( - "id", "policy_id", "question", "answer", "created_at", "chat_type" - ).eq("user_id", user_id).order("created_at", desc=True).range(offset, offset + page_size - 1).execute().data - logging.debug("Found %d chat logs", len(chat_logs) if chat_logs else 0) - - chat_logs_count_result = ( - supabase.table("chat_logs") - .select("id", **{"count": "exact", "head": True}) - .eq("user_id", user_id) - .execute() - ) - total_chat_logs = int(getattr(chat_logs_count_result, "count", 0) or 0) - except Exception as e: - logging.exception("Error fetching chat logs: %s", e) - chat_logs = [] - total_chat_logs = 0 - - # Get comparisons - try: - comparisons = supabase.table("comparisons").select( - "id", "policy_1_id", "policy_2_id", "created_at", "comparison_result" - ).eq("user_id", user_id).order("created_at", desc=True).execute().data - logging.debug("Found %d comparisons", len(comparisons) if comparisons else 0) - except Exception as e: - logging.exception("Error fetching comparisons: %s", e) - comparisons = [] - - # Build activity timeline - activities = [] - - # Add policy uploads - for policy in policies: - activities.append({ - "id": f"upload_{policy['id']}", - "type": "upload", - "title": f"Policy Upload: {policy.get('policy_name', 'Unnamed Policy')}", - "description": policy.get('policy_number', 'No policy number provided'), - "timestamp": policy['created_at'], - "status": "completed", - "details": { - "filesProcessed": 1, - "policyId": policy['id'], - "hasFile": bool(policy.get('uploaded_file_url')) - } - }) - - # Add chat interactions - for chat in chat_logs: - chat_type = chat.get('chat_type', 'single_policy') - title_suffix = "Multi-Policy Chat" if chat_type == "multiple_policies" else "Policy Chat" - - activities.append({ - "id": f"chat_{chat['id']}", - "type": "chat", - "title": f"{title_suffix}: {chat['question'][:50]}{'...' if len(chat['question']) > 50 else ''}", - "description": f"Asked about: {chat['question'][:120]}{'...' if len(chat['question']) > 120 else ''}", - "timestamp": chat['created_at'], - "status": "completed", - "details": { - "questionsAnswered": 1, - "chatType": chat_type, - "policyId": chat.get('policy_id'), - "questionLength": len(chat['question']), - "answerLength": len(chat.get('answer', '')) - } - }) - - # Add comparisons - for comp in comparisons: - activities.append({ - "id": f"comparison_{comp['id']}", - "type": "comparison", - "title": "Policy Comparison", - "description": f"Compared policies {comp['policy_1_id']} and {comp['policy_2_id']}", - "timestamp": comp['created_at'], - "status": "completed", - "details": { - "policiesCompared": 2, - "policy1Id": comp['policy_1_id'], - "policy2Id": comp['policy_2_id'], - "hasResult": bool(comp.get('comparison_result')) - } - }) - - # Sort all activities by timestamp (most recent first) - activities.sort(key=lambda x: x['timestamp'], reverse=True) - - # Add analysis activities (derived from policy uploads) - analysis_activities = [] - for policy in policies: - analysis_activities.append({ - "id": f"analysis_{policy['id']}", - "type": "analysis", - "title": f"Policy Analysis: {policy.get('policy_name', 'Unnamed Policy')}", - "description": f"Analyzed {policy.get('policy_name', 'policy')} for coverage details and insights", - "timestamp": policy['created_at'], - "status": "completed", - "details": { - "insightsGenerated": 3, # Typical number of insights - "policyId": policy['id'], - "analysisType": "automatic" - } - }) - - # Merge and sort all activities - all_activities = activities + analysis_activities - all_activities.sort(key=lambda x: x['timestamp'], reverse=True) - - total_activities = len(all_activities) - paginated_activities = all_activities[offset:offset + page_size] - - # Calculate summary statistics - stats = { - "totalActivities": total_activities, - "uploads": len([a for a in all_activities if a['type'] == 'upload']), - "analyses": len([a for a in all_activities if a['type'] == 'analysis']), - "chats": len([a for a in all_activities if a['type'] == 'chat']), - "comparisons": len([a for a in all_activities if a['type'] == 'comparison']), - "totalPolicies": len(policies) - } - - has_more_activities = offset + page_size < total_activities - has_more_chat_logs = offset + page_size < total_chat_logs - - logging.debug("Returning %d paginated activities with stats: %s", len(paginated_activities), stats) - - return { - "activities": paginated_activities, - "stats": stats, - "policies": policies, - "chat_logs": chat_logs, - "pagination": { - "page": page, - "page_size": page_size, - "has_more_activities": has_more_activities, - "has_more_chat_logs": has_more_chat_logs, - "next_page": page + 1 if (has_more_activities or has_more_chat_logs) else None, - "total_activities": total_activities, - "total_chat_logs": total_chat_logs, - }, - "success": True - } - except Exception as e: - logging.exception("Exception in get_comprehensive_history: %s", str(e)) - import traceback - traceback.print_exc() - raise HTTPException(status_code=500, detail="Error fetching comprehensive history.") - - -@app.get("/policies/{policy_id}/file-url") -def get_policy_file_url(policy_id: str, user_id: str = Depends(get_current_user)): - """Return a short-lived signed URL for a policy document stored in private bucket.""" - try: - _enforce_user_rate_limit("user_general", user_id, "/policies/file-url") - - policy_res = supabase.table("policies").select("uploaded_file_url").eq("id", policy_id).eq("user_id", user_id).limit(1).execute() - if not policy_res or not getattr(policy_res, "data", None): - raise HTTPException(status_code=404, detail="Policy not found.") - - file_path = policy_res.data[0].get("uploaded_file_url") - if not file_path: - raise HTTPException(status_code=404, detail="No file available for this policy.") - - signed_result: Any = supabase_storage.storage.from_(STORAGE_BUCKET).create_signed_url(file_path, SIGNED_URL_TTL_SECONDS) - - signed_url = None - if isinstance(signed_result, dict): - signed_url = signed_result.get("signedURL") or signed_result.get("signedUrl") - elif hasattr(signed_result, "get"): - signed_url = signed_result.get("signedURL") or signed_result.get("signedUrl") - - if not signed_url: - raise HTTPException(status_code=500, detail="Could not create signed URL.") - - return { - "policy_id": policy_id, - "signed_url": signed_url, - "expires_in": SIGNED_URL_TTL_SECONDS, - } - except HTTPException: - raise - except Exception as e: - logging.exception("get_policy_file_url failed: %s", e) - raise HTTPException(status_code=500, detail="Error generating file URL.") - -@app.get("/activities") -def get_activities(user_id: str = Depends(get_current_user)): - """ - Get dynamic user activities from the activities table. - - Returns: - dict: Recent activities with real data - """ - try: - logging.debug("Fetching activities for user_id: %s", user_id) - - # Use service role client to bypass RLS for activities - from src.db import supabase_storage - # Try fetching activities with both clients to be sure - try: - activities = supabase_storage.table("activities").select("*").eq("user_id", user_id).order("created_at", desc=True).limit(10).execute() - except Exception: - activities = supabase.table("activities").select("*").eq("user_id", user_id).order("created_at", desc=True).limit(10).execute() - - logging.debug("Service role query result: %s", activities) - - if activities and hasattr(activities, 'data') and isinstance(activities.data, list) and len(activities.data) > 0: - logging.debug("Found %d activities", len(activities.data)) - formatted_activities = [] - for activity in activities.data: - # Convert to frontend format - formatted_activity = { - "id": activity["id"], - "type": activity["type"], - "title": activity["title"], - "description": activity["description"], - "timestamp": activity["created_at"], - "status": activity["status"], - "details": activity.get("details", {}) - } - formatted_activities.append(formatted_activity) - - return { - "activities": formatted_activities, - "total": len(formatted_activities), - "success": True - } - else: - logging.debug("No activities found, returning sample activity") - # If user has policies but no activities, generate "Policy Uploaded" activities from policies - try: - policies_res = supabase.table("policies").select("id, policy_name, created_at").eq("user_id", user_id).order("created_at", desc=True).limit(5).execute() - if policies_res.data: - generated_activities = [] - for p in policies_res.data: - generated_activities.append({ - "id": f"gen-{p['id']}", - "type": "upload", - "title": "Policy Uploaded", - "description": f"Uploaded {p.get('policy_name', 'Policy')}", - "timestamp": p['created_at'], - "status": "completed", - "details": {"policy_id": p['id']} - }) - return { - "activities": generated_activities, - "total": len(generated_activities), - "success": True - } - except Exception as e: - logging.warning("Failed to generate activities from policies: %s", e) - - # Return sample activities if no real activities exist yet - return { - "activities": [ - { - "id": "sample-1", - "type": "upload", - "title": "Welcome to ClaimWise!", - "description": "Upload your first policy to see activities here", - "timestamp": datetime.utcnow().isoformat(), - "status": "completed", - "details": {} - } - ], - "total": 1, - "success": True - } - - except Exception as e: - logging.exception("Error fetching activities: %s", str(e)) - return { - "activities": [], - "total": 0, - "success": False, - "error": str(e) - } - - -@app.get("/dashboard/stats") -def dashboard_stats(user_id: str = Depends(get_current_user)): - """ - Return aggregated dashboard statistics relevant for analysis-only product. - - Returns: - dict: counts for uploaded documents, processed documents, analyses, and comparisons - """ - try: - # Count uploaded documents/policies - try: - # Optimize: Select only ID to count - policies = supabase.table("policies").select("id").eq("user_id", user_id).execute() - uploaded_count = len(policies.data) if policies and policies.data is not None else 0 - except Exception as e: - logging.exception("Error counting policies: %s", e) - uploaded_count = 0 - - # For documents processed, we consider policies with non-empty extracted_text - try: - # Optimize: Check for non-null extracted_text without fetching content if possible - # For now, we'll assume if it's in the DB, it's processed or processing - # To be more accurate but still fast, we could check a status column if it existed - documents_processed = uploaded_count - except Exception as e: - logging.exception("Error counting processed documents: %s", e) - documents_processed = uploaded_count # fallback - - # Analyses completed - count policies that have been uploaded (since each upload gets analyzed) - try: - # Each uploaded policy gets analyzed, so analyses_completed = uploaded_count - analyses_completed = uploaded_count - except Exception: - analyses_completed = uploaded_count - - # Comparisons run - try: - comparisons = supabase.table("comparisons").select("id").eq("user_id", user_id).execute().data - comparisons_run = len(comparisons) if comparisons else 0 - logging.debug("Found %d comparisons for user %s", comparisons_run, user_id) - except Exception as e: - logging.exception("Error counting comparisons: %s", e) - comparisons_run = 0 - logging.debug("dashboard_stats called for user_id: %s", user_id) - logging.debug("Final counts - uploaded: %d, processed: %d, analyses: %d, comparisons: %d", uploaded_count, documents_processed, analyses_completed, comparisons_run) - - return { - "uploadedDocuments": uploaded_count, - "documentsProcessed": documents_processed, - "analysesCompleted": analyses_completed, - "comparisonsRun": comparisons_run, - } - except Exception as e: - logging.exception("Exception in dashboard_stats: %s", str(e)) - raise HTTPException(status_code=500, detail="Error fetching dashboard stats.") - - -@app.get("/dashboard/stats-dev") -def dashboard_stats_dev(user_id: str = Depends(get_current_user)): - """ - Development-only authenticated stats endpoint. - Returns sample aggregated counts or attempts to compute global counts. - """ - try: - _require_debug_routes_enabled() - _require_admin_user(user_id) - uploaded_count = 0 - documents_processed = 0 - analyses_completed = 0 - comparisons_run = 0 - - try: - policies_res = supabase.table("policies").select("id", "extracted_text").execute() - policies = policies_res.data if policies_res and policies_res.data else [] - uploaded_count = len(policies) - documents_processed = len([p for p in policies if p.get("extracted_text")]) - except Exception as e: - logging.exception("/dashboard/stats-dev - policies query failed: %s", e) - - try: - analyses_res = supabase.table("analyses").select("id").execute() - analyses_completed = len(analyses_res.data) if analyses_res and analyses_res.data else 0 - except Exception: - analyses_completed = uploaded_count # fallback - - try: - comparisons_res = supabase.table("comparisons").select("id").execute() - comparisons_run = len(comparisons_res.data) if comparisons_res and comparisons_res.data else 0 - logging.debug("/dashboard/stats-dev - found %d total comparisons", comparisons_run) - except Exception as e: - logging.exception("/dashboard/stats-dev - comparisons query failed: %s", e) - comparisons_run = 0 - - # If no data found, return a small sample so frontend shows something - if uploaded_count == 0 and documents_processed == 0 and analyses_completed == 0 and comparisons_run == 0: - return {"uploadedDocuments": 2, "documentsProcessed": 2, "analysesCompleted": 2, "comparisonsRun": 1} - - return { - "uploadedDocuments": uploaded_count, - "documentsProcessed": documents_processed, - "analysesCompleted": analyses_completed, - "comparisonsRun": comparisons_run, - } - except Exception as e: - logging.exception("Exception in dashboard_stats_dev: %s", str(e)) - return {"uploadedDocuments": 2, "documentsProcessed": 2, "analysesCompleted": 2, "comparisonsRun": 1} - - -@app.get("/dashboard/metrics") -def dashboard_metrics(user_id: str = Depends(get_current_user)): - """ - Return comprehensive dashboard metrics including protection score, risks, coverage, and analysis. - Fetches real data from the database instead of using hardcoded values. - """ - try: - # Get all user policies - Select validation_metadata instead of coverage_amount - policies_res = supabase.table("policies").select("id, validation_score, validation_metadata, extracted_text, policy_name, created_at").eq("user_id", user_id).execute() - policies = policies_res.data if policies_res and policies_res.data else [] - - # Calculate protection score based on validation and coverage - protection_score = 0 - risks_found = 0 - total_coverage = 0 - - if policies: - # Protection score based on average validation score and completeness - validation_scores = [] - risk_count = 0 - coverage_amounts = [] - - for policy in policies: - # Get validation score if available - validation_score = policy.get("validation_score", 0.75) - validation_scores.append(validation_score * 100) # Convert to percentage - - # Get analysis from metadata - metadata = policy.get('validation_metadata') or {} - if not isinstance(metadata, dict): - metadata = {} - analysis_result = metadata.get('analysis_result', {}) - - # Fallback: Try to extract coverage from extracted_text if analysis is missing - if not analysis_result and policy.get('extracted_text'): - # Simple regex fallback for coverage amount - import re - text = policy.get('extracted_text', '') - # Look for patterns like "Sum Insured: Rs. 5,00,000" or "Coverage: 500000" - coverage_patterns = [ - r'Sum Insured\s*[:\-\s]\s*(?:Rs\.?|INR|₹)?\s*([\d,]+)', - r'Coverage Amount\s*[:\-\s]\s*(?:Rs\.?|INR|₹)?\s*([\d,]+)', - r'Total Coverage\s*[:\-\s]\s*(?:Rs\.?|INR|₹)?\s*([\d,]+)' - ] - for pattern in coverage_patterns: - match = re.search(pattern, text, re.IGNORECASE) - if match: - try: - amount_str = match.group(1).replace(',', '') - coverage_amounts.append(float(amount_str)) - break - except: - pass - - # Count risks/gaps from analysis if available - if isinstance(analysis_result, dict): - risk_count += len(analysis_result.get("gaps_and_risks", [])) - risk_count += len(analysis_result.get("exclusions", [])) - - # Extract coverage amount from analysis result - coverage = analysis_result.get("coverage_amount") - if coverage: - try: - # Remove currency symbols, commas, and whitespace - import re - clean_coverage = re.sub(r'[^\d.]', '', str(coverage)) - if clean_coverage: - coverage_amounts.append(float(clean_coverage)) - except (ValueError, TypeError): - logging.warning(f"Failed to parse coverage amount: {coverage}") - pass - - # Calculate average protection score - if validation_scores: - protection_score = int(sum(validation_scores) / len(validation_scores)) - - risks_found = risk_count - - # Calculate total coverage - if coverage_amounts: - total_coverage = sum(coverage_amounts) - - # Format coverage as currency (assuming INR) - if total_coverage > 0: - # Convert to Lakh format (1 Lakh = 100,000) - coverage_in_lakh = total_coverage / 100000 - total_coverage_formatted = f"₹{coverage_in_lakh:.2f} Lakh" - else: - total_coverage_formatted = "₹0 Lakh" - - # Generate quick insight based on data - quick_insight = "" - if not policies: - quick_insight = "Scan your first policy to get personalized savings insights." - elif risks_found > 0: - quick_insight = f"Found {risks_found} potential gaps in your coverage. Review analysis for savings opportunities." - elif protection_score >= 80: - quick_insight = "Your policies provide comprehensive coverage. Consider reviewing annually for best deals." - else: - quick_insight = "Some coverage gaps detected. Explore our comparison tool to find better options." - - return { - "protectionScore": protection_score, - "risksFound": risks_found, - "totalCoverage": total_coverage_formatted, - "quickInsight": quick_insight, - "policiesCount": len(policies) - } - except Exception as e: - logging.exception("Exception in dashboard_metrics: %s", str(e)) - raise HTTPException(status_code=500, detail="Error fetching dashboard metrics.") - - -@app.get("/dashboard/metrics-dev") -def dashboard_metrics_dev(user_id: str = Depends(get_current_user)): - """ - Development-only authenticated dashboard metrics endpoint. - Returns sample metrics for admin debugging. - """ - try: - _require_debug_routes_enabled() - _require_admin_user(user_id) - # Fetch all policies regardless of user for dev purposes - policies_res = supabase.table("policies").select("*").execute() - policies = policies_res.data if policies_res and policies_res.data else [] - - protection_score = 78 - risks_found = 0 - total_coverage = 0 - - if policies: - validation_scores = [] - risk_count = 0 - coverage_amounts = [] - - for policy in policies[:5]: # Limit to first 5 for dev - validation_score = policy.get("validation_score", 0.75) - validation_scores.append(validation_score * 100) - - analysis = supabase.table("analyses").select("analysis_result").eq("policy_id", policy['id']).execute() - if analysis.data: - analysis_result = analysis.data[0].get("analysis_result", {}) - if isinstance(analysis_result, dict): - risk_count += len(analysis_result.get("gaps_and_risks", [])) - - coverage = policy.get("coverage_amount") - if coverage: - try: - coverage_amounts.append(float(coverage)) - except (ValueError, TypeError): - pass - - if validation_scores: - protection_score = int(sum(validation_scores) / len(validation_scores)) - risks_found = risk_count - if coverage_amounts: - total_coverage = sum(coverage_amounts) - - if total_coverage > 0: - coverage_in_lakh = total_coverage / 100000 - total_coverage_formatted = f"₹{coverage_in_lakh:.2f} Lakh" - else: - total_coverage_formatted = "₹50.00 Lakh" - - quick_insight = "You could save 15% on premiums by switching to HDFC Ergo." if policies else "Scan your first policy to get personalized savings insights." - - return { - "protectionScore": protection_score, - "risksFound": risks_found, - "totalCoverage": total_coverage_formatted, - "quickInsight": quick_insight, - "policiesCount": len(policies) - } - except Exception as e: - logging.exception("Exception in dashboard_metrics_dev: %s", str(e)) - return { - "protectionScore": 78, - "risksFound": 3, - "totalCoverage": "₹50.00 Lakh", - "quickInsight": "You could save 15% on premiums by switching to HDFC Ergo.", - "policiesCount": 0 - } - - -@app.post("/create-test-comparison") -def create_test_comparison(user_id: str = Depends(get_current_user)): - """ - Create a test comparison record for testing dashboard stats. - """ - try: - _require_debug_routes_enabled() - _require_admin_user(user_id) - # Get user's policies to use in comparison - policies = supabase.table("policies").select("id").eq("user_id", user_id).limit(2).execute().data - if len(policies) >= 2: - # Create comparison with real policy IDs - result = supabase.table("comparisons").insert({ - "user_id": user_id, - "policy_1_id": policies[0]['id'], - "policy_2_id": policies[1]['id'], - "comparison_result": "Test comparison created for dashboard testing" - }).execute() - logging.debug("Test comparison created: %s", result) - return {"success": True, "message": "Test comparison created", "comparison_id": result.data[0].get('id') if result.data else None} - else: - # Create comparison with placeholder IDs - result = supabase.table("comparisons").insert({ - "user_id": user_id, - "policy_1_id": "test_policy_1", - "policy_2_id": "test_policy_2", - "comparison_result": "Test comparison created for dashboard testing" - }).execute() - logging.debug("Test comparison created with placeholders: %s", result) - return {"success": True, "message": "Test comparison created with placeholder IDs", "comparison_id": result.data[0].get('id') if result.data else None} - except Exception as e: - logging.exception("Error creating test comparison: %s", e) - import traceback - traceback.print_exc() - raise HTTPException(status_code=500, detail="Error creating test comparison.") - - -@app.get("/test-comparisons-table") -def test_comparisons_table(user_id: str = Depends(get_current_user)): - """ - Test endpoint to check if comparisons table exists and what data is in it. - """ - try: - _require_debug_routes_enabled() - _require_admin_user(user_id) - # Try to read from comparisons table - result = supabase.table("comparisons").select("*").limit(10).execute() - logging.debug("Comparisons table query result: %s", result) - - return { - "success": True, - "total_comparisons": len(result.data) if result.data else 0, - "sample_data": result.data[:5] if result.data else [] - } - except Exception as e: - logging.exception("Error querying comparisons table: %s", e) - import traceback - traceback.print_exc() - return { - "success": False, - "error": "Query failed", - "message": "Comparisons table might not exist or has permission issues" - } - -@app.get("/history-legacy") -def history(user_id: str = Depends(get_current_user)): - """ - Retrieve history of policies and comparisons for the user. - - Args: - user_id (str): ID of the authenticated user. - - Returns: - dict: List of policies and comparisons. - """ - try: - _require_debug_routes_enabled() - policies = supabase.table("policies").select("id", "policy_name", "policy_number", "created_at").eq("user_id", user_id).execute().data - comparisons = supabase.table("comparisons").select("id", "policy_1_id", "policy_2_id", "created_at", "comparison_result").eq("user_id", user_id).execute().data - return {"policies": policies, "comparisons": comparisons} - except Exception as e: - logging.exception("history_legacy failed: %s", e) - raise HTTPException(status_code=500, detail="Error fetching history.") - -@app.post("/refresh-token") -async def refresh(token: str = Form(...)): - """ - Refresh the access token using a refresh token. - - Args: - token (str): Refresh token from client. - - Returns: - dict: New access and refresh tokens. - """ - return await refresh_token(token) - - -from fastapi import APIRouter, UploadFile, HTTPException -from .gemini_files import upload_pdf, poll_file_status, extract_text - -router = APIRouter() - -@router.post("/test-gemini") -async def test_gemini(file: UploadFile, user_id: str = Depends(get_current_user)): - """ - Test endpoint for Gemini Files API integration. - """ - temp_file_path = None - try: - # Save the uploaded file temporarily - with tempfile.NamedTemporaryFile(delete=False, suffix=f"_{file.filename}") as temp_file: - content = await file.read() - temp_file.write(content) - temp_file_path = temp_file.name - - # Step 1: Upload the file (now returns file_id, file_uri) - file_id, file_uri = upload_pdf(temp_file_path) - - # Step 2: Poll the file status - status = poll_file_status(file_id) - if status != "ACTIVE": - raise HTTPException(status_code=500, detail="File did not become ACTIVE") - - # Step 3: Extract text locally only (no API usage) - # Always use the local temporary file for extraction - extracted_text = extract_text(temp_file_path) - - return {"file_id": file_id, "file_uri": file_uri, "status": status, "extracted_text": extracted_text} - - except Exception as e: - logging.exception("/test-gemini failed") - raise HTTPException(status_code=500, detail=str(e)) - - finally: - # Ensure temporary file is cleaned up - try: - if temp_file_path and os.path.exists(temp_file_path): - os.unlink(temp_file_path) - except Exception: - pass - -# Include the router for the /test-gemini endpoint -app.include_router(router) \ No newline at end of file diff --git a/backend/src/main_app.py b/backend/src/main_app.py new file mode 100644 index 0000000..623b758 --- /dev/null +++ b/backend/src/main_app.py @@ -0,0 +1,89 @@ +import os +import logging +from typing import Any + +from fastapi import HTTPException +from src.db import supabase +from src.rate_limiting import rate_limiter + + +APP_ENV = os.getenv("APP_ENV", os.getenv("ENVIRONMENT", "development")).lower() +ALLOW_DEBUG_ROUTES = os.getenv("ALLOW_DEBUG_ROUTES", "false").lower() == "true" + + +def _configured_admin_user_ids() -> set[str]: + raw_ids = os.getenv("CLAIMWISE_ADMIN_USER_IDS", "") + return {admin_id.strip() for admin_id in raw_ids.split(",") if admin_id.strip()} + + +def _is_admin_user(user_id: str) -> bool: + if not user_id: + return False + if user_id in _configured_admin_user_ids(): + return True + try: + role_row = ( + supabase.table("users") + .select("role, is_admin") + .eq("id", user_id) + .limit(1) + .execute() + ) + if role_row and getattr(role_row, "data", None): + row = role_row.data[0] + role = str(row.get("role", "")).strip().lower() + is_admin = bool(row.get("is_admin", False)) + return is_admin or role == "admin" + except Exception: + logging.debug( + "Admin check via users table unavailable; using env allowlist only" + ) + return False + + +def _require_admin_user(user_id: str) -> None: + if not _is_admin_user(user_id): + raise HTTPException(status_code=403, detail="Admin access required.") + + +def _enforce_user_rate_limit(limit_name: str, user_id: str, endpoint: str) -> None: + result = rate_limiter.check_rate_limit( + limit_name=limit_name, identifier=user_id, endpoint=endpoint + ) + if not result.allowed: + raise HTTPException( + status_code=429, + detail=f"Rate limit exceeded. Retry after {result.retry_after or 1} seconds.", + headers=result.to_headers(), + ) + + +def _require_debug_routes_enabled() -> None: + if APP_ENV == "production" and not ALLOW_DEBUG_ROUTES: + raise HTTPException(status_code=404, detail="Not found") + + +def _duplicate_conflict_detail(error_obj: Any) -> str: + default_message = "A resource already exists" + constraint_name = None + try: + if error_obj is None: + return default_message + constraint_name = getattr(error_obj, "constraint_name", None) + if not constraint_name: + diag = getattr(error_obj, "diag", None) + constraint_name = ( + getattr(diag, "constraint_name", None) if diag is not None else None + ) + if not constraint_name: + orig = getattr(error_obj, "orig", None) + if orig is not None: + diag = getattr(orig, "diag", None) + constraint_name = ( + getattr(diag, "constraint_name", None) if diag is not None else None + ) + except Exception: + constraint_name = None + if constraint_name: + return f"Conflict: duplicate value for constraint '{constraint_name}'" + return default_message diff --git a/backend/src/rag.py b/backend/src/rag.py index af3e5b0..4623147 100644 --- a/backend/src/rag.py +++ b/backend/src/rag.py @@ -4,169 +4,200 @@ from datetime import datetime from src.db import supabase, supabase_storage -from src.embeddings import embed_texts_with_cache -from src.content_filters import filter_boilerplate_content, deduplicate_chunks, should_embed_chunk +from src.embeddings import embed_texts +from src.content_filters import ( + filter_boilerplate_content, + deduplicate_chunks, + should_embed_chunk, +) logger = logging.getLogger(__name__) EXPECTED_EMBED_DIM = int(os.getenv("EMBEDDING_DIM", "384")) -def chunk_texts(texts: List[str], chunk_size: int = 500, overlap: int = 50) -> List[str]: - """ - Split one or more input texts into word-based chunks with a fixed overlap. - - Args: - texts: list of strings to be chunked (we join them before splitting by words) - chunk_size: approximate number of words per chunk - overlap: number of words to overlap between consecutive chunks - - Returns: - List of chunk strings. - """ - words = " ".join(texts).split() - chunks: List[str] = [] - i = 0 - while i < len(words): - j = min(i + chunk_size, len(words)) - chunks.append(" ".join(words[i:j])) - - # Move forward by chunk_size - overlap, but ensure we make progress - step = max(1, chunk_size - overlap) # Always move at least 1 word forward - i += step - - # Safety check to prevent infinite loops - if len(chunks) > 10000: # Reasonable upper limit - logger.warning(f"Stopping chunking at {len(chunks)} chunks to prevent memory issues") - break - - return chunks - - -def index_documents(text: str, document_id: str, chunk_size: int = 500, overlap: int = 50) -> List[Tuple[str, Optional[List[float]]]]: - """ - Chunk the provided text, obtain embeddings for each chunk, and insert them into - the `document_chunks` table in Supabase. Uses service-role client when available. - - Includes content filtering to optimize embedding quality and reduce API costs. - - Returns a list of (chunk_text, embedding) for the successfully inserted chunks. - """ - # Step 1: Filter boilerplate content to improve embedding quality - logger.info(f"Original text length: {len(text)} characters") - filtered_text = filter_boilerplate_content(text) - logger.info(f"Filtered text length: {len(filtered_text)} characters (saved {len(text) - len(filtered_text)} chars)") - - # Step 2: Create chunks - chunks = chunk_texts([filtered_text], chunk_size, overlap) - logger.info(f"Created {len(chunks)} initial chunks") - - # Step 3: Deduplicate chunks to reduce API calls - unique_chunks = deduplicate_chunks(chunks) - logger.info(f"After deduplication: {len(unique_chunks)} unique chunks (saved {len(chunks) - len(unique_chunks)} duplicates)") - - # Step 4: Filter chunks worth embedding - quality_chunks = [chunk for chunk in unique_chunks if should_embed_chunk(chunk)] - logger.info(f"After quality filtering: {len(quality_chunks)} chunks worth embedding (filtered out {len(unique_chunks) - len(quality_chunks)} low-quality chunks)") - - result: List[Tuple[str, Optional[List[float]]]] = [] - - # Step 5: Attempt to embed quality chunks; raise exceptions on embedding failures - # to ensure the caller knows that RAG indexing failed. - try: - embs = embed_texts_with_cache(quality_chunks) - if embs is None: - raise RuntimeError("Embedding API returned None - check GEMINI_API_KEY and API status") - except Exception as e: - logger.error("Embedding call failed: %s", e) - raise RuntimeError(f"Failed to generate embeddings for document {document_id}: {str(e)}") from e - - # Normalize embeddings list length - if embs is None: - embs = [None] * len(quality_chunks) - elif len(embs) < len(quality_chunks): - embs = list(embs) + [None] * (len(quality_chunks) - len(embs)) - - service_client = supabase_storage or supabase - - for idx, chunk in enumerate(quality_chunks): - emb = embs[idx] if idx < len(embs) else None - - # Validate embedding dimensionality if present - if emb is not None: - if EXPECTED_EMBED_DIM and len(emb) != EXPECTED_EMBED_DIM: +def chunk_texts( + texts: List[str], chunk_size: int = 500, overlap: int = 50 +) -> List[str]: + """ + Split one or more input texts into word-based chunks with a fixed overlap. + + Args: + texts: list of strings to be chunked (we join them before splitting by words) + chunk_size: approximate number of words per chunk + overlap: number of words to overlap between consecutive chunks + + Returns: + List of chunk strings. + """ + words = " ".join(texts).split() + chunks: List[str] = [] + i = 0 + while i < len(words): + j = min(i + chunk_size, len(words)) + chunks.append(" ".join(words[i:j])) + + # Move forward by chunk_size - overlap, but ensure we make progress + step = max(1, chunk_size - overlap) # Always move at least 1 word forward + i += step + + # Safety check to prevent infinite loops + if len(chunks) > 10000: # Reasonable upper limit + logger.warning( + f"Stopping chunking at {len(chunks)} chunks to prevent memory issues" + ) + break + + return chunks + + +async def index_documents( + text: str, document_id: str, chunk_size: int = 500, overlap: int = 50 +) -> List[Tuple[str, Optional[List[float]]]]: + """ + Chunk the provided text, obtain embeddings for each chunk, and insert them into + the `document_chunks` table in Supabase. Uses service-role client when available. + + Includes content filtering to optimize embedding quality and reduce API costs. + + Returns a list of (chunk_text, embedding) for the successfully inserted chunks. + """ + # Step 1: Filter boilerplate content to improve embedding quality + logger.info(f"Original text length: {len(text)} characters") + filtered_text = filter_boilerplate_content(text) + logger.info( + f"Filtered text length: {len(filtered_text)} characters (saved {len(text) - len(filtered_text)} chars)" + ) + + # Step 2: Create chunks + chunks = chunk_texts([filtered_text], chunk_size, overlap) + logger.info(f"Created {len(chunks)} initial chunks") + + # Step 3: Deduplicate chunks to reduce API calls + unique_chunks = deduplicate_chunks(chunks) + logger.info( + f"After deduplication: {len(unique_chunks)} unique chunks (saved {len(chunks) - len(unique_chunks)} duplicates)" + ) + + # Step 4: Filter chunks worth embedding + quality_chunks = [chunk for chunk in unique_chunks if should_embed_chunk(chunk)] + logger.info( + f"After quality filtering: {len(quality_chunks)} chunks worth embedding (filtered out {len(unique_chunks) - len(quality_chunks)} low-quality chunks)" + ) + + result: List[Tuple[str, Optional[List[float]]]] = [] + + # Step 5: Attempt to embed quality chunks; raise exceptions on embedding failures + # to ensure the caller knows that RAG indexing failed. + try: + embs = await embed_texts(quality_chunks) + if embs is None: + raise RuntimeError( + "Embedding API returned None - check GEMINI_API_KEY and API status" + ) + except Exception as e: + logger.error("Embedding call failed: %s", e) + raise RuntimeError( + f"Failed to generate embeddings for document {document_id}: {str(e)}" + ) from e + + # Normalize embeddings list length + if len(embs) < len(quality_chunks): + embs = list(embs) + [None] * (len(quality_chunks) - len(embs)) + + service_client = supabase_storage or supabase + + for idx, chunk in enumerate(quality_chunks): + emb = embs[idx] if idx < len(embs) else None + + # Validate embedding dimensionality if present + if emb is not None: + if EXPECTED_EMBED_DIM and len(emb) != EXPECTED_EMBED_DIM: + logger.error( + "Embedding dimension mismatch for doc %s chunk %d: got %d expected %d", + document_id, + idx, + len(emb), + EXPECTED_EMBED_DIM, + ) + raise RuntimeError( + f"Embedding dimension mismatch for doc {document_id} chunk {idx}: got {len(emb)} expected {EXPECTED_EMBED_DIM}" + ) + + row = { + "policy_id": document_id, # Use policy_id instead of document_id + "chunk_index": idx, + "content": chunk, + } + if emb is not None: + row["embedding"] = emb + + try: + # First try with policy_id (current schema) + res = service_client.table("document_chunks").insert(row).execute() + err = getattr(res, "error", None) + if err: + logger.error( + "Failed to insert chunk %d for doc %s: %s", idx, document_id, err + ) + continue + + result.append((chunk, emb)) + except Exception as e: + # If foreign key constraint fails, it might be schema mismatch + if "foreign key constraint" in str(e).lower(): + logger.error( + "Document chunks table foreign key constraint failed for policy %s - RAG indexing cannot proceed", + document_id, + ) + logger.debug("Full error: %s", e) + raise RuntimeError( + f"Cannot index document {document_id}: foreign key constraint failure. Ensure the policy exists in the policies table." + ) from e + else: + logger.exception( + "Exception inserting chunk %d for doc %s: %s", idx, document_id, e + ) + raise RuntimeError( + f"Failed to insert document chunk {idx} for document {document_id}: {str(e)}" + ) from e + + return result + + +async def retrieve_top_k( + query: str, k: int = 5, policy_id: Optional[str] = None +) -> List[Tuple[int, str, float]]: + """ + Retrieve the top-k most relevant document chunks for the given query. + + Embeds the query and calls the Supabase RPC `vector_search_document_chunks`. + """ + q_embs = await embed_texts([query]) + if not q_embs: + return [] + q_emb = q_embs[0] + if q_emb is not None and EXPECTED_EMBED_DIM and len(q_emb) != EXPECTED_EMBED_DIM: logger.error( - "Embedding dimension mismatch for doc %s chunk %d: got %d expected %d", - document_id, - idx, - len(emb), - EXPECTED_EMBED_DIM, + "Query embedding dimension mismatch: got %d expected %d", + len(q_emb), + EXPECTED_EMBED_DIM, ) raise RuntimeError( - f"Embedding dimension mismatch for doc {document_id} chunk {idx}: got {len(emb)} expected {EXPECTED_EMBED_DIM}" + f"Query embedding dimension mismatch: got {len(q_emb)} expected {EXPECTED_EMBED_DIM}" ) - row = { - "policy_id": document_id, # Use policy_id instead of document_id - "chunk_index": idx, - "content": chunk, - } - if emb is not None: - row["embedding"] = emb - + service_client = supabase_storage or supabase try: - # First try with policy_id (current schema) - res = service_client.table("document_chunks").insert(row).execute() - err = getattr(res, "error", None) - if err: - logger.error("Failed to insert chunk %d for doc %s: %s", idx, document_id, err) - continue - - result.append((chunk, emb)) + params = {"query_embedding": q_emb, "limit_count": k} + if policy_id: + params["filter_policy_id"] = policy_id + + res = service_client.postgrest.rpc( + "vector_search_document_chunks", params + ).execute() + rows = res.data or [] + return [(r.get("id"), r.get("content"), r.get("score")) for r in rows] except Exception as e: - # If foreign key constraint fails, it might be schema mismatch - if "foreign key constraint" in str(e).lower(): - logger.error("Document chunks table foreign key constraint failed for policy %s - RAG indexing cannot proceed", document_id) - logger.debug("Full error: %s", e) - raise RuntimeError(f"Cannot index document {document_id}: foreign key constraint failure. Ensure the policy exists in the policies table.") from e - else: - logger.exception("Exception inserting chunk %d for doc %s: %s", idx, document_id, e) - raise RuntimeError(f"Failed to insert document chunk {idx} for document {document_id}: {str(e)}") from e - - return result - - -def retrieve_top_k(query: str, k: int = 5, policy_id: Optional[str] = None) -> List[Tuple[int, str, float]]: - """ - Retrieve the top-k most relevant document chunks for the given query. - - Embeds the query and calls the Supabase RPC `vector_search_document_chunks`. - """ - q_embs = embed_texts_with_cache([query]) - if not q_embs: - return [] - q_emb = q_embs[0] - if q_emb is not None and EXPECTED_EMBED_DIM and len(q_emb) != EXPECTED_EMBED_DIM: - logger.error( - "Query embedding dimension mismatch: got %d expected %d", - len(q_emb), - EXPECTED_EMBED_DIM, - ) - raise RuntimeError( - f"Query embedding dimension mismatch: got {len(q_emb)} expected {EXPECTED_EMBED_DIM}" - ) - - service_client = supabase_storage or supabase - try: - params = {"query_embedding": q_emb, "limit_count": k} - if policy_id: - params["filter_policy_id"] = policy_id - - res = service_client.postgrest.rpc( - "vector_search_document_chunks", params - ).execute() - rows = res.data or [] - return [(r.get("id"), r.get("content"), r.get("score")) for r in rows] - except Exception as e: - logger.exception("RPC vector search failed: %s", e) - return [] \ No newline at end of file + logger.exception("RPC vector search failed: %s", e) + return [] diff --git a/backend/response.json b/backend/src/repositories/__init__.py similarity index 100% rename from backend/response.json rename to backend/src/repositories/__init__.py diff --git a/backend/src/repositories/policy_repository.py b/backend/src/repositories/policy_repository.py new file mode 100644 index 0000000..678fe13 --- /dev/null +++ b/backend/src/repositories/policy_repository.py @@ -0,0 +1,82 @@ +import logging +from typing import Optional, Dict, Any, List +from src.db import supabase, supabase_storage + + +class PolicyRepository: + def find_by_user( + self, user_id: str, select_fields: str = "id" + ) -> List[Dict[str, Any]]: + try: + result = ( + supabase.table("policies") + .select(select_fields) + .eq("user_id", user_id) + .order("created_at", desc=True) + .execute() + ) + return result.data if result.data else [] + except Exception as e: + logging.exception("PolicyRepository.find_by_user failed: %s", e) + return [] + + def find_by_id( + self, policy_id: str, user_id: str, select_fields: str = "*" + ) -> Optional[Dict[str, Any]]: + try: + result = ( + supabase.table("policies") + .select(select_fields) + .eq("id", policy_id) + .eq("user_id", user_id) + .limit(1) + .execute() + ) + return result.data[0] if result.data else None + except Exception as e: + logging.exception("PolicyRepository.find_by_id failed: %s", e) + return None + + def create(self, data: Dict[str, Any]) -> Optional[Dict[str, Any]]: + svc = supabase_storage or supabase + try: + user_check = ( + svc.table("users").select("id").eq("id", data["user_id"]).execute() + ) + if not (user_check and getattr(user_check, "data", None)): + logging.error("User id %s not found in users table", data["user_id"]) + return None + response = svc.table("policies").insert(data).execute() + resp_err = getattr(response, "error", None) + if resp_err: + logging.error("Supabase insert error: %s", resp_err) + return None + return response.data[0] if response.data else None + except Exception as e: + logging.exception("PolicyRepository.create failed: %s", e) + return None + + def delete(self, policy_id: str, auth_client) -> bool: + try: + auth_client.table("document_chunks").delete().eq( + "policy_id", policy_id + ).execute() + auth_client.table("policies").delete().eq("id", policy_id).execute() + return True + except Exception as e: + logging.exception("PolicyRepository.delete failed: %s", e) + return False + + def update(self, policy_id: str, user_id: str, data: Dict[str, Any]) -> bool: + try: + result = ( + supabase.table("policies") + .update(data) + .eq("id", policy_id) + .eq("user_id", user_id) + .execute() + ) + return bool(result.data) + except Exception as e: + logging.exception("PolicyRepository.update failed: %s", e) + return False diff --git a/backend/src/routes/__init__.py b/backend/src/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/backend/src/routes/admin.py b/backend/src/routes/admin.py new file mode 100644 index 0000000..b5b54e4 --- /dev/null +++ b/backend/src/routes/admin.py @@ -0,0 +1,280 @@ +import os +import logging +import tempfile +import traceback + +from fastapi import APIRouter, UploadFile, File, Depends, HTTPException, Form +from src.db import supabase, supabase_storage +from src.auth import get_current_user +from src.main_app import _require_debug_routes_enabled, _require_admin_user + +router = APIRouter() + + +@router.get("/debug/user-policies") +def debug_user_policies(user_id: str = Depends(get_current_user)): + try: + _require_debug_routes_enabled() + _require_admin_user(user_id) + policies = ( + supabase.table("policies") + .select("id", "policy_name", "user_id", "created_at") + .eq("user_id", user_id) + .execute() + .data + ) + return { + "user_id": user_id, + "policy_count": len(policies), + "policies": policies[:5], + } + except Exception as e: + logging.exception("debug_user_policies failed: %s", e) + raise HTTPException(status_code=500, detail="Debug endpoint failed.") + + +@router.get("/debug/gemini-config") +def debug_gemini_config(user_id: str = Depends(get_current_user)): + try: + _require_debug_routes_enabled() + _require_admin_user(user_id) + from src.llm import make_llm_request + + api_key = os.getenv("GEMINI_API_KEY") + key_status = { + "has_api_key": bool(api_key), + "key_length": len(api_key) if api_key else 0, + "key_prefix": api_key[:8] + "..." if api_key and len(api_key) > 8 else None, + } + test_prompt = "Respond with exactly: 'Gemini Pro API working correctly'" + response = make_llm_request(test_prompt) + response_str = str(response) if response is not None else "" + return { + "status": "success", + "model": "gemini-3-flash", + "api_key_status": key_status, + "test_response": response_str[:100] + "..." + if len(response_str) > 100 + else response_str, + "response_length": len(response_str), + "message": "Gemini Pro API is working correctly with your student account!", + } + except Exception as e: + logging.exception("debug_gemini_config failed: %s", e) + error_str = str(e) + return { + "status": "error", + "error": "Gemini API diagnostic check failed", + "is_quota_error": "429" in error_str or "quota" in error_str.lower(), + "is_auth_error": "401" in error_str or "unauthorized" in error_str.lower(), + "is_model_error": "model" in error_str.lower(), + "suggestions": [ + "Verify your GEMINI_API_KEY environment variable is set correctly", + "Check if your student account has access to gemini-1.5-pro model", + "Try switching to gemini-1.5-flash if pro access is limited", + "Ensure your API key hasn't expired", + ], + } + + +@router.get("/debug/api-status") +def debug_api_status(user_id: str = Depends(get_current_user)): + try: + _require_debug_routes_enabled() + _require_admin_user(user_id) + from src.llm_groq import get_api_status + + return get_api_status() + except Exception as e: + logging.exception("debug_api_status failed: %s", e) + return { + "status": "error", + "error": "Failed to check API status", + "message": "Failed to check API status", + } + + +@router.get("/debug/policies") +def debug_list_policies(user_id: str = Depends(get_current_user)): + try: + _require_debug_routes_enabled() + _require_admin_user(user_id) + policies = ( + supabase.table("policies") + .select("id, policy_name, policy_number, created_at, extracted_text") + .eq("user_id", user_id) + .execute() + .data + ) + return { + "total_policies": len(policies), + "policies": [ + { + "id": policy["id"], + "current_name": policy.get("policy_name"), + "policy_number": policy.get("policy_number"), + "created_at": policy.get("created_at"), + "text_length": len(policy.get("extracted_text", "")), + "is_test_data": "test insurance policy" + in (policy.get("extracted_text", "")).lower(), + } + for policy in policies + ], + } + except Exception as e: + logging.exception("debug_list_policies failed: %s", e) + raise HTTPException(status_code=500, detail="Error fetching policies.") + + +@router.post("/debug/update-policy-name/{policy_id}") +def debug_update_policy_name( + policy_id: str, new_name: str, user_id: str = Depends(get_current_user) +): + try: + _require_debug_routes_enabled() + _require_admin_user(user_id) + result = ( + supabase.table("policies") + .select("id, policy_name") + .eq("id", policy_id) + .eq("user_id", user_id) + .execute() + ) + if not result.data: + raise HTTPException(status_code=404, detail="Policy not found") + old_name = result.data[0].get("policy_name") + supabase.table("policies").update({"policy_name": new_name}).eq( + "id", policy_id + ).eq("user_id", user_id).execute() + return { + "message": "Policy name updated successfully", + "policy_id": policy_id, + "old_name": old_name, + "new_name": new_name, + } + except Exception as e: + logging.exception("debug_update_policy_name failed: %s", e) + raise HTTPException(status_code=500, detail="Error updating policy name.") + + +@router.get("/debug/policy-content/{policy_id}") +def debug_policy_content(policy_id: str, user_id: str = Depends(get_current_user)): + try: + _require_debug_routes_enabled() + _require_admin_user(user_id) + result = ( + supabase.table("policies") + .select("*") + .eq("id", policy_id) + .eq("user_id", user_id) + .execute() + ) + if not result.data: + raise HTTPException(status_code=404, detail="Policy not found") + policy = result.data[0] + extracted_text = policy.get("extracted_text", "") + text_length = len(extracted_text) + is_test_data = ( + "test insurance policy for automated testing" in extracted_text.lower() + ) + has_sufficient_content = text_length > 200 and not is_test_data + return { + "policy_id": policy_id, + "policy_name": policy.get("policy_name"), + "text_length": text_length, + "is_test_data": is_test_data, + "has_sufficient_content": has_sufficient_content, + "text_preview": extracted_text[:200] + ("..." if text_length > 200 else ""), + "recommendations": [ + "Upload actual policy documents instead of test files" + if is_test_data + else None, + "Ensure extraction extracted the document properly" + if text_length < 100 + else None, + "Policy content looks good for chat" + if has_sufficient_content + else None, + ], + } + except IndexError: + raise HTTPException(status_code=404, detail="Policy not found") + except Exception as e: + logging.exception("debug_policy_content failed: %s", e) + raise HTTPException(status_code=500, detail="Debug endpoint failed.") + + +@router.post("/create-test-comparison") +def create_test_comparison(user_id: str = Depends(get_current_user)): + try: + _require_debug_routes_enabled() + _require_admin_user(user_id) + policies = ( + supabase.table("policies") + .select("id") + .eq("user_id", user_id) + .limit(2) + .execute() + .data + ) + if len(policies) >= 2: + result = ( + supabase.table("comparisons") + .insert( + { + "user_id": user_id, + "policy_1_id": policies[0]["id"], + "policy_2_id": policies[1]["id"], + "comparison_result": "Test comparison created for dashboard testing", + } + ) + .execute() + ) + return { + "success": True, + "message": "Test comparison created", + "comparison_id": result.data[0].get("id") if result.data else None, + } + else: + result = ( + supabase.table("comparisons") + .insert( + { + "user_id": user_id, + "policy_1_id": "test_policy_1", + "policy_2_id": "test_policy_2", + "comparison_result": "Test comparison created for dashboard testing", + } + ) + .execute() + ) + return { + "success": True, + "message": "Test comparison created with placeholder IDs", + "comparison_id": result.data[0].get("id") if result.data else None, + } + except Exception as e: + logging.exception("Error creating test comparison: %s", e) + traceback.print_exc() + raise HTTPException(status_code=500, detail="Error creating test comparison.") + + +@router.get("/test-comparisons-table") +def test_comparisons_table(user_id: str = Depends(get_current_user)): + try: + _require_debug_routes_enabled() + _require_admin_user(user_id) + result = supabase.table("comparisons").select("*").limit(10).execute() + return { + "success": True, + "total_comparisons": len(result.data) if result.data else 0, + "sample_data": result.data[:5] if result.data else [], + } + except Exception as e: + logging.exception("Error querying comparisons table: %s", e) + traceback.print_exc() + return { + "success": False, + "error": "Query failed", + "message": "Comparisons table might not exist or has permission issues", + } diff --git a/backend/src/routes/analysis.py b/backend/src/routes/analysis.py new file mode 100644 index 0000000..79088e7 --- /dev/null +++ b/backend/src/routes/analysis.py @@ -0,0 +1,131 @@ +import logging + +from fastapi import APIRouter, Form, Depends, HTTPException +from src.db import supabase +from src.auth import get_current_user +from src.models import CompareRequest +from src.llm_groq import analyze_policy, compare_policies +from src.services.activity_service import log_activity + +router = APIRouter() + + +@router.post("/analyze-policy") +def analyze(policy_id: str = Form(...), user_id: str = Depends(get_current_user)): + from src.main_app import _enforce_user_rate_limit + + _enforce_user_rate_limit("analysis", user_id, "/analyze-policy") + try: + result = ( + supabase.table("policies") + .select("extracted_text", "policy_number", "policy_name") + .eq("id", policy_id) + .eq("user_id", user_id) + .execute() + ) + if not result.data: + raise HTTPException( + status_code=404, detail="Policy not found for this user." + ) + policy = result.data[0] + analysis = analyze_policy(policy["extracted_text"]) + metadata = policy.get("validation_metadata") or {} + if not isinstance(metadata, dict): + metadata = {} + metadata["analysis_result"] = analysis + gaps_count = len(analysis.get("gaps_and_risks", [])) + exclusions_count = len(analysis.get("exclusions", [])) + total_risks = gaps_count + exclusions_count + risk_impact = min(total_risks * 0.5, 90) + validation_score = max((100 - risk_impact) / 100, 0.1) + supabase.table("policies").update( + {"validation_metadata": metadata, "validation_score": validation_score} + ).eq("id", policy_id).execute() + log_activity( + user_id=user_id, + activity_type="analysis", + title="Policy Analysis Completed", + description=f"{policy.get('policy_name', 'Policy')} analyzed successfully", + details={ + "policy_id": policy_id, + "policy_type": analysis.get("policy_type", "Unknown"), + "provider": analysis.get("provider", "Unknown"), + "analysis_score": analysis.get("claim_readiness_score", 0), + }, + ) + return {"analysis": analysis} + except IndexError: + raise HTTPException(status_code=404, detail="Policy not found for this user.") + except Exception as e: + logging.exception("Analyze policy failed: %s", e) + raise HTTPException(status_code=500, detail="Error analyzing policy.") + + +@router.post("/compare-policies") +def compare(request: CompareRequest, user_id: str = Depends(get_current_user)): + from src.main_app import _enforce_user_rate_limit + + _enforce_user_rate_limit("analysis", user_id, "/compare-policies") + try: + policy_ids = request.policy_ids + policy_1_id = policy_ids[0] + policy_2_id = policy_ids[1] + res1 = ( + supabase.table("policies") + .select("extracted_text", "policy_number", "policy_name") + .eq("id", policy_1_id) + .eq("user_id", user_id) + .execute() + ) + res2 = ( + supabase.table("policies") + .select("extracted_text", "policy_number", "policy_name") + .eq("id", policy_2_id) + .eq("user_id", user_id) + .execute() + ) + if not res1.data or not res2.data: + raise HTTPException( + status_code=404, detail="One or both policies not found for this user." + ) + pol1 = res1.data[0] + pol2 = res2.data[0] + comparison_result_text = compare_policies( + pol1["extracted_text"], + pol2["extracted_text"], + pol1.get("policy_number"), + pol2.get("policy_number"), + ) + comparison_data = { + "user_id": user_id, + "policy_1_id": policy_1_id, + "policy_2_id": policy_2_id, + "comparison_result": comparison_result_text, + } + result = supabase.table("comparisons").insert(comparison_data).execute() + log_activity( + user_id=user_id, + activity_type="comparison", + title="Policy Comparison Completed", + description=f"Compared {pol1.get('policy_name', 'Policy 1')} vs {pol2.get('policy_name', 'Policy 2')}", + details={ + "policy_1_id": policy_1_id, + "policy_2_id": policy_2_id, + "policy_1_name": pol1.get("policy_name", "Policy 1"), + "policy_2_name": pol2.get("policy_name", "Policy 2"), + "total_policies": len(policy_ids), + }, + ) + return { + "comparison": comparison_result_text, + "comparison_id": result.data[0].get("id") if result.data else None, + } + except IndexError: + raise HTTPException( + status_code=404, detail="One or both policies not found for this user." + ) + except HTTPException: + raise + except Exception as e: + logging.exception("Error in compare-policies: %s", str(e)) + raise HTTPException(status_code=500, detail="Error comparing policies.") diff --git a/backend/src/routes/auth.py b/backend/src/routes/auth.py new file mode 100644 index 0000000..dac792c --- /dev/null +++ b/backend/src/routes/auth.py @@ -0,0 +1,47 @@ +import os +import logging +import tempfile + +from fastapi import APIRouter, UploadFile, File, Depends, HTTPException, Form +from src.auth import get_current_user, refresh_token + +router = APIRouter() + + +@router.post("/refresh-token") +async def refresh(token: str = Form(...)): + return await refresh_token(token) + + +@router.post("/test-gemini") +async def test_gemini(file: UploadFile, user_id: str = Depends(get_current_user)): + temp_file_path = None + try: + with tempfile.NamedTemporaryFile( + delete=False, suffix=f"_{file.filename}" + ) as temp_file: + content = await file.read() + temp_file.write(content) + temp_file_path = temp_file.name + from src.gemini_files import upload_pdf, poll_file_status, extract_text + + file_id, file_uri = upload_pdf(temp_file_path) + status = poll_file_status(file_id) + if status != "ACTIVE": + raise HTTPException(status_code=500, detail="File did not become ACTIVE") + extracted_text = extract_text(temp_file_path) + return { + "file_id": file_id, + "file_uri": file_uri, + "status": status, + "extracted_text": extracted_text, + } + except Exception as e: + logging.exception("/test-gemini failed") + raise HTTPException(status_code=500, detail=str(e)) + finally: + try: + if temp_file_path and os.path.exists(temp_file_path): + os.unlink(temp_file_path) + except Exception: + pass diff --git a/backend/src/routes/chat.py b/backend/src/routes/chat.py new file mode 100644 index 0000000..cb6bcb2 --- /dev/null +++ b/backend/src/routes/chat.py @@ -0,0 +1,180 @@ +import logging + +from fastapi import APIRouter, Depends, HTTPException +from src.db import supabase +from src.auth import get_current_user +from src.models import ChatRequest, MultiPolicyChatRequest, ChatResponse +from src.services.activity_service import log_activity + +router = APIRouter() + + +@router.post("/chat", response_model=ChatResponse) +async def chat(request: ChatRequest, user_id: str = Depends(get_current_user)): + from src.main_app import _enforce_user_rate_limit + + _enforce_user_rate_limit("chat", user_id, "/chat") + try: + policy_id = request.policy_id + question = request.question + result = ( + supabase.table("policies") + .select("extracted_text", "policy_number", "policy_name") + .eq("id", policy_id) + .eq("user_id", user_id) + .execute() + ) + if not result.data: + raise HTTPException( + status_code=404, detail="Policy not found for this user." + ) + policy = result.data[0] + extracted_text = policy.get("extracted_text", "") + if not extracted_text or len(extracted_text) < 50: + return ChatResponse( + answer="This policy appears to have no extracted text. Please try re-uploading the policy document.", + citations=[], + ) + try: + from src.rag import retrieve_top_k + except Exception as e: + logging.exception("Failed to import rag.retrieve_top_k: %s", e) + return ChatResponse( + answer="Server misconfiguration: retrieval not available", citations=[] + ) + top = [] + try: + top = await retrieve_top_k(question, k=5, policy_id=policy_id) + except Exception as e: + logging.exception("retrieve_top_k failed: %s", e) + prompt_parts = [] + citations = [] + for idx, content, score in top: + excerpt = ( + (content[:400] + "...") + if content and len(content) > 400 + else (content or "") + ) + prompt_parts.append(excerpt) + citations.append({"id": idx, "excerpt": excerpt, "score": score}) + if not prompt_parts: + context_str = extracted_text[:2000] + else: + context_str = "\n\n".join(prompt_parts) + final_prompt = f"You are an insurance expert. Use the provided context to answer the question. Context:\n{context_str}\n\nQuestion: {question}\n\nAnswer succinctly and cite chunks by id when referenced." + answer = None + try: + from src.llm import make_llm_request + + answer = make_llm_request(final_prompt) + except Exception as e: + logging.exception("Gemini generation failed, trying fallback: %s", e) + try: + from src.llm_groq import chat_with_policy as fallback_chat + + answer = fallback_chat( + extracted_text, question, policy.get("policy_number") + ) + except Exception as e2: + logging.exception("Fallback chat failed: %s", e2) + answer = None + if not answer: + answer = "I could not generate a response. Please try again or rephrase your question." + log_activity( + user_id=user_id, + activity_type="chat", + title=f"Asked about {policy.get('policy_name', 'Policy')}", + description=f"AI assistant answered question about policy coverage", + details={ + "policy_id": policy_id, + "question": question[:100] + "..." if len(question) > 100 else question, + "chat_type": "single_policy", + }, + ) + supabase.table("chat_logs").insert( + { + "user_id": user_id, + "policy_id": policy_id, + "question": question, + "answer": str(answer) if answer is not None else "", + } + ).execute() + return ChatResponse( + answer=str(answer) if answer is not None else "", citations=citations + ) + except IndexError: + raise HTTPException(status_code=404, detail="Policy not found for this user.") + except Exception as e: + logging.exception( + "Chat error for policy %s: %s", + getattr(request, "policy_id", "unknown"), + str(e), + ) + raise HTTPException(status_code=500, detail="Error processing chat.") + + +@router.post("/chat-multiple", response_model=ChatResponse) +def chat_multiple_policies( + request: MultiPolicyChatRequest, user_id: str = Depends(get_current_user) +): + from src.main_app import _enforce_user_rate_limit + + _enforce_user_rate_limit("chat", user_id, "/chat-multiple") + try: + question = request.question + policies = ( + supabase.table("policies") + .select("id", "extracted_text", "policy_number") + .eq("user_id", user_id) + .execute() + .data + ) + if not policies: + raise HTTPException( + status_code=404, detail="No policies found for this user." + ) + policies_data = [] + for policy in policies: + policies_data.append( + { + "id": policy["id"], + "extracted_text": policy["extracted_text"], + "policy_number": policy.get( + "policy_number", f"Policy {policy['id']}" + ), + } + ) + from src.llm_groq import chat_with_multiple_policies + + answer = chat_with_multiple_policies(policies_data, question) + if not answer: + answer = "I could not generate a comprehensive response across your policies. Please try again." + answer_str = str(answer) if answer is not None else "" + supabase.table("chat_logs").insert( + { + "user_id": user_id, + "policy_id": None, + "question": question, + "answer": answer_str, + "chat_type": "multiple_policies", + } + ).execute() + log_activity( + user_id=user_id, + activity_type="chat", + title="Asked across all policies", + description=f"AI assistant answered multi-policy question", + details={ + "question": question[:100] + "..." if len(question) > 100 else question, + "policies_count": len(policies), + "chat_type": "multiple_policies", + }, + ) + return ChatResponse(answer=answer_str, citations=[]) + except HTTPException: + raise + except Exception as e: + logging.exception("chat_multiple_policies failed: %s", e) + raise HTTPException( + status_code=500, detail="Error processing multi-policy chat." + ) diff --git a/backend/src/routes/dashboard.py b/backend/src/routes/dashboard.py new file mode 100644 index 0000000..a0b25c3 --- /dev/null +++ b/backend/src/routes/dashboard.py @@ -0,0 +1,579 @@ +import logging +import re +from datetime import datetime + +from fastapi import APIRouter, Depends, HTTPException +from src.db import supabase, supabase_storage +from src.auth import get_current_user + +router = APIRouter() + + +@router.get("/history") +def get_comprehensive_history( + page: int = 1, page_size: int = 50, user_id: str = Depends(get_current_user) +): + from src.main_app import _enforce_user_rate_limit + + _enforce_user_rate_limit("user_general", user_id, "/history") + try: + page = max(1, page) + page_size = max(1, min(page_size, 100)) + offset = (page - 1) * page_size + policies = [] + try: + policies = ( + supabase.table("policies") + .select( + "id", + "policy_name", + "policy_number", + "created_at", + "uploaded_file_url", + ) + .eq("user_id", user_id) + .order("created_at", desc=True) + .execute() + .data + ) + except Exception as e: + logging.exception("Error fetching policies: %s", e) + policies = [] + total_chat_logs = 0 + chat_logs = [] + try: + chat_logs = ( + supabase.table("chat_logs") + .select( + "id", "policy_id", "question", "answer", "created_at", "chat_type" + ) + .eq("user_id", user_id) + .order("created_at", desc=True) + .range(offset, offset + page_size - 1) + .execute() + .data + ) + chat_logs_count_result = ( + supabase.table("chat_logs") + .select("id", **{"count": "exact", "head": True}) + .eq("user_id", user_id) + .execute() + ) + total_chat_logs = int(getattr(chat_logs_count_result, "count", 0) or 0) + except Exception as e: + logging.exception("Error fetching chat logs: %s", e) + chat_logs = [] + total_chat_logs = 0 + comparisons = [] + try: + comparisons = ( + supabase.table("comparisons") + .select( + "id", + "policy_1_id", + "policy_2_id", + "created_at", + "comparison_result", + ) + .eq("user_id", user_id) + .order("created_at", desc=True) + .execute() + .data + ) + except Exception as e: + logging.exception("Error fetching comparisons: %s", e) + comparisons = [] + activities = [] + for policy in policies: + activities.append( + { + "id": f"upload_{policy['id']}", + "type": "upload", + "title": f"Policy Upload: {policy.get('policy_name', 'Unnamed Policy')}", + "description": policy.get( + "policy_number", "No policy number provided" + ), + "timestamp": policy["created_at"], + "status": "completed", + "details": { + "filesProcessed": 1, + "policyId": policy["id"], + "hasFile": bool(policy.get("uploaded_file_url")), + }, + } + ) + for chat in chat_logs: + chat_type = chat.get("chat_type", "single_policy") + title_suffix = ( + "Multi-Policy Chat" + if chat_type == "multiple_policies" + else "Policy Chat" + ) + activities.append( + { + "id": f"chat_{chat['id']}", + "type": "chat", + "title": f"{title_suffix}: {chat['question'][:50]}{'...' if len(chat['question']) > 50 else ''}", + "description": f"Asked about: {chat['question'][:120]}{'...' if len(chat['question']) > 120 else ''}", + "timestamp": chat["created_at"], + "status": "completed", + "details": { + "questionsAnswered": 1, + "chatType": chat_type, + "policyId": chat.get("policy_id"), + "questionLength": len(chat["question"]), + "answerLength": len(chat.get("answer", "")), + }, + } + ) + for comp in comparisons: + activities.append( + { + "id": f"comparison_{comp['id']}", + "type": "comparison", + "title": "Policy Comparison", + "description": f"Compared policies {comp['policy_1_id']} and {comp['policy_2_id']}", + "timestamp": comp["created_at"], + "status": "completed", + "details": { + "policiesCompared": 2, + "policy1Id": comp["policy_1_id"], + "policy2Id": comp["policy_2_id"], + "hasResult": bool(comp.get("comparison_result")), + }, + } + ) + activities.sort(key=lambda x: x["timestamp"], reverse=True) + analysis_activities = [] + for policy in policies: + analysis_activities.append( + { + "id": f"analysis_{policy['id']}", + "type": "analysis", + "title": f"Policy Analysis: {policy.get('policy_name', 'Unnamed Policy')}", + "description": f"Analyzed {policy.get('policy_name', 'policy')} for coverage details and insights", + "timestamp": policy["created_at"], + "status": "completed", + "details": { + "insightsGenerated": 3, + "policyId": policy["id"], + "analysisType": "automatic", + }, + } + ) + all_activities = activities + analysis_activities + all_activities.sort(key=lambda x: x["timestamp"], reverse=True) + total_activities = len(all_activities) + paginated_activities = all_activities[offset : offset + page_size] + stats = { + "totalActivities": total_activities, + "uploads": len([a for a in all_activities if a["type"] == "upload"]), + "analyses": len([a for a in all_activities if a["type"] == "analysis"]), + "chats": len([a for a in all_activities if a["type"] == "chat"]), + "comparisons": len( + [a for a in all_activities if a["type"] == "comparison"] + ), + "totalPolicies": len(policies), + } + has_more_activities = offset + page_size < total_activities + has_more_chat_logs = offset + page_size < total_chat_logs + return { + "activities": paginated_activities, + "stats": stats, + "policies": policies, + "chat_logs": chat_logs, + "pagination": { + "page": page, + "page_size": page_size, + "has_more_activities": has_more_activities, + "has_more_chat_logs": has_more_chat_logs, + "next_page": page + 1 + if (has_more_activities or has_more_chat_logs) + else None, + "total_activities": total_activities, + "total_chat_logs": total_chat_logs, + }, + "success": True, + } + except Exception as e: + logging.exception("Exception in get_comprehensive_history: %s", str(e)) + raise HTTPException( + status_code=500, detail="Error fetching comprehensive history." + ) + + +@router.get("/activities") +def get_activities(user_id: str = Depends(get_current_user)): + try: + try: + activities = ( + supabase_storage.table("activities") + .select("*") + .eq("user_id", user_id) + .order("created_at", desc=True) + .limit(10) + .execute() + ) + except Exception: + activities = ( + supabase.table("activities") + .select("*") + .eq("user_id", user_id) + .order("created_at", desc=True) + .limit(10) + .execute() + ) + if ( + activities + and hasattr(activities, "data") + and isinstance(activities.data, list) + and len(activities.data) > 0 + ): + formatted = [] + for activity in activities.data: + formatted.append( + { + "id": activity["id"], + "type": activity["type"], + "title": activity["title"], + "description": activity["description"], + "timestamp": activity["created_at"], + "status": activity["status"], + "details": activity.get("details", {}), + } + ) + return {"activities": formatted, "total": len(formatted), "success": True} + else: + try: + policies_res = ( + supabase.table("policies") + .select("id, policy_name, created_at") + .eq("user_id", user_id) + .order("created_at", desc=True) + .limit(5) + .execute() + ) + if policies_res.data: + generated = [] + for p in policies_res.data: + generated.append( + { + "id": f"gen-{p['id']}", + "type": "upload", + "title": "Policy Uploaded", + "description": f"Uploaded {p.get('policy_name', 'Policy')}", + "timestamp": p["created_at"], + "status": "completed", + "details": {"policy_id": p["id"]}, + } + ) + return { + "activities": generated, + "total": len(generated), + "success": True, + } + except Exception as e: + logging.warning("Failed to generate activities from policies: %s", e) + return { + "activities": [ + { + "id": "sample-1", + "type": "upload", + "title": "Welcome to ClaimWise!", + "description": "Upload your first policy to see activities here", + "timestamp": datetime.utcnow().isoformat(), + "status": "completed", + "details": {}, + } + ], + "total": 1, + "success": True, + } + except Exception as e: + logging.exception("Error fetching activities: %s", str(e)) + return {"activities": [], "total": 0, "success": False, "error": str(e)} + + +@router.get("/dashboard/stats") +def dashboard_stats(user_id: str = Depends(get_current_user)): + try: + uploaded_count = 0 + try: + policies = ( + supabase.table("policies").select("id").eq("user_id", user_id).execute() + ) + uploaded_count = ( + len(policies.data) if policies and policies.data is not None else 0 + ) + except Exception as e: + logging.exception("Error counting policies: %s", e) + uploaded_count = 0 + documents_processed = uploaded_count + analyses_completed = uploaded_count + comparisons_run = 0 + try: + comparisons = ( + supabase.table("comparisons") + .select("id") + .eq("user_id", user_id) + .execute() + .data + ) + comparisons_run = len(comparisons) if comparisons else 0 + except Exception as e: + logging.exception("Error counting comparisons: %s", e) + comparisons_run = 0 + return { + "uploadedDocuments": uploaded_count, + "documentsProcessed": documents_processed, + "analysesCompleted": analyses_completed, + "comparisonsRun": comparisons_run, + } + except Exception as e: + logging.exception("Exception in dashboard_stats: %s", str(e)) + raise HTTPException(status_code=500, detail="Error fetching dashboard stats.") + + +@router.get("/dashboard/stats-dev") +def dashboard_stats_dev(user_id: str = Depends(get_current_user)): + from src.main_app import _require_debug_routes_enabled, _require_admin_user + + _require_debug_routes_enabled() + _require_admin_user(user_id) + try: + uploaded_count = 0 + documents_processed = 0 + analyses_completed = 0 + comparisons_run = 0 + try: + policies_res = ( + supabase.table("policies").select("id", "extracted_text").execute() + ) + policies = policies_res.data if policies_res and policies_res.data else [] + uploaded_count = len(policies) + documents_processed = len([p for p in policies if p.get("extracted_text")]) + except Exception as e: + logging.exception("/dashboard/stats-dev - policies query failed: %s", e) + try: + analyses_res = supabase.table("analyses").select("id").execute() + analyses_completed = ( + len(analyses_res.data) if analyses_res and analyses_res.data else 0 + ) + except Exception: + analyses_completed = uploaded_count + try: + comparisons_res = supabase.table("comparisons").select("id").execute() + comparisons_run = ( + len(comparisons_res.data) + if comparisons_res and comparisons_res.data + else 0 + ) + except Exception as e: + logging.exception("/dashboard/stats-dev - comparisons query failed: %s", e) + comparisons_run = 0 + if ( + uploaded_count == 0 + and documents_processed == 0 + and analyses_completed == 0 + and comparisons_run == 0 + ): + return { + "uploadedDocuments": 2, + "documentsProcessed": 2, + "analysesCompleted": 2, + "comparisonsRun": 1, + } + return { + "uploadedDocuments": uploaded_count, + "documentsProcessed": documents_processed, + "analysesCompleted": analyses_completed, + "comparisonsRun": comparisons_run, + } + except Exception as e: + logging.exception("Exception in dashboard_stats_dev: %s", str(e)) + return { + "uploadedDocuments": 2, + "documentsProcessed": 2, + "analysesCompleted": 2, + "comparisonsRun": 1, + } + + +@router.get("/dashboard/metrics") +def dashboard_metrics(user_id: str = Depends(get_current_user)): + try: + policies_res = ( + supabase.table("policies") + .select( + "id, validation_score, validation_metadata, extracted_text, policy_name, created_at" + ) + .eq("user_id", user_id) + .execute() + ) + policies = policies_res.data if policies_res and policies_res.data else [] + protection_score = 0 + risks_found = 0 + total_coverage = 0 + if policies: + validation_scores = [] + risk_count = 0 + coverage_amounts = [] + for policy in policies: + validation_score = policy.get("validation_score", 0.75) + validation_scores.append(validation_score * 100) + metadata = policy.get("validation_metadata") or {} + if not isinstance(metadata, dict): + metadata = {} + analysis_result = metadata.get("analysis_result", {}) + if not analysis_result and policy.get("extracted_text"): + text = policy.get("extracted_text", "") + coverage_patterns = [ + r"Sum Insured\s*[:\-\s]\s*(?:Rs\.?|INR|₹)?\s*([\d,]+)", + r"Coverage Amount\s*[:\-\s]\s*(?:Rs\.?|INR|₹)?\s*([\d,]+)", + r"Total Coverage\s*[:\-\s]\s*(?:Rs\.?|INR|₹)?\s*([\d,]+)", + ] + for pattern in coverage_patterns: + match = re.search(pattern, text, re.IGNORECASE) + if match: + try: + amount_str = match.group(1).replace(",", "") + coverage_amounts.append(float(amount_str)) + break + except Exception: + pass + if isinstance(analysis_result, dict): + risk_count += len(analysis_result.get("gaps_and_risks", [])) + risk_count += len(analysis_result.get("exclusions", [])) + coverage = analysis_result.get("coverage_amount") + if coverage: + try: + clean_coverage = re.sub(r"[^\d.]", "", str(coverage)) + if clean_coverage: + coverage_amounts.append(float(clean_coverage)) + except (ValueError, TypeError): + pass + if validation_scores: + protection_score = int(sum(validation_scores) / len(validation_scores)) + risks_found = risk_count + if coverage_amounts: + total_coverage = sum(coverage_amounts) + coverage_in_lakh = total_coverage / 100000 if total_coverage > 0 else 0 + total_coverage_formatted = ( + f"₹{coverage_in_lakh:.2f} Lakh" if total_coverage > 0 else "₹0 Lakh" + ) + quick_insight = "" + if not policies: + quick_insight = ( + "Scan your first policy to get personalized savings insights." + ) + elif risks_found > 0: + quick_insight = f"Found {risks_found} potential gaps in your coverage. Review analysis for savings opportunities." + elif protection_score >= 80: + quick_insight = "Your policies provide comprehensive coverage. Consider reviewing annually for best deals." + else: + quick_insight = "Some coverage gaps detected. Explore our comparison tool to find better options." + return { + "protectionScore": protection_score, + "risksFound": risks_found, + "totalCoverage": total_coverage_formatted, + "quickInsight": quick_insight, + "policiesCount": len(policies), + } + except Exception as e: + logging.exception("Exception in dashboard_metrics: %s", str(e)) + raise HTTPException(status_code=500, detail="Error fetching dashboard metrics.") + + +@router.get("/dashboard/metrics-dev") +def dashboard_metrics_dev(user_id: str = Depends(get_current_user)): + from src.main_app import _require_debug_routes_enabled, _require_admin_user + + _require_debug_routes_enabled() + _require_admin_user(user_id) + try: + policies_res = supabase.table("policies").select("*").execute() + policies = policies_res.data if policies_res and policies_res.data else [] + protection_score = 78 + risks_found = 0 + total_coverage = 0 + if policies: + validation_scores = [] + risk_count = 0 + coverage_amounts = [] + for policy in policies[:5]: + validation_score = policy.get("validation_score", 0.75) + validation_scores.append(validation_score * 100) + analysis = ( + supabase.table("analyses") + .select("analysis_result") + .eq("policy_id", policy["id"]) + .execute() + ) + if analysis.data: + analysis_result = analysis.data[0].get("analysis_result", {}) + if isinstance(analysis_result, dict): + risk_count += len(analysis_result.get("gaps_and_risks", [])) + coverage = policy.get("coverage_amount") + if coverage: + try: + coverage_amounts.append(float(coverage)) + except (ValueError, TypeError): + pass + if validation_scores: + protection_score = int(sum(validation_scores) / len(validation_scores)) + risks_found = risk_count + if coverage_amounts: + total_coverage = sum(coverage_amounts) + coverage_in_lakh = total_coverage / 100000 if total_coverage > 0 else 0 + total_coverage_formatted = ( + f"₹{coverage_in_lakh:.2f} Lakh" if total_coverage > 0 else "₹50.00 Lakh" + ) + quick_insight = ( + "You could save 15% on premiums by switching to HDFC Ergo." + if policies + else "Scan your first policy to get personalized savings insights." + ) + return { + "protectionScore": protection_score, + "risksFound": risks_found, + "totalCoverage": total_coverage_formatted, + "quickInsight": quick_insight, + "policiesCount": len(policies), + } + except Exception as e: + logging.exception("Exception in dashboard_metrics_dev: %s", str(e)) + return { + "protectionScore": 78, + "risksFound": 3, + "totalCoverage": "₹50.00 Lakh", + "quickInsight": "You could save 15% on premiums by switching to HDFC Ergo.", + "policiesCount": 0, + } + + +@router.get("/history-legacy") +def history(user_id: str = Depends(get_current_user)): + from src.main_app import _require_debug_routes_enabled + + _require_debug_routes_enabled() + try: + policies = ( + supabase.table("policies") + .select("id", "policy_name", "policy_number", "created_at") + .eq("user_id", user_id) + .execute() + .data + ) + comparisons = ( + supabase.table("comparisons") + .select( + "id", "policy_1_id", "policy_2_id", "created_at", "comparison_result" + ) + .eq("user_id", user_id) + .execute() + .data + ) + return {"policies": policies, "comparisons": comparisons} + except Exception as e: + logging.exception("history_legacy failed: %s", e) + raise HTTPException(status_code=500, detail="Error fetching history.") diff --git a/backend/src/routes/monitoring.py b/backend/src/routes/monitoring.py new file mode 100644 index 0000000..b7155e1 --- /dev/null +++ b/backend/src/routes/monitoring.py @@ -0,0 +1,39 @@ +from fastapi import APIRouter, Depends +from src.auth import get_current_user +from src.monitoring import monitor, get_health_status + +router = APIRouter() + + +@router.get("/") +def root(): + return {"message": "ClaimWise Backend"} + + +@router.get("/healthz") +def healthz(): + return {"status": "ok"} + + +@router.get("/monitoring/summary") +def monitoring_summary(user_id: str = Depends(get_current_user)): + from src.main_app import _require_admin_user + + _require_admin_user(user_id) + return monitor.get_performance_summary() + + +@router.get("/monitoring/endpoints") +def monitoring_endpoints(user_id: str = Depends(get_current_user)): + from src.main_app import _require_admin_user + + _require_admin_user(user_id) + return monitor.get_endpoint_stats() + + +@router.get("/monitoring/health") +async def monitoring_health(user_id: str = Depends(get_current_user)): + from src.main_app import _require_admin_user + + _require_admin_user(user_id) + return await get_health_status() diff --git a/backend/src/routes/policies.py b/backend/src/routes/policies.py new file mode 100644 index 0000000..dd9c0b7 --- /dev/null +++ b/backend/src/routes/policies.py @@ -0,0 +1,392 @@ +import os +import logging +import tempfile +import time +import uuid +from datetime import datetime +from typing import Union, Dict, Any + +from fastapi import ( + APIRouter, + UploadFile, + File, + HTTPException, + Form, + Depends, + BackgroundTasks, +) +from supabase import create_client + +from src.db import supabase, supabase_storage +from src.auth import get_current_user, oauth2_scheme +from src.models import UploadResponse +from src.document_validator import validate_insurance_document +from src.repositories.policy_repository import PolicyRepository +from src.services.activity_service import log_activity + +router = APIRouter() +policy_repo = PolicyRepository() + +STORAGE_BUCKET = os.getenv("SUPABASE_STORAGE_BUCKET", "project") +APP_ENV = os.getenv("APP_ENV", os.getenv("ENVIRONMENT", "development")).lower() +MAX_UPLOAD_SIZE_MB = int(os.getenv("MAX_UPLOAD_SIZE_MB", "10")) +MAX_UPLOAD_BYTES = MAX_UPLOAD_SIZE_MB * 1024 * 1024 +ALLOWED_UPLOAD_EXTENSIONS = {"pdf"} +ALLOWED_UPLOAD_MIME_TYPES = {"application/pdf"} +ENABLE_DOCUMENT_VALIDATION = ( + os.getenv("ENABLE_DOCUMENT_VALIDATION", "true").lower() == "true" +) +SIGNED_URL_TTL_SECONDS = int(os.getenv("SIGNED_URL_TTL_SECONDS", "3600")) + + +def _duplicate_conflict_detail(error_obj: Any) -> str: + default_message = "A resource already exists" + constraint_name = None + try: + if error_obj is None: + return default_message + constraint_name = getattr(error_obj, "constraint_name", None) + if not constraint_name: + diag = getattr(error_obj, "diag", None) + constraint_name = ( + getattr(diag, "constraint_name", None) if diag is not None else None + ) + if not constraint_name: + orig = getattr(error_obj, "orig", None) + if orig is not None: + diag = getattr(orig, "diag", None) + constraint_name = ( + getattr(diag, "constraint_name", None) if diag is not None else None + ) + except Exception: + constraint_name = None + if constraint_name: + return f"Conflict: duplicate value for constraint '{constraint_name}'" + return default_message + + +@router.post("/upload-policy", response_model=UploadResponse) +async def upload_policy( + background_tasks: BackgroundTasks, + user_id: str = Depends(get_current_user), + policy_name: str = Form(None), + policy_number: str = Form(None), + file: UploadFile = File(None), + text_input: str = Form(None), + sync_indexing: bool = Form(False), +): + from src.main_app import _enforce_user_rate_limit + + _enforce_user_rate_limit("upload", user_id, "/upload-policy") + + if file and text_input: + raise HTTPException( + status_code=400, detail="Provide either a file or text, not both." + ) + elif not file and not text_input: + raise HTTPException(status_code=400, detail="Provide a file or text.") + + extracted_text = None + file_bytes = None + if file: + if not file.filename: + raise HTTPException( + status_code=400, detail="File must have a valid filename" + ) + file_type = ( + file.filename.rsplit(".", 1)[-1].lower() if "." in file.filename else "" + ) + if file_type not in ALLOWED_UPLOAD_EXTENSIONS: + raise HTTPException(status_code=400, detail="Only PDF files are supported.") + content_type = (file.content_type or "").lower().strip() + if content_type not in ALLOWED_UPLOAD_MIME_TYPES: + raise HTTPException( + status_code=400, detail="Unsupported file content type." + ) + declared_size = file.headers.get("content-length") if file.headers else None + if ( + declared_size + and declared_size.isdigit() + and int(declared_size) > MAX_UPLOAD_BYTES + ): + raise HTTPException( + status_code=413, + detail=f"File is too large. Max allowed size is {MAX_UPLOAD_SIZE_MB}MB.", + ) + file_bytes = await file.read() + if len(file_bytes) > MAX_UPLOAD_BYTES: + raise HTTPException( + status_code=413, + detail=f"File is too large. Max allowed size is {MAX_UPLOAD_SIZE_MB}MB.", + ) + temp_file_path = None + try: + with tempfile.NamedTemporaryFile( + delete=False, suffix=f"_{file.filename}" + ) as temp_file: + temp_file.write(file_bytes) + temp_file_path = temp_file.name + try: + from src.gemini_files import extract_text + except Exception as import_err: + logging.exception("Failed to import gemini_files: %s", import_err) + raise HTTPException( + status_code=500, + detail="Server misconfiguration: PDF extraction module not available", + ) + extracted_text = extract_text(temp_file_path) + if not extracted_text: + raise HTTPException( + status_code=400, detail="No text extracted from file." + ) + finally: + try: + if temp_file_path and os.path.exists(temp_file_path): + os.unlink(temp_file_path) + except Exception: + pass + else: + extracted_text = text_input + + if ENABLE_DOCUMENT_VALIDATION and extracted_text: + source_name = file.filename if file and file.filename else "text_input" + validation_report = validate_insurance_document(extracted_text, source_name) + if not validation_report.is_valid: + raise HTTPException( + status_code=400, + detail="Uploaded content does not appear to be a valid insurance policy document.", + ) + + file_url = None + if file and file_bytes: + storage_path = f"policies/{user_id}/{file.filename}" + try: + try: + supabase_storage.storage.from_(STORAGE_BUCKET).upload( + storage_path, file_bytes + ) + file_url = storage_path + except Exception as upload_error: + error_str = str(upload_error) + if "Duplicate" in error_str or "already exists" in error_str: + timestamp = str(int(time.time())) + if file.filename: + filename_parts = file.filename.rsplit(".", 1) + unique_filename = ( + f"{filename_parts[0]}_{timestamp}.{filename_parts[1]}" + if len(filename_parts) == 2 + else f"{file.filename}_{timestamp}" + ) + else: + unique_filename = f"uploaded_file_{timestamp}" + storage_path = f"policies/{user_id}/{unique_filename}" + supabase_storage.storage.from_(STORAGE_BUCKET).upload( + storage_path, file_bytes + ) + file_url = storage_path + else: + raise upload_error + except Exception as storage_error: + logging.exception("Storage Error: %s", storage_error) + raise HTTPException(status_code=500, detail="File storage failed.") + + data = { + "user_id": user_id, + "policy_name": policy_name, + "policy_number": policy_number, + "extracted_text": extracted_text, + "uploaded_file_url": file_url, + } + try: + svc = supabase_storage or supabase + try: + user_check = svc.table("users").select("id").eq("id", user_id).execute() + if not (user_check and getattr(user_check, "data", None)): + raise HTTPException( + status_code=403, + detail="User profile not found. Please re-authenticate.", + ) + except HTTPException: + raise + except Exception: + logging.warning( + "Could not verify user existence prior to insert; continuing" + ) + + response = svc.table("policies").insert(data).execute() + resp_err = getattr(response, "error", None) + if resp_err: + logging.error("Supabase insert error: %s", resp_err) + err_msg = getattr(resp_err, "message", str(resp_err)) + err_code = getattr(resp_err, "code", "") + if "foreign key" in str(err_msg).lower() or err_code == "23503": + raise HTTPException( + status_code=403, + detail="Invalid user or authentication state. Please re-authenticate.", + ) + if ( + err_code == "23505" + or "duplicate key" in str(err_msg).lower() + or "already exists" in str(err_msg).lower() + ): + raise HTTPException( + status_code=409, detail=_duplicate_conflict_detail(resp_err) + ) + raise HTTPException(status_code=500, detail="Failed to save policy.") + + if not (response and getattr(response, "data", None)): + raise HTTPException(status_code=500, detail="Failed to save policy.") + + policy_id = response.data[0]["id"] + + if sync_indexing: + try: + from src.rag import index_documents + + await index_documents(extracted_text, policy_id) + except Exception as e: + logging.exception("Synchronous indexing failed: %s", e) + else: + try: + from src.rag import index_documents + + background_tasks.add_task(index_documents, extracted_text, policy_id) + except Exception as e: + logging.exception("Failed to schedule background indexing: %s", e) + + log_activity( + user_id=user_id, + activity_type="upload", + title=f"Uploaded {policy_name or 'Policy'}", + description=f"{policy_name or 'Policy document'} successfully uploaded and processed", + details={ + "policy_id": policy_id, + "file_type": "file" if file else "text", + "file_name": file.filename if file else None, + "text_length": len(extracted_text), + }, + ) + + return UploadResponse( + policy_id=policy_id, + extracted_text=extracted_text, + status="indexing_started", + indexing_mode="synchronous" if sync_indexing else "background", + ) + except HTTPException: + raise + except Exception as db_error: + logging.exception("Database save error: %s", db_error) + err_str = str(db_error).lower() + if ( + "foreign key" in err_str + or "violates foreign key" in err_str + or "23503" in err_str + ): + raise HTTPException( + status_code=403, + detail="Invalid user or authentication state. Please re-authenticate.", + ) + if ( + "23505" in err_str + or "duplicate key" in err_str + or "already exists" in err_str + ): + raise HTTPException( + status_code=409, detail=_duplicate_conflict_detail(db_error) + ) + raise HTTPException(status_code=500, detail="Database save failed.") + + +@router.get("/policies") +def get_user_policies(user_id: str = Depends(get_current_user)): + try: + policies = ( + supabase.table("policies") + .select( + "id, policy_name, policy_number, created_at, validation_score, validation_metadata, uploaded_file_url" + ) + .eq("user_id", user_id) + .order("created_at", desc=True) + .execute() + ) + data = policies.data if policies.data else [] + return {"policies": data, "total": len(data), "success": True} + except Exception as e: + logging.exception("Error fetching policies for user %s: %s", user_id, e) + raise HTTPException(status_code=500, detail="Error fetching policies.") + + +@router.get("/policies/{policy_id}/file-url") +def get_policy_file_url(policy_id: str, user_id: str = Depends(get_current_user)): + from src.main_app import _enforce_user_rate_limit + + _enforce_user_rate_limit("user_general", user_id, "/policies/file-url") + policy_res = ( + supabase.table("policies") + .select("uploaded_file_url") + .eq("id", policy_id) + .eq("user_id", user_id) + .limit(1) + .execute() + ) + if not policy_res or not getattr(policy_res, "data", None): + raise HTTPException(status_code=404, detail="Policy not found.") + file_path = policy_res.data[0].get("uploaded_file_url") + if not file_path: + raise HTTPException( + status_code=404, detail="No file available for this policy." + ) + signed_result = supabase_storage.storage.from_(STORAGE_BUCKET).create_signed_url( + file_path, SIGNED_URL_TTL_SECONDS + ) + signed_url = None + if isinstance(signed_result, dict): + signed_url = signed_result.get("signedURL") or signed_result.get("signedUrl") + elif hasattr(signed_result, "get"): + signed_url = signed_result.get("signedURL") or signed_result.get("signedUrl") + if not signed_url: + raise HTTPException(status_code=500, detail="Could not create signed URL.") + return { + "policy_id": policy_id, + "signed_url": signed_url, + "expires_in": SIGNED_URL_TTL_SECONDS, + } + + +@router.delete("/policies/{policy_id}") +def delete_policy( + policy_id: str, + user_id: str = Depends(get_current_user), + token: str = Depends(oauth2_scheme), +): + try: + url = os.getenv("SUPABASE_URL", "") + key = os.getenv("SUPABASE_KEY", "") + auth_client = create_client(url, key) + auth_client.postgrest.auth(token) + policy = ( + auth_client.table("policies") + .select("id", "uploaded_file_url") + .eq("id", policy_id) + .execute() + .data + ) + if not policy: + raise HTTPException( + status_code=404, detail="Policy not found or access denied." + ) + policy_repo.delete(policy_id, auth_client) + log_activity( + user_id=user_id, + activity_type="delete", + title="Policy Deleted", + description=f"Policy {policy_id} deleted", + details={"policy_id": policy_id}, + ) + return {"message": "Policy deleted successfully"} + except HTTPException: + raise + except Exception as e: + logging.exception("Error deleting policy: %s", e) + raise HTTPException(status_code=500, detail="Error deleting policy.") diff --git a/backend/src/services/__init__.py b/backend/src/services/__init__.py new file mode 100644 index 0000000..f9ec103 --- /dev/null +++ b/backend/src/services/__init__.py @@ -0,0 +1,3 @@ +from .activity_service import log_activity + +__all__ = ["log_activity"] diff --git a/backend/src/services/activity_service.py b/backend/src/services/activity_service.py new file mode 100644 index 0000000..dbe0f41 --- /dev/null +++ b/backend/src/services/activity_service.py @@ -0,0 +1,31 @@ +import uuid +import logging +from datetime import datetime +from typing import Union, Dict +from src.db import supabase + + +def log_activity( + user_id: str, + activity_type: str, + title: str, + description: str, + details: Union[Dict, None] = None, +): + try: + activity_data = { + "id": str(uuid.uuid4()), + "user_id": user_id, + "type": activity_type, + "title": title, + "description": description, + "details": details or {}, + "status": "completed", + "created_at": datetime.utcnow().isoformat(), + } + result = supabase.table("activities").insert(activity_data).execute() + logging.debug("Activity logged: %s - %s", activity_type, title) + return result.data[0] if result.data else None + except Exception as e: + logging.exception("Error logging activity: %s", e) + return None diff --git a/docs/ARCHITECTURAL_REVIEW.md b/docs/api/architectural-review.md similarity index 100% rename from docs/ARCHITECTURAL_REVIEW.md rename to docs/api/architectural-review.md diff --git a/docs/RAG_REVIEW.md b/docs/api/rag-review.md similarity index 100% rename from docs/RAG_REVIEW.md rename to docs/api/rag-review.md diff --git a/frontend/app/admin/policies/page.tsx b/frontend/app/admin/policies/page.tsx index ed163b5..999279c 100644 --- a/frontend/app/admin/policies/page.tsx +++ b/frontend/app/admin/policies/page.tsx @@ -9,7 +9,7 @@ import { Input } from "@/components/ui/input" import { Badge } from "@/components/ui/badge" import { ArrowLeft, Edit2, Save, X } from "lucide-react" import Link from "next/link" -import { supabase } from "@/lib/supabase" +import { getSupabase } from "@/lib/get-supabase" import { useAuth } from "@/hooks/use-auth" import { fetchWithTimeout } from "@/lib/fetch-with-timeout" @@ -55,6 +55,7 @@ export default function PolicyManagerPage() { const fetchPolicies = async () => { try { + const supabase = await getSupabase() const session = await supabase.auth.getSession() const token = session.data.session?.access_token @@ -84,6 +85,7 @@ export default function PolicyManagerPage() { const updatePolicyName = async (policyId: string, name: string) => { try { + const supabase = await getSupabase() const session = await supabase.auth.getSession() const token = session.data.session?.access_token diff --git a/frontend/app/analyze/page.tsx b/frontend/app/analyze/page.tsx index b62d8d7..d241b8e 100644 --- a/frontend/app/analyze/page.tsx +++ b/frontend/app/analyze/page.tsx @@ -20,7 +20,7 @@ import { cn } from "@/lib/utils" import type { BackendPolicyRecord } from "@/types/policies" // Dynamic policy data from backend/AI -import { supabase } from "@/lib/supabase" +import { getSupabase } from "@/lib/get-supabase" import { createApiUrlWithLogging } from "@/lib/url-utils" import { fetchWithTimeout } from "@/lib/fetch-with-timeout" @@ -46,6 +46,7 @@ export default function AnalyzePage() { try { // Get Supabase JWT and user info + const supabase = await getSupabase() const session = await supabase.auth.getSession() const token = session.data.session?.access_token const userId = session.data.session?.user?.id @@ -283,6 +284,7 @@ export default function AnalyzePage() { if (!confirm("Are you sure you want to delete this policy?")) return try { + const supabase = await getSupabase() const session = await supabase.auth.getSession() const token = session.data.session?.access_token diff --git a/frontend/app/chat/page.tsx b/frontend/app/chat/page.tsx index 31f8823..416c203 100644 --- a/frontend/app/chat/page.tsx +++ b/frontend/app/chat/page.tsx @@ -11,9 +11,9 @@ import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@ import { Badge } from "@/components/ui/badge" import { ArrowLeft, MessageSquare, Bot, AlertCircle, FileText, Globe, History } from "lucide-react" import Link from "next/link" -import { supabase } from "@/lib/supabase" -import type { PolicySummary } from "@/lib/api" +import type { PolicySummary } from "@/types/policies" +import { getSupabase } from "@/lib/get-supabase" import { createApiUrlWithLogging } from "@/lib/url-utils" import { fetchWithTimeout } from "@/lib/fetch-with-timeout" import type { BackendPolicyRecord } from "@/types/policies" @@ -110,6 +110,7 @@ export default function ChatPage() { const loadPoliciesAndHistory = async () => { try { + const supabase = await getSupabase() const session = await supabase.auth.getSession() const token = session.data.session?.access_token const user = session.data.session?.user @@ -194,6 +195,7 @@ export default function ChatPage() { try { setLoadingMoreHistory(true) const nextPage = historyPage + 1 + const supabase = await getSupabase() const session = await supabase.auth.getSession() const token = session.data.session?.access_token if (!token) { @@ -288,6 +290,7 @@ export default function ChatPage() { setError("") try { + const supabase = await getSupabase() const chatSession = await supabase.auth.getSession() const token = chatSession.data.session?.access_token @@ -394,6 +397,7 @@ export default function ChatPage() { const handleClearHistory = async () => { setMessages([]) // Clear user-specific localStorage + const supabase = await getSupabase() const session = await supabase.auth.getSession() const user = session.data.session?.user if (user) { diff --git a/frontend/app/globals.css b/frontend/app/globals.css index 4e19fd8..b35f3f2 100644 --- a/frontend/app/globals.css +++ b/frontend/app/globals.css @@ -1,7 +1,6 @@ @tailwind base; @tailwind components; @tailwind utilities; -@import "tw-animate-css"; :root { /* Base: Slate-50 / Slate-900 */ diff --git a/frontend/app/upload/page.tsx b/frontend/app/upload/page.tsx index 5835f3d..cb2d137 100644 --- a/frontend/app/upload/page.tsx +++ b/frontend/app/upload/page.tsx @@ -1,7 +1,7 @@ "use client" import { useState, useEffect } from "react" -import { supabase } from "@/lib/supabase" +import { getSupabase } from "@/lib/get-supabase" import { createApiUrlWithLogging } from "@/lib/url-utils" import { fetchWithTimeout } from "@/lib/fetch-with-timeout" import { useRouter } from "next/navigation" @@ -92,6 +92,7 @@ export default function UploadPage() { const originalFile = files[0]._originalFile || files[0] formData.append("file", originalFile, files[0].name) + const supabase = await getSupabase() const session = await supabase.auth.getSession() const token = session.data.session?.access_token diff --git a/frontend/chat-migration-cleanup.js b/frontend/chat-migration-cleanup.js deleted file mode 100644 index f1923e3..0000000 --- a/frontend/chat-migration-cleanup.js +++ /dev/null @@ -1,82 +0,0 @@ -/** - * Chat History Migration and Cleanup - * - * This utility helps migrate from global localStorage to user-specific storage - * and cleans up any potential data leakage from the old implementation. - * - * Run this in browser console on any ClaimWise page: - */ - -(function() { - console.log('🔧 ClaimWise Chat History Migration Tool'); - console.log('========================================='); - - // Check if there's old global chat history - const oldKey = 'claimwise_chat_history'; - const oldData = localStorage.getItem(oldKey); - - if (oldData) { - console.log('⚠️ Found old global chat history:', oldData.length, 'characters'); - console.log('📝 This data was potentially shared between users (PRIVACY ISSUE)'); - - // Remove the old global data to prevent further leakage - localStorage.removeItem(oldKey); - console.log('✅ Removed old global chat history for security'); - - try { - const parsedData = JSON.parse(oldData); - console.log('📊 Old data contained', parsedData.length, 'messages'); - console.log('🔒 This data has been cleared and will now be loaded from secure server storage'); - } catch (e) { - console.log('❌ Old data was corrupted'); - } - } else { - console.log('✅ No old global chat history found'); - } - - // Show current user-specific keys - const userKeys = []; - for (let i = 0; i < localStorage.length; i++) { - const key = localStorage.key(i); - if (key && key.startsWith('claimwise_chat_history_')) { - userKeys.push(key); - } - } - - if (userKeys.length > 0) { - console.log('🔐 Found', userKeys.length, 'user-specific chat history keys:'); - userKeys.forEach(key => { - const data = localStorage.getItem(key); - const userId = key.replace('claimwise_chat_history_', ''); - try { - const messages = JSON.parse(data || '[]'); - console.log(` 📱 User ${userId.slice(0, 8)}...: ${messages.length} messages`); - } catch (e) { - console.log(` ❌ User ${userId.slice(0, 8)}...: corrupted data`); - } - }); - } else { - console.log('💾 No user-specific chat history found in localStorage'); - console.log('📡 Chat history will be loaded from secure server storage'); - } - - console.log('\n🎯 MIGRATION COMPLETE'); - console.log('🔐 Chat data is now properly isolated per user'); - console.log('📡 New chats will be saved to server with user authentication'); - console.log('🧹 Refresh the page to load your personal chat history'); -})(); - -// Instructions for developers: -console.log(` -🔧 DEVELOPER TESTING COMMANDS: - -// Check for any remaining global keys that could cause leakage: -Object.keys(localStorage).filter(k => k.includes('claimwise') && !k.includes('_')) - -// View current user's chat storage: -const userId = 'YOUR_USER_ID'; // Get from supabase.auth.getUser() -localStorage.getItem('claimwise_chat_history_' + userId) - -// Clear everything for testing: -Object.keys(localStorage).forEach(k => k.startsWith('claimwise_chat_history') && localStorage.removeItem(k)) -`); diff --git a/frontend/components/analysis/policy-card.tsx b/frontend/components/analysis/policy-card.tsx deleted file mode 100644 index 91ef10f..0000000 --- a/frontend/components/analysis/policy-card.tsx +++ /dev/null @@ -1,192 +0,0 @@ -"use client" - -import { useState } from "react" -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { FileText, Calendar, DollarSign, Shield, AlertTriangle, CheckCircle, ChevronDown, ChevronUp } from "lucide-react" -import type { PolicySummary } from "@/lib/api" - -interface PolicyCardProps { - policy: PolicySummary - onViewDetails?: () => void - onCompare?: () => void - isSelected?: boolean -} - -export function PolicyCard({ policy, onViewDetails, onCompare, isSelected }: PolicyCardProps) { - const [showAllFeatures, setShowAllFeatures] = useState(false) - - // Format currency in Indian format - const formatIndianCurrency = (amount: string) => { - if (!amount || amount === 'Not specified') return amount - - // Extract numeric value - const numericValue = amount.replace(/[^0-9.]/g, '') - if (!numericValue) return amount - - const number = parseFloat(numericValue) - if (isNaN(number)) return amount - - // Format in Indian numbering system - return `₹${number.toLocaleString('en-IN')}` - } - - const getPolicyTypeColor = (type: string | undefined | null) => { - if (!type) return "bg-gray-100 text-gray-800" - - switch (type.toLowerCase()) { - case "health": - return "bg-green-100 text-green-800" - case "auto": - return "bg-blue-100 text-blue-800" - case "home": - return "bg-orange-100 text-orange-800" - case "life": - return "bg-purple-100 text-purple-800" - default: - return "bg-gray-100 text-gray-800" - } - } - - const isExpiringSoon = () => { - if (!policy.expirationDate) return false - - const expirationDate = new Date(policy.expirationDate) - if (Number.isNaN(expirationDate.getTime())) return false - const today = new Date() - const daysUntilExpiration = Math.ceil((expirationDate.getTime() - today.getTime()) / (1000 * 3600 * 24)) - return daysUntilExpiration <= 30 - } - - const getExpirationLabel = () => { - if (!policy.expirationDate) return 'Not specified' - const expirationDate = new Date(policy.expirationDate) - if (Number.isNaN(expirationDate.getTime())) return 'Not specified' - return expirationDate.toLocaleDateString() - } - - return ( - - -
-
-
- -
-
- - {policy.fileName || 'Unknown Policy'} - -
- {policy.policyType || 'Unknown Type'} - {isExpiringSoon() && ( - - - Expiring Soon - - )} -
-
-
-
-
- - - {/* Provider and Coverage */} -
-
-

Provider

-

- {policy.provider || 'Unknown Provider'} -

-
-
-

Coverage Amount

-

- {formatIndianCurrency(policy.coverageAmount || 'Not specified')} -

-
-
- - {/* Premium and Deductible */} -
-
- -
-

Premium

-

- {formatIndianCurrency(policy.premium || 'Not specified')} -

-
-
-
- -
-

Deductible

-

- {formatIndianCurrency(policy.deductible || 'Not specified')} -

-
-
-
- - {/* Expiration Date */} -
- -
-

Expires

-

- {getExpirationLabel()} -

-
-
- - {/* Key Features */} -
-

Key Features

-
- {(policy.keyFeatures || []).slice(0, showAllFeatures ? undefined : 3).map((feature, index) => ( -
- -

- {feature} -

-
- ))} - {(policy.keyFeatures || []).length > 3 && ( - - )} -
-
- - {/* Action Buttons */} -
- - -
-
-
- ) -} diff --git a/frontend/components/analysis/policy-comparison-new.tsx b/frontend/components/analysis/policy-comparison-new.tsx deleted file mode 100644 index b163aa8..0000000 --- a/frontend/components/analysis/policy-comparison-new.tsx +++ /dev/null @@ -1,485 +0,0 @@ -"use client" - -import { useState } from "react" -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import { Badge } from "@/components/ui/badge" -import { Button } from "@/components/ui/button" -import { CheckCircle, X, AlertTriangle, TrendingUp, TrendingDown, DollarSign, Shield, BarChart3, ChevronUp, ChevronDown } from "lucide-react" -import type { PolicySummary } from "@/lib/api" - -interface PolicyComparisonProps { - policies: PolicySummary[] - onRemovePolicy: (policyId: string) => void -} - -export function PolicyComparison({ policies, onRemovePolicy }: PolicyComparisonProps) { - const [expandedFeatures, setExpandedFeatures] = useState<{ [key: string]: boolean }>({}) - - // Format currency in Indian format - const formatIndianCurrency = (amount: string) => { - if (!amount || amount === 'Not specified') return amount - - // Extract numeric value - const numericValue = amount.replace(/[^0-9.]/g, '') - if (!numericValue) return amount - - const number = parseFloat(numericValue) - if (isNaN(number)) return amount - - // Format in Indian numbering system - return `₹${number.toLocaleString('en-IN')}` - } - - const toggleFeatures = (policyId: string) => { - setExpandedFeatures(prev => ({ - ...prev, - [policyId]: !prev[policyId] - })) - } - - const getPremiumComparison = (premium: string, allPremiums: string[]) => { - const numericPremiums = allPremiums.map((p) => Number.parseFloat(p.replace(/[^0-9.]/g, ""))) - const currentPremium = Number.parseFloat(premium.replace(/[^0-9.]/g, "")) - const minPremium = Math.min(...numericPremiums) - const maxPremium = Math.max(...numericPremiums) - - if (currentPremium === minPremium) return "lowest" - if (currentPremium === maxPremium) return "highest" - return "middle" - } - - if (policies.length === 0) { - return ( - - -
-
- -
-
-

No Policies Selected for Comparison

-

- Go to the Overview tab and click "Compare" on the policies you want to compare side by side. -

- - 💡 Tip: Select 2-3 policies to see the best comparison insights - -
-
-
-
- ) - } - - return ( -
- {/* Comparison Header */} -
-

Policy Comparison

-

Comparing {policies.length} policies

-
- - {/* Enhanced Comparison Content */} -
- {/* Quick Comparison Summary */} - - - - - Quick Comparison Overview - - - -
- {/* Best Premium */} -
-
Best Premium
-
- {(() => { - const validPremiums = policies.map(p => ({ - policy: p, - amount: parseFloat(p.premium.replace(/[^0-9.]/g, "") || "0") - })).filter(p => p.amount > 0) - if (validPremiums.length === 0) return "N/A" - const lowest = validPremiums.reduce((min, p) => p.amount < min.amount ? p : min) - return formatIndianCurrency(lowest.amount.toString()) - })()} -
-
- {(() => { - const validPremiums = policies.map(p => ({ - policy: p, - amount: parseFloat(p.premium.replace(/[^0-9.]/g, "") || "0") - })).filter(p => p.amount > 0) - if (validPremiums.length === 0) return "" - const lowest = validPremiums.reduce((min, p) => p.amount < min.amount ? p : min) - return lowest.policy.provider - })()} -
-
- - {/* Highest Coverage */} -
-
Highest Coverage
-
- {(() => { - const validCoverage = policies.map(p => ({ - policy: p, - amount: parseFloat(p.coverageAmount.replace(/[^0-9.]/g, "") || "0") - })).filter(p => p.amount > 0) - if (validCoverage.length === 0) return "N/A" - const highest = validCoverage.reduce((max, p) => p.amount > max.amount ? p : max) - return `₹${(highest.amount / 100000).toFixed(1)}L` - })()} -
-
- {(() => { - const validCoverage = policies.map(p => ({ - policy: p, - amount: parseFloat(p.coverageAmount.replace(/[^0-9.]/g, "") || "0") - })).filter(p => p.amount > 0) - if (validCoverage.length === 0) return "" - const highest = validCoverage.reduce((max, p) => p.amount > max.amount ? p : max) - return highest.policy.provider - })()} -
-
- - {/* Best Value */} -
-
Best Value Score
-
- {(() => { - // Calculate value score (coverage/premium ratio) - const scores = policies.map(p => { - const premium = parseFloat(p.premium.replace(/[^0-9.]/g, "") || "0") - const coverage = parseFloat(p.coverageAmount.replace(/[^0-9.]/g, "") || "0") - return { policy: p, score: premium > 0 ? coverage / premium : 0 } - }).filter(s => s.score > 0) - - if (scores.length === 0) return "N/A" - const best = scores.reduce((max, s) => s.score > max.score ? s : max) - return `${(best.score).toFixed(0)}x` - })()} -
-
Coverage/Premium Ratio
-
-
-
-
- - {/* Detailed Side-by-Side Comparison */} - - - Detailed Policy Comparison - - -
- - - - - {policies.map((policy) => ( - - ))} - - - - {/* Provider Row */} - - - {policies.map((policy) => ( - - ))} - - - {/* Premium Row with Comparison */} - - - {policies.map((policy) => { - const comparison = getPremiumComparison(policy.premium, policies.map((p) => p.premium)) - return ( - - ) - })} - - - {/* Coverage Amount Row */} - - - {policies.map((policy) => ( - - ))} - - - {/* Deductible Row */} - - - {policies.map((policy) => ( - - ))} - - - {/* Expiration Row */} - - - {policies.map((policy) => ( - - ))} - - - {/* Key Features Comparison */} - - - {policies.map((policy) => ( - - ))} - - -
Feature -
-
{policy.fileName}
- {policy.policyType} - -
-
Provider -
{policy.provider}
-
Annual Premium -
-
{formatIndianCurrency(policy.premium)}
- {comparison === "lowest" && ( - - - Best Price - - )} - {comparison === "highest" && ( - - - Highest - - )} -
-
Coverage Amount -
{formatIndianCurrency(policy.coverageAmount)}
-
Deductible -
{formatIndianCurrency(policy.deductible)}
-
Expiration Date -
-
- {new Date(policy.expirationDate).toLocaleDateString('en-IN')} -
- {(() => { - const daysUntilExpiration = Math.ceil( - (new Date(policy.expirationDate).getTime() - new Date().getTime()) / (1000 * 3600 * 24), - ) - if (daysUntilExpiration <= 30) { - return ( - - - {daysUntilExpiration} days left - - ) - } - return
{daysUntilExpiration} days left
- })()} -
-
Key Features -
- {policy.keyFeatures.slice(0, expandedFeatures[policy.id] ? undefined : 4).map((feature, index) => ( -
- - {feature} -
- ))} - {policy.keyFeatures.length > 4 && ( - - )} -
-
-
-
-
- - {/* Value Comparison Chart */} - - - - - Value Analysis - - - -
- {policies.map((policy, index) => { - const premium = parseFloat(policy.premium.replace(/[^0-9.]/g, "") || "0") - const coverage = parseFloat(policy.coverageAmount.replace(/[^0-9.]/g, "") || "0") - const valueScore = premium > 0 ? (coverage / premium) : 0 - const maxValue = Math.max(...policies.map(p => { - const prem = parseFloat(p.premium.replace(/[^0-9.]/g, "") || "0") - const cov = parseFloat(p.coverageAmount.replace(/[^0-9.]/g, "") || "0") - return prem > 0 ? cov / prem : 0 - })) - const widthPercent = maxValue > 0 ? (valueScore / maxValue) * 100 : 0 - - return ( -
-
-
{policy.fileName}
-
Score: {valueScore.toFixed(1)}x
-
-
-
-
-
- ) - })} -
- - -
- - {/* Comparison Insights */} - {policies.length >= 2 && ( - - - - - Smart Comparison Insights - - - -
- {/* Premium Savings Analysis */} - {(() => { - const premiums = policies.map((p) => Number.parseFloat(p.premium.replace(/[^0-9.]/g, "") || "0")) - const validPremiums = premiums.filter(p => p > 0) - if (validPremiums.length >= 2) { - const minPremium = Math.min(...validPremiums) - const maxPremium = Math.max(...validPremiums) - const savings = maxPremium - minPremium - const lowestPolicy = policies[premiums.indexOf(minPremium)] - - return ( -
-

- - Premium Analysis -

-
-
- - - {lowestPolicy.fileName} offers the lowest premium at {formatIndianCurrency(lowestPolicy.premium)} - -
-
- - - Potential annual savings: {formatIndianCurrency(savings.toString())} - -
-
-
- ) - } - return null - })()} - - {/* Coverage Comparison */} -
-

- - Coverage Analysis -

-
- {(() => { - const providers = [...new Set(policies.map(p => p.provider))] - const policyTypes = [...new Set(policies.map(p => p.policyType))] - - return ( - <> -
- - - Comparing {policies.length} policies from {providers.length} provider{providers.length !== 1 ? 's' : ''} - {providers.length > 1 ? ` (${providers.join(', ')})` : ` (${providers[0]})`} - -
- {policyTypes.length > 1 && ( -
- - - Note: Comparing different policy types ({policyTypes.join(', ')}) - ensure coverage needs align - -
- )} - - ) - })()} -
-
- - {/* Expiration Warnings */} - {(() => { - const expiringSoon = policies.filter((p) => { - const daysUntilExpiration = Math.ceil( - (new Date(p.expirationDate).getTime() - new Date().getTime()) / (1000 * 3600 * 24) - ) - return daysUntilExpiration <= 60 - }) - - if (expiringSoon.length > 0) { - return ( -
-

- - Renewal Timeline -

-

- {expiringSoon.length === 1 - ? `${expiringSoon[0].fileName} expires soon` - : `${expiringSoon.length} policies expire within 60 days` - } - Consider renewal timing when making decisions. -

-
- ) - } - return null - })()} -
-
-
- )} -
- ) -} diff --git a/frontend/components/chat/chat-widget.tsx b/frontend/components/chat/chat-widget.tsx index ac9a93b..691084c 100644 --- a/frontend/components/chat/chat-widget.tsx +++ b/frontend/components/chat/chat-widget.tsx @@ -6,6 +6,9 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { ChatInput } from "./chat-input" import { Message } from "./message" import { MessageSquare, X, Minimize2 } from "lucide-react" +import { getSupabase } from "@/lib/get-supabase" +import { createApiUrlWithLogging } from "@/lib/url-utils" +import { fetchWithTimeout } from "@/lib/fetch-with-timeout" interface ChatMessage { id: string @@ -32,33 +35,55 @@ export function ChatWidget() { setMessages((prev) => [...prev, userMessage]) setIsLoading(true) - // Use requestIdleCallback for better responsiveness - if (typeof requestIdleCallback !== 'undefined') { - requestIdleCallback(() => { - setTimeout(() => { - const assistantMessage: ChatMessage = { - id: (Date.now() + 1).toString(), - content: - "I'd be happy to help you with that question about your insurance policies. Let me analyze your coverage details...", - role: "assistant", - timestamp: new Date(), - } - setMessages((prev) => [...prev, assistantMessage]) - setIsLoading(false) - }, 300) // Reduced from 1500ms + try { + const supabase = await getSupabase() + const session = await supabase.auth.getSession() + const token = session.data.session?.access_token + + if (!token) { + throw new Error("Not authenticated") + } + + const url = createApiUrlWithLogging("/chat-multiple") + const response = await fetchWithTimeout(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify({ question: content }), + timeoutMs: 30000, }) - } else { - setTimeout(() => { - const assistantMessage: ChatMessage = { - id: (Date.now() + 1).toString(), - content: - "I'd be happy to help you with that question about your insurance policies. Let me analyze your coverage details...", - role: "assistant", - timestamp: new Date(), - } - setMessages((prev) => [...prev, assistantMessage]) - setIsLoading(false) - }, 300) + + if (!response.ok) { + const errorBody = await response.text().catch(() => "") + throw new Error(errorBody || `Request failed: ${response.statusText}`) + } + + const data = await response.json() + + const assistantMessage: ChatMessage = { + id: (Date.now() + 1).toString(), + content: data.answer || "I could not generate a response. Please try again.", + role: "assistant", + timestamp: new Date(), + policyReferences: data.citations?.length > 0 + ? data.citations.map((c: { id?: number; excerpt?: string }) => `Chunk #${c.id ?? "?"}`) + : undefined, + } + + setMessages((prev) => [...prev, assistantMessage]) + } catch (err) { + const errorMessage = err instanceof Error ? err.message : "Service temporarily unavailable" + const fallbackMessage: ChatMessage = { + id: (Date.now() + 1).toString(), + content: `Sorry, I encountered an error: ${errorMessage}. Please try again later.`, + role: "assistant", + timestamp: new Date(), + } + setMessages((prev) => [...prev, fallbackMessage]) + } finally { + setIsLoading(false) } }, []) diff --git a/frontend/components/dashboard/quick-actions.tsx b/frontend/components/dashboard/quick-actions.tsx deleted file mode 100644 index 8dcb42d..0000000 --- a/frontend/components/dashboard/quick-actions.tsx +++ /dev/null @@ -1,55 +0,0 @@ -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import { Button } from "@/components/ui/button" -import { Upload, FileText, MessageSquare, BarChart3, ArrowRight } from "lucide-react" -import Link from "next/link" - -const quickActions = [ - { - title: "Upload Policy", - icon: Upload, - href: "/upload", - variant: "default" as const, - }, - { - title: "Analyze Policies", - icon: FileText, - href: "/analyze", - variant: "outline" as const, - }, - { - title: "Chat Assistant", - icon: MessageSquare, - href: "/chat", - variant: "outline" as const, - }, - { - title: "Compare Policies", - icon: BarChart3, - href: "/compare", - variant: "outline" as const, - }, -] - -export function QuickActions() { - return ( - - - Quick Actions - - - {quickActions.map((action) => ( - - - - ))} - - - ) -} diff --git a/frontend/components/dashboard/recent-activity.tsx b/frontend/components/dashboard/recent-activity.tsx index d92dfc7..dc3f82e 100644 --- a/frontend/components/dashboard/recent-activity.tsx +++ b/frontend/components/dashboard/recent-activity.tsx @@ -4,7 +4,7 @@ import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Button } from "@/components/ui/button" import { FileText, MessageSquare, BarChart3, Upload, Clock, ArrowRight } from "lucide-react" import { useEffect, useState } from "react" -import { supabase } from "@/lib/supabase" +import { getSupabase } from "@/lib/get-supabase" import { Table, TableBody, @@ -37,6 +37,7 @@ export function RecentActivity() { const fetchActivities = async () => { try { // Wait for Supabase session to load, and refresh if needed + const supabase = await getSupabase() const sessionResult = await supabase.auth.getSession() let session = sessionResult.data.session let token = session?.access_token diff --git a/frontend/components/dashboard/stats-card.tsx b/frontend/components/dashboard/stats-card.tsx deleted file mode 100644 index 73ded91..0000000 --- a/frontend/components/dashboard/stats-card.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" -import type { LucideIcon } from "lucide-react" -import { cn } from "@/lib/utils" - -interface StatsCardProps { - title: string - value: string | number - description?: string - icon: LucideIcon - trend?: string - alert?: boolean -} - -export function StatsCard({ title, value, description, icon: Icon, trend, alert }: StatsCardProps) { - return ( - - - {title} - - - -
{value}
-

- {description} -

- {trend && ( -

- {trend} -

- )} -
-
- ) -} diff --git a/frontend/components/ui/calendar.tsx b/frontend/components/ui/calendar.tsx index 4d7c46a..6c6cbd3 100644 --- a/frontend/components/ui/calendar.tsx +++ b/frontend/components/ui/calendar.tsx @@ -29,7 +29,7 @@ function Calendar({ svg]:rotate-180`, String.raw`rtl:**:[.rdp-button\_previous>svg]:rotate-180`, className diff --git a/frontend/components/ui/sidebar.tsx b/frontend/components/ui/sidebar.tsx index 1ee5a45..54f8af7 100644 --- a/frontend/components/ui/sidebar.tsx +++ b/frontend/components/ui/sidebar.tsx @@ -222,7 +222,7 @@ function Sidebar({ "group-data-[collapsible=offcanvas]:w-0", "group-data-[side=right]:rotate-180", variant === "floating" || variant === "inset" - ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4)))]" + ? "group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(1rem))]" : "group-data-[collapsible=icon]:w-(--sidebar-width-icon)" )} /> @@ -235,7 +235,7 @@ function Sidebar({ : "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]", // Adjust the padding for floating and inset variants. variant === "floating" || variant === "inset" - ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(--spacing(4))+2px)]" + ? "p-2 group-data-[collapsible=icon]:w-[calc(var(--sidebar-width-icon)+(1rem)+2px)]" : "group-data-[collapsible=icon]:w-(--sidebar-width-icon) group-data-[side=left]:border-r group-data-[side=right]:border-l", className )} diff --git a/frontend/debug.html b/frontend/debug.html deleted file mode 100644 index 835f8a7..0000000 --- a/frontend/debug.html +++ /dev/null @@ -1,46 +0,0 @@ - - - - ClaimWise Debug - - -

ClaimWise Debug

- -

LocalStorage Contents:

-
- -

Test API Call:

- -
- - - - diff --git a/frontend/hooks/use-auth.tsx b/frontend/hooks/use-auth.tsx index a16c6e6..704261c 100644 --- a/frontend/hooks/use-auth.tsx +++ b/frontend/hooks/use-auth.tsx @@ -3,8 +3,8 @@ import type React from "react" import { createContext, useContext, useEffect, useState } from "react" -import type { User } from "@supabase/supabase-js" -import { supabase } from "@/lib/supabase" +import type { User, AuthChangeEvent, Session } from "@supabase/supabase-js" +import { getSupabase } from "@/lib/get-supabase" interface AuthContextType { user: User | null @@ -34,7 +34,7 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { const [isAdmin, setIsAdmin] = useState(false) const [userRole, setUserRole] = useState(null) - const resolveUserRole = async (user: User): Promise<{ role: string | null; isAdmin: boolean }> => { + const resolveUserRole = async (supabase: any, user: any): Promise<{ role: string | null; isAdmin: boolean }> => { try { const { data: userRow } = await supabase .from("users") @@ -59,107 +59,118 @@ export const AuthProvider = ({ children }: { children: React.ReactNode }) => { let isMounted = true - // Get initial session - const getInitialSession = async () => { - try { - const { - data: { session }, - } = await supabase.auth.getSession() - - if (isMounted) { - console.log("[AuthProvider] Initial session:", session ? `User: ${session.user.email}` : "No session") - setUser(session?.user ?? null) - if (session?.user) { - const roleInfo = await resolveUserRole(session.user) - if (isMounted) { - setUserRole(roleInfo.role) - setIsAdmin(roleInfo.isAdmin) + const init = async () => { + const supabase = await getSupabase() + + // Get initial session + const getInitialSession = async () => { + try { + const { + data: { session }, + } = await supabase.auth.getSession() + + if (isMounted) { + console.log("[AuthProvider] Initial session:", session ? `User: ${session.user.email}` : "No session") + setUser(session?.user ?? null) + if (session?.user) { + const roleInfo = await resolveUserRole(supabase, session.user) + if (isMounted) { + setUserRole(roleInfo.role) + setIsAdmin(roleInfo.isAdmin) + } + } else { + setUserRole(null) + setIsAdmin(false) } - } else { + } + } catch (error) { + // Ignore AbortError during cleanup + if (error instanceof Error && error.name === 'AbortError') { + console.debug("[AuthProvider] Session request aborted (cleanup)") + return + } + if (isMounted) { + console.warn("[AuthProvider] Failed to get session:", error) + setUser(null) setUserRole(null) setIsAdmin(false) } - } - } catch (error) { - // Ignore AbortError during cleanup - if (error instanceof Error && error.name === 'AbortError') { - console.debug("[AuthProvider] Session request aborted (cleanup)") - return - } - if (isMounted) { - console.warn("[AuthProvider] Failed to get session:", error) - setUser(null) - setUserRole(null) - setIsAdmin(false) - } - } finally { - if (isMounted) { - setLoading(false) + } finally { + if (isMounted) { + setLoading(false) + } } } - } - getInitialSession() - - // Listen for auth changes (OAuth redirects, email signups, etc.) - const { - data: { subscription }, - } = supabase.auth.onAuthStateChange(async (event, session) => { - if (!isMounted) return - - console.log("[AuthProvider] Auth state change:", event, "User:", session?.user?.email ?? "None") - setUser(session?.user ?? null) - setLoading(true) - - if (session?.user) { - const roleInfo = await resolveUserRole(session.user) - if (isMounted) { - setUserRole(roleInfo.role) - setIsAdmin(roleInfo.isAdmin) + await getInitialSession() + + // Listen for auth changes (OAuth redirects, email signups, etc.) + const { + data: { subscription }, + } = supabase.auth.onAuthStateChange(async (event: AuthChangeEvent, session: Session | null) => { + if (!isMounted) return + + console.log("[AuthProvider] Auth state change:", event, "User:", session?.user?.email ?? "None") + setUser(session?.user ?? null) + setLoading(true) + + if (session?.user) { + const roleInfo = await resolveUserRole(supabase, session.user) + if (isMounted) { + setUserRole(roleInfo.role) + setIsAdmin(roleInfo.isAdmin) + setLoading(false) + } + } else { + setUserRole(null) + setIsAdmin(false) setLoading(false) } - } else { - setUserRole(null) - setIsAdmin(false) - setLoading(false) - } - // Auto-sync user to database after OAuth or email signup - if ((event === "SIGNED_IN" || event === "USER_UPDATED") && session?.user) { - try { - const { data: existingUser } = await supabase - .from("users") - .select("id") - .eq("id", session.user.id) - .single() - - if (!existingUser && isMounted) { - const userName = - session.user.user_metadata?.full_name || - session.user.user_metadata?.name || - session.user.email?.split("@")[0] || - "Unknown User" - - console.log("[AuthProvider] Creating user profile:", session.user.email) - await supabase + // Auto-sync user to database after OAuth or email signup + if ((event === "SIGNED_IN" || event === "USER_UPDATED") && session?.user) { + try { + const { data: existingUser } = await supabase .from("users") - .insert({ - id: session.user.id, - email: session.user.email, - name: userName, - }) - } - } catch (err) { - if (isMounted) { - console.warn("[AuthProvider] User sync failed (non-critical):", err) + .select("id") + .eq("id", session.user.id) + .single() + + if (!existingUser && isMounted) { + const userName = + session.user.user_metadata?.full_name || + session.user.user_metadata?.name || + session.user.email?.split("@")[0] || + "Unknown User" + + console.log("[AuthProvider] Creating user profile:", session.user.email) + await supabase + .from("users") + .insert({ + id: session.user.id, + email: session.user.email, + name: userName, + }) + } + } catch (err) { + if (isMounted) { + console.warn("[AuthProvider] User sync failed (non-critical):", err) + } } } - } + }) + + return subscription + } + + let subscription: { unsubscribe: () => void } | null = null + init().then((sub) => { + subscription = sub }) return () => { isMounted = false - subscription.unsubscribe() + subscription?.unsubscribe() } }, []) diff --git a/frontend/lib/api.ts b/frontend/lib/api.ts index 87176e9..341967f 100644 --- a/frontend/lib/api.ts +++ b/frontend/lib/api.ts @@ -1,270 +1,46 @@ -// API utilities for backend communication -import { supabase } from "./supabase" -import { createApiUrlWithLogging } from "./url-utils" -import { fetchWithTimeout } from "./fetch-with-timeout" +import { getSupabase } from "@/lib/get-supabase" +import { createApiUrlWithLogging } from "@/lib/url-utils" +import { fetchWithTimeout } from "@/lib/fetch-with-timeout" import type { BackendPolicyRecord } from "@/types/policies" -export interface PolicyAnalysisRequest { - files: File[] - userId: string -} - -export interface PolicyAnalysisResponse { - analysisId: string - status: "processing" | "completed" | "error" - results?: { - policies: PolicySummary[] - insights: string[] - recommendations: string[] - } - error?: string -} - -export interface PolicySummary { - id: string - fileName: string - policyType: string - provider: string - coverageAmount: string - premium: string - deductible: string - keyFeatures: string[] - expirationDate: string - rawAnalysis?: { - coverage?: string - exclusions?: string - claim_process?: string - claim_readiness_score?: number - waiting_period?: string - copay?: string - } -} - -interface PoliciesApiResponse { - policies?: BackendPolicyRecord[] -} - -const mapPolicyFromBackend = (policy: BackendPolicyRecord): PolicySummary => { - const analysis = policy?.validation_metadata?.analysis_result || {} - return { - id: policy.id, - fileName: policy.policy_name || (policy.policy_number ? `Policy ${policy.policy_number}` : `Policy ${String(policy.id).slice(-8)}`), - policyType: analysis.policy_type || policy.policy_type || "Unknown", - provider: analysis.provider || policy.provider || "Unknown Provider", - coverageAmount: analysis.coverage_amount || "Not specified", - premium: analysis.premium || "Not specified", - deductible: analysis.deductible || "Not specified", - keyFeatures: Array.isArray(analysis.key_features) ? analysis.key_features : [], - expirationDate: analysis.expiration_date || "Not specified", - rawAnalysis: analysis, - } -} - -export const uploadPolicies = async (files: File[], userId: string): Promise => { - const formData = new FormData() - files.forEach((file, index) => { - formData.append(`file_${index}`, file) - }) - formData.append("user_id", userId) - - // Get Supabase JWT - const session = await supabase.auth.getSession() - const token = session.data.session?.access_token - - try { - const apiUrl = createApiUrlWithLogging("/analyze-policy"); - const response = await fetchWithTimeout(apiUrl, { - method: "POST", - body: formData, - headers: token ? { Authorization: `Bearer ${token}` } : undefined, - timeoutMs: 15000, - }) - - if (!response.ok) { - throw new Error(`HTTP error! status: ${response.status}`) - } - - return await response.json() - } catch (error) { - console.error("Error uploading policies:", error) - throw error - } -} +export type { PolicySummary } from "@/types/policies" -export const comparePolicies = async (policyIds: string[]): Promise => { - // Get Supabase JWT +export async function getPolicies() { + const supabase = await getSupabase() const session = await supabase.auth.getSession() const token = session.data.session?.access_token - try { - const apiUrl = createApiUrlWithLogging("/compare-policies"); - const response = await fetchWithTimeout(apiUrl, { - method: "POST", - headers: { - "Content-Type": "application/json", - ...(token ? { Authorization: `Bearer ${token}` } : {}), - }, - body: JSON.stringify({ policy_ids: policyIds }), - timeoutMs: 15000, - }) - - if (!response.ok) { - const errorText = await response.text() - throw new Error(`HTTP error! status: ${response.status}, message: ${errorText}`) - } - - return await response.json() - } catch (error) { - console.error("Error comparing policies:", error) - throw error + if (!session.data.session) { + throw new Error("Not authenticated") } -} - -export const getPolicies = async (): Promise => { - try { - // Get Supabase JWT - const session = await supabase.auth.getSession() - const token = session.data.session?.access_token - - if (!token) { - throw new Error("Not authenticated. Please log in.") - } - const apiUrl = createApiUrlWithLogging(`/policies`); - const response = await fetchWithTimeout(apiUrl, { - headers: { Authorization: `Bearer ${token}` }, - timeoutMs: 12000, - }) - - if (!response.ok) { - const errorDetail = await response.text() - throw new Error(`Failed to fetch policies: ${response.status} - ${errorDetail}`) - } - - const data = (await response.json()) as PoliciesApiResponse - const backendPolicies = Array.isArray(data?.policies) ? data.policies : [] - return backendPolicies.map(mapPolicyFromBackend) - } catch (error) { - console.error("Error fetching policies:", error) - // Re-throw to allow caller to handle the error - throw error - } -} - -export const chatWithPolicies = async (message: string, policyIds: string[]): Promise => { - // Get Supabase JWT - const session = await supabase.auth.getSession() - const token = session.data.session?.access_token - - try { - if (policyIds.length > 1 && !policyIds.includes("all")) { - throw new Error("Multiple specific policy IDs are not supported in a single request. Select one policy or use 'all'.") - } - - const isMultiPolicy = policyIds.length === 0 || policyIds.includes("all") - const apiUrl = isMultiPolicy - ? createApiUrlWithLogging("/chat-multiple") - : createApiUrlWithLogging("/chat") - - const response = await fetchWithTimeout(apiUrl, { - method: "POST", - headers: { - ...(token ? { Authorization: `Bearer ${token}` } : {}), - "Content-Type": "application/json", - }, - ...(isMultiPolicy - ? { body: JSON.stringify({ question: message }) } - : { - body: JSON.stringify({ - question: message, - policy_id: policyIds[0], - }), - }), - timeoutMs: 15000, - }) - - if (!response.ok) { - const errorText = await response.text() - throw new Error(`HTTP error! status: ${response.status}, message: ${errorText}`) - } - - return await response.json() - } catch (error) { - console.error("Error in chat:", error) - throw error - } -} - -export interface HistoryActivity { - id: string - type: "upload" | "analysis" | "chat" | "comparison" - title: string - description: string - timestamp: string - status: "completed" | "processing" | "failed" - details?: { - filesProcessed?: number - insightsGenerated?: number - questionsAnswered?: number - policiesCompared?: number - policyId?: string - chatType?: string - analysisType?: string - } -} + const url = createApiUrlWithLogging("/policies") + const response = await fetchWithTimeout(url, { + headers: token ? { Authorization: `Bearer ${token}` } : undefined, + timeoutMs: 12000, + }) -export interface HistoryResponse { - activities: HistoryActivity[] - stats: { - totalActivities: number - uploads: number - analyses: number - chats: number - comparisons: number - totalPolicies: number - } - policies: unknown[] - pagination?: { - page: number - page_size: number - has_more_activities: boolean - has_more_chat_logs: boolean - next_page: number | null - total_activities: number - total_chat_logs: number + if (!response.ok) { + throw new Error(`Failed to fetch policies: ${response.statusText}`) } - success: boolean -} - -export const getActivityHistory = async (): Promise => { - try { - // Get Supabase JWT - const session = await supabase.auth.getSession() - const token = session.data.session?.access_token - - if (!token) { - throw new Error("No authentication token available") - } - const apiUrl = createApiUrlWithLogging("/history"); - const response = await fetchWithTimeout(apiUrl, { - method: "GET", - headers: { - "Authorization": `Bearer ${token}`, - "Content-Type": "application/json", - }, - timeoutMs: 12000, - }) + const payload = await response.json() + const payloadRecord = payload as { policies?: BackendPolicyRecord[] } + const backendPolicies = Array.isArray(payloadRecord?.policies) ? payloadRecord.policies : [] - if (!response.ok) { - const errorText = await response.text() - throw new Error(`HTTP error! status: ${response.status}, message: ${errorText}`) + return backendPolicies.map((p) => { + const a = p?.validation_metadata?.analysis_result || {} + return { + id: p.id, + fileName: p.policy_name || (p.policy_number ? `Policy ${p.policy_number}` : `Policy ${String(p.id).slice(0, 8)}`), + policyType: a.policy_type || p.policy_type || "Insurance", + provider: a.provider || p.provider || "Unknown Provider", + coverageAmount: a.coverage_amount || "Not specified", + premium: a.premium || "Not specified", + deductible: a.deductible || "Not specified", + keyFeatures: Array.isArray(a.key_features) ? a.key_features : [a.coverage || "Basic coverage"], + expirationDate: a.expiration_date || "Not specified", + rawAnalysis: a, } - - const data = (await response.json()) as HistoryResponse - return data - } catch (error) { - console.error("Error fetching activity history:", error) - throw error - } + }) } diff --git a/frontend/lib/auth-backup.ts b/frontend/lib/auth-backup.ts deleted file mode 100644 index 2b25637..0000000 --- a/frontend/lib/auth-backup.ts +++ /dev/null @@ -1,160 +0,0 @@ -import { supabase } from "./supabase" -import type { User } from "@supabase/supabase-js" - -interface AuthOperationError { - message: string - name: string - status: number -} - -// Completely bypass any user sync -const syncUserToDatabase = async (user: User) => { - console.log('User authenticated successfully:', user.email) - return -} - -export const signInWithProvider = async (provider: 'google' | 'github') => { - try { - const { data, error } = await supabase.auth.signInWithOAuth({ - provider, - options: { - redirectTo: `${window.location.origin}/dashboard`, - queryParams: { - source: "social_auth" - } - }, - }) - - return { data, error } - } catch (err) { - console.error('Social login error:', err) - return { - data: null, - error: { - message: 'Social login failed. Please try again.', - name: 'SocialLoginError', - status: 500 - } as AuthOperationError - } - } -} - -export interface AuthUser extends User { - email: string -} - -// Alternative signup using fetch directly -export const signUpDirect = async (email: string, password: string, fullName: string) => { - try { - console.log('Attempting direct signup for:', email) - - const response = await fetch(`https://pmsooebddaeddjyabghw.supabase.co/auth/v1/signup`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - 'apikey': 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBtc29vZWJkZGFlZGRqeWFiZ2h3Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ4OTI4MjQsImV4cCI6MjA3MDQ2ODgyNH0.2BlwCRTvCsseKn75RaarZ9p1NM2AWVSpsZiPxsE2LDI', - 'Authorization': 'Bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBtc29vZWJkZGFlZGRqeWFiZ2h3Iiwicm9sZSI6ImFub24iLCJpYXQiOjE3NTQ4OTI4MjQsImV4cCI6MjA3MDQ2ODgyNH0.2BlwCRTvCsseKn75RaarZ9p1NM2AWVSpsZiPxsE2LDI', - }, - body: JSON.stringify({ - email, - password, - data: { - full_name: fullName - } - }) - }) - - const data = await response.json() - - if (!response.ok) { - console.error('Direct signup failed:', data) - return { - data: null, - error: { - message: data.msg || data.error_description || 'Direct signup failed', - status: response.status - } - } - } - - console.log('Direct signup successful:', data) - return { - data: { - user: data.user, - session: data.session - }, - error: null - } - } catch (err) { - console.error('Direct signup error:', err) - return { - data: null, - error: { - message: 'Network error during direct signup', - status: 500 - } - } - } -} - -export const signUp = async (email: string, password: string, fullName: string) => { - console.log('Starting signup process for:', email) - - // First try the normal Supabase client method - try { - const { data, error } = await supabase.auth.signUp({ - email, - password, - options: { - emailRedirectTo: `${window.location.origin}/dashboard`, - data: { - full_name: fullName, - }, - }, - }) - - console.log('Supabase signup response:', { - user: data?.user ? 'User created' : 'No user', - session: data?.session ? 'Session created' : 'No session', - error: error ? error.message : 'No error' - }) - - // If successful, return it - if (!error && data?.user) { - await syncUserToDatabase(data.user) - return { data, error } - } - - // If it failed with database error, try direct method - if (error && error.message.includes('Database error')) { - console.log('Trying direct signup method as fallback...') - return await signUpDirect(email, password, fullName) - } - - return { data, error } - } catch (err) { - console.error('Signup error, trying direct method:', err) - return await signUpDirect(email, password, fullName) - } -} - -export const signIn = async (email: string, password: string) => { - const { data, error } = await supabase.auth.signInWithPassword({ - email, - password, - }) - return { data, error } -} - -export const signOut = async () => { - const { error } = await supabase.auth.signOut() - return { error } -} - -export const getCurrentUser = async () => { - const { - data: { user }, - error, - } = await supabase.auth.getUser() - return { user, error } -} diff --git a/frontend/lib/get-supabase.ts b/frontend/lib/get-supabase.ts new file mode 100644 index 0000000..4c0e4f7 --- /dev/null +++ b/frontend/lib/get-supabase.ts @@ -0,0 +1,16 @@ +// eslint-disable-next-line @typescript-eslint/no-explicit-any +let supabaseClient: any = null + +export async function getSupabase() { + if (supabaseClient) return supabaseClient + const { createClient } = await import("@supabase/supabase-js") + const supabaseUrl = process.env.NEXT_PUBLIC_SUPABASE_URL || "" + const supabaseAnonKey = process.env.NEXT_PUBLIC_SUPABASE_ANON_KEY || "" + if (!supabaseUrl || !supabaseAnonKey) { + console.warn("Supabase environment variables not found, using mock client") + supabaseClient = createClient("https://dummy.supabase.co", "dummy-key") + } else { + supabaseClient = createClient(supabaseUrl, supabaseAnonKey) + } + return supabaseClient +} diff --git a/frontend/next.config.mjs b/frontend/next.config.mjs index e8d6e41..c17eca2 100644 --- a/frontend/next.config.mjs +++ b/frontend/next.config.mjs @@ -10,50 +10,41 @@ const nextConfig = { compress: true, productionBrowserSourceMaps: false, - ...(process.env.NODE_ENV === "production" - ? { - experimental: { - // Keep import optimization in production only; this can delay dev chunk emission. - optimizePackageImports: [ - "@radix-ui/react-accordion", - "@radix-ui/react-alert-dialog", - "@radix-ui/react-aspect-ratio", - "@radix-ui/react-avatar", - "@radix-ui/react-checkbox", - "@radix-ui/react-collapsible", - "@radix-ui/react-context-menu", - "@radix-ui/react-dialog", - "@radix-ui/react-dropdown-menu", - "@radix-ui/react-hover-card", - "@radix-ui/react-label", - "@radix-ui/react-menubar", - "@radix-ui/react-navigation-menu", - "@radix-ui/react-popover", - "@radix-ui/react-progress", - "@radix-ui/react-radio-group", - "@radix-ui/react-scroll-area", - "@radix-ui/react-select", - "@radix-ui/react-separator", - "@radix-ui/react-slider", - "@radix-ui/react-slot", - "@radix-ui/react-switch", - "@radix-ui/react-tabs", - "@radix-ui/react-toast", - "@radix-ui/react-toggle", - "@radix-ui/react-toggle-group", - "@radix-ui/react-tooltip", - ], - }, - } - : {}), - - webpack: (config, { isServer }) => { - // Prevent spurious client-side ChunkLoadError timeouts in slower dev environments. - if (!isServer && config.output) { - config.output.chunkLoadTimeout = 300000 - } + experimental: { + optimizePackageImports: [ + "@radix-ui/react-accordion", + "@radix-ui/react-alert-dialog", + "@radix-ui/react-aspect-ratio", + "@radix-ui/react-avatar", + "@radix-ui/react-checkbox", + "@radix-ui/react-collapsible", + "@radix-ui/react-context-menu", + "@radix-ui/react-dialog", + "@radix-ui/react-dropdown-menu", + "@radix-ui/react-hover-card", + "@radix-ui/react-label", + "@radix-ui/react-menubar", + "@radix-ui/react-navigation-menu", + "@radix-ui/react-popover", + "@radix-ui/react-progress", + "@radix-ui/react-radio-group", + "@radix-ui/react-scroll-area", + "@radix-ui/react-select", + "@radix-ui/react-separator", + "@radix-ui/react-slider", + "@radix-ui/react-slot", + "@radix-ui/react-switch", + "@radix-ui/react-tabs", + "@radix-ui/react-toast", + "@radix-ui/react-toggle", + "@radix-ui/react-toggle-group", + "@radix-ui/react-tooltip", + "lucide-react", + ], + }, - return config + turbopack: { + // Turbopack-specific configuration }, } diff --git a/frontend/package.json b/frontend/package.json index feacdc2..61ab454 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -4,7 +4,7 @@ "private": true, "scripts": { "build": "next build", - "dev": "next dev", + "dev": "set NODE_OPTIONS=--max-old-space-size=4096 && next dev --turbo", "lint": "next lint", "start": "next start" }, @@ -37,7 +37,7 @@ "@radix-ui/react-toggle": "1.1.1", "@radix-ui/react-toggle-group": "1.1.1", "@radix-ui/react-tooltip": "1.1.6", - "@supabase/supabase-js": "latest", + "@supabase/supabase-js": "2.44.4", "autoprefixer": "^10.4.20", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", diff --git a/frontend/public/placeholder-logo.png b/frontend/public/placeholder-logo.png deleted file mode 100644 index 8a792ac..0000000 Binary files a/frontend/public/placeholder-logo.png and /dev/null differ diff --git a/frontend/public/placeholder-user.jpg b/frontend/public/placeholder-user.jpg deleted file mode 100644 index 6fa7543..0000000 Binary files a/frontend/public/placeholder-user.jpg and /dev/null differ diff --git a/frontend/public/placeholder.jpg b/frontend/public/placeholder.jpg deleted file mode 100644 index 6bfe963..0000000 Binary files a/frontend/public/placeholder.jpg and /dev/null differ diff --git a/frontend/test-auth.html b/frontend/test-auth.html deleted file mode 100644 index 77f6030..0000000 --- a/frontend/test-auth.html +++ /dev/null @@ -1,98 +0,0 @@ - - - - Supabase Auth Test - - - -

Direct Supabase Auth Test

-
- - - - -
-
- - - - diff --git a/frontend/types/policies.ts b/frontend/types/policies.ts index 1677604..8d0d88b 100644 --- a/frontend/types/policies.ts +++ b/frontend/types/policies.ts @@ -18,6 +18,26 @@ export interface PolicyValidationMetadata { analysis_result?: PolicyAnalysisResult } +export interface PolicySummary { + id: string + fileName: string + policyType: string + provider: string + coverageAmount: string + premium: string + deductible: string + keyFeatures: string[] + expirationDate: string + rawAnalysis?: { + coverage?: string + exclusions?: string + claim_process?: string + claim_readiness_score?: number + waiting_period?: string + copay?: string + } +} + export interface BackendPolicyRecord { id: string policy_name?: string diff --git a/requirements.txt b/requirements.txt deleted file mode 100644 index 55a3865..0000000 Binary files a/requirements.txt and /dev/null differ diff --git a/runtime.txt b/runtime.txt deleted file mode 100644 index 524777a..0000000 --- a/runtime.txt +++ /dev/null @@ -1 +0,0 @@ -python-3.11.16 diff --git a/test/conftest.py b/test/conftest.py new file mode 100644 index 0000000..af7e479 --- /dev/null +++ b/test/conftest.py @@ -0,0 +1,6 @@ +import pytest + + +@pytest.fixture +def anyio_backend(): + return "asyncio" diff --git a/test/test_rag.py b/test/test_rag.py index c403a7c..7a9513c 100644 --- a/test/test_rag.py +++ b/test/test_rag.py @@ -1,201 +1,139 @@ import pytest import sys from pathlib import Path +import importlib -# Add backend/src to Python path for imports -backend_src = Path(__file__).parent.parent / "src" -sys.path.insert(0, str(backend_src)) +REPO_ROOT = Path(__file__).resolve().parents[1] +BACKEND_ROOT = REPO_ROOT / "backend" +if str(BACKEND_ROOT) not in sys.path: + sys.path.insert(0, str(BACKEND_ROOT)) -import tempfile -import os -from unittest.mock import Mock, patch, MagicMock -from typing import List, Optional +from unittest.mock import Mock, patch -from src.rag import chunk_texts, index_documents, retrieve_top_k -from src.embeddings import embed_texts +rag = importlib.import_module("src.rag") +chunk_texts = rag.chunk_texts +index_documents = rag.index_documents +retrieve_top_k = rag.retrieve_top_k + +pytestmark = pytest.mark.anyio class TestChunkTexts: def test_chunk_texts_basic(self): - """Test basic chunking functionality.""" text = "This is a test document. It has multiple sentences. Each sentence should be included." chunks = chunk_texts([text], chunk_size=5, overlap=2) - assert len(chunks) > 0 assert all(isinstance(chunk, str) for chunk in chunks) - # First chunk should contain first few words assert "This is a test" in chunks[0] def test_chunk_texts_empty(self): - """Test chunking with empty input.""" chunks = chunk_texts([], chunk_size=5, overlap=2) assert chunks == [] def test_chunk_texts_overlap(self): - """Test that overlap works correctly.""" text = "one two three four five six seven eight nine ten" chunks = chunk_texts([text], chunk_size=4, overlap=2) - - # Should have overlapping words assert len(chunks) >= 2 - # Check that overlapping words appear in consecutive chunks if len(chunks) >= 2: - # Find common words between first and second chunk words1 = chunks[0].split() words2 = chunks[1].split() common = set(words1) & set(words2) - assert len(common) > 0 # Should have overlapping words - - -class TestEmbedTexts: - @patch('src.embeddings.client') - def test_embed_texts_success(self, mock_client): - """Test successful embedding generation.""" - # Mock the API response - mock_response = [ - Mock(values=[0.1, 0.2, 0.3]), - Mock(values=[0.4, 0.5, 0.6]) - ] - mock_client.generate_embeddings.return_value = mock_response - - texts = ["first text", "second text"] - embeddings = embed_texts(texts) - - assert len(embeddings) == 2 - assert embeddings[0] == [0.1, 0.2, 0.3] - assert embeddings[1] == [0.4, 0.5, 0.6] - - @patch('src.embeddings.client') - def test_embed_texts_retry_on_rate_limit(self, mock_client): - """Test retry logic on rate limit.""" - # First call fails with rate limit, second succeeds - mock_client.generate_embeddings.side_effect = [ - Exception("429 rate limit"), - [Mock(values=[0.1, 0.2, 0.3])] - ] - - with patch('src.embeddings.time.sleep'): # Skip actual sleep - embeddings = embed_texts(["test"], max_retries=2, delay=0.1) - - assert len(embeddings) == 1 - assert embeddings[0] == [0.1, 0.2, 0.3] - assert mock_client.generate_embeddings.call_count == 2 - - @patch('src.embeddings.client') - def test_embed_texts_failure_after_retries(self, mock_client): - """Test handling of persistent failures.""" - mock_client.generate_embeddings.side_effect = Exception("429 rate limit") - - with patch('src.embeddings.time.sleep'): - embeddings = embed_texts(["test"], max_retries=2, delay=0.1) - - assert len(embeddings) == 1 - assert embeddings[0] is None + assert len(common) > 0 class TestIndexDocuments: - @patch('src.rag.supabase_storage') - @patch('src.rag.embed_texts') - def test_index_documents_with_embeddings(self, mock_embed, mock_supabase): - """Test successful indexing with embeddings.""" - # Mock embeddings - mock_embed.return_value = [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]] - - # Mock successful DB insert - mock_result = Mock() - mock_result.error = None - mock_supabase.table().insert().execute.return_value = mock_result - - text = "This is a test document for indexing." - result = index_documents(text, "doc123", chunk_size=3, overlap=1) - - assert len(result) >= 1 - # Check that insert was called - mock_supabase.table.assert_called_with("document_chunks") - - @patch('src.rag.supabase_storage') - @patch('src.rag.embed_texts') - def test_index_documents_without_embeddings(self, mock_embed, mock_supabase): - """Test indexing when embeddings fail.""" - # Mock embedding failure - mock_embed.side_effect = Exception("API key missing") - - # Mock successful DB insert - mock_result = Mock() - mock_result.error = None - mock_supabase.table().insert().execute.return_value = mock_result - - text = "This is a test document." - result = index_documents(text, "doc123") - - # Should still index chunks, just without embeddings - assert len(result) >= 1 - # Check that chunks were inserted without embedding field - mock_supabase.table().insert.assert_called() - - @patch('src.rag.supabase_storage') - @patch('src.rag.embed_texts') - def test_index_documents_dimension_mismatch(self, mock_embed, mock_supabase): - """Test handling of embedding dimension mismatch.""" - # Mock embeddings with wrong dimension - mock_embed.return_value = [[0.1, 0.2]] # 2D instead of expected 768D - - # Mock successful DB insert - mock_result = Mock() - mock_result.error = None - mock_supabase.table().insert().execute.return_value = mock_result - - with patch('src.rag.EXPECTED_EMBED_DIM', 768): - text = "Test document" - result = index_documents(text, "doc123") - - # Should store chunks without embeddings due to dimension mismatch + async def test_index_documents_with_embeddings(self): + with ( + patch("src.rag.supabase_storage") as mock_supabase, + patch("src.rag.embed_texts") as mock_embed, + patch("src.rag.should_embed_chunk", return_value=True) as _, + patch("src.rag.EXPECTED_EMBED_DIM", 3), + ): + mock_embed.return_value = [[0.1, 0.2, 0.3], [0.4, 0.5, 0.6]] + mock_result = Mock() + mock_result.error = None + mock_supabase.table().insert().execute.return_value = mock_result + + text = "This is a test document for indexing." + result = await index_documents(text, "doc123", chunk_size=3, overlap=1) + assert len(result) >= 1 + mock_supabase.table.assert_called_with("document_chunks") + + async def test_index_documents_without_embeddings(self): + with ( + patch("src.rag.supabase_storage") as mock_supabase, + patch("src.rag.embed_texts") as mock_embed, + patch("src.rag.should_embed_chunk", return_value=True) as _, + ): + mock_embed.side_effect = Exception("API key missing") + mock_result = Mock() + mock_result.error = None + mock_supabase.table().insert().execute.return_value = mock_result + + text = "This is a test document." + with pytest.raises(RuntimeError): + await index_documents(text, "doc123") + + async def test_index_documents_dimension_mismatch(self): + with ( + patch("src.rag.supabase_storage") as mock_supabase, + patch("src.rag.embed_texts") as mock_embed, + patch("src.rag.should_embed_chunk", return_value=True) as _, + patch("src.rag.EXPECTED_EMBED_DIM", 768), + ): + mock_embed.return_value = [[0.1, 0.2]] + mock_result = Mock() + mock_result.error = None + mock_supabase.table().insert().execute.return_value = mock_result + + text = "Test document" + with pytest.raises(RuntimeError, match="dimension mismatch"): + await index_documents(text, "doc123") class TestRetrieveTopK: - @patch('src.rag.supabase_storage') - @patch('src.rag.embed_texts') - def test_retrieve_top_k_success(self, mock_embed, mock_supabase): - """Test successful retrieval.""" - # Mock query embedding - mock_embed.return_value = [[0.1, 0.2, 0.3]] - - # Mock RPC response - mock_rpc_result = Mock() - mock_rpc_result.data = [ - {"id": 1, "content": "First chunk", "score": 0.9}, - {"id": 2, "content": "Second chunk", "score": 0.8} - ] - mock_supabase.postgrest.rpc().execute.return_value = mock_rpc_result - - results = retrieve_top_k("test query", k=2) - - assert len(results) == 2 - assert results[0] == (1, "First chunk", 0.9) - assert results[1] == (2, "Second chunk", 0.8) - - @patch('src.rag.supabase_storage') - @patch('src.rag.embed_texts') - def test_retrieve_top_k_embedding_failure(self, mock_embed, mock_supabase): - """Test retrieval when embedding fails.""" - mock_embed.return_value = [] # Empty embeddings - - results = retrieve_top_k("test query", k=5) - - assert results == [] - mock_supabase.postgrest.rpc.assert_not_called() - - @patch('src.rag.supabase_storage') - @patch('src.rag.embed_texts') - def test_retrieve_top_k_rpc_failure(self, mock_embed, mock_supabase): - """Test retrieval when RPC fails.""" - mock_embed.return_value = [[0.1, 0.2, 0.3]] - mock_supabase.postgrest.rpc().execute.side_effect = Exception("RPC failed") - - results = retrieve_top_k("test query", k=5) - - assert results == [] + async def test_retrieve_top_k_success(self): + with ( + patch("src.rag.supabase_storage") as mock_supabase, + patch("src.rag.embed_texts") as mock_embed, + patch("src.rag.EXPECTED_EMBED_DIM", 3), + ): + mock_embed.return_value = [[0.1, 0.2, 0.3]] + mock_rpc_result = Mock() + mock_rpc_result.data = [ + {"id": 1, "content": "First chunk", "score": 0.9}, + {"id": 2, "content": "Second chunk", "score": 0.8}, + ] + mock_supabase.postgrest.rpc().execute.return_value = mock_rpc_result + + results = await retrieve_top_k("test query", k=2) + assert len(results) == 2 + assert results[0] == (1, "First chunk", 0.9) + assert results[1] == (2, "Second chunk", 0.8) + + async def test_retrieve_top_k_embedding_failure(self): + with ( + patch("src.rag.supabase_storage") as mock_supabase, + patch("src.rag.embed_texts") as mock_embed, + ): + mock_embed.return_value = [] + + results = await retrieve_top_k("test query", k=5) + assert results == [] + mock_supabase.postgrest.rpc.assert_not_called() + + async def test_retrieve_top_k_rpc_failure(self): + with ( + patch("src.rag.supabase_storage") as mock_supabase, + patch("src.rag.embed_texts") as mock_embed, + patch("src.rag.EXPECTED_EMBED_DIM", 3), + ): + mock_embed.return_value = [[0.1, 0.2, 0.3]] + mock_supabase.postgrest.rpc().execute.side_effect = Exception("RPC failed") + + results = await retrieve_top_k("test query", k=5) + assert results == [] if __name__ == "__main__": diff --git a/test/test_rag_chunk.py b/test/test_rag_chunk.py index c00e41b..75e416f 100644 --- a/test/test_rag_chunk.py +++ b/test/test_rag_chunk.py @@ -8,4 +8,5 @@ def test_chunk_texts_basic(): "one two three four", "four five six seven", "seven eight nine ten", + "ten", ]