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 (
- Provider
- {policy.provider || 'Unknown Provider'}
- Coverage Amount
- {formatIndianCurrency(policy.coverageAmount || 'Not specified')}
- Premium
- {formatIndianCurrency(policy.premium || 'Not specified')}
- Deductible
- {formatIndianCurrency(policy.deductible || 'Not specified')}
- Expires
- {getExpirationLabel()}
- Key Features
- {feature}
-
- Go to the Overview tab and click "Compare" on the policies you want to compare side by side.
- No Policies Selected for Comparison
-
Comparing {policies.length} policies
-| Feature | - {policies.map((policy) => ( -
-
-
- {policy.fileName}
- |
- ))}
-
|---|---|
| Provider | - {policies.map((policy) => ( -
- {policy.provider}
- |
- ))}
-
| Annual Premium | - {policies.map((policy) => { - const comparison = getPremiumComparison(policy.premium, policies.map((p) => p.premium)) - return ( -
-
-
- {formatIndianCurrency(policy.premium)}
- {comparison === "lowest" && (
- |
- )
- })}
-
| Coverage Amount | - {policies.map((policy) => ( -
- {formatIndianCurrency(policy.coverageAmount)}
- |
- ))}
-
| Deductible | - {policies.map((policy) => ( -
- {formatIndianCurrency(policy.deductible)}
- |
- ))}
-
| Expiration Date | - {policies.map((policy) => ( -
-
-
-
- {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
- })()}
- |
- ))}
-
| Key Features | - {policies.map((policy) => ( -
-
- {policy.keyFeatures.slice(0, expandedFeatures[policy.id] ? undefined : 4).map((feature, index) => (
-
-
-
- ))}
- {policy.keyFeatures.length > 4 && (
-
- )}
- |
- ))}
-
- {expiringSoon.length === 1 - ? `${expiringSoon[0].fileName} expires soon` - : `${expiringSoon.length} policies expire within 60 days` - } - Consider renewal timing when making decisions. -
-- {description} -
- {trend && ( -- {trend} -
- )} -