LogicGoInfotechSpaces commited on
Commit
68e64de
·
verified ·
1 Parent(s): e88ec4d

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +45 -148
app.py CHANGED
@@ -1,23 +1,12 @@
1
  # main.py
2
  """
3
  Financial Health Score Service (FastAPI)
4
-
5
- Features:
6
- - Fetches user budgets and last-30-day transactions from MongoDB
7
- - Looks up transaction currency (from `currencies` collection) and embeds currency code
8
- - Builds a careful prompt for OpenAI (gpt-4o-mini) that instructs usage of currency code
9
- - Calls OpenAI and reliably extracts JSON output
10
- - Returns {"userId", "score", "explanation"}
11
-
12
- IMPORTANT:
13
- - Ensure MONGO_URI and OPENAI_API_KEY are set in your environment.
14
- - The `currencies` collection name is assumed to be "currencies".
15
- - This file uses the OpenAI Python client (OpenAI(api_key=...)) per your earlier setup.
16
  """
17
 
18
  import json
19
  import os
20
- import re
21
  from datetime import datetime, timedelta
22
 
23
  from bson import ObjectId
@@ -43,12 +32,9 @@ if not OPENAI_API_KEY:
43
  mongo_client = MongoClient(MONGO_URI)
44
  default_db = mongo_client.get_default_database()
45
 
46
- if default_db is None:
47
- raise RuntimeError("Unable to determine default database from MONGO_URI")
48
-
49
  budget_collection = default_db["budget"]
50
  transaction_collection = default_db["transactions"]
51
- currencies_collection = default_db["currencies"] # <-- currencies collection
52
 
53
  # OpenAI client
54
  openai = OpenAI(api_key=OPENAI_API_KEY)
@@ -80,15 +66,14 @@ def normalize_budgets(budgets):
80
  head_categories = []
81
  heads = budget.get("headCategories") or []
82
 
83
- if isinstance(heads, list):
84
- for head in heads:
85
- head_categories.append({
86
- "spendLimitType": head.get("spendLimitType"),
87
- "spendAmount": safe_number(head.get("spendAmount")),
88
- "maxAmount": safe_number(head.get("maxAmount")),
89
- "remainingAmount": safe_number(head.get("remainingAmount")),
90
- "notifications": head.get("notifications") or [],
91
- })
92
 
93
  normalized.append({
94
  "name": budget.get("name"),
@@ -106,42 +91,26 @@ def normalize_budgets(budgets):
106
 
107
 
108
  def normalize_transactions(transactions):
109
- """
110
- Trim transactions and attach currency code (e.g., 'EUR', 'INR', 'USD') when possible.
111
- """
112
  trimmed = []
113
  for txn in transactions:
114
  date_value = txn.get("date")
 
115
 
116
- if isinstance(date_value, datetime):
117
- date_str = date_value.date().isoformat()
118
- else:
119
- date_str = None
120
-
121
- # ---- Currency lookup ----
122
  currency_code = None
123
  currency_id = txn.get("currency")
124
 
125
  try:
126
- # currency may be stored as an ObjectId already; handle strings too
127
  if isinstance(currency_id, ObjectId):
128
  currency_doc = currencies_collection.find_one({"_id": currency_id})
129
- elif isinstance(currency_id, dict) and "$oid" in currency_id:
130
- # sometimes the raw export contains {"$oid": "..."}
131
- try:
132
- currency_doc = currencies_collection.find_one({"_id": ObjectId(currency_id["$oid"])})
133
- except Exception:
134
- currency_doc = None
135
  elif isinstance(currency_id, str):
136
- try:
137
- currency_doc = currencies_collection.find_one({"_id": ObjectId(currency_id)})
138
- except Exception:
139
- currency_doc = currencies_collection.find_one({"code": currency_id}) or currencies_collection.find_one({"currency": currency_id})
140
  else:
141
  currency_doc = None
142
 
143
  if currency_doc:
144
- # Option A: use currency code only (e.g., "EUR")
145
  currency_code = currency_doc.get("code") or currency_doc.get("currency")
146
 
147
  except Exception:
@@ -150,23 +119,23 @@ def normalize_transactions(transactions):
150
  trimmed.append({
151
  "type": txn.get("type"),
152
  "amount": safe_number(txn.get("amount")),
153
- "currency": currency_code, # <-- added
154
  "date": date_str,
155
  })
156
  return trimmed
157
 
158
 
159
  def score_prompt(budgets, transactions):
160
- # We instruct the model to use currency code when mentioning amounts (Option A)
161
- return f"""
162
- You are a succinct financial wellness expert. Using the budgets and last 30 days of transactions below,
163
- rate the user's financial health on a scale from 0 to 100 (higher is better).
 
164
 
165
- IMPORTANT:
166
- - When referring to monetary amounts, ALWAYS prefix with the currency code if available.
167
- Example: "EUR 10,000", "INR 5,000", "USD 200".
168
- - If a transaction has no currency code, you may use the number only (e.g., 1000).
169
- - Keep the explanation short (one or two sentences) and directly related to budgets and transactions.
170
 
171
  Budgets:
172
  {json.dumps(normalize_budgets(budgets), indent=2)}
@@ -174,64 +143,10 @@ Budgets:
174
  Transactions (last 30 days):
175
  {json.dumps(normalize_transactions(transactions), indent=2)}
176
 
177
- Respond only with valid JSON, nothing else, using this exact shape:
178
- {{ "score": number, "explanation": "short explanation" }}
179
  """
180
-
181
-
182
- def extract_json_payload(text):
183
- """Extract JSON from plain text or fenced code blocks."""
184
- trimmed = (text or "").strip()
185
-
186
- # try fenced json block first
187
- fenced = re.search(r"```(?:json)?\s*([\s\S]*?)```", trimmed)
188
- if fenced:
189
- return json.loads(fenced.group(1).strip())
190
-
191
- # try to find first { ... } substring
192
- first_obj = re.search(r"(\{[\s\S]*\})", trimmed)
193
- if first_obj:
194
- return json.loads(first_obj.group(1))
195
-
196
- # last resort: direct JSON load
197
- return json.loads(trimmed)
198
-
199
-
200
- # ===========================
201
- # BULLETPROOF OPENAI EXTRACTOR
202
- # ===========================
203
- def extract_openai_text(response):
204
- """
205
- Robust extractor for OpenAI SDK responses.
206
- Handles several possible message wrappers and returns the assistant text.
207
- """
208
- try:
209
- # Best-effort to access nested choice message content
210
- choices = getattr(response, "choices", None) or response.get("choices") if isinstance(response, dict) else None
211
- if not choices:
212
- # fallback: maybe response is a dict-like structure
213
- return str(response)
214
-
215
- msg = choices[0].get("message") if isinstance(choices[0], dict) else getattr(choices[0], "message", None)
216
- if not msg:
217
- return str(choices[0])
218
-
219
- # If message exposes 'content'
220
- if isinstance(msg, dict) and "content" in msg:
221
- return msg["content"].strip()
222
- if hasattr(msg, "content"):
223
- return msg.content.strip()
224
-
225
- # If message is a repr like ChatCompletionMessage(content='...'), extract via regex
226
- msg_str = str(msg)
227
- match = re.search(r"content='([\s\S]*?)'", msg_str)
228
- if match:
229
- return match.group(1).strip()
230
-
231
- # fallback
232
- return msg_str.strip()
233
- except Exception:
234
- return str(response)
235
 
236
 
237
  # ===========================
@@ -250,7 +165,7 @@ def financial_score(payload: ScoreRequest):
250
  except Exception:
251
  raise HTTPException(status_code=400, detail="Invalid userId")
252
 
253
- # Fetch budgets (all budgets created by this user)
254
  budgets = list(budget_collection.find({"createdBy": user_id}))
255
 
256
  # Fetch last 30 days transactions
@@ -259,10 +174,9 @@ def financial_score(payload: ScoreRequest):
259
  transaction_collection.find({
260
  "user": user_id,
261
  "date": {"$gte": thirty_days_ago}
262
- }).sort("date", -1).limit(100)
263
  )
264
 
265
- # If neither budgets nor recent transactions exist -> score 0
266
  if not budgets and not transactions:
267
  return {
268
  "userId": payload.userId,
@@ -270,45 +184,29 @@ def financial_score(payload: ScoreRequest):
270
  "explanation": "No budgets or recent transactions found."
271
  }
272
 
273
- prompt = score_prompt(budgets, transactions)
 
274
 
275
- # Call OpenAI
276
  try:
277
  response = openai.chat.completions.create(
278
- model="gpt-4o-mini",
279
- temperature=0.6,
280
- messages=[{"role": "user", "content": prompt}],
 
281
  )
282
  except Exception as exc:
283
  raise HTTPException(status_code=502, detail=f"OpenAI request failed: {exc}")
284
 
285
- model_output = extract_openai_text(response)
 
286
 
287
- # Parse JSON payload from model output
288
- try:
289
- parsed = extract_json_payload(model_output)
290
- except Exception:
291
- raise HTTPException(
292
- status_code=502,
293
- detail={
294
- "error": "Unable to parse OpenAI response as JSON",
295
- "rawResponse": model_output
296
- },
297
- )
298
 
299
- # Validate required fields
300
- if "score" not in parsed or "explanation" not in parsed:
301
- raise HTTPException(
302
- status_code=502,
303
- detail={
304
- "error": "OpenAI response missing required fields",
305
- "rawResponse": model_output
306
- }
307
- )
308
-
309
- # Clamp score to 0..100
310
  try:
311
- score_val = int(float(parsed["score"]))
312
  score_val = max(0, min(100, score_val))
313
  except Exception:
314
  score_val = 0
@@ -316,6 +214,5 @@ def financial_score(payload: ScoreRequest):
316
  return {
317
  "userId": payload.userId,
318
  "score": score_val,
319
- "explanation": parsed["explanation"]
320
  }
321
-
 
1
  # main.py
2
  """
3
  Financial Health Score Service (FastAPI)
4
+ Now using GPT-4.1 with strict JSON mode.
5
+ This guarantees the model ALWAYS returns valid JSON.
 
 
 
 
 
 
 
 
 
 
6
  """
7
 
8
  import json
9
  import os
 
10
  from datetime import datetime, timedelta
11
 
12
  from bson import ObjectId
 
32
  mongo_client = MongoClient(MONGO_URI)
33
  default_db = mongo_client.get_default_database()
34
 
 
 
 
35
  budget_collection = default_db["budget"]
36
  transaction_collection = default_db["transactions"]
37
+ currencies_collection = default_db["currencies"]
38
 
39
  # OpenAI client
40
  openai = OpenAI(api_key=OPENAI_API_KEY)
 
66
  head_categories = []
67
  heads = budget.get("headCategories") or []
68
 
69
+ for head in heads:
70
+ head_categories.append({
71
+ "spendLimitType": head.get("spendLimitType"),
72
+ "spendAmount": safe_number(head.get("spendAmount")),
73
+ "maxAmount": safe_number(head.get("maxAmount")),
74
+ "remainingAmount": safe_number(head.get("remainingAmount")),
75
+ "notifications": head.get("notifications") or [],
76
+ })
 
77
 
78
  normalized.append({
79
  "name": budget.get("name"),
 
91
 
92
 
93
  def normalize_transactions(transactions):
 
 
 
94
  trimmed = []
95
  for txn in transactions:
96
  date_value = txn.get("date")
97
+ date_str = date_value.date().isoformat() if isinstance(date_value, datetime) else None
98
 
 
 
 
 
 
 
99
  currency_code = None
100
  currency_id = txn.get("currency")
101
 
102
  try:
103
+ # Handle ObjectId or string forms
104
  if isinstance(currency_id, ObjectId):
105
  currency_doc = currencies_collection.find_one({"_id": currency_id})
 
 
 
 
 
 
106
  elif isinstance(currency_id, str):
107
+ currency_doc = currencies_collection.find_one({"_id": ObjectId(currency_id)})
108
+ elif isinstance(currency_id, dict) and "$oid" in currency_id:
109
+ currency_doc = currencies_collection.find_one({"_id": ObjectId(currency_id["$oid"])})
 
110
  else:
111
  currency_doc = None
112
 
113
  if currency_doc:
 
114
  currency_code = currency_doc.get("code") or currency_doc.get("currency")
115
 
116
  except Exception:
 
119
  trimmed.append({
120
  "type": txn.get("type"),
121
  "amount": safe_number(txn.get("amount")),
122
+ "currency": currency_code,
123
  "date": date_str,
124
  })
125
  return trimmed
126
 
127
 
128
  def score_prompt(budgets, transactions):
129
+ return {
130
+ "role": "user",
131
+ "content": f"""
132
+ You are a financial wellness expert. Using the budgets and last 30 days of transactions below,
133
+ rate the user’s financial health from 0 to 100 (higher = better).
134
 
135
+ Rules:
136
+ - Always prefix amounts with currency code when available (e.g., INR 5000).
137
+ - Keep the explanation short (1–2 sentences).
138
+ - Consider income, expenses, spending consistency, and remaining budgets.
 
139
 
140
  Budgets:
141
  {json.dumps(normalize_budgets(budgets), indent=2)}
 
143
  Transactions (last 30 days):
144
  {json.dumps(normalize_transactions(transactions), indent=2)}
145
 
146
+ Respond ONLY using this exact JSON structure:
147
+ {{ "score": number, "explanation": "string" }}
148
  """
149
+ }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
150
 
151
 
152
  # ===========================
 
165
  except Exception:
166
  raise HTTPException(status_code=400, detail="Invalid userId")
167
 
168
+ # Fetch budgets
169
  budgets = list(budget_collection.find({"createdBy": user_id}))
170
 
171
  # Fetch last 30 days transactions
 
174
  transaction_collection.find({
175
  "user": user_id,
176
  "date": {"$gte": thirty_days_ago}
177
+ }).sort("date", -1).limit(120)
178
  )
179
 
 
180
  if not budgets and not transactions:
181
  return {
182
  "userId": payload.userId,
 
184
  "explanation": "No budgets or recent transactions found."
185
  }
186
 
187
+ # Build messages
188
+ messages = [score_prompt(budgets, transactions)]
189
 
190
+ # -- Strict JSON Mode using GPT-4.1 --
191
  try:
192
  response = openai.chat.completions.create(
193
+ model="gpt-4.1",
194
+ response_format={"type": "json_object"}, # 🔥 Guarantees valid JSON
195
+ messages=messages,
196
+ temperature=0.4,
197
  )
198
  except Exception as exc:
199
  raise HTTPException(status_code=502, detail=f"OpenAI request failed: {exc}")
200
 
201
+ # Direct JSON — no more parsing issues
202
+ parsed = response.choices[0].message.parsed
203
 
204
+ # Validate shapes
205
+ score_val = parsed.get("score", 0)
206
+ explanation = parsed.get("explanation", "")
 
 
 
 
 
 
 
 
207
 
 
 
 
 
 
 
 
 
 
 
 
208
  try:
209
+ score_val = int(float(score_val))
210
  score_val = max(0, min(100, score_val))
211
  except Exception:
212
  score_val = 0
 
214
  return {
215
  "userId": payload.userId,
216
  "score": score_val,
217
+ "explanation": explanation
218
  }