omgy commited on
Commit
085d1c5
·
verified ·
1 Parent(s): 2486f75

Upload 20 files

Browse files
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