Upload 20 files
Browse files- Dockerfile +38 -0
- app/__init__.py +8 -0
- app/agents/__init__.py +23 -0
- app/agents/master_agent.py +276 -0
- app/agents/tools.py +247 -0
- app/config.py +85 -0
- app/main.py +155 -0
- app/routers/admin.py +243 -0
- app/routers/auth.py +238 -0
- app/routers/chat.py +182 -0
- app/routers/loan.py +279 -0
- app/schemas.py +243 -0
- app/services/firebase_service.py +491 -0
- app/services/pdf_service.py +323 -0
- app/services/session_service.py +388 -0
- app/services/underwriting_service.py +396 -0
- app/utils/__init__.py +24 -0
- app/utils/logger.py +227 -0
- requirements.txt +34 -0
- sanctions/.gitkeep +2 -0
Dockerfile
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Dockerfile for FinAgent Backend - Hugging Face Spaces Deployment
|
| 2 |
+
FROM python:3.11-slim
|
| 3 |
+
|
| 4 |
+
# Set working directory
|
| 5 |
+
WORKDIR /app
|
| 6 |
+
|
| 7 |
+
# Set environment variables
|
| 8 |
+
ENV PYTHONUNBUFFERED=1
|
| 9 |
+
ENV PYTHONDONTWRITEBYTECODE=1
|
| 10 |
+
|
| 11 |
+
# Install system dependencies
|
| 12 |
+
RUN apt-get update && apt-get install -y \
|
| 13 |
+
build-essential \
|
| 14 |
+
curl \
|
| 15 |
+
&& rm -rf /var/lib/apt/lists/*
|
| 16 |
+
|
| 17 |
+
# Copy requirements first for better caching
|
| 18 |
+
COPY requirements.txt .
|
| 19 |
+
|
| 20 |
+
# Install Python dependencies
|
| 21 |
+
RUN pip install --no-cache-dir --upgrade pip && \
|
| 22 |
+
pip install --no-cache-dir -r requirements.txt
|
| 23 |
+
|
| 24 |
+
# Copy application code
|
| 25 |
+
COPY . .
|
| 26 |
+
|
| 27 |
+
# Create directories for runtime data
|
| 28 |
+
RUN mkdir -p sanctions logs
|
| 29 |
+
|
| 30 |
+
# Expose port (Hugging Face uses 7860)
|
| 31 |
+
EXPOSE 7860
|
| 32 |
+
|
| 33 |
+
# Health check
|
| 34 |
+
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
|
| 35 |
+
CMD curl -f http://localhost:7860/health || exit 1
|
| 36 |
+
|
| 37 |
+
# Run the application
|
| 38 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860", "--workers", "1"]
|
app/__init__.py
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
FinAgent - Chat2Sanction Backend Application
|
| 3 |
+
AI-powered loan processing platform with conversational interface.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
__version__ = "1.0.0"
|
| 7 |
+
__author__ = "FinAgent Team"
|
| 8 |
+
__description__ = "AI-powered loan processing platform"
|
app/agents/__init__.py
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Agents module for LangChain-based conversational AI.
|
| 3 |
+
Contains the master agent and tool definitions.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from app.agents.master_agent import MasterAgent, master_agent
|
| 7 |
+
from app.agents.tools import (
|
| 8 |
+
all_tools,
|
| 9 |
+
create_loan_application_tool,
|
| 10 |
+
fetch_user_profile_tool,
|
| 11 |
+
generate_sanction_letter_tool,
|
| 12 |
+
run_underwriting_tool,
|
| 13 |
+
)
|
| 14 |
+
|
| 15 |
+
__all__ = [
|
| 16 |
+
"master_agent",
|
| 17 |
+
"MasterAgent",
|
| 18 |
+
"all_tools",
|
| 19 |
+
"fetch_user_profile_tool",
|
| 20 |
+
"run_underwriting_tool",
|
| 21 |
+
"generate_sanction_letter_tool",
|
| 22 |
+
"create_loan_application_tool",
|
| 23 |
+
]
|
app/agents/master_agent.py
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Master agent using LangChain with Gemini LLM.
|
| 3 |
+
Orchestrates loan processing conversation and tool calls.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import json
|
| 7 |
+
from typing import Any, Dict, List, Optional
|
| 8 |
+
|
| 9 |
+
from app.agents.tools import all_tools
|
| 10 |
+
from app.config import settings
|
| 11 |
+
from app.utils.logger import default_logger as logger
|
| 12 |
+
from langchain.agents import AgentExecutor, create_react_agent
|
| 13 |
+
from langchain.memory import ConversationBufferMemory
|
| 14 |
+
from langchain.prompts import PromptTemplate
|
| 15 |
+
from langchain_google_genai import ChatGoogleGenerativeAI
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class MasterAgent:
|
| 19 |
+
"""Master conversational agent for loan processing."""
|
| 20 |
+
|
| 21 |
+
def __init__(self):
|
| 22 |
+
"""Initialize the master agent with Gemini LLM and tools."""
|
| 23 |
+
self.llm = self._initialize_llm()
|
| 24 |
+
self.tools = all_tools
|
| 25 |
+
self.memory_store: Dict[str, ConversationBufferMemory] = {}
|
| 26 |
+
self.agent_executor = None
|
| 27 |
+
self._initialize_agent()
|
| 28 |
+
|
| 29 |
+
def _initialize_llm(self) -> ChatGoogleGenerativeAI:
|
| 30 |
+
"""
|
| 31 |
+
Initialize Gemini LLM.
|
| 32 |
+
|
| 33 |
+
Returns:
|
| 34 |
+
ChatGoogleGenerativeAI instance
|
| 35 |
+
"""
|
| 36 |
+
try:
|
| 37 |
+
llm = ChatGoogleGenerativeAI(
|
| 38 |
+
model=settings.GEMINI_MODEL,
|
| 39 |
+
temperature=settings.GEMINI_TEMPERATURE,
|
| 40 |
+
google_api_key=settings.GOOGLE_API_KEY,
|
| 41 |
+
convert_system_message_to_human=True,
|
| 42 |
+
)
|
| 43 |
+
logger.info(f"Initialized Gemini LLM: {settings.GEMINI_MODEL}")
|
| 44 |
+
return llm
|
| 45 |
+
except Exception as e:
|
| 46 |
+
logger.error(f"Failed to initialize Gemini LLM: {str(e)}")
|
| 47 |
+
raise
|
| 48 |
+
|
| 49 |
+
def _initialize_agent(self) -> None:
|
| 50 |
+
"""Initialize the ReAct agent with tools."""
|
| 51 |
+
try:
|
| 52 |
+
# Create the prompt template
|
| 53 |
+
template = """You are FinAgent, an AI-powered loan processing assistant for personal loans.
|
| 54 |
+
|
| 55 |
+
Your role is to help users get instant personal loan approvals through a conversational interface.
|
| 56 |
+
|
| 57 |
+
IMPORTANT GUIDELINES:
|
| 58 |
+
1. Be friendly, professional, and conversational
|
| 59 |
+
2. Guide users through the loan application process step by step
|
| 60 |
+
3. Use the available tools to fetch user data, evaluate eligibility, and generate sanction letters
|
| 61 |
+
4. Always explain decisions clearly in simple language
|
| 62 |
+
5. Never expose technical details or raw tool outputs to users
|
| 63 |
+
6. Format monetary values in Indian Rupees (₹) with proper formatting
|
| 64 |
+
7. Be empathetic when delivering rejection decisions and suggest improvements
|
| 65 |
+
|
| 66 |
+
CONVERSATION FLOW:
|
| 67 |
+
1. Greet the user warmly and understand their loan requirement
|
| 68 |
+
2. Ask for loan amount and tenure if not provided
|
| 69 |
+
3. Use fetch_user_profile tool to get user's financial data
|
| 70 |
+
4. Use run_underwriting tool to evaluate loan eligibility
|
| 71 |
+
5. Explain the decision clearly with key details (amount, EMI, tenure, interest rate)
|
| 72 |
+
6. If approved, use generate_sanction_letter tool to create the sanction letter
|
| 73 |
+
7. Provide the loan_id and next steps to the user
|
| 74 |
+
|
| 75 |
+
DECISION EXPLANATION:
|
| 76 |
+
- APPROVED: Congratulate the user, explain the approved amount, EMI, tenure, and risk band
|
| 77 |
+
- ADJUST: Explain why adjustment is needed and what the new terms are
|
| 78 |
+
- REJECTED: Be empathetic, explain the reasons (credit score, FOIR), and suggest improvements
|
| 79 |
+
|
| 80 |
+
AVAILABLE TOOLS:
|
| 81 |
+
{tools}
|
| 82 |
+
|
| 83 |
+
TOOL NAMES: {tool_names}
|
| 84 |
+
|
| 85 |
+
When using tools, follow this format:
|
| 86 |
+
Thought: [Your reasoning about what to do next]
|
| 87 |
+
Action: [Tool name from {tool_names}]
|
| 88 |
+
Action Input: [Input for the tool]
|
| 89 |
+
Observation: [Tool output]
|
| 90 |
+
... (repeat Thought/Action/Action Input/Observation as needed)
|
| 91 |
+
Thought: I now know the final answer
|
| 92 |
+
Final Answer: [Your response to the user in natural, friendly language]
|
| 93 |
+
|
| 94 |
+
Current conversation:
|
| 95 |
+
{chat_history}
|
| 96 |
+
|
| 97 |
+
User: {input}
|
| 98 |
+
|
| 99 |
+
{agent_scratchpad}"""
|
| 100 |
+
|
| 101 |
+
prompt = PromptTemplate(
|
| 102 |
+
input_variables=[
|
| 103 |
+
"input",
|
| 104 |
+
"chat_history",
|
| 105 |
+
"agent_scratchpad",
|
| 106 |
+
"tools",
|
| 107 |
+
"tool_names",
|
| 108 |
+
],
|
| 109 |
+
template=template,
|
| 110 |
+
)
|
| 111 |
+
|
| 112 |
+
# Create the agent
|
| 113 |
+
agent = create_react_agent(self.llm, self.tools, prompt)
|
| 114 |
+
|
| 115 |
+
# Create agent executor
|
| 116 |
+
self.agent_executor = AgentExecutor(
|
| 117 |
+
agent=agent,
|
| 118 |
+
tools=self.tools,
|
| 119 |
+
verbose=True,
|
| 120 |
+
handle_parsing_errors=True,
|
| 121 |
+
max_iterations=10,
|
| 122 |
+
)
|
| 123 |
+
|
| 124 |
+
logger.info("Master agent initialized successfully")
|
| 125 |
+
|
| 126 |
+
except Exception as e:
|
| 127 |
+
logger.error(f"Failed to initialize agent: {str(e)}")
|
| 128 |
+
raise
|
| 129 |
+
|
| 130 |
+
def get_or_create_memory(self, session_id: str) -> ConversationBufferMemory:
|
| 131 |
+
"""
|
| 132 |
+
Get or create conversation memory for a session.
|
| 133 |
+
|
| 134 |
+
Args:
|
| 135 |
+
session_id: Session ID
|
| 136 |
+
|
| 137 |
+
Returns:
|
| 138 |
+
ConversationBufferMemory instance
|
| 139 |
+
"""
|
| 140 |
+
if session_id not in self.memory_store:
|
| 141 |
+
self.memory_store[session_id] = ConversationBufferMemory(
|
| 142 |
+
memory_key="chat_history",
|
| 143 |
+
return_messages=False,
|
| 144 |
+
input_key="input",
|
| 145 |
+
)
|
| 146 |
+
logger.info(f"Created new memory for session {session_id}")
|
| 147 |
+
|
| 148 |
+
return self.memory_store[session_id]
|
| 149 |
+
|
| 150 |
+
def chat(self, session_id: str, user_id: str, message: str) -> Dict[str, Any]:
|
| 151 |
+
"""
|
| 152 |
+
Process a chat message and return response.
|
| 153 |
+
|
| 154 |
+
Args:
|
| 155 |
+
session_id: Session ID
|
| 156 |
+
user_id: User ID
|
| 157 |
+
message: User message
|
| 158 |
+
|
| 159 |
+
Returns:
|
| 160 |
+
Dictionary with reply, decision, loan_id, and metadata
|
| 161 |
+
"""
|
| 162 |
+
try:
|
| 163 |
+
logger.info(f"Processing chat for session {session_id}, user {user_id}")
|
| 164 |
+
|
| 165 |
+
# Get conversation memory
|
| 166 |
+
memory = self.get_or_create_memory(session_id)
|
| 167 |
+
|
| 168 |
+
# Prepare input with user context
|
| 169 |
+
agent_input = {
|
| 170 |
+
"input": f"[User ID: {user_id}] {message}",
|
| 171 |
+
"chat_history": memory.load_memory_variables({}).get(
|
| 172 |
+
"chat_history", ""
|
| 173 |
+
),
|
| 174 |
+
}
|
| 175 |
+
|
| 176 |
+
# Run agent
|
| 177 |
+
result = self.agent_executor.invoke(agent_input)
|
| 178 |
+
|
| 179 |
+
# Extract response
|
| 180 |
+
response_text = result.get(
|
| 181 |
+
"output",
|
| 182 |
+
"I apologize, but I encountered an issue processing your request.",
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
# Save to memory
|
| 186 |
+
memory.save_context({"input": message}, {"output": response_text})
|
| 187 |
+
|
| 188 |
+
# Parse response for structured data
|
| 189 |
+
parsed_data = self._parse_response(response_text)
|
| 190 |
+
|
| 191 |
+
return {
|
| 192 |
+
"reply": response_text,
|
| 193 |
+
"decision": parsed_data.get("decision"),
|
| 194 |
+
"loan_id": parsed_data.get("loan_id"),
|
| 195 |
+
"meta": parsed_data.get("meta"),
|
| 196 |
+
}
|
| 197 |
+
|
| 198 |
+
except Exception as e:
|
| 199 |
+
logger.error(f"Error in chat processing: {str(e)}")
|
| 200 |
+
return {
|
| 201 |
+
"reply": "I apologize, but I encountered an error processing your request. Please try again.",
|
| 202 |
+
"decision": None,
|
| 203 |
+
"loan_id": None,
|
| 204 |
+
"meta": {"error": str(e)},
|
| 205 |
+
}
|
| 206 |
+
|
| 207 |
+
def _parse_response(self, response_text: str) -> Dict[str, Any]:
|
| 208 |
+
"""
|
| 209 |
+
Parse agent response to extract structured data.
|
| 210 |
+
|
| 211 |
+
Args:
|
| 212 |
+
response_text: Agent response text
|
| 213 |
+
|
| 214 |
+
Returns:
|
| 215 |
+
Dictionary with parsed data
|
| 216 |
+
"""
|
| 217 |
+
parsed = {
|
| 218 |
+
"decision": None,
|
| 219 |
+
"loan_id": None,
|
| 220 |
+
"meta": {},
|
| 221 |
+
}
|
| 222 |
+
|
| 223 |
+
# Look for decision keywords
|
| 224 |
+
if "approved" in response_text.lower():
|
| 225 |
+
if (
|
| 226 |
+
"adjustment" in response_text.lower()
|
| 227 |
+
or "adjust" in response_text.lower()
|
| 228 |
+
):
|
| 229 |
+
parsed["decision"] = "ADJUST"
|
| 230 |
+
else:
|
| 231 |
+
parsed["decision"] = "APPROVED"
|
| 232 |
+
elif (
|
| 233 |
+
"rejected" in response_text.lower()
|
| 234 |
+
or "cannot approve" in response_text.lower()
|
| 235 |
+
):
|
| 236 |
+
parsed["decision"] = "REJECTED"
|
| 237 |
+
|
| 238 |
+
# Look for loan ID pattern
|
| 239 |
+
import re
|
| 240 |
+
|
| 241 |
+
loan_id_match = re.search(
|
| 242 |
+
r"loan[_\s]?id[:\s]+([a-zA-Z0-9\-]+)", response_text, re.IGNORECASE
|
| 243 |
+
)
|
| 244 |
+
if loan_id_match:
|
| 245 |
+
parsed["loan_id"] = loan_id_match.group(1)
|
| 246 |
+
|
| 247 |
+
# Look for EMI amount
|
| 248 |
+
emi_match = re.search(
|
| 249 |
+
r"₹\s?([\d,]+(?:\.\d{2})?)\s*(?:per month|monthly|emi)",
|
| 250 |
+
response_text,
|
| 251 |
+
re.IGNORECASE,
|
| 252 |
+
)
|
| 253 |
+
if emi_match:
|
| 254 |
+
parsed["meta"]["emi"] = emi_match.group(1)
|
| 255 |
+
|
| 256 |
+
return parsed
|
| 257 |
+
|
| 258 |
+
def clear_session_memory(self, session_id: str) -> bool:
|
| 259 |
+
"""
|
| 260 |
+
Clear conversation memory for a session.
|
| 261 |
+
|
| 262 |
+
Args:
|
| 263 |
+
session_id: Session ID
|
| 264 |
+
|
| 265 |
+
Returns:
|
| 266 |
+
True if cleared, False if not found
|
| 267 |
+
"""
|
| 268 |
+
if session_id in self.memory_store:
|
| 269 |
+
del self.memory_store[session_id]
|
| 270 |
+
logger.info(f"Cleared memory for session {session_id}")
|
| 271 |
+
return True
|
| 272 |
+
return False
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
# Singleton instance
|
| 276 |
+
master_agent = MasterAgent()
|
app/agents/tools.py
ADDED
|
@@ -0,0 +1,247 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
LangChain tools for the master agent.
|
| 3 |
+
Defines tool functions that the agent can call to interact with services.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import json
|
| 7 |
+
from typing import Any, Dict
|
| 8 |
+
|
| 9 |
+
from app.services.firebase_service import firebase_service
|
| 10 |
+
from app.services.pdf_service import pdf_service
|
| 11 |
+
from app.services.underwriting_service import underwriting_service
|
| 12 |
+
from app.utils.logger import default_logger as logger
|
| 13 |
+
from langchain.tools import Tool
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def fetch_user_profile_tool_func(user_id: str) -> str:
|
| 17 |
+
"""
|
| 18 |
+
Fetch user profile from Firebase.
|
| 19 |
+
|
| 20 |
+
Args:
|
| 21 |
+
user_id: User ID to fetch
|
| 22 |
+
|
| 23 |
+
Returns:
|
| 24 |
+
JSON string with user profile or error message
|
| 25 |
+
"""
|
| 26 |
+
try:
|
| 27 |
+
logger.info(f"Tool: Fetching user profile for {user_id}")
|
| 28 |
+
|
| 29 |
+
profile = firebase_service.get_user_profile(user_id)
|
| 30 |
+
|
| 31 |
+
if not profile:
|
| 32 |
+
return json.dumps({"success": False, "error": "User profile not found"})
|
| 33 |
+
|
| 34 |
+
# Return relevant profile information
|
| 35 |
+
result = {
|
| 36 |
+
"success": True,
|
| 37 |
+
"user_id": profile.get("user_id"),
|
| 38 |
+
"full_name": profile.get("full_name"),
|
| 39 |
+
"monthly_income": profile.get("monthly_income"),
|
| 40 |
+
"existing_emi": profile.get("existing_emi"),
|
| 41 |
+
"credit_score": profile.get("mock_credit_score"),
|
| 42 |
+
"segment": profile.get("segment"),
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
return json.dumps(result, indent=2)
|
| 46 |
+
|
| 47 |
+
except Exception as e:
|
| 48 |
+
logger.error(f"Error in fetch_user_profile_tool: {str(e)}")
|
| 49 |
+
return json.dumps({"success": False, "error": str(e)})
|
| 50 |
+
|
| 51 |
+
|
| 52 |
+
def run_underwriting_tool_func(payload: str) -> str:
|
| 53 |
+
"""
|
| 54 |
+
Run underwriting evaluation for a loan application.
|
| 55 |
+
|
| 56 |
+
Args:
|
| 57 |
+
payload: JSON string with user_id, requested_amount, and requested_tenure_months
|
| 58 |
+
|
| 59 |
+
Returns:
|
| 60 |
+
JSON string with underwriting decision
|
| 61 |
+
"""
|
| 62 |
+
try:
|
| 63 |
+
logger.info(f"Tool: Running underwriting")
|
| 64 |
+
|
| 65 |
+
# Parse payload
|
| 66 |
+
data = json.loads(payload)
|
| 67 |
+
user_id = data.get("user_id")
|
| 68 |
+
requested_amount = float(data.get("requested_amount", 0))
|
| 69 |
+
requested_tenure = int(data.get("requested_tenure_months", 0))
|
| 70 |
+
|
| 71 |
+
# Fetch user profile
|
| 72 |
+
user_profile = firebase_service.get_user_profile(user_id)
|
| 73 |
+
|
| 74 |
+
if not user_profile:
|
| 75 |
+
return json.dumps({"success": False, "error": "User profile not found"})
|
| 76 |
+
|
| 77 |
+
# Run underwriting
|
| 78 |
+
decision = underwriting_service.evaluate_application(
|
| 79 |
+
user_profile, requested_amount, requested_tenure
|
| 80 |
+
)
|
| 81 |
+
|
| 82 |
+
result = {
|
| 83 |
+
"success": True,
|
| 84 |
+
"decision": decision["decision"],
|
| 85 |
+
"approved_amount": decision["approved_amount"],
|
| 86 |
+
"tenure_months": decision["tenure_months"],
|
| 87 |
+
"emi": decision["emi"],
|
| 88 |
+
"interest_rate": decision["interest_rate"],
|
| 89 |
+
"credit_score": decision["credit_score"],
|
| 90 |
+
"foir": decision["foir"],
|
| 91 |
+
"risk_band": decision["risk_band"],
|
| 92 |
+
"explanation": decision["explanation"],
|
| 93 |
+
"total_payable": decision.get("total_payable", 0),
|
| 94 |
+
"processing_fee": decision.get("processing_fee", 0),
|
| 95 |
+
}
|
| 96 |
+
|
| 97 |
+
return json.dumps(result, indent=2)
|
| 98 |
+
|
| 99 |
+
except Exception as e:
|
| 100 |
+
logger.error(f"Error in run_underwriting_tool: {str(e)}")
|
| 101 |
+
return json.dumps({"success": False, "error": str(e)})
|
| 102 |
+
|
| 103 |
+
|
| 104 |
+
def generate_sanction_letter_tool_func(payload: str) -> str:
|
| 105 |
+
"""
|
| 106 |
+
Generate sanction letter PDF for approved loan.
|
| 107 |
+
|
| 108 |
+
Args:
|
| 109 |
+
payload: JSON string with loan application data
|
| 110 |
+
|
| 111 |
+
Returns:
|
| 112 |
+
JSON string with PDF path and URL
|
| 113 |
+
"""
|
| 114 |
+
try:
|
| 115 |
+
logger.info(f"Tool: Generating sanction letter")
|
| 116 |
+
|
| 117 |
+
# Parse payload
|
| 118 |
+
loan_data = json.loads(payload)
|
| 119 |
+
|
| 120 |
+
# Validate that loan is approved
|
| 121 |
+
decision = loan_data.get("decision")
|
| 122 |
+
if decision not in ["APPROVED", "ADJUST"]:
|
| 123 |
+
return json.dumps(
|
| 124 |
+
{
|
| 125 |
+
"success": False,
|
| 126 |
+
"error": "Cannot generate sanction letter for rejected loans",
|
| 127 |
+
}
|
| 128 |
+
)
|
| 129 |
+
|
| 130 |
+
# Create loan application record in Firebase
|
| 131 |
+
loan_record = firebase_service.create_loan_application(loan_data)
|
| 132 |
+
loan_id = loan_record.get("loan_id")
|
| 133 |
+
|
| 134 |
+
# Generate PDF
|
| 135 |
+
pdf_result = pdf_service.generate_sanction_letter(loan_record)
|
| 136 |
+
|
| 137 |
+
# Update loan record with PDF path
|
| 138 |
+
firebase_service.update_loan_application(
|
| 139 |
+
loan_id,
|
| 140 |
+
{
|
| 141 |
+
"sanction_pdf_path": pdf_result["pdf_path"],
|
| 142 |
+
"sanction_pdf_url": pdf_result["pdf_url"],
|
| 143 |
+
},
|
| 144 |
+
)
|
| 145 |
+
|
| 146 |
+
result = {
|
| 147 |
+
"success": True,
|
| 148 |
+
"loan_id": loan_id,
|
| 149 |
+
"pdf_path": pdf_result["pdf_path"],
|
| 150 |
+
"pdf_url": pdf_result["pdf_url"],
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
return json.dumps(result, indent=2)
|
| 154 |
+
|
| 155 |
+
except Exception as e:
|
| 156 |
+
logger.error(f"Error in generate_sanction_letter_tool: {str(e)}")
|
| 157 |
+
return json.dumps({"success": False, "error": str(e)})
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
def create_loan_application_tool_func(payload: str) -> str:
|
| 161 |
+
"""
|
| 162 |
+
Create a loan application record in Firebase.
|
| 163 |
+
|
| 164 |
+
Args:
|
| 165 |
+
payload: JSON string with loan application data
|
| 166 |
+
|
| 167 |
+
Returns:
|
| 168 |
+
JSON string with created loan application
|
| 169 |
+
"""
|
| 170 |
+
try:
|
| 171 |
+
logger.info(f"Tool: Creating loan application")
|
| 172 |
+
|
| 173 |
+
# Parse payload
|
| 174 |
+
loan_data = json.loads(payload)
|
| 175 |
+
|
| 176 |
+
# Create loan application
|
| 177 |
+
loan_record = firebase_service.create_loan_application(loan_data)
|
| 178 |
+
|
| 179 |
+
result = {
|
| 180 |
+
"success": True,
|
| 181 |
+
"loan_id": loan_record.get("loan_id"),
|
| 182 |
+
"user_id": loan_record.get("user_id"),
|
| 183 |
+
"decision": loan_record.get("decision"),
|
| 184 |
+
"approved_amount": loan_record.get("approved_amount"),
|
| 185 |
+
}
|
| 186 |
+
|
| 187 |
+
return json.dumps(result, indent=2)
|
| 188 |
+
|
| 189 |
+
except Exception as e:
|
| 190 |
+
logger.error(f"Error in create_loan_application_tool: {str(e)}")
|
| 191 |
+
return json.dumps({"success": False, "error": str(e)})
|
| 192 |
+
|
| 193 |
+
|
| 194 |
+
# Define LangChain tools
|
| 195 |
+
fetch_user_profile_tool = Tool(
|
| 196 |
+
name="fetch_user_profile",
|
| 197 |
+
func=fetch_user_profile_tool_func,
|
| 198 |
+
description="""
|
| 199 |
+
Fetch user profile information from the database.
|
| 200 |
+
Input should be a user_id string.
|
| 201 |
+
Returns JSON with user's full_name, monthly_income, existing_emi, credit_score, and segment.
|
| 202 |
+
Use this tool to get user information before processing loan applications.
|
| 203 |
+
""",
|
| 204 |
+
)
|
| 205 |
+
|
| 206 |
+
run_underwriting_tool = Tool(
|
| 207 |
+
name="run_underwriting",
|
| 208 |
+
func=run_underwriting_tool_func,
|
| 209 |
+
description="""
|
| 210 |
+
Run underwriting evaluation for a loan application.
|
| 211 |
+
Input should be a JSON string with keys: user_id, requested_amount, requested_tenure_months.
|
| 212 |
+
Example: {"user_id": "123", "requested_amount": 500000, "requested_tenure_months": 36}
|
| 213 |
+
Returns JSON with decision (APPROVED/REJECTED/ADJUST), approved_amount, emi, interest_rate, foir, risk_band, and explanation.
|
| 214 |
+
Use this tool after getting user profile to evaluate loan eligibility.
|
| 215 |
+
""",
|
| 216 |
+
)
|
| 217 |
+
|
| 218 |
+
generate_sanction_letter_tool = Tool(
|
| 219 |
+
name="generate_sanction_letter",
|
| 220 |
+
func=generate_sanction_letter_tool_func,
|
| 221 |
+
description="""
|
| 222 |
+
Generate a sanction letter PDF for an approved loan application.
|
| 223 |
+
Input should be a JSON string with complete loan application data including:
|
| 224 |
+
user_id, full_name, decision, approved_amount, tenure_months, emi, interest_rate, credit_score, foir, risk_band, explanation.
|
| 225 |
+
Returns JSON with loan_id, pdf_path, and pdf_url.
|
| 226 |
+
Use this tool only after underwriting approval (APPROVED or ADJUST decision).
|
| 227 |
+
""",
|
| 228 |
+
)
|
| 229 |
+
|
| 230 |
+
create_loan_application_tool = Tool(
|
| 231 |
+
name="create_loan_application",
|
| 232 |
+
func=create_loan_application_tool_func,
|
| 233 |
+
description="""
|
| 234 |
+
Create a loan application record in the database without generating PDF.
|
| 235 |
+
Input should be a JSON string with loan application data.
|
| 236 |
+
Returns JSON with loan_id and basic loan information.
|
| 237 |
+
Use this tool to save loan application data for rejected or pending applications.
|
| 238 |
+
""",
|
| 239 |
+
)
|
| 240 |
+
|
| 241 |
+
# List of all tools
|
| 242 |
+
all_tools = [
|
| 243 |
+
fetch_user_profile_tool,
|
| 244 |
+
run_underwriting_tool,
|
| 245 |
+
generate_sanction_letter_tool,
|
| 246 |
+
create_loan_application_tool,
|
| 247 |
+
]
|
app/config.py
ADDED
|
@@ -0,0 +1,85 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Configuration module for FinAgent backend.
|
| 3 |
+
Handles environment variables and service initialization.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
from functools import lru_cache
|
| 8 |
+
from typing import List
|
| 9 |
+
|
| 10 |
+
from pydantic_settings import BaseSettings
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class Settings(BaseSettings):
|
| 14 |
+
"""Application settings loaded from environment variables."""
|
| 15 |
+
|
| 16 |
+
# API Configuration
|
| 17 |
+
APP_NAME: str = "FinAgent - Chat2Sanction API"
|
| 18 |
+
APP_VERSION: str = "1.0.0"
|
| 19 |
+
DEBUG: bool = True
|
| 20 |
+
|
| 21 |
+
# Gemini AI Configuration
|
| 22 |
+
GOOGLE_API_KEY: str
|
| 23 |
+
GEMINI_MODEL: str = "gemini-1.5-pro"
|
| 24 |
+
GEMINI_TEMPERATURE: float = 0.3
|
| 25 |
+
|
| 26 |
+
# Firebase Configuration
|
| 27 |
+
FIREBASE_PROJECT_ID: str = "finagent-fdc80"
|
| 28 |
+
FIREBASE_API_KEY: str = "AIzaSyCS95PURecjhaLyyEc7RSYfR63YiN2kzec"
|
| 29 |
+
FIREBASE_AUTH_DOMAIN: str = "finagent-fdc80.firebaseapp.com"
|
| 30 |
+
FIREBASE_STORAGE_BUCKET: str = "finagent-fdc80.firebasestorage.app"
|
| 31 |
+
FIREBASE_MESSAGING_SENDER_ID: str = "613182907538"
|
| 32 |
+
FIREBASE_APP_ID: str = "1:613182907538:web:5fb81003908baccecbf24b"
|
| 33 |
+
FIREBASE_MEASUREMENT_ID: str = "G-CLXYJVNRSP"
|
| 34 |
+
|
| 35 |
+
# Firebase Admin SDK (for Firestore and Auth verification)
|
| 36 |
+
# Can be JSON string or path to service account file
|
| 37 |
+
FIREBASE_CREDENTIALS: str = "" # Will use Application Default Credentials if empty
|
| 38 |
+
|
| 39 |
+
# CORS Configuration
|
| 40 |
+
ALLOWED_ORIGINS: List[str] = [
|
| 41 |
+
"http://localhost:3000",
|
| 42 |
+
"http://localhost:5173",
|
| 43 |
+
"https://finagent-fdc80.web.app",
|
| 44 |
+
"https://finagent-fdc80.firebaseapp.com",
|
| 45 |
+
]
|
| 46 |
+
|
| 47 |
+
# Server Configuration
|
| 48 |
+
HOST: str = "0.0.0.0"
|
| 49 |
+
PORT: int = 8000
|
| 50 |
+
WORKERS: int = 1
|
| 51 |
+
|
| 52 |
+
# PDF Configuration
|
| 53 |
+
PDF_OUTPUT_DIR: str = "sanctions"
|
| 54 |
+
PDF_VALIDITY_DAYS: int = 7
|
| 55 |
+
|
| 56 |
+
# Loan Configuration
|
| 57 |
+
DEFAULT_INTEREST_RATE: float = 12.0 # Annual percentage
|
| 58 |
+
MIN_LOAN_AMOUNT: float = 50000.0
|
| 59 |
+
MAX_LOAN_AMOUNT: float = 5000000.0
|
| 60 |
+
MIN_TENURE_MONTHS: int = 6
|
| 61 |
+
MAX_TENURE_MONTHS: int = 60
|
| 62 |
+
|
| 63 |
+
# Underwriting Thresholds
|
| 64 |
+
EXCELLENT_CREDIT_SCORE: int = 720
|
| 65 |
+
GOOD_CREDIT_SCORE: int = 680
|
| 66 |
+
FOIR_THRESHOLD_A: float = 0.4
|
| 67 |
+
FOIR_THRESHOLD_B: float = 0.5
|
| 68 |
+
|
| 69 |
+
class Config:
|
| 70 |
+
env_file = ".env"
|
| 71 |
+
env_file_encoding = "utf-8"
|
| 72 |
+
case_sensitive = True
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
@lru_cache()
|
| 76 |
+
def get_settings() -> Settings:
|
| 77 |
+
"""
|
| 78 |
+
Get cached settings instance.
|
| 79 |
+
Uses LRU cache to avoid recreating settings on every call.
|
| 80 |
+
"""
|
| 81 |
+
return Settings()
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
# Global settings instance
|
| 85 |
+
settings = get_settings()
|
app/main.py
ADDED
|
@@ -0,0 +1,155 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Main FastAPI application for FinAgent backend.
|
| 3 |
+
Chat2Sanction - AI-powered loan processing platform.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
from contextlib import asynccontextmanager
|
| 8 |
+
|
| 9 |
+
from app.config import settings
|
| 10 |
+
from app.routers import admin, auth, chat, loan
|
| 11 |
+
from app.utils.logger import setup_logger
|
| 12 |
+
from fastapi import FastAPI
|
| 13 |
+
from fastapi.middleware.cors import CORSMiddleware
|
| 14 |
+
from fastapi.responses import JSONResponse
|
| 15 |
+
|
| 16 |
+
# Setup logger
|
| 17 |
+
logger = setup_logger("finagent", log_file="finagent.log")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@asynccontextmanager
|
| 21 |
+
async def lifespan(app: FastAPI):
|
| 22 |
+
"""
|
| 23 |
+
Lifespan context manager for startup and shutdown events.
|
| 24 |
+
"""
|
| 25 |
+
# Startup
|
| 26 |
+
logger.info("=" * 80)
|
| 27 |
+
logger.info(f"Starting {settings.APP_NAME} v{settings.APP_VERSION}")
|
| 28 |
+
logger.info("=" * 80)
|
| 29 |
+
logger.info(f"Environment: {'Development' if settings.DEBUG else 'Production'}")
|
| 30 |
+
logger.info(f"Firebase Project: {settings.FIREBASE_PROJECT_ID}")
|
| 31 |
+
logger.info(f"Gemini Model: {settings.GEMINI_MODEL}")
|
| 32 |
+
|
| 33 |
+
# Initialize services
|
| 34 |
+
try:
|
| 35 |
+
from app.agents.master_agent import master_agent
|
| 36 |
+
from app.services.firebase_service import firebase_service
|
| 37 |
+
from app.services.pdf_service import pdf_service
|
| 38 |
+
from app.services.session_service import session_service
|
| 39 |
+
from app.services.underwriting_service import underwriting_service
|
| 40 |
+
|
| 41 |
+
logger.info("All services initialized successfully")
|
| 42 |
+
except Exception as e:
|
| 43 |
+
logger.error(f"Failed to initialize services: {str(e)}")
|
| 44 |
+
|
| 45 |
+
# Create output directories
|
| 46 |
+
os.makedirs(settings.PDF_OUTPUT_DIR, exist_ok=True)
|
| 47 |
+
logger.info(f"PDF output directory: {settings.PDF_OUTPUT_DIR}")
|
| 48 |
+
|
| 49 |
+
logger.info("✓ Application startup complete")
|
| 50 |
+
logger.info("=" * 80)
|
| 51 |
+
|
| 52 |
+
yield
|
| 53 |
+
|
| 54 |
+
# Shutdown
|
| 55 |
+
logger.info("=" * 80)
|
| 56 |
+
logger.info("Shutting down application")
|
| 57 |
+
logger.info("=" * 80)
|
| 58 |
+
|
| 59 |
+
|
| 60 |
+
# Create FastAPI app
|
| 61 |
+
app = FastAPI(
|
| 62 |
+
title=settings.APP_NAME,
|
| 63 |
+
description="AI-powered loan processing platform with conversational interface",
|
| 64 |
+
version=settings.APP_VERSION,
|
| 65 |
+
lifespan=lifespan,
|
| 66 |
+
docs_url="/api/docs",
|
| 67 |
+
redoc_url="/api/redoc",
|
| 68 |
+
openapi_url="/api/openapi.json",
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
# Configure CORS
|
| 72 |
+
app.add_middleware(
|
| 73 |
+
CORSMiddleware,
|
| 74 |
+
allow_origins=settings.ALLOWED_ORIGINS,
|
| 75 |
+
allow_credentials=True,
|
| 76 |
+
allow_methods=["*"],
|
| 77 |
+
allow_headers=["*"],
|
| 78 |
+
)
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
# Health check endpoint
|
| 82 |
+
@app.get("/")
|
| 83 |
+
async def root():
|
| 84 |
+
"""Root endpoint with API information."""
|
| 85 |
+
return {
|
| 86 |
+
"name": settings.APP_NAME,
|
| 87 |
+
"version": settings.APP_VERSION,
|
| 88 |
+
"status": "running",
|
| 89 |
+
"docs": "/api/docs",
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
@app.get("/health")
|
| 94 |
+
async def health_check():
|
| 95 |
+
"""Health check endpoint."""
|
| 96 |
+
from app.services.firebase_service import firebase_service
|
| 97 |
+
|
| 98 |
+
return {
|
| 99 |
+
"status": "healthy",
|
| 100 |
+
"firebase": "connected" if firebase_service.initialized else "disconnected",
|
| 101 |
+
"gemini": "configured" if settings.GOOGLE_API_KEY else "not configured",
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
|
| 105 |
+
# Include routers
|
| 106 |
+
app.include_router(auth.router, prefix="/api/auth", tags=["Authentication"])
|
| 107 |
+
app.include_router(chat.router, prefix="/api/chat", tags=["Chat"])
|
| 108 |
+
app.include_router(loan.router, prefix="/api/loan", tags=["Loans"])
|
| 109 |
+
app.include_router(admin.router, prefix="/api/admin", tags=["Admin"])
|
| 110 |
+
|
| 111 |
+
|
| 112 |
+
# Global exception handler
|
| 113 |
+
@app.exception_handler(Exception)
|
| 114 |
+
async def global_exception_handler(request, exc):
|
| 115 |
+
"""Global exception handler for unhandled errors."""
|
| 116 |
+
logger.error(f"Unhandled exception: {str(exc)}", exc_info=True)
|
| 117 |
+
return JSONResponse(
|
| 118 |
+
status_code=500,
|
| 119 |
+
content={
|
| 120 |
+
"error": "Internal server error",
|
| 121 |
+
"detail": str(exc) if settings.DEBUG else "An unexpected error occurred",
|
| 122 |
+
},
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
|
| 126 |
+
# Request logging middleware
|
| 127 |
+
@app.middleware("http")
|
| 128 |
+
async def log_requests(request, call_next):
|
| 129 |
+
"""Log all HTTP requests."""
|
| 130 |
+
from time import time
|
| 131 |
+
|
| 132 |
+
start_time = time()
|
| 133 |
+
|
| 134 |
+
# Process request
|
| 135 |
+
response = await call_next(request)
|
| 136 |
+
|
| 137 |
+
# Log request
|
| 138 |
+
duration = (time() - start_time) * 1000 # Convert to milliseconds
|
| 139 |
+
logger.info(
|
| 140 |
+
f"{request.method} {request.url.path} - {response.status_code} ({duration:.2f}ms)"
|
| 141 |
+
)
|
| 142 |
+
|
| 143 |
+
return response
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
if __name__ == "__main__":
|
| 147 |
+
import uvicorn
|
| 148 |
+
|
| 149 |
+
uvicorn.run(
|
| 150 |
+
"app.main:app",
|
| 151 |
+
host=settings.HOST,
|
| 152 |
+
port=settings.PORT,
|
| 153 |
+
reload=settings.DEBUG,
|
| 154 |
+
workers=settings.WORKERS,
|
| 155 |
+
)
|
app/routers/admin.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Admin router for dashboard metrics and loan management.
|
| 3 |
+
Provides endpoints for admin analytics and bulk operations.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from typing import Optional
|
| 7 |
+
|
| 8 |
+
from app.schemas import AdminLoansResponse, AdminMetrics, LoanListItem, MessageResponse
|
| 9 |
+
from app.services.firebase_service import firebase_service
|
| 10 |
+
from app.utils.logger import default_logger as logger
|
| 11 |
+
from fastapi import APIRouter, HTTPException, Query, status
|
| 12 |
+
|
| 13 |
+
router = APIRouter()
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@router.get("/metrics", response_model=AdminMetrics)
|
| 17 |
+
async def get_admin_metrics():
|
| 18 |
+
"""
|
| 19 |
+
Get aggregated metrics for admin dashboard.
|
| 20 |
+
|
| 21 |
+
Returns:
|
| 22 |
+
AdminMetrics with loan statistics and analytics
|
| 23 |
+
"""
|
| 24 |
+
try:
|
| 25 |
+
logger.info("Fetching admin metrics")
|
| 26 |
+
|
| 27 |
+
summary = firebase_service.get_admin_summary()
|
| 28 |
+
|
| 29 |
+
metrics = AdminMetrics(
|
| 30 |
+
total_applications=summary.get("total_applications", 0),
|
| 31 |
+
approved_count=summary.get("approved_count", 0),
|
| 32 |
+
rejected_count=summary.get("rejected_count", 0),
|
| 33 |
+
adjust_count=summary.get("adjust_count", 0),
|
| 34 |
+
avg_loan_amount=round(summary.get("avg_loan_amount", 0), 2),
|
| 35 |
+
avg_emi=round(summary.get("avg_emi", 0), 2),
|
| 36 |
+
avg_credit_score=round(summary.get("avg_credit_score", 0), 0),
|
| 37 |
+
today_applications=summary.get("today_applications", 0),
|
| 38 |
+
risk_distribution=summary.get("risk_distribution", {}),
|
| 39 |
+
)
|
| 40 |
+
|
| 41 |
+
return metrics
|
| 42 |
+
|
| 43 |
+
except Exception as e:
|
| 44 |
+
logger.error(f"Error fetching admin metrics: {str(e)}")
|
| 45 |
+
raise HTTPException(
|
| 46 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 47 |
+
detail="Failed to fetch admin metrics",
|
| 48 |
+
)
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
@router.get("/loans", response_model=AdminLoansResponse)
|
| 52 |
+
async def get_all_loans(
|
| 53 |
+
page: int = Query(1, ge=1, description="Page number"),
|
| 54 |
+
page_size: int = Query(20, ge=1, le=100, description="Items per page"),
|
| 55 |
+
decision: Optional[str] = Query(None, description="Filter by decision"),
|
| 56 |
+
risk_band: Optional[str] = Query(None, description="Filter by risk band"),
|
| 57 |
+
):
|
| 58 |
+
"""
|
| 59 |
+
Get all loan applications with pagination and filtering.
|
| 60 |
+
|
| 61 |
+
Args:
|
| 62 |
+
page: Page number (starts at 1)
|
| 63 |
+
page_size: Number of items per page
|
| 64 |
+
decision: Optional filter by decision (APPROVED/REJECTED/ADJUST)
|
| 65 |
+
risk_band: Optional filter by risk band (A/B/C)
|
| 66 |
+
|
| 67 |
+
Returns:
|
| 68 |
+
AdminLoansResponse with paginated loan list
|
| 69 |
+
"""
|
| 70 |
+
try:
|
| 71 |
+
logger.info(f"Fetching loans: page={page}, page_size={page_size}")
|
| 72 |
+
|
| 73 |
+
# Calculate offset
|
| 74 |
+
offset = (page - 1) * page_size
|
| 75 |
+
|
| 76 |
+
# Fetch loans
|
| 77 |
+
all_loans = firebase_service.get_all_loans(limit=page_size * 10, offset=0)
|
| 78 |
+
|
| 79 |
+
# Apply filters
|
| 80 |
+
filtered_loans = all_loans
|
| 81 |
+
if decision:
|
| 82 |
+
filtered_loans = [
|
| 83 |
+
loan for loan in filtered_loans if loan.get("decision") == decision
|
| 84 |
+
]
|
| 85 |
+
if risk_band:
|
| 86 |
+
filtered_loans = [
|
| 87 |
+
loan for loan in filtered_loans if loan.get("risk_band") == risk_band
|
| 88 |
+
]
|
| 89 |
+
|
| 90 |
+
# Get total count
|
| 91 |
+
total = len(filtered_loans)
|
| 92 |
+
|
| 93 |
+
# Apply pagination
|
| 94 |
+
start_idx = offset
|
| 95 |
+
end_idx = start_idx + page_size
|
| 96 |
+
paginated_loans = filtered_loans[start_idx:end_idx]
|
| 97 |
+
|
| 98 |
+
# Format loan list
|
| 99 |
+
loan_items = []
|
| 100 |
+
for loan in paginated_loans:
|
| 101 |
+
# Get user profile for full name
|
| 102 |
+
user_id = loan.get("user_id")
|
| 103 |
+
user_profile = firebase_service.get_user_profile(user_id)
|
| 104 |
+
full_name = (
|
| 105 |
+
user_profile.get("full_name", "User") if user_profile else "User"
|
| 106 |
+
)
|
| 107 |
+
|
| 108 |
+
loan_items.append(
|
| 109 |
+
LoanListItem(
|
| 110 |
+
loan_id=loan.get("loan_id"),
|
| 111 |
+
user_id=loan.get("user_id"),
|
| 112 |
+
full_name=full_name,
|
| 113 |
+
requested_amount=loan.get("requested_amount", 0),
|
| 114 |
+
approved_amount=loan.get("approved_amount", 0),
|
| 115 |
+
decision=loan.get("decision", "PENDING"),
|
| 116 |
+
risk_band=loan.get("risk_band", "C"),
|
| 117 |
+
created_at=loan.get("created_at"),
|
| 118 |
+
)
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
response = AdminLoansResponse(
|
| 122 |
+
loans=loan_items, total=total, page=page, page_size=page_size
|
| 123 |
+
)
|
| 124 |
+
|
| 125 |
+
return response
|
| 126 |
+
|
| 127 |
+
except Exception as e:
|
| 128 |
+
logger.error(f"Error fetching loans: {str(e)}")
|
| 129 |
+
raise HTTPException(
|
| 130 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 131 |
+
detail="Failed to fetch loans",
|
| 132 |
+
)
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
@router.get("/stats/summary")
|
| 136 |
+
async def get_stats_summary():
|
| 137 |
+
"""
|
| 138 |
+
Get detailed statistics summary.
|
| 139 |
+
|
| 140 |
+
Returns:
|
| 141 |
+
Detailed statistics including approval rates, average amounts, etc.
|
| 142 |
+
"""
|
| 143 |
+
try:
|
| 144 |
+
logger.info("Fetching detailed statistics")
|
| 145 |
+
|
| 146 |
+
summary = firebase_service.get_admin_summary()
|
| 147 |
+
|
| 148 |
+
total = summary.get("total_applications", 0)
|
| 149 |
+
approved = summary.get("approved_count", 0)
|
| 150 |
+
rejected = summary.get("rejected_count", 0)
|
| 151 |
+
adjust = summary.get("adjust_count", 0)
|
| 152 |
+
|
| 153 |
+
# Calculate rates
|
| 154 |
+
approval_rate = (approved / total * 100) if total > 0 else 0
|
| 155 |
+
rejection_rate = (rejected / total * 100) if total > 0 else 0
|
| 156 |
+
adjustment_rate = (adjust / total * 100) if total > 0 else 0
|
| 157 |
+
|
| 158 |
+
stats = {
|
| 159 |
+
"overview": {
|
| 160 |
+
"total_applications": total,
|
| 161 |
+
"approved_count": approved,
|
| 162 |
+
"rejected_count": rejected,
|
| 163 |
+
"adjust_count": adjust,
|
| 164 |
+
"today_applications": summary.get("today_applications", 0),
|
| 165 |
+
},
|
| 166 |
+
"rates": {
|
| 167 |
+
"approval_rate": round(approval_rate, 2),
|
| 168 |
+
"rejection_rate": round(rejection_rate, 2),
|
| 169 |
+
"adjustment_rate": round(adjustment_rate, 2),
|
| 170 |
+
},
|
| 171 |
+
"averages": {
|
| 172 |
+
"avg_loan_amount": round(summary.get("avg_loan_amount", 0), 2),
|
| 173 |
+
"avg_emi": round(summary.get("avg_emi", 0), 2),
|
| 174 |
+
"avg_credit_score": round(summary.get("avg_credit_score", 0), 0),
|
| 175 |
+
},
|
| 176 |
+
"risk_distribution": summary.get("risk_distribution", {}),
|
| 177 |
+
}
|
| 178 |
+
|
| 179 |
+
return stats
|
| 180 |
+
|
| 181 |
+
except Exception as e:
|
| 182 |
+
logger.error(f"Error fetching stats summary: {str(e)}")
|
| 183 |
+
raise HTTPException(
|
| 184 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 185 |
+
detail="Failed to fetch statistics",
|
| 186 |
+
)
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
@router.get("/health")
|
| 190 |
+
async def health_check():
|
| 191 |
+
"""
|
| 192 |
+
Health check endpoint for admin services.
|
| 193 |
+
|
| 194 |
+
Returns:
|
| 195 |
+
Health status
|
| 196 |
+
"""
|
| 197 |
+
try:
|
| 198 |
+
# Check Firebase connection
|
| 199 |
+
firebase_status = (
|
| 200 |
+
"connected" if firebase_service.initialized else "disconnected"
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
return {
|
| 204 |
+
"status": "healthy",
|
| 205 |
+
"firebase": firebase_status,
|
| 206 |
+
"timestamp": __import__("datetime").datetime.utcnow().isoformat(),
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
except Exception as e:
|
| 210 |
+
logger.error(f"Health check failed: {str(e)}")
|
| 211 |
+
raise HTTPException(
|
| 212 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 213 |
+
detail="Health check failed",
|
| 214 |
+
)
|
| 215 |
+
|
| 216 |
+
|
| 217 |
+
@router.post("/cleanup")
|
| 218 |
+
async def cleanup_old_data():
|
| 219 |
+
"""
|
| 220 |
+
Cleanup old sessions and temporary data.
|
| 221 |
+
|
| 222 |
+
Returns:
|
| 223 |
+
Cleanup result
|
| 224 |
+
"""
|
| 225 |
+
try:
|
| 226 |
+
logger.info("Running cleanup tasks")
|
| 227 |
+
|
| 228 |
+
from app.services.session_service import session_service
|
| 229 |
+
|
| 230 |
+
# Cleanup old sessions (older than 24 hours)
|
| 231 |
+
deleted_sessions = session_service.cleanup_old_sessions(max_age_hours=24)
|
| 232 |
+
|
| 233 |
+
return MessageResponse(
|
| 234 |
+
message=f"Cleanup completed: {deleted_sessions} sessions removed",
|
| 235 |
+
success=True,
|
| 236 |
+
)
|
| 237 |
+
|
| 238 |
+
except Exception as e:
|
| 239 |
+
logger.error(f"Cleanup error: {str(e)}")
|
| 240 |
+
raise HTTPException(
|
| 241 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 242 |
+
detail="Cleanup failed",
|
| 243 |
+
)
|
app/routers/auth.py
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Authentication router for user login and registration.
|
| 3 |
+
Handles Firebase Authentication integration.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from datetime import datetime
|
| 7 |
+
from typing import Optional
|
| 8 |
+
|
| 9 |
+
from app.config import settings
|
| 10 |
+
from app.schemas import (
|
| 11 |
+
ErrorResponse,
|
| 12 |
+
LoginRequest,
|
| 13 |
+
LoginResponse,
|
| 14 |
+
MessageResponse,
|
| 15 |
+
RegisterRequest,
|
| 16 |
+
)
|
| 17 |
+
from app.services.firebase_service import firebase_service
|
| 18 |
+
from app.utils.logger import default_logger as logger
|
| 19 |
+
from fastapi import APIRouter, Header, HTTPException, status
|
| 20 |
+
|
| 21 |
+
router = APIRouter()
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@router.post("/login", response_model=LoginResponse)
|
| 25 |
+
async def login(request: LoginRequest):
|
| 26 |
+
"""
|
| 27 |
+
Login endpoint for user authentication.
|
| 28 |
+
|
| 29 |
+
For hackathon: Simple email/password validation.
|
| 30 |
+
In production: This would validate Firebase ID tokens.
|
| 31 |
+
|
| 32 |
+
Args:
|
| 33 |
+
request: Login request with email and password
|
| 34 |
+
|
| 35 |
+
Returns:
|
| 36 |
+
LoginResponse with access token and user info
|
| 37 |
+
"""
|
| 38 |
+
try:
|
| 39 |
+
logger.info(f"Login attempt for email: {request.email}")
|
| 40 |
+
|
| 41 |
+
# For hackathon: Simple validation
|
| 42 |
+
if len(request.password) < 6:
|
| 43 |
+
raise HTTPException(
|
| 44 |
+
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid credentials"
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
# Try to find user by email in Firestore
|
| 48 |
+
# In production, Firebase Auth would handle this
|
| 49 |
+
user_profile = None
|
| 50 |
+
|
| 51 |
+
# For now, we'll check if user exists or create a mock response
|
| 52 |
+
# In production, you'd verify Firebase ID token here
|
| 53 |
+
|
| 54 |
+
# Generate a mock user_id from email (for demo)
|
| 55 |
+
user_id = request.email.split("@")[0].replace(".", "_")
|
| 56 |
+
|
| 57 |
+
# Try to fetch existing user
|
| 58 |
+
user_profile = firebase_service.get_user_profile(user_id)
|
| 59 |
+
|
| 60 |
+
if not user_profile:
|
| 61 |
+
# For demo: Create a basic profile if doesn't exist
|
| 62 |
+
logger.info(f"Creating new user profile for {request.email}")
|
| 63 |
+
user_profile = {
|
| 64 |
+
"user_id": user_id,
|
| 65 |
+
"email": request.email,
|
| 66 |
+
"full_name": request.email.split("@")[0].title(),
|
| 67 |
+
"monthly_income": 50000.0,
|
| 68 |
+
"existing_emi": 0.0,
|
| 69 |
+
"mock_credit_score": 680,
|
| 70 |
+
"segment": "New to Credit",
|
| 71 |
+
}
|
| 72 |
+
firebase_service.create_user_profile(user_profile)
|
| 73 |
+
|
| 74 |
+
# Generate access token (in production, use proper JWT)
|
| 75 |
+
access_token = f"finagent_token_{user_id}_{datetime.utcnow().timestamp()}"
|
| 76 |
+
|
| 77 |
+
response = LoginResponse(
|
| 78 |
+
access_token=access_token,
|
| 79 |
+
token_type="Bearer",
|
| 80 |
+
user_id=user_profile.get("user_id", user_id),
|
| 81 |
+
full_name=user_profile.get("full_name", "User"),
|
| 82 |
+
email=user_profile.get("email", request.email),
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
logger.info(f"Login successful for user: {user_id}")
|
| 86 |
+
return response
|
| 87 |
+
|
| 88 |
+
except HTTPException:
|
| 89 |
+
raise
|
| 90 |
+
except Exception as e:
|
| 91 |
+
logger.error(f"Login error: {str(e)}")
|
| 92 |
+
raise HTTPException(
|
| 93 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Login failed"
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
@router.post("/register", response_model=LoginResponse)
|
| 98 |
+
async def register(request: RegisterRequest):
|
| 99 |
+
"""
|
| 100 |
+
Register a new user.
|
| 101 |
+
|
| 102 |
+
Args:
|
| 103 |
+
request: Registration request with user details
|
| 104 |
+
|
| 105 |
+
Returns:
|
| 106 |
+
LoginResponse with access token and user info
|
| 107 |
+
"""
|
| 108 |
+
try:
|
| 109 |
+
logger.info(f"Registration attempt for email: {request.email}")
|
| 110 |
+
|
| 111 |
+
# Generate user_id from email
|
| 112 |
+
user_id = request.email.split("@")[0].replace(".", "_")
|
| 113 |
+
|
| 114 |
+
# Check if user already exists
|
| 115 |
+
existing_user = firebase_service.get_user_profile(user_id)
|
| 116 |
+
if existing_user:
|
| 117 |
+
raise HTTPException(
|
| 118 |
+
status_code=status.HTTP_400_BAD_REQUEST, detail="User already exists"
|
| 119 |
+
)
|
| 120 |
+
|
| 121 |
+
# Create user profile
|
| 122 |
+
user_profile = {
|
| 123 |
+
"user_id": user_id,
|
| 124 |
+
"email": request.email,
|
| 125 |
+
"full_name": request.full_name,
|
| 126 |
+
"monthly_income": request.monthly_income,
|
| 127 |
+
"existing_emi": request.existing_emi,
|
| 128 |
+
"mock_credit_score": 650, # Default credit score
|
| 129 |
+
"segment": "New to Credit",
|
| 130 |
+
}
|
| 131 |
+
|
| 132 |
+
created_profile = firebase_service.create_user_profile(user_profile)
|
| 133 |
+
|
| 134 |
+
# Generate access token
|
| 135 |
+
access_token = f"finagent_token_{user_id}_{datetime.utcnow().timestamp()}"
|
| 136 |
+
|
| 137 |
+
response = LoginResponse(
|
| 138 |
+
access_token=access_token,
|
| 139 |
+
token_type="Bearer",
|
| 140 |
+
user_id=created_profile.get("user_id", user_id),
|
| 141 |
+
full_name=created_profile.get("full_name"),
|
| 142 |
+
email=created_profile.get("email"),
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
logger.info(f"Registration successful for user: {user_id}")
|
| 146 |
+
return response
|
| 147 |
+
|
| 148 |
+
except HTTPException:
|
| 149 |
+
raise
|
| 150 |
+
except Exception as e:
|
| 151 |
+
logger.error(f"Registration error: {str(e)}")
|
| 152 |
+
raise HTTPException(
|
| 153 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 154 |
+
detail="Registration failed",
|
| 155 |
+
)
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
@router.post("/verify-token")
|
| 159 |
+
async def verify_token(authorization: Optional[str] = Header(None)):
|
| 160 |
+
"""
|
| 161 |
+
Verify Firebase ID token.
|
| 162 |
+
|
| 163 |
+
Args:
|
| 164 |
+
authorization: Authorization header with Bearer token
|
| 165 |
+
|
| 166 |
+
Returns:
|
| 167 |
+
Token verification result
|
| 168 |
+
"""
|
| 169 |
+
try:
|
| 170 |
+
if not authorization:
|
| 171 |
+
raise HTTPException(
|
| 172 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 173 |
+
detail="Authorization header missing",
|
| 174 |
+
)
|
| 175 |
+
|
| 176 |
+
# Extract token from Bearer scheme
|
| 177 |
+
parts = authorization.split()
|
| 178 |
+
if len(parts) != 2 or parts[0].lower() != "bearer":
|
| 179 |
+
raise HTTPException(
|
| 180 |
+
status_code=status.HTTP_401_UNAUTHORIZED,
|
| 181 |
+
detail="Invalid authorization header format",
|
| 182 |
+
)
|
| 183 |
+
|
| 184 |
+
token = parts[1]
|
| 185 |
+
|
| 186 |
+
# For hackathon: Simple token validation
|
| 187 |
+
if token.startswith("finagent_token_"):
|
| 188 |
+
# Extract user_id from token
|
| 189 |
+
parts = token.split("_")
|
| 190 |
+
if len(parts) >= 3:
|
| 191 |
+
user_id = "_".join(parts[2:-1])
|
| 192 |
+
return MessageResponse(message="Token valid", success=True)
|
| 193 |
+
|
| 194 |
+
# In production: Verify Firebase ID token
|
| 195 |
+
# decoded_token = firebase_service.verify_token(token)
|
| 196 |
+
# if decoded_token:
|
| 197 |
+
# return MessageResponse(message="Token valid", success=True)
|
| 198 |
+
|
| 199 |
+
raise HTTPException(
|
| 200 |
+
status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token"
|
| 201 |
+
)
|
| 202 |
+
|
| 203 |
+
except HTTPException:
|
| 204 |
+
raise
|
| 205 |
+
except Exception as e:
|
| 206 |
+
logger.error(f"Token verification error: {str(e)}")
|
| 207 |
+
raise HTTPException(
|
| 208 |
+
status_code=status.HTTP_401_UNAUTHORIZED, detail="Token verification failed"
|
| 209 |
+
)
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
@router.post("/logout")
|
| 213 |
+
async def logout(authorization: Optional[str] = Header(None)):
|
| 214 |
+
"""
|
| 215 |
+
Logout endpoint.
|
| 216 |
+
|
| 217 |
+
For stateless JWT tokens, this is mainly for client-side cleanup.
|
| 218 |
+
In production with Firebase, you might want to revoke refresh tokens.
|
| 219 |
+
|
| 220 |
+
Args:
|
| 221 |
+
authorization: Authorization header with Bearer token
|
| 222 |
+
|
| 223 |
+
Returns:
|
| 224 |
+
Success message
|
| 225 |
+
"""
|
| 226 |
+
try:
|
| 227 |
+
logger.info("Logout request received")
|
| 228 |
+
|
| 229 |
+
# In production: Could revoke Firebase refresh tokens here
|
| 230 |
+
# or add token to blacklist
|
| 231 |
+
|
| 232 |
+
return MessageResponse(message="Logged out successfully", success=True)
|
| 233 |
+
|
| 234 |
+
except Exception as e:
|
| 235 |
+
logger.error(f"Logout error: {str(e)}")
|
| 236 |
+
raise HTTPException(
|
| 237 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR, detail="Logout failed"
|
| 238 |
+
)
|
app/routers/chat.py
ADDED
|
@@ -0,0 +1,182 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Chat router for conversational loan processing.
|
| 3 |
+
Handles chat messages and integrates with the master agent.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from typing import Optional
|
| 7 |
+
|
| 8 |
+
from app.agents.master_agent import master_agent
|
| 9 |
+
from app.schemas import ChatRequest, ChatResponse, ErrorResponse
|
| 10 |
+
from app.services.firebase_service import firebase_service
|
| 11 |
+
from app.services.session_service import session_service
|
| 12 |
+
from app.utils.logger import default_logger as logger
|
| 13 |
+
from fastapi import APIRouter, HTTPException, status
|
| 14 |
+
|
| 15 |
+
router = APIRouter()
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@router.post("/", response_model=ChatResponse)
|
| 19 |
+
async def chat(request: ChatRequest):
|
| 20 |
+
"""
|
| 21 |
+
Process a chat message and return agent response.
|
| 22 |
+
|
| 23 |
+
Args:
|
| 24 |
+
request: Chat request with session_id, user_id, and message
|
| 25 |
+
|
| 26 |
+
Returns:
|
| 27 |
+
ChatResponse with agent reply and metadata
|
| 28 |
+
"""
|
| 29 |
+
try:
|
| 30 |
+
logger.info(
|
| 31 |
+
f"Chat request from user {request.user_id}, session {request.session_id}"
|
| 32 |
+
)
|
| 33 |
+
|
| 34 |
+
# Get or create session
|
| 35 |
+
session_id = session_service.get_or_create_session(
|
| 36 |
+
request.session_id, request.user_id
|
| 37 |
+
)
|
| 38 |
+
|
| 39 |
+
# Fetch user profile if not in session
|
| 40 |
+
user_profile = session_service.get_user_profile(session_id)
|
| 41 |
+
if not user_profile:
|
| 42 |
+
user_profile = firebase_service.get_user_profile(request.user_id)
|
| 43 |
+
if user_profile:
|
| 44 |
+
session_service.set_user_profile(session_id, user_profile)
|
| 45 |
+
|
| 46 |
+
# Add user message to session history
|
| 47 |
+
session_service.add_to_chat_history(session_id, "user", request.message)
|
| 48 |
+
|
| 49 |
+
# Determine current step
|
| 50 |
+
current_step = session_service.get_step(session_id)
|
| 51 |
+
|
| 52 |
+
# Process message with master agent
|
| 53 |
+
agent_response = master_agent.chat(session_id, request.user_id, request.message)
|
| 54 |
+
|
| 55 |
+
# Add agent response to session history
|
| 56 |
+
session_service.add_to_chat_history(
|
| 57 |
+
session_id, "assistant", agent_response["reply"]
|
| 58 |
+
)
|
| 59 |
+
|
| 60 |
+
# Update session step based on decision
|
| 61 |
+
new_step = current_step
|
| 62 |
+
if agent_response.get("decision"):
|
| 63 |
+
if agent_response["decision"] in ["APPROVED", "ADJUST"]:
|
| 64 |
+
new_step = "SANCTION_GENERATED"
|
| 65 |
+
elif agent_response["decision"] == "REJECTED":
|
| 66 |
+
new_step = "REJECTED"
|
| 67 |
+
elif "loan" in request.message.lower() and "amount" in request.message.lower():
|
| 68 |
+
new_step = "GATHERING_DETAILS"
|
| 69 |
+
elif current_step == "GATHERING_DETAILS":
|
| 70 |
+
new_step = "UNDERWRITING"
|
| 71 |
+
|
| 72 |
+
session_service.set_step(session_id, new_step)
|
| 73 |
+
|
| 74 |
+
# Prepare response
|
| 75 |
+
response = ChatResponse(
|
| 76 |
+
reply=agent_response["reply"],
|
| 77 |
+
step=new_step,
|
| 78 |
+
decision=agent_response.get("decision"),
|
| 79 |
+
loan_id=agent_response.get("loan_id"),
|
| 80 |
+
meta=agent_response.get("meta"),
|
| 81 |
+
session_id=session_id,
|
| 82 |
+
)
|
| 83 |
+
|
| 84 |
+
logger.info(
|
| 85 |
+
f"Chat response sent: decision={response.decision}, step={response.step}"
|
| 86 |
+
)
|
| 87 |
+
return response
|
| 88 |
+
|
| 89 |
+
except Exception as e:
|
| 90 |
+
logger.error(f"Chat error: {str(e)}")
|
| 91 |
+
raise HTTPException(
|
| 92 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 93 |
+
detail="Failed to process chat message",
|
| 94 |
+
)
|
| 95 |
+
|
| 96 |
+
|
| 97 |
+
@router.get("/history/{session_id}")
|
| 98 |
+
async def get_chat_history(session_id: str):
|
| 99 |
+
"""
|
| 100 |
+
Get chat history for a session.
|
| 101 |
+
|
| 102 |
+
Args:
|
| 103 |
+
session_id: Session ID
|
| 104 |
+
|
| 105 |
+
Returns:
|
| 106 |
+
List of chat messages
|
| 107 |
+
"""
|
| 108 |
+
try:
|
| 109 |
+
history = session_service.get_chat_history(session_id)
|
| 110 |
+
return {"session_id": session_id, "history": history, "count": len(history)}
|
| 111 |
+
|
| 112 |
+
except Exception as e:
|
| 113 |
+
logger.error(f"Error fetching chat history: {str(e)}")
|
| 114 |
+
raise HTTPException(
|
| 115 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 116 |
+
detail="Failed to fetch chat history",
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
+
@router.delete("/session/{session_id}")
|
| 121 |
+
async def clear_session(session_id: str):
|
| 122 |
+
"""
|
| 123 |
+
Clear a chat session.
|
| 124 |
+
|
| 125 |
+
Args:
|
| 126 |
+
session_id: Session ID
|
| 127 |
+
|
| 128 |
+
Returns:
|
| 129 |
+
Success message
|
| 130 |
+
"""
|
| 131 |
+
try:
|
| 132 |
+
session_service.clear_session(session_id)
|
| 133 |
+
master_agent.clear_session_memory(session_id)
|
| 134 |
+
|
| 135 |
+
logger.info(f"Cleared session {session_id}")
|
| 136 |
+
return {"message": "Session cleared successfully", "success": True}
|
| 137 |
+
|
| 138 |
+
except Exception as e:
|
| 139 |
+
logger.error(f"Error clearing session: {str(e)}")
|
| 140 |
+
raise HTTPException(
|
| 141 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 142 |
+
detail="Failed to clear session",
|
| 143 |
+
)
|
| 144 |
+
|
| 145 |
+
|
| 146 |
+
@router.get("/session/{session_id}/info")
|
| 147 |
+
async def get_session_info(session_id: str):
|
| 148 |
+
"""
|
| 149 |
+
Get session information and state.
|
| 150 |
+
|
| 151 |
+
Args:
|
| 152 |
+
session_id: Session ID
|
| 153 |
+
|
| 154 |
+
Returns:
|
| 155 |
+
Session information
|
| 156 |
+
"""
|
| 157 |
+
try:
|
| 158 |
+
session = session_service.get_session(session_id)
|
| 159 |
+
|
| 160 |
+
if not session:
|
| 161 |
+
raise HTTPException(
|
| 162 |
+
status_code=status.HTTP_404_NOT_FOUND, detail="Session not found"
|
| 163 |
+
)
|
| 164 |
+
|
| 165 |
+
# Return session info without sensitive data
|
| 166 |
+
return {
|
| 167 |
+
"session_id": session["session_id"],
|
| 168 |
+
"user_id": session["user_id"],
|
| 169 |
+
"current_step": session["current_step"],
|
| 170 |
+
"message_count": len(session["chat_history"]),
|
| 171 |
+
"created_at": session["created_at"].isoformat(),
|
| 172 |
+
"updated_at": session["updated_at"].isoformat(),
|
| 173 |
+
}
|
| 174 |
+
|
| 175 |
+
except HTTPException:
|
| 176 |
+
raise
|
| 177 |
+
except Exception as e:
|
| 178 |
+
logger.error(f"Error fetching session info: {str(e)}")
|
| 179 |
+
raise HTTPException(
|
| 180 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 181 |
+
detail="Failed to fetch session info",
|
| 182 |
+
)
|
app/routers/loan.py
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Loan router for loan application operations and sanction letter management.
|
| 3 |
+
Handles loan retrieval, PDF generation, and loan status queries.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from typing import Optional
|
| 7 |
+
|
| 8 |
+
from app.schemas import (
|
| 9 |
+
ErrorResponse,
|
| 10 |
+
LoanApplication,
|
| 11 |
+
LoanSummaryResponse,
|
| 12 |
+
MessageResponse,
|
| 13 |
+
SanctionLetterResponse,
|
| 14 |
+
)
|
| 15 |
+
from app.services.firebase_service import firebase_service
|
| 16 |
+
from app.services.pdf_service import pdf_service
|
| 17 |
+
from app.utils.logger import default_logger as logger
|
| 18 |
+
from fastapi import APIRouter, HTTPException, status
|
| 19 |
+
from fastapi.responses import FileResponse
|
| 20 |
+
|
| 21 |
+
router = APIRouter()
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@router.get("/{loan_id}", response_model=LoanSummaryResponse)
|
| 25 |
+
async def get_loan(loan_id: str):
|
| 26 |
+
"""
|
| 27 |
+
Get loan application details by ID.
|
| 28 |
+
|
| 29 |
+
Args:
|
| 30 |
+
loan_id: Loan application ID
|
| 31 |
+
|
| 32 |
+
Returns:
|
| 33 |
+
LoanSummaryResponse with loan details
|
| 34 |
+
"""
|
| 35 |
+
try:
|
| 36 |
+
logger.info(f"Fetching loan application: {loan_id}")
|
| 37 |
+
|
| 38 |
+
# Fetch loan from Firebase
|
| 39 |
+
loan = firebase_service.get_loan_application(loan_id)
|
| 40 |
+
|
| 41 |
+
if not loan:
|
| 42 |
+
raise HTTPException(
|
| 43 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 44 |
+
detail="Loan application not found",
|
| 45 |
+
)
|
| 46 |
+
|
| 47 |
+
# Fetch user profile for full name
|
| 48 |
+
user_id = loan.get("user_id")
|
| 49 |
+
user_profile = firebase_service.get_user_profile(user_id)
|
| 50 |
+
full_name = user_profile.get("full_name", "User") if user_profile else "User"
|
| 51 |
+
|
| 52 |
+
# Prepare response
|
| 53 |
+
response = LoanSummaryResponse(
|
| 54 |
+
loan_id=loan.get("loan_id"),
|
| 55 |
+
user_id=loan.get("user_id"),
|
| 56 |
+
full_name=full_name,
|
| 57 |
+
approved_amount=loan.get("approved_amount", 0),
|
| 58 |
+
tenure_months=loan.get("tenure_months", 0),
|
| 59 |
+
emi=loan.get("emi", 0),
|
| 60 |
+
interest_rate=loan.get("interest_rate", 0),
|
| 61 |
+
decision=loan.get("decision", "PENDING"),
|
| 62 |
+
risk_band=loan.get("risk_band", "C"),
|
| 63 |
+
created_at=loan.get("created_at"),
|
| 64 |
+
sanction_pdf_url=loan.get("sanction_pdf_url"),
|
| 65 |
+
)
|
| 66 |
+
|
| 67 |
+
return response
|
| 68 |
+
|
| 69 |
+
except HTTPException:
|
| 70 |
+
raise
|
| 71 |
+
except Exception as e:
|
| 72 |
+
logger.error(f"Error fetching loan: {str(e)}")
|
| 73 |
+
raise HTTPException(
|
| 74 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 75 |
+
detail="Failed to fetch loan application",
|
| 76 |
+
)
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
@router.get("/{loan_id}/sanction-pdf")
|
| 80 |
+
async def get_sanction_pdf(loan_id: str):
|
| 81 |
+
"""
|
| 82 |
+
Get sanction letter PDF for a loan application.
|
| 83 |
+
|
| 84 |
+
Args:
|
| 85 |
+
loan_id: Loan application ID
|
| 86 |
+
|
| 87 |
+
Returns:
|
| 88 |
+
PDF file response or URL to PDF
|
| 89 |
+
"""
|
| 90 |
+
try:
|
| 91 |
+
logger.info(f"Fetching sanction PDF for loan: {loan_id}")
|
| 92 |
+
|
| 93 |
+
# Check if PDF exists
|
| 94 |
+
if not pdf_service.pdf_exists(loan_id):
|
| 95 |
+
# Try to fetch loan and regenerate PDF if approved
|
| 96 |
+
loan = firebase_service.get_loan_application(loan_id)
|
| 97 |
+
|
| 98 |
+
if not loan:
|
| 99 |
+
raise HTTPException(
|
| 100 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 101 |
+
detail="Loan application not found",
|
| 102 |
+
)
|
| 103 |
+
|
| 104 |
+
if loan.get("decision") not in ["APPROVED", "ADJUST"]:
|
| 105 |
+
raise HTTPException(
|
| 106 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 107 |
+
detail="Sanction letter only available for approved loans",
|
| 108 |
+
)
|
| 109 |
+
|
| 110 |
+
# Get user profile for full name
|
| 111 |
+
user_profile = firebase_service.get_user_profile(loan.get("user_id"))
|
| 112 |
+
if user_profile:
|
| 113 |
+
loan["full_name"] = user_profile.get("full_name", "Valued Customer")
|
| 114 |
+
|
| 115 |
+
# Generate PDF
|
| 116 |
+
pdf_result = pdf_service.generate_sanction_letter(loan)
|
| 117 |
+
pdf_path = pdf_result["pdf_path"]
|
| 118 |
+
else:
|
| 119 |
+
pdf_path = pdf_service.get_pdf_path(loan_id)
|
| 120 |
+
|
| 121 |
+
# Return PDF file
|
| 122 |
+
return FileResponse(
|
| 123 |
+
path=pdf_path,
|
| 124 |
+
media_type="application/pdf",
|
| 125 |
+
filename=f"sanction_letter_{loan_id}.pdf",
|
| 126 |
+
)
|
| 127 |
+
|
| 128 |
+
except HTTPException:
|
| 129 |
+
raise
|
| 130 |
+
except Exception as e:
|
| 131 |
+
logger.error(f"Error fetching sanction PDF: {str(e)}")
|
| 132 |
+
raise HTTPException(
|
| 133 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 134 |
+
detail="Failed to fetch sanction letter PDF",
|
| 135 |
+
)
|
| 136 |
+
|
| 137 |
+
|
| 138 |
+
@router.get("/{loan_id}/sanction-info", response_model=SanctionLetterResponse)
|
| 139 |
+
async def get_sanction_info(loan_id: str):
|
| 140 |
+
"""
|
| 141 |
+
Get sanction letter information without downloading PDF.
|
| 142 |
+
|
| 143 |
+
Args:
|
| 144 |
+
loan_id: Loan application ID
|
| 145 |
+
|
| 146 |
+
Returns:
|
| 147 |
+
SanctionLetterResponse with PDF path and URL
|
| 148 |
+
"""
|
| 149 |
+
try:
|
| 150 |
+
logger.info(f"Fetching sanction info for loan: {loan_id}")
|
| 151 |
+
|
| 152 |
+
# Fetch loan
|
| 153 |
+
loan = firebase_service.get_loan_application(loan_id)
|
| 154 |
+
|
| 155 |
+
if not loan:
|
| 156 |
+
raise HTTPException(
|
| 157 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 158 |
+
detail="Loan application not found",
|
| 159 |
+
)
|
| 160 |
+
|
| 161 |
+
if loan.get("decision") not in ["APPROVED", "ADJUST"]:
|
| 162 |
+
raise HTTPException(
|
| 163 |
+
status_code=status.HTTP_400_BAD_REQUEST,
|
| 164 |
+
detail="Sanction letter only available for approved loans",
|
| 165 |
+
)
|
| 166 |
+
|
| 167 |
+
pdf_path = loan.get("sanction_pdf_path", "")
|
| 168 |
+
pdf_url = loan.get("sanction_pdf_url", f"/api/loan/{loan_id}/sanction-pdf")
|
| 169 |
+
|
| 170 |
+
response = SanctionLetterResponse(
|
| 171 |
+
loan_id=loan_id, pdf_url=pdf_url, pdf_path=pdf_path
|
| 172 |
+
)
|
| 173 |
+
|
| 174 |
+
return response
|
| 175 |
+
|
| 176 |
+
except HTTPException:
|
| 177 |
+
raise
|
| 178 |
+
except Exception as e:
|
| 179 |
+
logger.error(f"Error fetching sanction info: {str(e)}")
|
| 180 |
+
raise HTTPException(
|
| 181 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 182 |
+
detail="Failed to fetch sanction letter info",
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
@router.get("/user/{user_id}/loans")
|
| 187 |
+
async def get_user_loans(user_id: str, limit: int = 10):
|
| 188 |
+
"""
|
| 189 |
+
Get all loan applications for a user.
|
| 190 |
+
|
| 191 |
+
Args:
|
| 192 |
+
user_id: User ID
|
| 193 |
+
limit: Maximum number of loans to return
|
| 194 |
+
|
| 195 |
+
Returns:
|
| 196 |
+
List of loan applications
|
| 197 |
+
"""
|
| 198 |
+
try:
|
| 199 |
+
logger.info(f"Fetching loans for user: {user_id}")
|
| 200 |
+
|
| 201 |
+
loans = firebase_service.get_user_loans(user_id)
|
| 202 |
+
|
| 203 |
+
# Limit results
|
| 204 |
+
loans = loans[:limit]
|
| 205 |
+
|
| 206 |
+
# Get user profile for full name
|
| 207 |
+
user_profile = firebase_service.get_user_profile(user_id)
|
| 208 |
+
full_name = user_profile.get("full_name", "User") if user_profile else "User"
|
| 209 |
+
|
| 210 |
+
# Format loans
|
| 211 |
+
loan_list = []
|
| 212 |
+
for loan in loans:
|
| 213 |
+
loan_list.append(
|
| 214 |
+
{
|
| 215 |
+
"loan_id": loan.get("loan_id"),
|
| 216 |
+
"user_id": loan.get("user_id"),
|
| 217 |
+
"full_name": full_name,
|
| 218 |
+
"requested_amount": loan.get("requested_amount", 0),
|
| 219 |
+
"approved_amount": loan.get("approved_amount", 0),
|
| 220 |
+
"emi": loan.get("emi", 0),
|
| 221 |
+
"tenure_months": loan.get("tenure_months", 0),
|
| 222 |
+
"decision": loan.get("decision"),
|
| 223 |
+
"risk_band": loan.get("risk_band"),
|
| 224 |
+
"created_at": loan.get("created_at").isoformat()
|
| 225 |
+
if loan.get("created_at")
|
| 226 |
+
else None,
|
| 227 |
+
}
|
| 228 |
+
)
|
| 229 |
+
|
| 230 |
+
return {"user_id": user_id, "loans": loan_list, "count": len(loan_list)}
|
| 231 |
+
|
| 232 |
+
except Exception as e:
|
| 233 |
+
logger.error(f"Error fetching user loans: {str(e)}")
|
| 234 |
+
raise HTTPException(
|
| 235 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 236 |
+
detail="Failed to fetch user loans",
|
| 237 |
+
)
|
| 238 |
+
|
| 239 |
+
|
| 240 |
+
@router.delete("/{loan_id}/sanction-pdf")
|
| 241 |
+
async def delete_sanction_pdf(loan_id: str):
|
| 242 |
+
"""
|
| 243 |
+
Delete sanction letter PDF for a loan.
|
| 244 |
+
|
| 245 |
+
Args:
|
| 246 |
+
loan_id: Loan application ID
|
| 247 |
+
|
| 248 |
+
Returns:
|
| 249 |
+
Success message
|
| 250 |
+
"""
|
| 251 |
+
try:
|
| 252 |
+
logger.info(f"Deleting sanction PDF for loan: {loan_id}")
|
| 253 |
+
|
| 254 |
+
# Delete PDF file
|
| 255 |
+
deleted = pdf_service.delete_pdf(loan_id)
|
| 256 |
+
|
| 257 |
+
if not deleted:
|
| 258 |
+
raise HTTPException(
|
| 259 |
+
status_code=status.HTTP_404_NOT_FOUND,
|
| 260 |
+
detail="Sanction letter PDF not found",
|
| 261 |
+
)
|
| 262 |
+
|
| 263 |
+
# Update loan record to remove PDF path
|
| 264 |
+
firebase_service.update_loan_application(
|
| 265 |
+
loan_id, {"sanction_pdf_path": None, "sanction_pdf_url": None}
|
| 266 |
+
)
|
| 267 |
+
|
| 268 |
+
return MessageResponse(
|
| 269 |
+
message="Sanction letter PDF deleted successfully", success=True
|
| 270 |
+
)
|
| 271 |
+
|
| 272 |
+
except HTTPException:
|
| 273 |
+
raise
|
| 274 |
+
except Exception as e:
|
| 275 |
+
logger.error(f"Error deleting sanction PDF: {str(e)}")
|
| 276 |
+
raise HTTPException(
|
| 277 |
+
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
|
| 278 |
+
detail="Failed to delete sanction letter PDF",
|
| 279 |
+
)
|
app/schemas.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Pydantic schemas for request/response validation.
|
| 3 |
+
Defines data models for API endpoints.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from datetime import datetime
|
| 7 |
+
from typing import Any, Dict, List, Optional
|
| 8 |
+
|
| 9 |
+
from pydantic import BaseModel, EmailStr, Field
|
| 10 |
+
|
| 11 |
+
# ============================================================================
|
| 12 |
+
# Authentication Schemas
|
| 13 |
+
# ============================================================================
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
class LoginRequest(BaseModel):
|
| 17 |
+
"""Login request with email and password."""
|
| 18 |
+
|
| 19 |
+
email: EmailStr
|
| 20 |
+
password: str = Field(..., min_length=6)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
class LoginResponse(BaseModel):
|
| 24 |
+
"""Login response with user info and token."""
|
| 25 |
+
|
| 26 |
+
access_token: str
|
| 27 |
+
token_type: str = "Bearer"
|
| 28 |
+
user_id: str
|
| 29 |
+
full_name: str
|
| 30 |
+
email: str
|
| 31 |
+
|
| 32 |
+
|
| 33 |
+
class RegisterRequest(BaseModel):
|
| 34 |
+
"""User registration request."""
|
| 35 |
+
|
| 36 |
+
email: EmailStr
|
| 37 |
+
password: str = Field(..., min_length=6)
|
| 38 |
+
full_name: str = Field(..., min_length=2)
|
| 39 |
+
monthly_income: float = Field(..., gt=0)
|
| 40 |
+
existing_emi: float = Field(default=0.0, ge=0)
|
| 41 |
+
|
| 42 |
+
|
| 43 |
+
# ============================================================================
|
| 44 |
+
# Chat Schemas
|
| 45 |
+
# ============================================================================
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
class ChatRequest(BaseModel):
|
| 49 |
+
"""Chat message request."""
|
| 50 |
+
|
| 51 |
+
session_id: str
|
| 52 |
+
user_id: str
|
| 53 |
+
message: str = Field(..., min_length=1, max_length=2000)
|
| 54 |
+
|
| 55 |
+
|
| 56 |
+
class ChatResponse(BaseModel):
|
| 57 |
+
"""Chat response with agent reply and metadata."""
|
| 58 |
+
|
| 59 |
+
reply: str
|
| 60 |
+
step: Optional[str] = (
|
| 61 |
+
None # WELCOME, GATHERING_DETAILS, UNDERWRITING, SANCTION_GENERATED
|
| 62 |
+
)
|
| 63 |
+
decision: Optional[str] = None # APPROVED, REJECTED, ADJUST
|
| 64 |
+
loan_id: Optional[str] = None
|
| 65 |
+
meta: Optional[Dict[str, Any]] = None
|
| 66 |
+
session_id: str
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
# ============================================================================
|
| 70 |
+
# Loan Schemas
|
| 71 |
+
# ============================================================================
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
class LoanApplicationRequest(BaseModel):
|
| 75 |
+
"""Manual loan application request."""
|
| 76 |
+
|
| 77 |
+
user_id: str
|
| 78 |
+
requested_amount: float = Field(..., gt=0)
|
| 79 |
+
requested_tenure_months: int = Field(..., gt=0, le=60)
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
class LoanDecision(BaseModel):
|
| 83 |
+
"""Loan underwriting decision details."""
|
| 84 |
+
|
| 85 |
+
decision: str # APPROVED, REJECTED, ADJUST
|
| 86 |
+
approved_amount: float
|
| 87 |
+
tenure_months: int
|
| 88 |
+
emi: float
|
| 89 |
+
interest_rate: float
|
| 90 |
+
credit_score: int
|
| 91 |
+
foir: float
|
| 92 |
+
risk_band: str # A, B, C
|
| 93 |
+
explanation: str
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
class LoanApplication(BaseModel):
|
| 97 |
+
"""Complete loan application record."""
|
| 98 |
+
|
| 99 |
+
loan_id: str
|
| 100 |
+
user_id: str
|
| 101 |
+
requested_amount: float
|
| 102 |
+
requested_tenure_months: int
|
| 103 |
+
approved_amount: float
|
| 104 |
+
tenure_months: int
|
| 105 |
+
emi: float
|
| 106 |
+
interest_rate: float
|
| 107 |
+
credit_score: int
|
| 108 |
+
foir: float
|
| 109 |
+
decision: str
|
| 110 |
+
risk_band: str
|
| 111 |
+
explanation: str
|
| 112 |
+
sanction_pdf_path: Optional[str] = None
|
| 113 |
+
sanction_pdf_url: Optional[str] = None
|
| 114 |
+
created_at: datetime
|
| 115 |
+
updated_at: datetime
|
| 116 |
+
|
| 117 |
+
|
| 118 |
+
class LoanSummaryResponse(BaseModel):
|
| 119 |
+
"""Loan summary for display."""
|
| 120 |
+
|
| 121 |
+
loan_id: str
|
| 122 |
+
user_id: str
|
| 123 |
+
full_name: str
|
| 124 |
+
approved_amount: float
|
| 125 |
+
tenure_months: int
|
| 126 |
+
emi: float
|
| 127 |
+
interest_rate: float
|
| 128 |
+
decision: str
|
| 129 |
+
risk_band: str
|
| 130 |
+
created_at: datetime
|
| 131 |
+
sanction_pdf_url: Optional[str] = None
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
class SanctionLetterResponse(BaseModel):
|
| 135 |
+
"""Sanction letter PDF response."""
|
| 136 |
+
|
| 137 |
+
loan_id: str
|
| 138 |
+
pdf_url: str
|
| 139 |
+
pdf_path: str
|
| 140 |
+
|
| 141 |
+
|
| 142 |
+
# ============================================================================
|
| 143 |
+
# User Profile Schemas
|
| 144 |
+
# ============================================================================
|
| 145 |
+
|
| 146 |
+
|
| 147 |
+
class UserProfile(BaseModel):
|
| 148 |
+
"""User profile information."""
|
| 149 |
+
|
| 150 |
+
user_id: str
|
| 151 |
+
full_name: str
|
| 152 |
+
email: str
|
| 153 |
+
monthly_income: float
|
| 154 |
+
existing_emi: float
|
| 155 |
+
mock_credit_score: int
|
| 156 |
+
segment: str = "New to Credit" # Existing Customer, New to Credit, etc.
|
| 157 |
+
created_at: Optional[datetime] = None
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
class UserProfileUpdate(BaseModel):
|
| 161 |
+
"""User profile update request."""
|
| 162 |
+
|
| 163 |
+
full_name: Optional[str] = None
|
| 164 |
+
monthly_income: Optional[float] = Field(None, gt=0)
|
| 165 |
+
existing_emi: Optional[float] = Field(None, ge=0)
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
# ============================================================================
|
| 169 |
+
# Admin Schemas
|
| 170 |
+
# ============================================================================
|
| 171 |
+
|
| 172 |
+
|
| 173 |
+
class AdminMetrics(BaseModel):
|
| 174 |
+
"""Admin dashboard metrics."""
|
| 175 |
+
|
| 176 |
+
total_applications: int
|
| 177 |
+
approved_count: int
|
| 178 |
+
rejected_count: int
|
| 179 |
+
adjust_count: int
|
| 180 |
+
avg_loan_amount: float
|
| 181 |
+
avg_emi: float
|
| 182 |
+
avg_credit_score: float
|
| 183 |
+
today_applications: int
|
| 184 |
+
risk_distribution: Dict[str, int] # {"A": 10, "B": 5, "C": 2}
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
class LoanListItem(BaseModel):
|
| 188 |
+
"""Abbreviated loan info for admin list."""
|
| 189 |
+
|
| 190 |
+
loan_id: str
|
| 191 |
+
user_id: str
|
| 192 |
+
full_name: str
|
| 193 |
+
requested_amount: float
|
| 194 |
+
approved_amount: float
|
| 195 |
+
decision: str
|
| 196 |
+
risk_band: str
|
| 197 |
+
created_at: datetime
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
class AdminLoansResponse(BaseModel):
|
| 201 |
+
"""List of loans for admin dashboard."""
|
| 202 |
+
|
| 203 |
+
loans: List[LoanListItem]
|
| 204 |
+
total: int
|
| 205 |
+
page: int
|
| 206 |
+
page_size: int
|
| 207 |
+
|
| 208 |
+
|
| 209 |
+
# ============================================================================
|
| 210 |
+
# Session Schemas
|
| 211 |
+
# ============================================================================
|
| 212 |
+
|
| 213 |
+
|
| 214 |
+
class SessionState(BaseModel):
|
| 215 |
+
"""Internal session state for chat context."""
|
| 216 |
+
|
| 217 |
+
session_id: str
|
| 218 |
+
user_id: str
|
| 219 |
+
current_step: str = "WELCOME"
|
| 220 |
+
loan_details: Optional[Dict[str, Any]] = None
|
| 221 |
+
chat_history: List[Dict[str, str]] = []
|
| 222 |
+
created_at: datetime = Field(default_factory=datetime.utcnow)
|
| 223 |
+
updated_at: datetime = Field(default_factory=datetime.utcnow)
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
# ============================================================================
|
| 227 |
+
# Generic Response Schemas
|
| 228 |
+
# ============================================================================
|
| 229 |
+
|
| 230 |
+
|
| 231 |
+
class MessageResponse(BaseModel):
|
| 232 |
+
"""Generic message response."""
|
| 233 |
+
|
| 234 |
+
message: str
|
| 235 |
+
success: bool = True
|
| 236 |
+
|
| 237 |
+
|
| 238 |
+
class ErrorResponse(BaseModel):
|
| 239 |
+
"""Error response."""
|
| 240 |
+
|
| 241 |
+
error: str
|
| 242 |
+
detail: Optional[str] = None
|
| 243 |
+
success: bool = False
|
app/services/firebase_service.py
ADDED
|
@@ -0,0 +1,491 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Firebase service for Firestore database and Authentication.
|
| 3 |
+
Handles all Firebase operations including user profiles and loan applications.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import json
|
| 7 |
+
import os
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
from typing import Any, Dict, List, Optional
|
| 10 |
+
|
| 11 |
+
import firebase_admin
|
| 12 |
+
from app.config import settings
|
| 13 |
+
from app.utils.logger import default_logger as logger
|
| 14 |
+
from firebase_admin import auth, credentials, firestore
|
| 15 |
+
from google.cloud.firestore_v1 import FieldFilter
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
class FirebaseService:
|
| 19 |
+
"""Service for Firebase Firestore and Authentication operations."""
|
| 20 |
+
|
| 21 |
+
def __init__(self):
|
| 22 |
+
"""Initialize Firebase Admin SDK and Firestore client."""
|
| 23 |
+
self.db: Optional[firestore.Client] = None
|
| 24 |
+
self.initialized = False
|
| 25 |
+
self._initialize_firebase()
|
| 26 |
+
|
| 27 |
+
def _initialize_firebase(self) -> None:
|
| 28 |
+
"""Initialize Firebase Admin SDK with credentials."""
|
| 29 |
+
try:
|
| 30 |
+
# Check if Firebase app is already initialized
|
| 31 |
+
if not firebase_admin._apps:
|
| 32 |
+
# Try to load credentials from environment or use default
|
| 33 |
+
if settings.FIREBASE_CREDENTIALS:
|
| 34 |
+
# If FIREBASE_CREDENTIALS is a JSON string
|
| 35 |
+
if settings.FIREBASE_CREDENTIALS.startswith("{"):
|
| 36 |
+
cred_dict = json.loads(settings.FIREBASE_CREDENTIALS)
|
| 37 |
+
cred = credentials.Certificate(cred_dict)
|
| 38 |
+
else:
|
| 39 |
+
# If it's a file path
|
| 40 |
+
cred = credentials.Certificate(settings.FIREBASE_CREDENTIALS)
|
| 41 |
+
|
| 42 |
+
firebase_admin.initialize_app(
|
| 43 |
+
cred, {"projectId": settings.FIREBASE_PROJECT_ID}
|
| 44 |
+
)
|
| 45 |
+
else:
|
| 46 |
+
# Use Application Default Credentials (for local dev with gcloud)
|
| 47 |
+
firebase_admin.initialize_app(
|
| 48 |
+
options={"projectId": settings.FIREBASE_PROJECT_ID}
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
self.db = firestore.client()
|
| 52 |
+
self.initialized = True
|
| 53 |
+
logger.info("Firebase initialized successfully")
|
| 54 |
+
|
| 55 |
+
except Exception as e:
|
| 56 |
+
logger.error(f"Failed to initialize Firebase: {str(e)}")
|
| 57 |
+
# For development, we can continue without Firebase
|
| 58 |
+
logger.warning("Running without Firebase connection (dev mode)")
|
| 59 |
+
self.initialized = False
|
| 60 |
+
|
| 61 |
+
# ========================================================================
|
| 62 |
+
# User Profile Operations
|
| 63 |
+
# ========================================================================
|
| 64 |
+
|
| 65 |
+
def get_user_profile(self, user_id: str) -> Optional[Dict[str, Any]]:
|
| 66 |
+
"""
|
| 67 |
+
Retrieve user profile from Firestore.
|
| 68 |
+
|
| 69 |
+
Args:
|
| 70 |
+
user_id: User ID
|
| 71 |
+
|
| 72 |
+
Returns:
|
| 73 |
+
User profile dict or None if not found
|
| 74 |
+
"""
|
| 75 |
+
if not self.initialized:
|
| 76 |
+
logger.warning("Firebase not initialized, returning mock data")
|
| 77 |
+
return self._get_mock_user_profile(user_id)
|
| 78 |
+
|
| 79 |
+
try:
|
| 80 |
+
doc_ref = self.db.collection("users").document(user_id)
|
| 81 |
+
doc = doc_ref.get()
|
| 82 |
+
|
| 83 |
+
if doc.exists:
|
| 84 |
+
profile = doc.to_dict()
|
| 85 |
+
profile["user_id"] = user_id
|
| 86 |
+
logger.info(f"Retrieved profile for user {user_id}")
|
| 87 |
+
return profile
|
| 88 |
+
else:
|
| 89 |
+
logger.warning(f"User profile not found: {user_id}")
|
| 90 |
+
return None
|
| 91 |
+
|
| 92 |
+
except Exception as e:
|
| 93 |
+
logger.error(f"Error fetching user profile: {str(e)}")
|
| 94 |
+
return None
|
| 95 |
+
|
| 96 |
+
def create_user_profile(self, user_data: Dict[str, Any]) -> Dict[str, Any]:
|
| 97 |
+
"""
|
| 98 |
+
Create a new user profile in Firestore.
|
| 99 |
+
|
| 100 |
+
Args:
|
| 101 |
+
user_data: User profile data
|
| 102 |
+
|
| 103 |
+
Returns:
|
| 104 |
+
Created user profile with user_id
|
| 105 |
+
"""
|
| 106 |
+
if not self.initialized:
|
| 107 |
+
logger.warning("Firebase not initialized, returning mock data")
|
| 108 |
+
return {**user_data, "user_id": "mock_user_123"}
|
| 109 |
+
|
| 110 |
+
try:
|
| 111 |
+
user_id = user_data.get("user_id")
|
| 112 |
+
if not user_id:
|
| 113 |
+
# Generate user_id from Firebase Auth UID or create new
|
| 114 |
+
user_id = self.db.collection("users").document().id
|
| 115 |
+
|
| 116 |
+
user_data["user_id"] = user_id
|
| 117 |
+
user_data["created_at"] = datetime.utcnow()
|
| 118 |
+
user_data["updated_at"] = datetime.utcnow()
|
| 119 |
+
|
| 120 |
+
# Set default values
|
| 121 |
+
user_data.setdefault("existing_emi", 0.0)
|
| 122 |
+
user_data.setdefault("mock_credit_score", 650)
|
| 123 |
+
user_data.setdefault("segment", "New to Credit")
|
| 124 |
+
|
| 125 |
+
doc_ref = self.db.collection("users").document(user_id)
|
| 126 |
+
doc_ref.set(user_data)
|
| 127 |
+
|
| 128 |
+
logger.info(f"Created user profile: {user_id}")
|
| 129 |
+
return user_data
|
| 130 |
+
|
| 131 |
+
except Exception as e:
|
| 132 |
+
logger.error(f"Error creating user profile: {str(e)}")
|
| 133 |
+
raise
|
| 134 |
+
|
| 135 |
+
def update_user_profile(
|
| 136 |
+
self, user_id: str, update_data: Dict[str, Any]
|
| 137 |
+
) -> Dict[str, Any]:
|
| 138 |
+
"""
|
| 139 |
+
Update an existing user profile.
|
| 140 |
+
|
| 141 |
+
Args:
|
| 142 |
+
user_id: User ID
|
| 143 |
+
update_data: Fields to update
|
| 144 |
+
|
| 145 |
+
Returns:
|
| 146 |
+
Updated user profile
|
| 147 |
+
"""
|
| 148 |
+
if not self.initialized:
|
| 149 |
+
logger.warning("Firebase not initialized")
|
| 150 |
+
return {"user_id": user_id, **update_data}
|
| 151 |
+
|
| 152 |
+
try:
|
| 153 |
+
update_data["updated_at"] = datetime.utcnow()
|
| 154 |
+
doc_ref = self.db.collection("users").document(user_id)
|
| 155 |
+
doc_ref.update(update_data)
|
| 156 |
+
|
| 157 |
+
logger.info(f"Updated user profile: {user_id}")
|
| 158 |
+
return self.get_user_profile(user_id)
|
| 159 |
+
|
| 160 |
+
except Exception as e:
|
| 161 |
+
logger.error(f"Error updating user profile: {str(e)}")
|
| 162 |
+
raise
|
| 163 |
+
|
| 164 |
+
# ========================================================================
|
| 165 |
+
# Loan Application Operations
|
| 166 |
+
# ========================================================================
|
| 167 |
+
|
| 168 |
+
def create_loan_application(self, loan_data: Dict[str, Any]) -> Dict[str, Any]:
|
| 169 |
+
"""
|
| 170 |
+
Create a new loan application in Firestore.
|
| 171 |
+
|
| 172 |
+
Args:
|
| 173 |
+
loan_data: Loan application data
|
| 174 |
+
|
| 175 |
+
Returns:
|
| 176 |
+
Created loan application with loan_id
|
| 177 |
+
"""
|
| 178 |
+
if not self.initialized:
|
| 179 |
+
logger.warning("Firebase not initialized, returning mock data")
|
| 180 |
+
return {**loan_data, "loan_id": "mock_loan_123"}
|
| 181 |
+
|
| 182 |
+
try:
|
| 183 |
+
# Generate loan ID
|
| 184 |
+
loan_ref = self.db.collection("loan_applications").document()
|
| 185 |
+
loan_id = loan_ref.id
|
| 186 |
+
|
| 187 |
+
loan_data["loan_id"] = loan_id
|
| 188 |
+
loan_data["created_at"] = datetime.utcnow()
|
| 189 |
+
loan_data["updated_at"] = datetime.utcnow()
|
| 190 |
+
|
| 191 |
+
loan_ref.set(loan_data)
|
| 192 |
+
|
| 193 |
+
logger.info(f"Created loan application: {loan_id}")
|
| 194 |
+
return loan_data
|
| 195 |
+
|
| 196 |
+
except Exception as e:
|
| 197 |
+
logger.error(f"Error creating loan application: {str(e)}")
|
| 198 |
+
raise
|
| 199 |
+
|
| 200 |
+
def update_loan_application(
|
| 201 |
+
self, loan_id: str, update_data: Dict[str, Any]
|
| 202 |
+
) -> Dict[str, Any]:
|
| 203 |
+
"""
|
| 204 |
+
Update an existing loan application.
|
| 205 |
+
|
| 206 |
+
Args:
|
| 207 |
+
loan_id: Loan application ID
|
| 208 |
+
update_data: Fields to update
|
| 209 |
+
|
| 210 |
+
Returns:
|
| 211 |
+
Updated loan application
|
| 212 |
+
"""
|
| 213 |
+
if not self.initialized:
|
| 214 |
+
logger.warning("Firebase not initialized")
|
| 215 |
+
return {"loan_id": loan_id, **update_data}
|
| 216 |
+
|
| 217 |
+
try:
|
| 218 |
+
update_data["updated_at"] = datetime.utcnow()
|
| 219 |
+
doc_ref = self.db.collection("loan_applications").document(loan_id)
|
| 220 |
+
doc_ref.update(update_data)
|
| 221 |
+
|
| 222 |
+
logger.info(f"Updated loan application: {loan_id}")
|
| 223 |
+
return self.get_loan_application(loan_id)
|
| 224 |
+
|
| 225 |
+
except Exception as e:
|
| 226 |
+
logger.error(f"Error updating loan application: {str(e)}")
|
| 227 |
+
raise
|
| 228 |
+
|
| 229 |
+
def get_loan_application(self, loan_id: str) -> Optional[Dict[str, Any]]:
|
| 230 |
+
"""
|
| 231 |
+
Retrieve a loan application by ID.
|
| 232 |
+
|
| 233 |
+
Args:
|
| 234 |
+
loan_id: Loan application ID
|
| 235 |
+
|
| 236 |
+
Returns:
|
| 237 |
+
Loan application dict or None if not found
|
| 238 |
+
"""
|
| 239 |
+
if not self.initialized:
|
| 240 |
+
logger.warning("Firebase not initialized, returning mock data")
|
| 241 |
+
return self._get_mock_loan_application(loan_id)
|
| 242 |
+
|
| 243 |
+
try:
|
| 244 |
+
doc_ref = self.db.collection("loan_applications").document(loan_id)
|
| 245 |
+
doc = doc_ref.get()
|
| 246 |
+
|
| 247 |
+
if doc.exists:
|
| 248 |
+
loan = doc.to_dict()
|
| 249 |
+
loan["loan_id"] = loan_id
|
| 250 |
+
logger.info(f"Retrieved loan application: {loan_id}")
|
| 251 |
+
return loan
|
| 252 |
+
else:
|
| 253 |
+
logger.warning(f"Loan application not found: {loan_id}")
|
| 254 |
+
return None
|
| 255 |
+
|
| 256 |
+
except Exception as e:
|
| 257 |
+
logger.error(f"Error fetching loan application: {str(e)}")
|
| 258 |
+
return None
|
| 259 |
+
|
| 260 |
+
def get_user_loans(self, user_id: str) -> List[Dict[str, Any]]:
|
| 261 |
+
"""
|
| 262 |
+
Get all loan applications for a user.
|
| 263 |
+
|
| 264 |
+
Args:
|
| 265 |
+
user_id: User ID
|
| 266 |
+
|
| 267 |
+
Returns:
|
| 268 |
+
List of loan applications
|
| 269 |
+
"""
|
| 270 |
+
if not self.initialized:
|
| 271 |
+
logger.warning("Firebase not initialized, returning empty list")
|
| 272 |
+
return []
|
| 273 |
+
|
| 274 |
+
try:
|
| 275 |
+
loans_ref = self.db.collection("loan_applications")
|
| 276 |
+
query = loans_ref.where(
|
| 277 |
+
filter=FieldFilter("user_id", "==", user_id)
|
| 278 |
+
).order_by("created_at", direction=firestore.Query.DESCENDING)
|
| 279 |
+
|
| 280 |
+
loans = []
|
| 281 |
+
for doc in query.stream():
|
| 282 |
+
loan = doc.to_dict()
|
| 283 |
+
loan["loan_id"] = doc.id
|
| 284 |
+
loans.append(loan)
|
| 285 |
+
|
| 286 |
+
logger.info(f"Retrieved {len(loans)} loans for user {user_id}")
|
| 287 |
+
return loans
|
| 288 |
+
|
| 289 |
+
except Exception as e:
|
| 290 |
+
logger.error(f"Error fetching user loans: {str(e)}")
|
| 291 |
+
return []
|
| 292 |
+
|
| 293 |
+
def get_all_loans(self, limit: int = 50, offset: int = 0) -> List[Dict[str, Any]]:
|
| 294 |
+
"""
|
| 295 |
+
Get all loan applications with pagination.
|
| 296 |
+
|
| 297 |
+
Args:
|
| 298 |
+
limit: Number of loans to retrieve
|
| 299 |
+
offset: Number of loans to skip
|
| 300 |
+
|
| 301 |
+
Returns:
|
| 302 |
+
List of loan applications
|
| 303 |
+
"""
|
| 304 |
+
if not self.initialized:
|
| 305 |
+
logger.warning("Firebase not initialized, returning empty list")
|
| 306 |
+
return []
|
| 307 |
+
|
| 308 |
+
try:
|
| 309 |
+
loans_ref = self.db.collection("loan_applications")
|
| 310 |
+
query = loans_ref.order_by(
|
| 311 |
+
"created_at", direction=firestore.Query.DESCENDING
|
| 312 |
+
).limit(limit)
|
| 313 |
+
|
| 314 |
+
if offset > 0:
|
| 315 |
+
# Get the document to start after
|
| 316 |
+
skip_query = loans_ref.order_by(
|
| 317 |
+
"created_at", direction=firestore.Query.DESCENDING
|
| 318 |
+
).limit(offset)
|
| 319 |
+
skip_docs = list(skip_query.stream())
|
| 320 |
+
if skip_docs:
|
| 321 |
+
query = query.start_after(skip_docs[-1])
|
| 322 |
+
|
| 323 |
+
loans = []
|
| 324 |
+
for doc in query.stream():
|
| 325 |
+
loan = doc.to_dict()
|
| 326 |
+
loan["loan_id"] = doc.id
|
| 327 |
+
loans.append(loan)
|
| 328 |
+
|
| 329 |
+
logger.info(
|
| 330 |
+
f"Retrieved {len(loans)} loans (limit={limit}, offset={offset})"
|
| 331 |
+
)
|
| 332 |
+
return loans
|
| 333 |
+
|
| 334 |
+
except Exception as e:
|
| 335 |
+
logger.error(f"Error fetching all loans: {str(e)}")
|
| 336 |
+
return []
|
| 337 |
+
|
| 338 |
+
# ========================================================================
|
| 339 |
+
# Admin Operations
|
| 340 |
+
# ========================================================================
|
| 341 |
+
|
| 342 |
+
def get_admin_summary(self) -> Dict[str, Any]:
|
| 343 |
+
"""
|
| 344 |
+
Get aggregated metrics for admin dashboard.
|
| 345 |
+
|
| 346 |
+
Returns:
|
| 347 |
+
Dictionary with admin metrics
|
| 348 |
+
"""
|
| 349 |
+
if not self.initialized:
|
| 350 |
+
logger.warning("Firebase not initialized, returning mock data")
|
| 351 |
+
return self._get_mock_admin_summary()
|
| 352 |
+
|
| 353 |
+
try:
|
| 354 |
+
loans_ref = self.db.collection("loan_applications")
|
| 355 |
+
loans = list(loans_ref.stream())
|
| 356 |
+
|
| 357 |
+
total = len(loans)
|
| 358 |
+
approved = 0
|
| 359 |
+
rejected = 0
|
| 360 |
+
adjust = 0
|
| 361 |
+
total_amount = 0
|
| 362 |
+
total_emi = 0
|
| 363 |
+
total_credit = 0
|
| 364 |
+
risk_dist = {"A": 0, "B": 0, "C": 0}
|
| 365 |
+
|
| 366 |
+
today = datetime.utcnow().date()
|
| 367 |
+
today_count = 0
|
| 368 |
+
|
| 369 |
+
for doc in loans:
|
| 370 |
+
loan = doc.to_dict()
|
| 371 |
+
|
| 372 |
+
decision = loan.get("decision", "")
|
| 373 |
+
if decision == "APPROVED":
|
| 374 |
+
approved += 1
|
| 375 |
+
elif decision == "REJECTED":
|
| 376 |
+
rejected += 1
|
| 377 |
+
elif decision == "ADJUST":
|
| 378 |
+
adjust += 1
|
| 379 |
+
|
| 380 |
+
total_amount += loan.get("approved_amount", 0)
|
| 381 |
+
total_emi += loan.get("emi", 0)
|
| 382 |
+
total_credit += loan.get("credit_score", 0)
|
| 383 |
+
|
| 384 |
+
risk_band = loan.get("risk_band", "C")
|
| 385 |
+
if risk_band in risk_dist:
|
| 386 |
+
risk_dist[risk_band] += 1
|
| 387 |
+
|
| 388 |
+
created_at = loan.get("created_at")
|
| 389 |
+
if created_at and created_at.date() == today:
|
| 390 |
+
today_count += 1
|
| 391 |
+
|
| 392 |
+
summary = {
|
| 393 |
+
"total_applications": total,
|
| 394 |
+
"approved_count": approved,
|
| 395 |
+
"rejected_count": rejected,
|
| 396 |
+
"adjust_count": adjust,
|
| 397 |
+
"avg_loan_amount": total_amount / total if total > 0 else 0,
|
| 398 |
+
"avg_emi": total_emi / total if total > 0 else 0,
|
| 399 |
+
"avg_credit_score": total_credit / total if total > 0 else 0,
|
| 400 |
+
"today_applications": today_count,
|
| 401 |
+
"risk_distribution": risk_dist,
|
| 402 |
+
}
|
| 403 |
+
|
| 404 |
+
logger.info("Generated admin summary")
|
| 405 |
+
return summary
|
| 406 |
+
|
| 407 |
+
except Exception as e:
|
| 408 |
+
logger.error(f"Error generating admin summary: {str(e)}")
|
| 409 |
+
return self._get_mock_admin_summary()
|
| 410 |
+
|
| 411 |
+
# ========================================================================
|
| 412 |
+
# Authentication Operations
|
| 413 |
+
# ========================================================================
|
| 414 |
+
|
| 415 |
+
def verify_token(self, id_token: str) -> Optional[Dict[str, Any]]:
|
| 416 |
+
"""
|
| 417 |
+
Verify Firebase ID token.
|
| 418 |
+
|
| 419 |
+
Args:
|
| 420 |
+
id_token: Firebase ID token from client
|
| 421 |
+
|
| 422 |
+
Returns:
|
| 423 |
+
Decoded token with user info or None if invalid
|
| 424 |
+
"""
|
| 425 |
+
if not self.initialized:
|
| 426 |
+
logger.warning("Firebase not initialized, skipping token verification")
|
| 427 |
+
return {"uid": "mock_user_123", "email": "test@example.com"}
|
| 428 |
+
|
| 429 |
+
try:
|
| 430 |
+
decoded_token = auth.verify_id_token(id_token)
|
| 431 |
+
logger.info(f"Token verified for user: {decoded_token.get('uid')}")
|
| 432 |
+
return decoded_token
|
| 433 |
+
|
| 434 |
+
except Exception as e:
|
| 435 |
+
logger.error(f"Token verification failed: {str(e)}")
|
| 436 |
+
return None
|
| 437 |
+
|
| 438 |
+
# ========================================================================
|
| 439 |
+
# Mock Data Methods (for development)
|
| 440 |
+
# ========================================================================
|
| 441 |
+
|
| 442 |
+
def _get_mock_user_profile(self, user_id: str) -> Dict[str, Any]:
|
| 443 |
+
"""Return mock user profile for development."""
|
| 444 |
+
return {
|
| 445 |
+
"user_id": user_id,
|
| 446 |
+
"full_name": "John Doe",
|
| 447 |
+
"email": "john.doe@example.com",
|
| 448 |
+
"monthly_income": 75000.0,
|
| 449 |
+
"existing_emi": 5000.0,
|
| 450 |
+
"mock_credit_score": 720,
|
| 451 |
+
"segment": "Existing Customer",
|
| 452 |
+
"created_at": datetime.utcnow(),
|
| 453 |
+
}
|
| 454 |
+
|
| 455 |
+
def _get_mock_loan_application(self, loan_id: str) -> Dict[str, Any]:
|
| 456 |
+
"""Return mock loan application for development."""
|
| 457 |
+
return {
|
| 458 |
+
"loan_id": loan_id,
|
| 459 |
+
"user_id": "mock_user_123",
|
| 460 |
+
"requested_amount": 500000.0,
|
| 461 |
+
"requested_tenure_months": 36,
|
| 462 |
+
"approved_amount": 500000.0,
|
| 463 |
+
"tenure_months": 36,
|
| 464 |
+
"emi": 16620.0,
|
| 465 |
+
"interest_rate": 12.0,
|
| 466 |
+
"credit_score": 720,
|
| 467 |
+
"foir": 0.29,
|
| 468 |
+
"decision": "APPROVED",
|
| 469 |
+
"risk_band": "A",
|
| 470 |
+
"explanation": "Approved based on excellent credit score and low FOIR",
|
| 471 |
+
"created_at": datetime.utcnow(),
|
| 472 |
+
"updated_at": datetime.utcnow(),
|
| 473 |
+
}
|
| 474 |
+
|
| 475 |
+
def _get_mock_admin_summary(self) -> Dict[str, Any]:
|
| 476 |
+
"""Return mock admin summary for development."""
|
| 477 |
+
return {
|
| 478 |
+
"total_applications": 25,
|
| 479 |
+
"approved_count": 18,
|
| 480 |
+
"rejected_count": 5,
|
| 481 |
+
"adjust_count": 2,
|
| 482 |
+
"avg_loan_amount": 425000.0,
|
| 483 |
+
"avg_emi": 14250.0,
|
| 484 |
+
"avg_credit_score": 695,
|
| 485 |
+
"today_applications": 3,
|
| 486 |
+
"risk_distribution": {"A": 12, "B": 10, "C": 3},
|
| 487 |
+
}
|
| 488 |
+
|
| 489 |
+
|
| 490 |
+
# Singleton instance
|
| 491 |
+
firebase_service = FirebaseService()
|
app/services/pdf_service.py
ADDED
|
@@ -0,0 +1,323 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
PDF service for generating loan sanction letters.
|
| 3 |
+
Uses ReportLab to create professional PDF documents.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import os
|
| 7 |
+
from datetime import datetime, timedelta
|
| 8 |
+
from typing import Any, Dict
|
| 9 |
+
|
| 10 |
+
from app.config import settings
|
| 11 |
+
from app.utils.logger import default_logger as logger
|
| 12 |
+
from reportlab.lib import colors
|
| 13 |
+
from reportlab.lib.pagesizes import A4, letter
|
| 14 |
+
from reportlab.lib.styles import ParagraphStyle, getSampleStyleSheet
|
| 15 |
+
from reportlab.lib.units import inch
|
| 16 |
+
from reportlab.platypus import (
|
| 17 |
+
Paragraph,
|
| 18 |
+
SimpleDocTemplate,
|
| 19 |
+
Spacer,
|
| 20 |
+
Table,
|
| 21 |
+
TableStyle,
|
| 22 |
+
)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
class PdfService:
|
| 26 |
+
"""Service for generating loan sanction letter PDFs."""
|
| 27 |
+
|
| 28 |
+
def __init__(self):
|
| 29 |
+
"""Initialize PDF service with output directory."""
|
| 30 |
+
self.output_dir = settings.PDF_OUTPUT_DIR
|
| 31 |
+
self.validity_days = settings.PDF_VALIDITY_DAYS
|
| 32 |
+
|
| 33 |
+
# Create output directory if it doesn't exist
|
| 34 |
+
os.makedirs(self.output_dir, exist_ok=True)
|
| 35 |
+
|
| 36 |
+
def generate_sanction_letter(self, loan_data: Dict[str, Any]) -> Dict[str, str]:
|
| 37 |
+
"""
|
| 38 |
+
Generate a professional sanction letter PDF.
|
| 39 |
+
|
| 40 |
+
Args:
|
| 41 |
+
loan_data: Loan application data with all details
|
| 42 |
+
|
| 43 |
+
Returns:
|
| 44 |
+
Dictionary with pdf_path and pdf_url
|
| 45 |
+
"""
|
| 46 |
+
try:
|
| 47 |
+
loan_id = loan_data.get("loan_id", "unknown")
|
| 48 |
+
filename = f"{loan_id}.pdf"
|
| 49 |
+
filepath = os.path.join(self.output_dir, filename)
|
| 50 |
+
|
| 51 |
+
# Create PDF document
|
| 52 |
+
doc = SimpleDocTemplate(
|
| 53 |
+
filepath,
|
| 54 |
+
pagesize=A4,
|
| 55 |
+
rightMargin=0.75 * inch,
|
| 56 |
+
leftMargin=0.75 * inch,
|
| 57 |
+
topMargin=1 * inch,
|
| 58 |
+
bottomMargin=0.75 * inch,
|
| 59 |
+
)
|
| 60 |
+
|
| 61 |
+
# Build content
|
| 62 |
+
elements = []
|
| 63 |
+
styles = getSampleStyleSheet()
|
| 64 |
+
|
| 65 |
+
# Custom styles
|
| 66 |
+
title_style = ParagraphStyle(
|
| 67 |
+
"CustomTitle",
|
| 68 |
+
parent=styles["Heading1"],
|
| 69 |
+
fontSize=20,
|
| 70 |
+
textColor=colors.HexColor("#10b981"),
|
| 71 |
+
spaceAfter=30,
|
| 72 |
+
alignment=1, # Center
|
| 73 |
+
fontName="Helvetica-Bold",
|
| 74 |
+
)
|
| 75 |
+
|
| 76 |
+
heading_style = ParagraphStyle(
|
| 77 |
+
"CustomHeading",
|
| 78 |
+
parent=styles["Heading2"],
|
| 79 |
+
fontSize=14,
|
| 80 |
+
textColor=colors.HexColor("#1f2937"),
|
| 81 |
+
spaceAfter=12,
|
| 82 |
+
fontName="Helvetica-Bold",
|
| 83 |
+
)
|
| 84 |
+
|
| 85 |
+
normal_style = ParagraphStyle(
|
| 86 |
+
"CustomNormal",
|
| 87 |
+
parent=styles["Normal"],
|
| 88 |
+
fontSize=11,
|
| 89 |
+
textColor=colors.HexColor("#374151"),
|
| 90 |
+
spaceAfter=8,
|
| 91 |
+
)
|
| 92 |
+
|
| 93 |
+
# Header
|
| 94 |
+
elements.append(Paragraph("FinAgent", title_style))
|
| 95 |
+
elements.append(Paragraph("Personal Loan Sanction Letter", heading_style))
|
| 96 |
+
elements.append(Spacer(1, 0.2 * inch))
|
| 97 |
+
|
| 98 |
+
# Reference details
|
| 99 |
+
sanction_date = datetime.utcnow()
|
| 100 |
+
validity_date = sanction_date + timedelta(days=self.validity_days)
|
| 101 |
+
|
| 102 |
+
ref_data = [
|
| 103 |
+
["Sanction Reference No:", loan_id],
|
| 104 |
+
["Sanction Date:", sanction_date.strftime("%B %d, %Y")],
|
| 105 |
+
[
|
| 106 |
+
"Validity Date:",
|
| 107 |
+
validity_date.strftime("%B %d, %Y")
|
| 108 |
+
+ f" ({self.validity_days} days)",
|
| 109 |
+
],
|
| 110 |
+
]
|
| 111 |
+
|
| 112 |
+
ref_table = Table(ref_data, colWidths=[2.5 * inch, 3.5 * inch])
|
| 113 |
+
ref_table.setStyle(
|
| 114 |
+
TableStyle(
|
| 115 |
+
[
|
| 116 |
+
("FONTNAME", (0, 0), (0, -1), "Helvetica-Bold"),
|
| 117 |
+
("FONTNAME", (1, 0), (1, -1), "Helvetica"),
|
| 118 |
+
("FONTSIZE", (0, 0), (-1, -1), 10),
|
| 119 |
+
("TEXTCOLOR", (0, 0), (-1, -1), colors.HexColor("#374151")),
|
| 120 |
+
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
| 121 |
+
("BOTTOMPADDING", (0, 0), (-1, -1), 8),
|
| 122 |
+
]
|
| 123 |
+
)
|
| 124 |
+
)
|
| 125 |
+
elements.append(ref_table)
|
| 126 |
+
elements.append(Spacer(1, 0.3 * inch))
|
| 127 |
+
|
| 128 |
+
# Applicant details
|
| 129 |
+
elements.append(Paragraph("Applicant Details", heading_style))
|
| 130 |
+
|
| 131 |
+
user_id = loan_data.get("user_id", "N/A")
|
| 132 |
+
full_name = loan_data.get("full_name", "Valued Customer")
|
| 133 |
+
|
| 134 |
+
applicant_data = [
|
| 135 |
+
["Applicant Name:", full_name],
|
| 136 |
+
["Customer ID:", user_id],
|
| 137 |
+
]
|
| 138 |
+
|
| 139 |
+
applicant_table = Table(applicant_data, colWidths=[2.5 * inch, 3.5 * inch])
|
| 140 |
+
applicant_table.setStyle(
|
| 141 |
+
TableStyle(
|
| 142 |
+
[
|
| 143 |
+
("FONTNAME", (0, 0), (0, -1), "Helvetica-Bold"),
|
| 144 |
+
("FONTNAME", (1, 0), (1, -1), "Helvetica"),
|
| 145 |
+
("FONTSIZE", (0, 0), (-1, -1), 10),
|
| 146 |
+
("TEXTCOLOR", (0, 0), (-1, -1), colors.HexColor("#374151")),
|
| 147 |
+
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
| 148 |
+
("BOTTOMPADDING", (0, 0), (-1, -1), 8),
|
| 149 |
+
]
|
| 150 |
+
)
|
| 151 |
+
)
|
| 152 |
+
elements.append(applicant_table)
|
| 153 |
+
elements.append(Spacer(1, 0.3 * inch))
|
| 154 |
+
|
| 155 |
+
# Loan details
|
| 156 |
+
elements.append(Paragraph("Loan Sanction Details", heading_style))
|
| 157 |
+
|
| 158 |
+
approved_amount = loan_data.get("approved_amount", 0)
|
| 159 |
+
tenure = loan_data.get("tenure_months", 0)
|
| 160 |
+
emi = loan_data.get("emi", 0)
|
| 161 |
+
interest_rate = loan_data.get("interest_rate", 0)
|
| 162 |
+
total_payable = loan_data.get("total_payable", emi * tenure)
|
| 163 |
+
processing_fee = loan_data.get("processing_fee", approved_amount * 0.02)
|
| 164 |
+
|
| 165 |
+
loan_details_data = [
|
| 166 |
+
["Sanctioned Amount:", f"₹ {approved_amount:,.2f}"],
|
| 167 |
+
[
|
| 168 |
+
"Tenure:",
|
| 169 |
+
f"{tenure} months ({tenure // 12} years {tenure % 12} months)",
|
| 170 |
+
],
|
| 171 |
+
["Interest Rate:", f"{interest_rate}% per annum"],
|
| 172 |
+
["Monthly EMI:", f"₹ {emi:,.2f}"],
|
| 173 |
+
["Total Amount Payable:", f"₹ {total_payable:,.2f}"],
|
| 174 |
+
["Processing Fee (2%):", f"₹ {processing_fee:,.2f}"],
|
| 175 |
+
["Risk Band:", loan_data.get("risk_band", "N/A")],
|
| 176 |
+
]
|
| 177 |
+
|
| 178 |
+
loan_table = Table(loan_details_data, colWidths=[2.5 * inch, 3.5 * inch])
|
| 179 |
+
loan_table.setStyle(
|
| 180 |
+
TableStyle(
|
| 181 |
+
[
|
| 182 |
+
("FONTNAME", (0, 0), (0, -1), "Helvetica-Bold"),
|
| 183 |
+
("FONTNAME", (1, 0), (1, -1), "Helvetica"),
|
| 184 |
+
("FONTSIZE", (0, 0), (-1, -1), 10),
|
| 185 |
+
("TEXTCOLOR", (0, 0), (-1, -1), colors.HexColor("#374151")),
|
| 186 |
+
("VALIGN", (0, 0), (-1, -1), "MIDDLE"),
|
| 187 |
+
("BOTTOMPADDING", (0, 0), (-1, -1), 8),
|
| 188 |
+
("BACKGROUND", (0, -2), (-1, -2), colors.HexColor("#f3f4f6")),
|
| 189 |
+
]
|
| 190 |
+
)
|
| 191 |
+
)
|
| 192 |
+
elements.append(loan_table)
|
| 193 |
+
elements.append(Spacer(1, 0.3 * inch))
|
| 194 |
+
|
| 195 |
+
# Terms and conditions
|
| 196 |
+
elements.append(Paragraph("Terms & Conditions", heading_style))
|
| 197 |
+
|
| 198 |
+
terms = [
|
| 199 |
+
"This sanction is valid for {} days from the date of issue.".format(
|
| 200 |
+
self.validity_days
|
| 201 |
+
),
|
| 202 |
+
"The loan is subject to verification of all documents submitted.",
|
| 203 |
+
"Processing fee is non-refundable and payable upfront.",
|
| 204 |
+
"EMI will be deducted on the same date every month.",
|
| 205 |
+
"Prepayment charges may apply as per loan agreement.",
|
| 206 |
+
"Interest rate is fixed for the entire tenure of the loan.",
|
| 207 |
+
"This is a system-generated sanction letter and is valid without signature.",
|
| 208 |
+
]
|
| 209 |
+
|
| 210 |
+
for i, term in enumerate(terms, 1):
|
| 211 |
+
term_text = f"{i}. {term}"
|
| 212 |
+
elements.append(Paragraph(term_text, normal_style))
|
| 213 |
+
|
| 214 |
+
elements.append(Spacer(1, 0.3 * inch))
|
| 215 |
+
|
| 216 |
+
# Next steps
|
| 217 |
+
elements.append(Paragraph("Next Steps", heading_style))
|
| 218 |
+
next_steps_text = """
|
| 219 |
+
Please submit the following documents to complete your loan processing:
|
| 220 |
+
<br/>• PAN Card<br/>
|
| 221 |
+
• Aadhaar Card<br/>
|
| 222 |
+
• Last 3 months salary slips<br/>
|
| 223 |
+
• Last 6 months bank statements<br/>
|
| 224 |
+
• Address proof<br/>
|
| 225 |
+
<br/>
|
| 226 |
+
Our loan officer will contact you within 2 business days to guide you through the documentation process.
|
| 227 |
+
"""
|
| 228 |
+
elements.append(Paragraph(next_steps_text, normal_style))
|
| 229 |
+
elements.append(Spacer(1, 0.3 * inch))
|
| 230 |
+
|
| 231 |
+
# Footer
|
| 232 |
+
footer_style = ParagraphStyle(
|
| 233 |
+
"Footer",
|
| 234 |
+
parent=styles["Normal"],
|
| 235 |
+
fontSize=9,
|
| 236 |
+
textColor=colors.HexColor("#6b7280"),
|
| 237 |
+
alignment=1, # Center
|
| 238 |
+
)
|
| 239 |
+
|
| 240 |
+
elements.append(Spacer(1, 0.5 * inch))
|
| 241 |
+
elements.append(
|
| 242 |
+
Paragraph(
|
| 243 |
+
"This is a system-generated document and does not require a signature.",
|
| 244 |
+
footer_style,
|
| 245 |
+
)
|
| 246 |
+
)
|
| 247 |
+
elements.append(
|
| 248 |
+
Paragraph(
|
| 249 |
+
"For queries, contact us at support@finagent.com | +91-1800-XXX-XXXX",
|
| 250 |
+
footer_style,
|
| 251 |
+
)
|
| 252 |
+
)
|
| 253 |
+
elements.append(
|
| 254 |
+
Paragraph(
|
| 255 |
+
f"Generated on {datetime.utcnow().strftime('%B %d, %Y at %H:%M UTC')}",
|
| 256 |
+
footer_style,
|
| 257 |
+
)
|
| 258 |
+
)
|
| 259 |
+
|
| 260 |
+
# Build PDF
|
| 261 |
+
doc.build(elements)
|
| 262 |
+
|
| 263 |
+
# Generate URL (in production, this would be a cloud storage URL)
|
| 264 |
+
pdf_url = f"/api/loan/{loan_id}/sanction-pdf"
|
| 265 |
+
|
| 266 |
+
logger.info(f"Generated sanction letter PDF: {filepath}")
|
| 267 |
+
|
| 268 |
+
return {"pdf_path": filepath, "pdf_url": pdf_url}
|
| 269 |
+
|
| 270 |
+
except Exception as e:
|
| 271 |
+
logger.error(f"Error generating PDF: {str(e)}")
|
| 272 |
+
raise
|
| 273 |
+
|
| 274 |
+
def get_pdf_path(self, loan_id: str) -> str:
|
| 275 |
+
"""
|
| 276 |
+
Get the file path for a sanction letter PDF.
|
| 277 |
+
|
| 278 |
+
Args:
|
| 279 |
+
loan_id: Loan application ID
|
| 280 |
+
|
| 281 |
+
Returns:
|
| 282 |
+
Full file path to the PDF
|
| 283 |
+
"""
|
| 284 |
+
filename = f"{loan_id}.pdf"
|
| 285 |
+
return os.path.join(self.output_dir, filename)
|
| 286 |
+
|
| 287 |
+
def pdf_exists(self, loan_id: str) -> bool:
|
| 288 |
+
"""
|
| 289 |
+
Check if a sanction letter PDF exists.
|
| 290 |
+
|
| 291 |
+
Args:
|
| 292 |
+
loan_id: Loan application ID
|
| 293 |
+
|
| 294 |
+
Returns:
|
| 295 |
+
True if PDF exists, False otherwise
|
| 296 |
+
"""
|
| 297 |
+
filepath = self.get_pdf_path(loan_id)
|
| 298 |
+
return os.path.exists(filepath)
|
| 299 |
+
|
| 300 |
+
def delete_pdf(self, loan_id: str) -> bool:
|
| 301 |
+
"""
|
| 302 |
+
Delete a sanction letter PDF.
|
| 303 |
+
|
| 304 |
+
Args:
|
| 305 |
+
loan_id: Loan application ID
|
| 306 |
+
|
| 307 |
+
Returns:
|
| 308 |
+
True if deleted successfully, False otherwise
|
| 309 |
+
"""
|
| 310 |
+
try:
|
| 311 |
+
filepath = self.get_pdf_path(loan_id)
|
| 312 |
+
if os.path.exists(filepath):
|
| 313 |
+
os.remove(filepath)
|
| 314 |
+
logger.info(f"Deleted PDF: {filepath}")
|
| 315 |
+
return True
|
| 316 |
+
return False
|
| 317 |
+
except Exception as e:
|
| 318 |
+
logger.error(f"Error deleting PDF: {str(e)}")
|
| 319 |
+
return False
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
# Singleton instance
|
| 323 |
+
pdf_service = PdfService()
|
app/services/session_service.py
ADDED
|
@@ -0,0 +1,388 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Session service for managing chat context and state.
|
| 3 |
+
Maintains in-memory session data for chat conversations.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from datetime import datetime
|
| 7 |
+
from typing import Any, Dict, Optional
|
| 8 |
+
from uuid import uuid4
|
| 9 |
+
|
| 10 |
+
from app.utils.logger import default_logger as logger
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
class SessionService:
|
| 14 |
+
"""Service for managing user chat sessions and context."""
|
| 15 |
+
|
| 16 |
+
def __init__(self):
|
| 17 |
+
"""Initialize session service with in-memory storage."""
|
| 18 |
+
self._sessions: Dict[str, Dict[str, Any]] = {}
|
| 19 |
+
self._max_history = 20 # Maximum chat history items per session
|
| 20 |
+
|
| 21 |
+
def create_session(self, user_id: str) -> str:
|
| 22 |
+
"""
|
| 23 |
+
Create a new chat session for a user.
|
| 24 |
+
|
| 25 |
+
Args:
|
| 26 |
+
user_id: User ID
|
| 27 |
+
|
| 28 |
+
Returns:
|
| 29 |
+
Session ID
|
| 30 |
+
"""
|
| 31 |
+
session_id = str(uuid4())
|
| 32 |
+
|
| 33 |
+
self._sessions[session_id] = {
|
| 34 |
+
"session_id": session_id,
|
| 35 |
+
"user_id": user_id,
|
| 36 |
+
"current_step": "WELCOME",
|
| 37 |
+
"loan_details": {},
|
| 38 |
+
"chat_history": [],
|
| 39 |
+
"user_profile": None,
|
| 40 |
+
"created_at": datetime.utcnow(),
|
| 41 |
+
"updated_at": datetime.utcnow(),
|
| 42 |
+
"context": {},
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
logger.info(f"Created session {session_id} for user {user_id}")
|
| 46 |
+
return session_id
|
| 47 |
+
|
| 48 |
+
def get_session(self, session_id: str) -> Optional[Dict[str, Any]]:
|
| 49 |
+
"""
|
| 50 |
+
Get session data by ID.
|
| 51 |
+
|
| 52 |
+
Args:
|
| 53 |
+
session_id: Session ID
|
| 54 |
+
|
| 55 |
+
Returns:
|
| 56 |
+
Session data dict or None if not found
|
| 57 |
+
"""
|
| 58 |
+
session = self._sessions.get(session_id)
|
| 59 |
+
|
| 60 |
+
if not session:
|
| 61 |
+
logger.warning(f"Session not found: {session_id}")
|
| 62 |
+
return None
|
| 63 |
+
|
| 64 |
+
return session
|
| 65 |
+
|
| 66 |
+
def update_session(self, session_id: str, updates: Dict[str, Any]) -> bool:
|
| 67 |
+
"""
|
| 68 |
+
Update session data.
|
| 69 |
+
|
| 70 |
+
Args:
|
| 71 |
+
session_id: Session ID
|
| 72 |
+
updates: Dictionary of fields to update
|
| 73 |
+
|
| 74 |
+
Returns:
|
| 75 |
+
True if updated successfully, False if session not found
|
| 76 |
+
"""
|
| 77 |
+
session = self._sessions.get(session_id)
|
| 78 |
+
|
| 79 |
+
if not session:
|
| 80 |
+
logger.warning(f"Cannot update, session not found: {session_id}")
|
| 81 |
+
return False
|
| 82 |
+
|
| 83 |
+
# Update fields
|
| 84 |
+
for key, value in updates.items():
|
| 85 |
+
if key != "session_id": # Don't allow changing session_id
|
| 86 |
+
session[key] = value
|
| 87 |
+
|
| 88 |
+
session["updated_at"] = datetime.utcnow()
|
| 89 |
+
|
| 90 |
+
logger.info(f"Updated session {session_id}")
|
| 91 |
+
return True
|
| 92 |
+
|
| 93 |
+
def add_to_chat_history(self, session_id: str, role: str, content: str) -> bool:
|
| 94 |
+
"""
|
| 95 |
+
Add a message to chat history.
|
| 96 |
+
|
| 97 |
+
Args:
|
| 98 |
+
session_id: Session ID
|
| 99 |
+
role: Message role (user/assistant/system)
|
| 100 |
+
content: Message content
|
| 101 |
+
|
| 102 |
+
Returns:
|
| 103 |
+
True if added successfully, False otherwise
|
| 104 |
+
"""
|
| 105 |
+
session = self._sessions.get(session_id)
|
| 106 |
+
|
| 107 |
+
if not session:
|
| 108 |
+
logger.warning(f"Cannot add to history, session not found: {session_id}")
|
| 109 |
+
return False
|
| 110 |
+
|
| 111 |
+
# Add message to history
|
| 112 |
+
message = {
|
| 113 |
+
"role": role,
|
| 114 |
+
"content": content,
|
| 115 |
+
"timestamp": datetime.utcnow().isoformat(),
|
| 116 |
+
}
|
| 117 |
+
|
| 118 |
+
session["chat_history"].append(message)
|
| 119 |
+
|
| 120 |
+
# Trim history if it exceeds max length
|
| 121 |
+
if len(session["chat_history"]) > self._max_history:
|
| 122 |
+
session["chat_history"] = session["chat_history"][-self._max_history :]
|
| 123 |
+
logger.debug(f"Trimmed chat history for session {session_id}")
|
| 124 |
+
|
| 125 |
+
session["updated_at"] = datetime.utcnow()
|
| 126 |
+
|
| 127 |
+
return True
|
| 128 |
+
|
| 129 |
+
def get_chat_history(self, session_id: str) -> list:
|
| 130 |
+
"""
|
| 131 |
+
Get chat history for a session.
|
| 132 |
+
|
| 133 |
+
Args:
|
| 134 |
+
session_id: Session ID
|
| 135 |
+
|
| 136 |
+
Returns:
|
| 137 |
+
List of chat messages
|
| 138 |
+
"""
|
| 139 |
+
session = self._sessions.get(session_id)
|
| 140 |
+
|
| 141 |
+
if not session:
|
| 142 |
+
return []
|
| 143 |
+
|
| 144 |
+
return session.get("chat_history", [])
|
| 145 |
+
|
| 146 |
+
def set_user_profile(self, session_id: str, user_profile: Dict[str, Any]) -> bool:
|
| 147 |
+
"""
|
| 148 |
+
Store user profile in session.
|
| 149 |
+
|
| 150 |
+
Args:
|
| 151 |
+
session_id: Session ID
|
| 152 |
+
user_profile: User profile data
|
| 153 |
+
|
| 154 |
+
Returns:
|
| 155 |
+
True if set successfully, False otherwise
|
| 156 |
+
"""
|
| 157 |
+
session = self._sessions.get(session_id)
|
| 158 |
+
|
| 159 |
+
if not session:
|
| 160 |
+
logger.warning(f"Cannot set profile, session not found: {session_id}")
|
| 161 |
+
return False
|
| 162 |
+
|
| 163 |
+
session["user_profile"] = user_profile
|
| 164 |
+
session["updated_at"] = datetime.utcnow()
|
| 165 |
+
|
| 166 |
+
logger.info(f"Set user profile for session {session_id}")
|
| 167 |
+
return True
|
| 168 |
+
|
| 169 |
+
def get_user_profile(self, session_id: str) -> Optional[Dict[str, Any]]:
|
| 170 |
+
"""
|
| 171 |
+
Get user profile from session.
|
| 172 |
+
|
| 173 |
+
Args:
|
| 174 |
+
session_id: Session ID
|
| 175 |
+
|
| 176 |
+
Returns:
|
| 177 |
+
User profile dict or None
|
| 178 |
+
"""
|
| 179 |
+
session = self._sessions.get(session_id)
|
| 180 |
+
|
| 181 |
+
if not session:
|
| 182 |
+
return None
|
| 183 |
+
|
| 184 |
+
return session.get("user_profile")
|
| 185 |
+
|
| 186 |
+
def set_loan_details(self, session_id: str, loan_details: Dict[str, Any]) -> bool:
|
| 187 |
+
"""
|
| 188 |
+
Store loan details in session.
|
| 189 |
+
|
| 190 |
+
Args:
|
| 191 |
+
session_id: Session ID
|
| 192 |
+
loan_details: Loan details data
|
| 193 |
+
|
| 194 |
+
Returns:
|
| 195 |
+
True if set successfully, False otherwise
|
| 196 |
+
"""
|
| 197 |
+
session = self._sessions.get(session_id)
|
| 198 |
+
|
| 199 |
+
if not session:
|
| 200 |
+
logger.warning(f"Cannot set loan details, session not found: {session_id}")
|
| 201 |
+
return False
|
| 202 |
+
|
| 203 |
+
session["loan_details"] = loan_details
|
| 204 |
+
session["updated_at"] = datetime.utcnow()
|
| 205 |
+
|
| 206 |
+
logger.info(f"Set loan details for session {session_id}")
|
| 207 |
+
return True
|
| 208 |
+
|
| 209 |
+
def get_loan_details(self, session_id: str) -> Dict[str, Any]:
|
| 210 |
+
"""
|
| 211 |
+
Get loan details from session.
|
| 212 |
+
|
| 213 |
+
Args:
|
| 214 |
+
session_id: Session ID
|
| 215 |
+
|
| 216 |
+
Returns:
|
| 217 |
+
Loan details dict (empty if not set)
|
| 218 |
+
"""
|
| 219 |
+
session = self._sessions.get(session_id)
|
| 220 |
+
|
| 221 |
+
if not session:
|
| 222 |
+
return {}
|
| 223 |
+
|
| 224 |
+
return session.get("loan_details", {})
|
| 225 |
+
|
| 226 |
+
def set_step(self, session_id: str, step: str) -> bool:
|
| 227 |
+
"""
|
| 228 |
+
Set current conversation step.
|
| 229 |
+
|
| 230 |
+
Args:
|
| 231 |
+
session_id: Session ID
|
| 232 |
+
step: Step name (WELCOME, GATHERING_DETAILS, UNDERWRITING, etc.)
|
| 233 |
+
|
| 234 |
+
Returns:
|
| 235 |
+
True if set successfully, False otherwise
|
| 236 |
+
"""
|
| 237 |
+
session = self._sessions.get(session_id)
|
| 238 |
+
|
| 239 |
+
if not session:
|
| 240 |
+
logger.warning(f"Cannot set step, session not found: {session_id}")
|
| 241 |
+
return False
|
| 242 |
+
|
| 243 |
+
session["current_step"] = step
|
| 244 |
+
session["updated_at"] = datetime.utcnow()
|
| 245 |
+
|
| 246 |
+
logger.info(f"Set step to {step} for session {session_id}")
|
| 247 |
+
return True
|
| 248 |
+
|
| 249 |
+
def get_step(self, session_id: str) -> str:
|
| 250 |
+
"""
|
| 251 |
+
Get current conversation step.
|
| 252 |
+
|
| 253 |
+
Args:
|
| 254 |
+
session_id: Session ID
|
| 255 |
+
|
| 256 |
+
Returns:
|
| 257 |
+
Current step name or "WELCOME" if not found
|
| 258 |
+
"""
|
| 259 |
+
session = self._sessions.get(session_id)
|
| 260 |
+
|
| 261 |
+
if not session:
|
| 262 |
+
return "WELCOME"
|
| 263 |
+
|
| 264 |
+
return session.get("current_step", "WELCOME")
|
| 265 |
+
|
| 266 |
+
def set_context(self, session_id: str, key: str, value: Any) -> bool:
|
| 267 |
+
"""
|
| 268 |
+
Set a context variable in the session.
|
| 269 |
+
|
| 270 |
+
Args:
|
| 271 |
+
session_id: Session ID
|
| 272 |
+
key: Context key
|
| 273 |
+
value: Context value
|
| 274 |
+
|
| 275 |
+
Returns:
|
| 276 |
+
True if set successfully, False otherwise
|
| 277 |
+
"""
|
| 278 |
+
session = self._sessions.get(session_id)
|
| 279 |
+
|
| 280 |
+
if not session:
|
| 281 |
+
logger.warning(f"Cannot set context, session not found: {session_id}")
|
| 282 |
+
return False
|
| 283 |
+
|
| 284 |
+
if "context" not in session:
|
| 285 |
+
session["context"] = {}
|
| 286 |
+
|
| 287 |
+
session["context"][key] = value
|
| 288 |
+
session["updated_at"] = datetime.utcnow()
|
| 289 |
+
|
| 290 |
+
return True
|
| 291 |
+
|
| 292 |
+
def get_context(self, session_id: str, key: str, default: Any = None) -> Any:
|
| 293 |
+
"""
|
| 294 |
+
Get a context variable from the session.
|
| 295 |
+
|
| 296 |
+
Args:
|
| 297 |
+
session_id: Session ID
|
| 298 |
+
key: Context key
|
| 299 |
+
default: Default value if not found
|
| 300 |
+
|
| 301 |
+
Returns:
|
| 302 |
+
Context value or default
|
| 303 |
+
"""
|
| 304 |
+
session = self._sessions.get(session_id)
|
| 305 |
+
|
| 306 |
+
if not session:
|
| 307 |
+
return default
|
| 308 |
+
|
| 309 |
+
context = session.get("context", {})
|
| 310 |
+
return context.get(key, default)
|
| 311 |
+
|
| 312 |
+
def clear_session(self, session_id: str) -> bool:
|
| 313 |
+
"""
|
| 314 |
+
Delete a session.
|
| 315 |
+
|
| 316 |
+
Args:
|
| 317 |
+
session_id: Session ID
|
| 318 |
+
|
| 319 |
+
Returns:
|
| 320 |
+
True if deleted successfully, False if not found
|
| 321 |
+
"""
|
| 322 |
+
if session_id in self._sessions:
|
| 323 |
+
del self._sessions[session_id]
|
| 324 |
+
logger.info(f"Cleared session {session_id}")
|
| 325 |
+
return True
|
| 326 |
+
|
| 327 |
+
logger.warning(f"Cannot clear, session not found: {session_id}")
|
| 328 |
+
return False
|
| 329 |
+
|
| 330 |
+
def get_or_create_session(self, session_id: str, user_id: str) -> str:
|
| 331 |
+
"""
|
| 332 |
+
Get existing session or create new one if not found.
|
| 333 |
+
|
| 334 |
+
Args:
|
| 335 |
+
session_id: Session ID (can be None or empty)
|
| 336 |
+
user_id: User ID
|
| 337 |
+
|
| 338 |
+
Returns:
|
| 339 |
+
Session ID (existing or newly created)
|
| 340 |
+
"""
|
| 341 |
+
if session_id and session_id in self._sessions:
|
| 342 |
+
return session_id
|
| 343 |
+
|
| 344 |
+
# Create new session
|
| 345 |
+
return self.create_session(user_id)
|
| 346 |
+
|
| 347 |
+
def get_session_count(self) -> int:
|
| 348 |
+
"""
|
| 349 |
+
Get total number of active sessions.
|
| 350 |
+
|
| 351 |
+
Returns:
|
| 352 |
+
Number of sessions
|
| 353 |
+
"""
|
| 354 |
+
return len(self._sessions)
|
| 355 |
+
|
| 356 |
+
def cleanup_old_sessions(self, max_age_hours: int = 24) -> int:
|
| 357 |
+
"""
|
| 358 |
+
Clean up sessions older than specified hours.
|
| 359 |
+
|
| 360 |
+
Args:
|
| 361 |
+
max_age_hours: Maximum age in hours
|
| 362 |
+
|
| 363 |
+
Returns:
|
| 364 |
+
Number of sessions deleted
|
| 365 |
+
"""
|
| 366 |
+
now = datetime.utcnow()
|
| 367 |
+
deleted = 0
|
| 368 |
+
|
| 369 |
+
# Find old sessions
|
| 370 |
+
old_sessions = []
|
| 371 |
+
for session_id, session in self._sessions.items():
|
| 372 |
+
age = now - session["updated_at"]
|
| 373 |
+
if age.total_seconds() > (max_age_hours * 3600):
|
| 374 |
+
old_sessions.append(session_id)
|
| 375 |
+
|
| 376 |
+
# Delete old sessions
|
| 377 |
+
for session_id in old_sessions:
|
| 378 |
+
del self._sessions[session_id]
|
| 379 |
+
deleted += 1
|
| 380 |
+
|
| 381 |
+
if deleted > 0:
|
| 382 |
+
logger.info(f"Cleaned up {deleted} old sessions")
|
| 383 |
+
|
| 384 |
+
return deleted
|
| 385 |
+
|
| 386 |
+
|
| 387 |
+
# Singleton instance
|
| 388 |
+
session_service = SessionService()
|
app/services/underwriting_service.py
ADDED
|
@@ -0,0 +1,396 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Underwriting service for loan decision logic.
|
| 3 |
+
Implements credit evaluation and loan approval rules.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import math
|
| 7 |
+
from datetime import datetime
|
| 8 |
+
from typing import Any, Dict
|
| 9 |
+
|
| 10 |
+
from app.config import settings
|
| 11 |
+
from app.utils.logger import default_logger as logger
|
| 12 |
+
from app.utils.logger import log_underwriting_decision
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class UnderwritingService:
|
| 16 |
+
"""Service for evaluating loan applications and making credit decisions."""
|
| 17 |
+
|
| 18 |
+
def __init__(self):
|
| 19 |
+
"""Initialize underwriting service with configuration."""
|
| 20 |
+
self.interest_rate = settings.DEFAULT_INTEREST_RATE
|
| 21 |
+
self.min_loan_amount = settings.MIN_LOAN_AMOUNT
|
| 22 |
+
self.max_loan_amount = settings.MAX_LOAN_AMOUNT
|
| 23 |
+
self.min_tenure = settings.MIN_TENURE_MONTHS
|
| 24 |
+
self.max_tenure = settings.MAX_TENURE_MONTHS
|
| 25 |
+
self.excellent_credit_score = settings.EXCELLENT_CREDIT_SCORE
|
| 26 |
+
self.good_credit_score = settings.GOOD_CREDIT_SCORE
|
| 27 |
+
self.foir_threshold_a = settings.FOIR_THRESHOLD_A
|
| 28 |
+
self.foir_threshold_b = settings.FOIR_THRESHOLD_B
|
| 29 |
+
|
| 30 |
+
def evaluate_application(
|
| 31 |
+
self,
|
| 32 |
+
user_profile: Dict[str, Any],
|
| 33 |
+
requested_amount: float,
|
| 34 |
+
requested_tenure_months: int,
|
| 35 |
+
) -> Dict[str, Any]:
|
| 36 |
+
"""
|
| 37 |
+
Evaluate a loan application and make a credit decision.
|
| 38 |
+
|
| 39 |
+
Args:
|
| 40 |
+
user_profile: User profile with income and credit info
|
| 41 |
+
requested_amount: Requested loan amount
|
| 42 |
+
requested_tenure_months: Requested tenure in months
|
| 43 |
+
|
| 44 |
+
Returns:
|
| 45 |
+
Decision dict with approval status, amount, EMI, etc.
|
| 46 |
+
"""
|
| 47 |
+
logger.info(
|
| 48 |
+
f"Evaluating loan: amount={requested_amount}, tenure={requested_tenure_months}"
|
| 49 |
+
)
|
| 50 |
+
|
| 51 |
+
# Extract user data
|
| 52 |
+
monthly_income = user_profile.get("monthly_income", 0)
|
| 53 |
+
existing_emi = user_profile.get("existing_emi", 0)
|
| 54 |
+
credit_score = user_profile.get("mock_credit_score", 650)
|
| 55 |
+
user_id = user_profile.get("user_id", "unknown")
|
| 56 |
+
|
| 57 |
+
# Validate basic requirements
|
| 58 |
+
validation_error = self._validate_loan_request(
|
| 59 |
+
requested_amount, requested_tenure_months, monthly_income
|
| 60 |
+
)
|
| 61 |
+
if validation_error:
|
| 62 |
+
return self._create_rejection_response(
|
| 63 |
+
user_id,
|
| 64 |
+
requested_amount,
|
| 65 |
+
requested_tenure_months,
|
| 66 |
+
credit_score,
|
| 67 |
+
0,
|
| 68 |
+
validation_error,
|
| 69 |
+
)
|
| 70 |
+
|
| 71 |
+
# Calculate EMI
|
| 72 |
+
emi = self._calculate_emi(requested_amount, requested_tenure_months)
|
| 73 |
+
|
| 74 |
+
# Calculate FOIR (Fixed Obligations to Income Ratio)
|
| 75 |
+
foir = self._calculate_foir(existing_emi, emi, monthly_income)
|
| 76 |
+
|
| 77 |
+
# Make decision based on credit score and FOIR
|
| 78 |
+
decision = self._make_decision(
|
| 79 |
+
credit_score,
|
| 80 |
+
foir,
|
| 81 |
+
requested_amount,
|
| 82 |
+
requested_tenure_months,
|
| 83 |
+
monthly_income,
|
| 84 |
+
existing_emi,
|
| 85 |
+
)
|
| 86 |
+
|
| 87 |
+
# Log decision
|
| 88 |
+
log_underwriting_decision(
|
| 89 |
+
logger,
|
| 90 |
+
user_id,
|
| 91 |
+
decision["decision"],
|
| 92 |
+
decision["approved_amount"],
|
| 93 |
+
credit_score,
|
| 94 |
+
foir,
|
| 95 |
+
)
|
| 96 |
+
|
| 97 |
+
return decision
|
| 98 |
+
|
| 99 |
+
def _validate_loan_request(
|
| 100 |
+
self, amount: float, tenure: int, monthly_income: float
|
| 101 |
+
) -> str:
|
| 102 |
+
"""
|
| 103 |
+
Validate basic loan request parameters.
|
| 104 |
+
|
| 105 |
+
Returns:
|
| 106 |
+
Error message if invalid, empty string if valid
|
| 107 |
+
"""
|
| 108 |
+
if amount < self.min_loan_amount:
|
| 109 |
+
return f"Loan amount must be at least ₹{self.min_loan_amount:,.0f}"
|
| 110 |
+
|
| 111 |
+
if amount > self.max_loan_amount:
|
| 112 |
+
return f"Loan amount cannot exceed ₹{self.max_loan_amount:,.0f}"
|
| 113 |
+
|
| 114 |
+
if tenure < self.min_tenure:
|
| 115 |
+
return f"Tenure must be at least {self.min_tenure} months"
|
| 116 |
+
|
| 117 |
+
if tenure > self.max_tenure:
|
| 118 |
+
return f"Tenure cannot exceed {self.max_tenure} months"
|
| 119 |
+
|
| 120 |
+
if monthly_income <= 0:
|
| 121 |
+
return "Valid monthly income is required"
|
| 122 |
+
|
| 123 |
+
return ""
|
| 124 |
+
|
| 125 |
+
def _calculate_emi(self, principal: float, tenure_months: int) -> float:
|
| 126 |
+
"""
|
| 127 |
+
Calculate EMI using reducing balance method.
|
| 128 |
+
|
| 129 |
+
Formula: EMI = P × r × (1 + r)^n / ((1 + r)^n - 1)
|
| 130 |
+
Where:
|
| 131 |
+
P = Principal loan amount
|
| 132 |
+
r = Monthly interest rate (annual rate / 12 / 100)
|
| 133 |
+
n = Tenure in months
|
| 134 |
+
|
| 135 |
+
Args:
|
| 136 |
+
principal: Loan amount
|
| 137 |
+
tenure_months: Loan tenure in months
|
| 138 |
+
|
| 139 |
+
Returns:
|
| 140 |
+
Monthly EMI amount
|
| 141 |
+
"""
|
| 142 |
+
# Convert annual interest rate to monthly rate
|
| 143 |
+
monthly_rate = self.interest_rate / 12 / 100
|
| 144 |
+
|
| 145 |
+
# Calculate EMI using the standard formula
|
| 146 |
+
if monthly_rate == 0:
|
| 147 |
+
# If interest rate is 0, EMI is simply principal / tenure
|
| 148 |
+
emi = principal / tenure_months
|
| 149 |
+
else:
|
| 150 |
+
# Standard EMI calculation
|
| 151 |
+
emi = (
|
| 152 |
+
principal
|
| 153 |
+
* monthly_rate
|
| 154 |
+
* math.pow(1 + monthly_rate, tenure_months)
|
| 155 |
+
/ (math.pow(1 + monthly_rate, tenure_months) - 1)
|
| 156 |
+
)
|
| 157 |
+
|
| 158 |
+
return round(emi, 2)
|
| 159 |
+
|
| 160 |
+
def _calculate_foir(
|
| 161 |
+
self, existing_emi: float, new_emi: float, monthly_income: float
|
| 162 |
+
) -> float:
|
| 163 |
+
"""
|
| 164 |
+
Calculate Fixed Obligations to Income Ratio.
|
| 165 |
+
|
| 166 |
+
FOIR = (Existing EMI + New EMI) / Monthly Income
|
| 167 |
+
|
| 168 |
+
Args:
|
| 169 |
+
existing_emi: Existing loan EMIs
|
| 170 |
+
new_emi: New loan EMI
|
| 171 |
+
monthly_income: Monthly income
|
| 172 |
+
|
| 173 |
+
Returns:
|
| 174 |
+
FOIR ratio (0.0 to 1.0)
|
| 175 |
+
"""
|
| 176 |
+
if monthly_income <= 0:
|
| 177 |
+
return 1.0 # Maximum FOIR if income is invalid
|
| 178 |
+
|
| 179 |
+
total_obligations = existing_emi + new_emi
|
| 180 |
+
foir = total_obligations / monthly_income
|
| 181 |
+
|
| 182 |
+
return round(foir, 3)
|
| 183 |
+
|
| 184 |
+
def _make_decision(
|
| 185 |
+
self,
|
| 186 |
+
credit_score: int,
|
| 187 |
+
foir: float,
|
| 188 |
+
requested_amount: float,
|
| 189 |
+
requested_tenure: int,
|
| 190 |
+
monthly_income: float,
|
| 191 |
+
existing_emi: float,
|
| 192 |
+
) -> Dict[str, Any]:
|
| 193 |
+
"""
|
| 194 |
+
Make loan decision based on credit score and FOIR.
|
| 195 |
+
|
| 196 |
+
Decision Rules:
|
| 197 |
+
- Risk Band A (Excellent): Credit >= 720 AND FOIR <= 0.4
|
| 198 |
+
→ APPROVED with full amount
|
| 199 |
+
- Risk Band B (Good): Credit >= 680 AND FOIR <= 0.5
|
| 200 |
+
→ APPROVED with adjusted amount (80%) OR suggest lower amount
|
| 201 |
+
- Risk Band C (Poor): Otherwise
|
| 202 |
+
→ REJECTED
|
| 203 |
+
|
| 204 |
+
Args:
|
| 205 |
+
credit_score: User's credit score
|
| 206 |
+
foir: Calculated FOIR
|
| 207 |
+
requested_amount: Requested loan amount
|
| 208 |
+
requested_tenure: Requested tenure
|
| 209 |
+
monthly_income: Monthly income
|
| 210 |
+
existing_emi: Existing EMIs
|
| 211 |
+
|
| 212 |
+
Returns:
|
| 213 |
+
Decision dictionary
|
| 214 |
+
"""
|
| 215 |
+
# Risk Band A: Excellent - Full Approval
|
| 216 |
+
if (
|
| 217 |
+
credit_score >= self.excellent_credit_score
|
| 218 |
+
and foir <= self.foir_threshold_a
|
| 219 |
+
):
|
| 220 |
+
return self._create_approval_response(
|
| 221 |
+
requested_amount,
|
| 222 |
+
requested_tenure,
|
| 223 |
+
credit_score,
|
| 224 |
+
foir,
|
| 225 |
+
"A",
|
| 226 |
+
f"Approved! Excellent credit score ({credit_score}) and healthy FOIR ({foir:.1%}). "
|
| 227 |
+
f"You qualify for the full amount with Risk Band A rating.",
|
| 228 |
+
)
|
| 229 |
+
|
| 230 |
+
# Risk Band B: Good - Conditional Approval or Adjustment
|
| 231 |
+
if credit_score >= self.good_credit_score and foir <= self.foir_threshold_b:
|
| 232 |
+
# If FOIR is slightly high, reduce the loan amount
|
| 233 |
+
if foir > self.foir_threshold_a:
|
| 234 |
+
# Calculate maximum affordable EMI
|
| 235 |
+
max_affordable_emi = (
|
| 236 |
+
monthly_income * self.foir_threshold_a
|
| 237 |
+
) - existing_emi
|
| 238 |
+
|
| 239 |
+
# Calculate maximum loan amount based on affordable EMI
|
| 240 |
+
monthly_rate = self.interest_rate / 12 / 100
|
| 241 |
+
if monthly_rate > 0:
|
| 242 |
+
adjusted_amount = (
|
| 243 |
+
max_affordable_emi
|
| 244 |
+
* (math.pow(1 + monthly_rate, requested_tenure) - 1)
|
| 245 |
+
/ (monthly_rate * math.pow(1 + monthly_rate, requested_tenure))
|
| 246 |
+
)
|
| 247 |
+
else:
|
| 248 |
+
adjusted_amount = max_affordable_emi * requested_tenure
|
| 249 |
+
|
| 250 |
+
adjusted_amount = round(adjusted_amount, 2)
|
| 251 |
+
|
| 252 |
+
# Ensure adjusted amount is at least minimum
|
| 253 |
+
if adjusted_amount < self.min_loan_amount:
|
| 254 |
+
return self._create_rejection_response(
|
| 255 |
+
"unknown",
|
| 256 |
+
requested_amount,
|
| 257 |
+
requested_tenure,
|
| 258 |
+
credit_score,
|
| 259 |
+
foir,
|
| 260 |
+
f"Your current FOIR ({foir:.1%}) is too high. "
|
| 261 |
+
f"Maximum affordable loan amount (₹{adjusted_amount:,.0f}) "
|
| 262 |
+
f"is below minimum requirement.",
|
| 263 |
+
)
|
| 264 |
+
|
| 265 |
+
return self._create_adjustment_response(
|
| 266 |
+
adjusted_amount,
|
| 267 |
+
requested_tenure,
|
| 268 |
+
credit_score,
|
| 269 |
+
foir,
|
| 270 |
+
"B",
|
| 271 |
+
f"Approved with adjustment! Your credit score ({credit_score}) is good, "
|
| 272 |
+
f"but your FOIR ({foir:.1%}) is slightly high. "
|
| 273 |
+
f"We can approve ₹{adjusted_amount:,.0f} instead of ₹{requested_amount:,.0f} "
|
| 274 |
+
f"to maintain healthy FOIR. Risk Band: B.",
|
| 275 |
+
)
|
| 276 |
+
else:
|
| 277 |
+
# Full approval for Risk Band B
|
| 278 |
+
return self._create_approval_response(
|
| 279 |
+
requested_amount,
|
| 280 |
+
requested_tenure,
|
| 281 |
+
credit_score,
|
| 282 |
+
foir,
|
| 283 |
+
"B",
|
| 284 |
+
f"Approved! Good credit score ({credit_score}) and acceptable FOIR ({foir:.1%}). "
|
| 285 |
+
f"Risk Band B rating.",
|
| 286 |
+
)
|
| 287 |
+
|
| 288 |
+
# Risk Band C: Poor - Rejection
|
| 289 |
+
reasons = []
|
| 290 |
+
if credit_score < self.good_credit_score:
|
| 291 |
+
reasons.append(
|
| 292 |
+
f"credit score ({credit_score}) is below minimum requirement ({self.good_credit_score})"
|
| 293 |
+
)
|
| 294 |
+
if foir > self.foir_threshold_b:
|
| 295 |
+
reasons.append(
|
| 296 |
+
f"FOIR ({foir:.1%}) exceeds maximum threshold ({self.foir_threshold_b:.1%})"
|
| 297 |
+
)
|
| 298 |
+
|
| 299 |
+
explanation = (
|
| 300 |
+
f"Unfortunately, we cannot approve this loan because "
|
| 301 |
+
f"{' and '.join(reasons)}. "
|
| 302 |
+
f"Please improve your credit profile and try again."
|
| 303 |
+
)
|
| 304 |
+
|
| 305 |
+
return self._create_rejection_response(
|
| 306 |
+
"unknown",
|
| 307 |
+
requested_amount,
|
| 308 |
+
requested_tenure,
|
| 309 |
+
credit_score,
|
| 310 |
+
foir,
|
| 311 |
+
explanation,
|
| 312 |
+
)
|
| 313 |
+
|
| 314 |
+
def _create_approval_response(
|
| 315 |
+
self,
|
| 316 |
+
amount: float,
|
| 317 |
+
tenure: int,
|
| 318 |
+
credit_score: int,
|
| 319 |
+
foir: float,
|
| 320 |
+
risk_band: str,
|
| 321 |
+
explanation: str,
|
| 322 |
+
) -> Dict[str, Any]:
|
| 323 |
+
"""Create an approval decision response."""
|
| 324 |
+
emi = self._calculate_emi(amount, tenure)
|
| 325 |
+
|
| 326 |
+
return {
|
| 327 |
+
"decision": "APPROVED",
|
| 328 |
+
"approved_amount": amount,
|
| 329 |
+
"tenure_months": tenure,
|
| 330 |
+
"emi": emi,
|
| 331 |
+
"interest_rate": self.interest_rate,
|
| 332 |
+
"credit_score": credit_score,
|
| 333 |
+
"foir": foir,
|
| 334 |
+
"risk_band": risk_band,
|
| 335 |
+
"explanation": explanation,
|
| 336 |
+
"total_payable": round(emi * tenure, 2),
|
| 337 |
+
"processing_fee": round(amount * 0.02, 2), # 2% processing fee
|
| 338 |
+
}
|
| 339 |
+
|
| 340 |
+
def _create_adjustment_response(
|
| 341 |
+
self,
|
| 342 |
+
adjusted_amount: float,
|
| 343 |
+
tenure: int,
|
| 344 |
+
credit_score: int,
|
| 345 |
+
foir: float,
|
| 346 |
+
risk_band: str,
|
| 347 |
+
explanation: str,
|
| 348 |
+
) -> Dict[str, Any]:
|
| 349 |
+
"""Create an adjustment decision response."""
|
| 350 |
+
emi = self._calculate_emi(adjusted_amount, tenure)
|
| 351 |
+
|
| 352 |
+
# Recalculate FOIR with adjusted amount
|
| 353 |
+
# Note: We don't have existing_emi here, so we use the provided foir
|
| 354 |
+
# In practice, you'd recalculate with actual existing_emi
|
| 355 |
+
|
| 356 |
+
return {
|
| 357 |
+
"decision": "ADJUST",
|
| 358 |
+
"approved_amount": adjusted_amount,
|
| 359 |
+
"tenure_months": tenure,
|
| 360 |
+
"emi": emi,
|
| 361 |
+
"interest_rate": self.interest_rate,
|
| 362 |
+
"credit_score": credit_score,
|
| 363 |
+
"foir": foir,
|
| 364 |
+
"risk_band": risk_band,
|
| 365 |
+
"explanation": explanation,
|
| 366 |
+
"total_payable": round(emi * tenure, 2),
|
| 367 |
+
"processing_fee": round(adjusted_amount * 0.02, 2),
|
| 368 |
+
}
|
| 369 |
+
|
| 370 |
+
def _create_rejection_response(
|
| 371 |
+
self,
|
| 372 |
+
user_id: str,
|
| 373 |
+
requested_amount: float,
|
| 374 |
+
tenure: int,
|
| 375 |
+
credit_score: int,
|
| 376 |
+
foir: float,
|
| 377 |
+
explanation: str,
|
| 378 |
+
) -> Dict[str, Any]:
|
| 379 |
+
"""Create a rejection decision response."""
|
| 380 |
+
return {
|
| 381 |
+
"decision": "REJECTED",
|
| 382 |
+
"approved_amount": 0.0,
|
| 383 |
+
"tenure_months": tenure,
|
| 384 |
+
"emi": 0.0,
|
| 385 |
+
"interest_rate": self.interest_rate,
|
| 386 |
+
"credit_score": credit_score,
|
| 387 |
+
"foir": foir,
|
| 388 |
+
"risk_band": "C",
|
| 389 |
+
"explanation": explanation,
|
| 390 |
+
"total_payable": 0.0,
|
| 391 |
+
"processing_fee": 0.0,
|
| 392 |
+
}
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
# Singleton instance
|
| 396 |
+
underwriting_service = UnderwritingService()
|
app/utils/__init__.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Utility modules for FinAgent backend.
|
| 3 |
+
Contains logging, helpers, and common utilities.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
from app.utils.logger import (
|
| 7 |
+
default_logger,
|
| 8 |
+
log_agent_action,
|
| 9 |
+
log_error,
|
| 10 |
+
log_request,
|
| 11 |
+
log_response,
|
| 12 |
+
log_underwriting_decision,
|
| 13 |
+
setup_logger,
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
__all__ = [
|
| 17 |
+
"setup_logger",
|
| 18 |
+
"default_logger",
|
| 19 |
+
"log_request",
|
| 20 |
+
"log_response",
|
| 21 |
+
"log_error",
|
| 22 |
+
"log_agent_action",
|
| 23 |
+
"log_underwriting_decision",
|
| 24 |
+
]
|
app/utils/logger.py
ADDED
|
@@ -0,0 +1,227 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Logging utility for structured logging across the application.
|
| 3 |
+
Provides consistent logging format and helpers.
|
| 4 |
+
"""
|
| 5 |
+
|
| 6 |
+
import logging
|
| 7 |
+
import sys
|
| 8 |
+
from datetime import datetime
|
| 9 |
+
from typing import Any, Dict, Optional
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class ColoredFormatter(logging.Formatter):
|
| 13 |
+
"""Custom formatter with colors for console output."""
|
| 14 |
+
|
| 15 |
+
# ANSI color codes
|
| 16 |
+
COLORS = {
|
| 17 |
+
"DEBUG": "\033[36m", # Cyan
|
| 18 |
+
"INFO": "\033[32m", # Green
|
| 19 |
+
"WARNING": "\033[33m", # Yellow
|
| 20 |
+
"ERROR": "\033[31m", # Red
|
| 21 |
+
"CRITICAL": "\033[35m", # Magenta
|
| 22 |
+
}
|
| 23 |
+
RESET = "\033[0m"
|
| 24 |
+
BOLD = "\033[1m"
|
| 25 |
+
|
| 26 |
+
def format(self, record: logging.LogRecord) -> str:
|
| 27 |
+
"""Format log record with colors."""
|
| 28 |
+
# Add color to level name
|
| 29 |
+
levelname = record.levelname
|
| 30 |
+
if levelname in self.COLORS:
|
| 31 |
+
record.levelname = (
|
| 32 |
+
f"{self.COLORS[levelname]}{self.BOLD}{levelname}{self.RESET}"
|
| 33 |
+
)
|
| 34 |
+
|
| 35 |
+
# Format the message
|
| 36 |
+
formatted = super().format(record)
|
| 37 |
+
|
| 38 |
+
return formatted
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def setup_logger(
|
| 42 |
+
name: str = "finagent",
|
| 43 |
+
level: int = logging.INFO,
|
| 44 |
+
log_file: Optional[str] = None,
|
| 45 |
+
) -> logging.Logger:
|
| 46 |
+
"""
|
| 47 |
+
Set up a logger with console and optional file handlers.
|
| 48 |
+
|
| 49 |
+
Args:
|
| 50 |
+
name: Logger name
|
| 51 |
+
level: Logging level (default: INFO)
|
| 52 |
+
log_file: Optional path to log file
|
| 53 |
+
|
| 54 |
+
Returns:
|
| 55 |
+
Configured logger instance
|
| 56 |
+
"""
|
| 57 |
+
logger = logging.getLogger(name)
|
| 58 |
+
logger.setLevel(level)
|
| 59 |
+
|
| 60 |
+
# Prevent duplicate handlers
|
| 61 |
+
if logger.handlers:
|
| 62 |
+
return logger
|
| 63 |
+
|
| 64 |
+
# Console handler with colors
|
| 65 |
+
console_handler = logging.StreamHandler(sys.stdout)
|
| 66 |
+
console_handler.setLevel(level)
|
| 67 |
+
console_formatter = ColoredFormatter(
|
| 68 |
+
fmt="%(asctime)s | %(levelname)s | %(name)s | %(message)s",
|
| 69 |
+
datefmt="%Y-%m-%d %H:%M:%S",
|
| 70 |
+
)
|
| 71 |
+
console_handler.setFormatter(console_formatter)
|
| 72 |
+
logger.addHandler(console_handler)
|
| 73 |
+
|
| 74 |
+
# File handler (optional)
|
| 75 |
+
if log_file:
|
| 76 |
+
file_handler = logging.FileHandler(log_file)
|
| 77 |
+
file_handler.setLevel(level)
|
| 78 |
+
file_formatter = logging.Formatter(
|
| 79 |
+
fmt="%(asctime)s | %(levelname)s | %(name)s | %(funcName)s:%(lineno)d | %(message)s",
|
| 80 |
+
datefmt="%Y-%m-%d %H:%M:%S",
|
| 81 |
+
)
|
| 82 |
+
file_handler.setFormatter(file_formatter)
|
| 83 |
+
logger.addHandler(file_handler)
|
| 84 |
+
|
| 85 |
+
return logger
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
def log_request(
|
| 89 |
+
logger: logging.Logger,
|
| 90 |
+
method: str,
|
| 91 |
+
path: str,
|
| 92 |
+
user_id: Optional[str] = None,
|
| 93 |
+
**kwargs: Any,
|
| 94 |
+
) -> None:
|
| 95 |
+
"""
|
| 96 |
+
Log an API request with structured data.
|
| 97 |
+
|
| 98 |
+
Args:
|
| 99 |
+
logger: Logger instance
|
| 100 |
+
method: HTTP method
|
| 101 |
+
path: Request path
|
| 102 |
+
user_id: Optional user ID
|
| 103 |
+
**kwargs: Additional context data
|
| 104 |
+
"""
|
| 105 |
+
context = {
|
| 106 |
+
"method": method,
|
| 107 |
+
"path": path,
|
| 108 |
+
"user_id": user_id,
|
| 109 |
+
"timestamp": datetime.utcnow().isoformat(),
|
| 110 |
+
**kwargs,
|
| 111 |
+
}
|
| 112 |
+
logger.info(f"Request: {method} {path}", extra={"context": context})
|
| 113 |
+
|
| 114 |
+
|
| 115 |
+
def log_response(
|
| 116 |
+
logger: logging.Logger,
|
| 117 |
+
method: str,
|
| 118 |
+
path: str,
|
| 119 |
+
status_code: int,
|
| 120 |
+
duration_ms: float,
|
| 121 |
+
**kwargs: Any,
|
| 122 |
+
) -> None:
|
| 123 |
+
"""
|
| 124 |
+
Log an API response with structured data.
|
| 125 |
+
|
| 126 |
+
Args:
|
| 127 |
+
logger: Logger instance
|
| 128 |
+
method: HTTP method
|
| 129 |
+
path: Request path
|
| 130 |
+
status_code: HTTP status code
|
| 131 |
+
duration_ms: Request duration in milliseconds
|
| 132 |
+
**kwargs: Additional context data
|
| 133 |
+
"""
|
| 134 |
+
context = {
|
| 135 |
+
"method": method,
|
| 136 |
+
"path": path,
|
| 137 |
+
"status_code": status_code,
|
| 138 |
+
"duration_ms": round(duration_ms, 2),
|
| 139 |
+
"timestamp": datetime.utcnow().isoformat(),
|
| 140 |
+
**kwargs,
|
| 141 |
+
}
|
| 142 |
+
logger.info(
|
| 143 |
+
f"Response: {method} {path} - {status_code} ({duration_ms:.2f}ms)",
|
| 144 |
+
extra={"context": context},
|
| 145 |
+
)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def log_error(
|
| 149 |
+
logger: logging.Logger,
|
| 150 |
+
error: Exception,
|
| 151 |
+
context: Optional[Dict[str, Any]] = None,
|
| 152 |
+
) -> None:
|
| 153 |
+
"""
|
| 154 |
+
Log an error with structured context.
|
| 155 |
+
|
| 156 |
+
Args:
|
| 157 |
+
logger: Logger instance
|
| 158 |
+
error: Exception instance
|
| 159 |
+
context: Optional context dictionary
|
| 160 |
+
"""
|
| 161 |
+
error_context = {
|
| 162 |
+
"error_type": type(error).__name__,
|
| 163 |
+
"error_message": str(error),
|
| 164 |
+
"timestamp": datetime.utcnow().isoformat(),
|
| 165 |
+
**(context or {}),
|
| 166 |
+
}
|
| 167 |
+
logger.error(
|
| 168 |
+
f"Error: {type(error).__name__}: {str(error)}",
|
| 169 |
+
extra={"context": error_context},
|
| 170 |
+
exc_info=True,
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def log_agent_action(
|
| 175 |
+
logger: logging.Logger, action: str, details: Dict[str, Any]
|
| 176 |
+
) -> None:
|
| 177 |
+
"""
|
| 178 |
+
Log an agent action (tool call, decision, etc.).
|
| 179 |
+
|
| 180 |
+
Args:
|
| 181 |
+
logger: Logger instance
|
| 182 |
+
action: Action name/type
|
| 183 |
+
details: Action details
|
| 184 |
+
"""
|
| 185 |
+
context = {
|
| 186 |
+
"action": action,
|
| 187 |
+
"timestamp": datetime.utcnow().isoformat(),
|
| 188 |
+
**details,
|
| 189 |
+
}
|
| 190 |
+
logger.info(f"Agent Action: {action}", extra={"context": context})
|
| 191 |
+
|
| 192 |
+
|
| 193 |
+
def log_underwriting_decision(
|
| 194 |
+
logger: logging.Logger,
|
| 195 |
+
user_id: str,
|
| 196 |
+
decision: str,
|
| 197 |
+
amount: float,
|
| 198 |
+
credit_score: int,
|
| 199 |
+
foir: float,
|
| 200 |
+
) -> None:
|
| 201 |
+
"""
|
| 202 |
+
Log an underwriting decision with key metrics.
|
| 203 |
+
|
| 204 |
+
Args:
|
| 205 |
+
logger: Logger instance
|
| 206 |
+
user_id: User ID
|
| 207 |
+
decision: Decision (APPROVED/REJECTED/ADJUST)
|
| 208 |
+
amount: Approved amount
|
| 209 |
+
credit_score: Credit score
|
| 210 |
+
foir: FOIR ratio
|
| 211 |
+
"""
|
| 212 |
+
context = {
|
| 213 |
+
"user_id": user_id,
|
| 214 |
+
"decision": decision,
|
| 215 |
+
"approved_amount": amount,
|
| 216 |
+
"credit_score": credit_score,
|
| 217 |
+
"foir": round(foir, 3),
|
| 218 |
+
"timestamp": datetime.utcnow().isoformat(),
|
| 219 |
+
}
|
| 220 |
+
logger.info(
|
| 221 |
+
f"Underwriting Decision: {decision} for user {user_id}",
|
| 222 |
+
extra={"context": context},
|
| 223 |
+
)
|
| 224 |
+
|
| 225 |
+
|
| 226 |
+
# Default logger instance
|
| 227 |
+
default_logger = setup_logger()
|
requirements.txt
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# FastAPI and Web Framework
|
| 2 |
+
fastapi==0.104.1
|
| 3 |
+
uvicorn[standard]==0.24.0
|
| 4 |
+
python-multipart==0.0.6
|
| 5 |
+
pydantic==2.5.0
|
| 6 |
+
pydantic-settings==2.1.0
|
| 7 |
+
|
| 8 |
+
# LangChain and AI
|
| 9 |
+
langchain==0.1.0
|
| 10 |
+
langchain-core==0.1.0
|
| 11 |
+
langchain-google-genai==0.0.5
|
| 12 |
+
google-generativeai==0.3.1
|
| 13 |
+
|
| 14 |
+
# Firebase
|
| 15 |
+
firebase-admin==6.3.0
|
| 16 |
+
google-cloud-firestore==2.13.1
|
| 17 |
+
|
| 18 |
+
# PDF Generation
|
| 19 |
+
reportlab==4.0.7
|
| 20 |
+
|
| 21 |
+
# Utilities
|
| 22 |
+
python-dotenv==1.0.0
|
| 23 |
+
typing-extensions==4.9.0
|
| 24 |
+
|
| 25 |
+
# Date and Time
|
| 26 |
+
python-dateutil==2.8.2
|
| 27 |
+
|
| 28 |
+
# HTTP Client (for testing)
|
| 29 |
+
httpx==0.25.2
|
| 30 |
+
|
| 31 |
+
# Development Tools (optional)
|
| 32 |
+
pytest==7.4.3
|
| 33 |
+
pytest-asyncio==0.21.1
|
| 34 |
+
black==23.12.0
|
sanctions/.gitkeep
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# This file ensures the sanctions directory is tracked by git
|
| 2 |
+
# PDF files will be generated in this directory at runtime
|