GenAICoder commited on
Commit
d781c81
·
verified ·
1 Parent(s): 7f87a63

Create deep_dive_agentic.py

Browse files
Files changed (1) hide show
  1. analytics/deep_dive_agentic.py +831 -0
analytics/deep_dive_agentic.py ADDED
@@ -0,0 +1,831 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # deep_dive_agentic.py
2
+
3
+ """
4
+ Agentic analytical code generation + execution engine using Hugging Face
5
+
6
+ FLOW:
7
+ User Question
8
+ ↓
9
+ LLM generates pandas code
10
+ ↓
11
+ Python executes code safely
12
+ ↓
13
+ LLM interprets results
14
+ ↓
15
+ Return code + interpretation
16
+
17
+ Environment:
18
+ export HUGGINGFACE_API_TOKEN=...
19
+
20
+ FIXES APPLIED (v2):
21
+ - FIX 1: exec() now uses a single merged namespace dict so result variables
22
+ are reliably written back (Python bug with separate globals/locals).
23
+ - FIX 2: Smart result detection — scans namespace for any new DataFrame/Series
24
+ instead of relying on hardcoded variable names (result_1, final_result).
25
+ - FIX 3: _fix_pandas_compatibility() is now actually called before exec().
26
+ """
27
+
28
+ # ---------------------------------------------------
29
+ # IMPORTS
30
+ # ---------------------------------------------------
31
+
32
+ import pandas as pd
33
+ import json
34
+ import os
35
+ import re
36
+
37
+ try:
38
+ from huggingface_hub import InferenceClient
39
+ except ImportError as exc:
40
+ raise ImportError(
41
+ "huggingface_hub is required. Install with `pip install huggingface-hub`."
42
+ ) from exc
43
+
44
+ from analytics.performance_analysis import generate_metric_view
45
+
46
+ # ---------------------------------------------------
47
+ # HF CONFIG
48
+ # ---------------------------------------------------
49
+
50
+ HF_CODE_MODEL_ID = os.environ.get("HF_MODEL_ID", "Qwen/Qwen2.5-Coder-7B-Instruct")
51
+ HF_MODEL_ID = os.environ.get("HF_MODEL_ID", "Qwen/Qwen2.5-7B-Instruct")
52
+ HF_TOKEN = os.environ.get("HUGGINGFACE_API_TOKEN")
53
+
54
+
55
+ # ---------------------------------------------------
56
+ # HELPER: GET INFERENCE CLIENT
57
+ # ---------------------------------------------------
58
+
59
+ def _get_hf_client():
60
+ if not HF_TOKEN:
61
+ raise RuntimeError(
62
+ "HUGGINGFACE_API_TOKEN is required. Set it in your environment."
63
+ )
64
+ return InferenceClient(token=HF_TOKEN)
65
+
66
+
67
+ # ---------------------------------------------------
68
+ # HELPER: EXTRACT JSON FROM LLM RESPONSE
69
+ # ---------------------------------------------------
70
+
71
+ def _extract_json(text: str):
72
+ match = re.search(r"\{.*\}", text, re.S)
73
+ if not match:
74
+ return None
75
+ payload = match.group(0)
76
+ try:
77
+ return json.loads(payload)
78
+ except json.JSONDecodeError:
79
+ try:
80
+ cleaned = re.sub(r"[\n\r]+", " ", payload)
81
+ cleaned = re.sub(r"(['\"])?([a-zA-Z0-9_]+)(['\"])?\s*:\s*", r'"\2": ', cleaned)
82
+ return json.loads(cleaned)
83
+ except Exception:
84
+ return None
85
+
86
+
87
+ # ---------------------------------------------------
88
+ # HELPER: FIX COMMON PANDAS COMPATIBILITY ISSUES
89
+ # ---------------------------------------------------
90
+
91
+ def _fix_pandas_compatibility(code: str) -> str:
92
+ """
93
+ Fix common pandas API compatibility issues in generated code.
94
+ Handles version differences between pandas versions.
95
+ """
96
+ # Fix: .reset_index(name=...) -> .reset_index(names=[...])
97
+ code = re.sub(
98
+ r"\.reset_index\(name=(['\"])([^'\"]+)\1\)",
99
+ r".reset_index(names=[\1\2\1])",
100
+ code
101
+ )
102
+
103
+ # Fix: .reset_index(name= with variable
104
+ code = re.sub(
105
+ r"\.reset_index\(name=([a-zA-Z_][a-zA-Z0-9_]*)\)",
106
+ r".reset_index(names=[\1])",
107
+ code
108
+ )
109
+
110
+ # Fix: df.append() deprecated in newer pandas -> pd.concat()
111
+ code = re.sub(
112
+ r"(\w+)\.append\((\w+),\s*ignore_index=True\)",
113
+ r"pd.concat([\1, \2], ignore_index=True)",
114
+ code
115
+ )
116
+
117
+ return code
118
+
119
+
120
+ # ---------------------------------------------------
121
+ # STEP 1: CODE GENERATION
122
+ # ---------------------------------------------------
123
+
124
+ def generate_analysis_requirements(
125
+ question: str,
126
+ acq: pd.DataFrame,
127
+ perf: pd.DataFrame,
128
+ master_df: pd.DataFrame
129
+ ):
130
+ """
131
+ LLM breaks down question into 1-3 structured analytics requirements.
132
+ Each requirement includes a description and executable pandas code.
133
+ """
134
+
135
+ client = _get_hf_client()
136
+
137
+ # Build detailed column descriptions
138
+ acq_cols = {
139
+ "account_id": "unique account identifier",
140
+ "booking_date": "when account was originated",
141
+ "booking_vintage": "year-month of origination (YYYY-MM)",
142
+ "fico_band": "FICO score bracket (e.g., 700-750, 750-800)",
143
+ "sourcing_channel": "acquisition channel (e.g., Online, Branch, Broker)",
144
+ "city_tier": "city classification (Tier-1, Tier-2, Tier-3)",
145
+ "occupation_type": "borrower occupation category",
146
+ "credit_limit": "approved credit line amount"
147
+ }
148
+
149
+ perf_cols = {
150
+ "account_id": "unique account identifier",
151
+ "reporting_month": "month of performance observation (YYYY-MM)",
152
+ "mob": "months on books (age of account in months)",
153
+ "dpd": "days past due (0, 30, 60, 90+)",
154
+ "balance": "current outstanding balance",
155
+ "ncl_amount": "net charge-off amount (dollars)",
156
+ "payment": "payment amount in period"
157
+ }
158
+
159
+ prompt = (
160
+ # ================================================================
161
+ # SECTION 1: ROLE & OBJECTIVE
162
+ # ================================================================
163
+ "You are a senior retail credit risk analyst with 15+ years of experience "
164
+ "managing consumer credit portfolios. Your job is to analyze a user question, "
165
+ "plan the right analytical approach, write executable pandas code, and return "
166
+ "a structured JSON plan.\n\n"
167
+
168
+ "You have access to credit portfolio data. You will generate up to 3 pandas "
169
+ "code blocks (requirements) to answer the question. Each requirement produces "
170
+ "one result table. A comparison of two periods can and should be done in ONE "
171
+ "requirement by building a single table with both periods as columns plus a "
172
+ "delta column — do not waste two requirements on what can be one clean table.\n\n"
173
+
174
+ # ================================================================
175
+ # SECTION 2: DATA DICTIONARY
176
+ # ================================================================
177
+ "================================================================\n"
178
+ "SECTION 2: DATA DICTIONARY\n"
179
+ "================================================================\n\n"
180
+
181
+ "THREE dataframes are available. Use master_df for any analysis that needs "
182
+ "both acquisition attributes and performance metrics together.\n\n"
183
+
184
+ "acq — acquisition data (one row per account):\n"
185
+ " - account_id : unique account identifier (string)\n"
186
+ " - booking_date : origination date (datetime)\n"
187
+ " - booking_vintage : origination month as STRING in format 'YYYY-MM' e.g. '2024-07'\n"
188
+ " - fico_band : FICO score bracket (string) e.g. '700-750', '750-800'\n"
189
+ " - sourcing_channel : acquisition channel (string) e.g. 'Online', 'Branch', 'Broker'\n"
190
+ " - city_tier : city classification (string) e.g. 'Tier-1', 'Tier-2', 'Tier-3'\n"
191
+ " - occupation_type : borrower occupation (string) e.g. 'Salaried', 'Self-Employed'\n"
192
+ " - credit_limit : approved credit line in currency units (float)\n\n"
193
+
194
+ "perf — monthly performance data (one row per account per month):\n"
195
+ " - account_id : unique account identifier (string)\n"
196
+ " - reporting_month : observation month as STRING in format 'YYYY-MM' e.g. '2024-10'\n"
197
+ " - mob : months on books — integer age of account (1, 2, 3 ... 12+)\n"
198
+ " - dpd : days past due at that mob (integer: 0, 30, 60, 90)\n"
199
+ " - balance : outstanding balance at that mob (float)\n"
200
+ " - ncl_amount : net charge-off amount at that mob (float, 0 if not charged off)\n"
201
+ " - payment : payment made in that month (float)\n\n"
202
+
203
+ "master_df — perf LEFT JOINED with acq on account_id. Contains ALL columns above.\n"
204
+ "IMPORTANT: Always use master_df when you need both segment columns AND performance columns.\n\n"
205
+
206
+ "SEGMENT COLUMNS available for groupby in master_df:\n"
207
+ " - booking_vintage, fico_band, sourcing_channel, city_tier, occupation_type\n\n"
208
+
209
+ # ================================================================
210
+ # SECTION 3: METRIC DEFINITIONS & EXACT FORMULAS
211
+ # ================================================================
212
+ "================================================================\n"
213
+ "SECTION 3: METRIC DEFINITIONS & EXACT FORMULAS\n"
214
+ "================================================================\n\n"
215
+
216
+ "--- METRIC 1: Yr1 NCL Rate (Year 1 Net Charge-Off Rate) ---\n"
217
+ "Definition: The annualised net charge-off rate for a vintage cohort over its first 12 months.\n"
218
+ "What it measures: Credit loss intensity. Higher is worse.\n"
219
+ "Formula:\n"
220
+ " Yr1 NCL Rate = sum(ncl_amount where mob IN 1..12) / (sum(balance where mob IN 1..12) / 12) x 100\n"
221
+ "Steps in pandas:\n"
222
+ " Step 1 — Filter master_df for selected vintages AND mob between 1 and 12\n"
223
+ " Step 2 — sum ncl_amount across all rows in that filter\n"
224
+ " Step 3 — sum balance across all rows in that filter, then divide by 12\n"
225
+ " Step 4 — divide Step 2 by Step 3, multiply by 100\n"
226
+ "Pandas recipe (single vintage group):\n"
227
+ " df_v = master_df[master_df['booking_vintage'].isin(vintages) & master_df['mob'].between(1,12)]\n"
228
+ " ncl_rate = (df_v['ncl_amount'].sum() / (df_v['balance'].sum() / 12)) * 100\n\n"
229
+
230
+ "--- METRIC 2: 30+@3 (Early Delinquency Rate at MOB 3) ---\n"
231
+ "Definition: Share of accounts that are 30+ days past due at exactly month 3.\n"
232
+ "What it measures: Early stress signal. Higher is worse.\n"
233
+ "Formula:\n"
234
+ " 30+@3 = count(accounts where mob==3 AND dpd>=30) / count(accounts where mob==3) x 100\n"
235
+ "Pandas recipe:\n"
236
+ " df_m3 = master_df[master_df['booking_vintage'].isin(vintages) & (master_df['mob']==3)]\n"
237
+ " rate_30_3 = (df_m3['dpd'] >= 30).sum() / len(df_m3) * 100\n\n"
238
+
239
+ "--- METRIC 3: 30+@6 (Delinquency Rate at MOB 6) ---\n"
240
+ "Definition: Share of accounts that are 30+ days past due at exactly month 6.\n"
241
+ "Formula:\n"
242
+ " 30+@6 = count(accounts where mob==6 AND dpd>=30) / count(accounts where mob==6) x 100\n"
243
+ "Pandas recipe:\n"
244
+ " df_m6 = master_df[master_df['booking_vintage'].isin(vintages) & (master_df['mob']==6)]\n"
245
+ " rate_30_6 = (df_m6['dpd'] >= 30).sum() / len(df_m6) * 100\n\n"
246
+
247
+ "--- METRIC 4: 60+@6 (Severe Delinquency Rate at MOB 6) ---\n"
248
+ "Definition: Share of accounts that are 60+ days past due at exactly month 6.\n"
249
+ "Formula:\n"
250
+ " 60+@6 = count(accounts where mob==6 AND dpd>=60) / count(accounts where mob==6) x 100\n"
251
+ "Pandas recipe:\n"
252
+ " rate_60_6 = (df_m6['dpd'] >= 60).sum() / len(df_m6) * 100\n\n"
253
+
254
+ "--- RISK BENCHMARKS (flag in output) ---\n"
255
+ "Metric | Green (Good) | Amber (Watch) | Red (Bad)\n"
256
+ "30+@3 | < 3% | 3% - 6% | > 6%\n"
257
+ "30+@6 | < 5% | 5% - 9% | > 9%\n"
258
+ "60+@6 | < 2% | 2% - 4% | > 4%\n"
259
+ "Yr1 NCL | < 3% | 3% - 6% | > 6%\n\n"
260
+
261
+ # ================================================================
262
+ # SECTION 4: DATE & VINTAGE FILTERING RECIPES
263
+ # ================================================================
264
+ "================================================================\n"
265
+ "SECTION 4: DATE & VINTAGE FILTERING RECIPES\n"
266
+ "================================================================\n\n"
267
+
268
+ "CRITICAL: booking_vintage and reporting_month are STRINGS in 'YYYY-MM' format.\n"
269
+ "Never use .dt accessor or pd.Grouper on these columns — they are not datetime.\n"
270
+ "Always filter using string operations or .isin() as shown below.\n\n"
271
+
272
+ "Single vintage:\n"
273
+ " master_df[master_df['booking_vintage'] == '2024-10']\n\n"
274
+
275
+ "Full year:\n"
276
+ " master_df[master_df['booking_vintage'].str.startswith('2024')]\n\n"
277
+
278
+ "Quarter (Q1=Jan-Mar, Q2=Apr-Jun, Q3=Jul-Sep, Q4=Oct-Dec):\n"
279
+ " q3_2024 = ['2024-07','2024-08','2024-09']\n"
280
+ " master_df[master_df['booking_vintage'].isin(q3_2024)]\n\n"
281
+
282
+ "Half year (H1=Jan-Jun, H2=Jul-Dec):\n"
283
+ " h1_2024 = ['2024-01','2024-02','2024-03','2024-04','2024-05','2024-06']\n"
284
+ " h2_2024 = ['2024-07','2024-08','2024-09','2024-10','2024-11','2024-12']\n\n"
285
+
286
+ "Comparison in ONE table (e.g. 2024 vs 2025 full year):\n"
287
+ " v2024 = master_df[master_df['booking_vintage'].str.startswith('2024') & master_df['mob'].between(1,12)]\n"
288
+ " v2025 = master_df[master_df['booking_vintage'].str.startswith('2025') & master_df['mob'].between(1,12)]\n"
289
+ " ncl_2024 = (v2024['ncl_amount'].sum() / (v2024['balance'].sum() / 12)) * 100\n"
290
+ " ncl_2025 = (v2025['ncl_amount'].sum() / (v2025['balance'].sum() / 12)) * 100\n"
291
+ " result_1 = pd.DataFrame({'Period':['2024','2025'], 'Yr1_NCL_Rate':[ncl_2024, ncl_2025]})\n"
292
+ " result_1['Delta_vs_2024'] = result_1['Yr1_NCL_Rate'] - result_1['Yr1_NCL_Rate'].iloc[0]\n\n"
293
+
294
+ "Segment-level comparison in ONE table:\n"
295
+ " # Compute metric per segment for each period, merge into one table\n"
296
+ " def ncl_rate(df): return (df['ncl_amount'].sum() / (df['balance'].sum() / 12)) * 100\n"
297
+ " seg_2024 = v2024.groupby('fico_band').apply(ncl_rate).reset_index()\n"
298
+ " seg_2024.columns = ['fico_band','NCL_2024']\n"
299
+ " seg_2025 = v2025.groupby('fico_band').apply(ncl_rate).reset_index()\n"
300
+ " seg_2025.columns = ['fico_band','NCL_2025']\n"
301
+ " result_2 = seg_2024.merge(seg_2025, on='fico_band')\n"
302
+ " result_2['Delta'] = result_2['NCL_2025'] - result_2['NCL_2024']\n"
303
+ " result_2 = result_2.sort_values('NCL_2025', ascending=False)\n\n"
304
+
305
+ # ================================================================
306
+ # SECTION 5: QUESTION TYPE GUIDE
307
+ # ================================================================
308
+ "================================================================\n"
309
+ "SECTION 5: QUESTION TYPE GUIDE — HOW TO PLAN YOUR REQUIREMENTS\n"
310
+ "================================================================\n\n"
311
+
312
+ "Read the question carefully and identify which type it is. Then plan accordingly.\n\n"
313
+
314
+ "TYPE 1 — DIRECT COMPARISON (e.g. 'compare NCL 2024 vs 2025', 'how did Q3 perform vs Q4')\n"
315
+ " Planning approach:\n"
316
+ " - Identify the two periods being compared\n"
317
+ " - Identify which metrics are relevant (if not stated, use Yr1 NCL + 30+@3 as default)\n"
318
+ " - Req 1: Overall metric comparison — ONE table with [Period, Metric, Delta]\n"
319
+ " - Req 2 (optional): Same comparison broken down by most relevant segment\n"
320
+ " - Req 3 (optional): Second segment breakdown or second metric family\n"
321
+ " - DO NOT use two requirements to compute the same thing for two periods separately.\n"
322
+ " Merge them into ONE table.\n\n"
323
+
324
+ "TYPE 2 — FOCUSED EXPLORATION (e.g. 'analyse FICO band performance', 'how is Tier-2 doing')\n"
325
+ " Planning approach:\n"
326
+ " - Identify the segment of interest\n"
327
+ " - Identify the time window (if not stated, use last 4 available quarters)\n"
328
+ " - Req 1: Metric summary across that segment — all values ranked worst to best\n"
329
+ " - Req 2: Trend over time for the highest-risk sub-segments identified in Req 1\n"
330
+ " - Req 3 (optional): Cross-segment comparison (e.g. FICO x channel interaction)\n\n"
331
+
332
+ "TYPE 3 — OPEN / DIAGNOSTIC (e.g. 'what is wrong with the portfolio', 'give me a full view')\n"
333
+ " These questions require broad scanning across all segments and metrics simultaneously.\n"
334
+ " This level of analysis requires significantly more compute, parallel execution, and\n"
335
+ " multiple LLM reasoning loops that are beyond the current system design.\n"
336
+ " ACTION: Return a single requirement that computes a high-level portfolio scorecard\n"
337
+ " (all 4 metrics for last 2 years), and include a note in the description explaining\n"
338
+ " that a full diagnostic requires an advanced multi-agent setup.\n\n"
339
+
340
+ # ================================================================
341
+ # SECTION 6: WORKED EXAMPLES
342
+ # ================================================================
343
+ "================================================================\n"
344
+ "SECTION 6: WORKED EXAMPLES\n"
345
+ "================================================================\n\n"
346
+
347
+ "EXAMPLE A — TYPE 1 COMPARISON:\n"
348
+ "User question: 'Compare Yr1 NCL for 2024 and 2025 across FICO bands'\n"
349
+ "Planning:\n"
350
+ " - Two periods: 2024 full year, 2025 full year\n"
351
+ " - Metric: Yr1 NCL\n"
352
+ " - Segment: fico_band\n"
353
+ " - Req 1: Overall NCL rate for 2024 vs 2025 in one table\n"
354
+ " - Req 2: NCL rate by fico_band for 2024 vs 2025 with delta, sorted worst first\n"
355
+ "Expected output shape for Req 2:\n"
356
+ " fico_band | NCL_2024 | NCL_2025 | Delta\n"
357
+ " 600-650 | 8.2 | 7.1 | -1.1 (improvement)\n"
358
+ " 650-700 | 5.4 | 4.8 | -0.6\n"
359
+ " 700-750 | 3.1 | 2.9 | -0.2\n"
360
+ " 750-800 | 1.8 | 1.5 | -0.3\n\n"
361
+
362
+ "EXAMPLE B — TYPE 2 EXPLORATION:\n"
363
+ "User question: 'How are different sourcing channels performing on early delinquency'\n"
364
+ "Planning:\n"
365
+ " - Segment: sourcing_channel\n"
366
+ " - Metrics: 30+@3 and 30+@6 (early delinquency family)\n"
367
+ " - Time window: last 4 quarters available in data\n"
368
+ " - Req 1: 30+@3 and 30+@6 rates per channel, sorted worst first\n"
369
+ " - Req 2: Trend of 30+@3 by channel across last 4 quarters (one row per quarter)\n"
370
+ "Expected output shape for Req 1:\n"
371
+ " sourcing_channel | rate_30_3 | rate_30_6 | risk_flag\n"
372
+ " Broker | 7.8 | 11.2 | RED\n"
373
+ " Online | 4.1 | 6.8 | AMBER\n"
374
+ " Branch | 2.3 | 4.1 | GREEN\n\n"
375
+
376
+ # ================================================================
377
+ # SECTION 7: CODE GENERATION RULES
378
+ # ================================================================
379
+ "================================================================\n"
380
+ "SECTION 7: CODE GENERATION RULES\n"
381
+ "================================================================\n\n"
382
+
383
+ "1. Always store final result in variable named exactly result_1, result_2, or result_3\n"
384
+ " matching the sequence number of the requirement.\n\n"
385
+
386
+ "2. Always use master_df when analysis needs both segment + performance columns.\n"
387
+ " Use acq only for acquisition-only analysis (e.g. credit limit distribution).\n"
388
+ " Use perf only for portfolio-wide performance with no segmentation.\n\n"
389
+
390
+ "3. booking_vintage and reporting_month are strings. Never use .dt on them.\n"
391
+ " Filter with == or .isin() or .str.startswith() only.\n\n"
392
+
393
+ "4. Column names are EXACTLY as listed in Section 2. Do not guess or invent column names.\n"
394
+ " If a column does not exist in the listed schema, do not use it.\n\n"
395
+
396
+ "5. For comparisons: build ONE merged table with both periods as columns + delta.\n"
397
+ " Do not produce two separate DataFrames for two periods.\n\n"
398
+
399
+ "6. Add a risk_flag column where relevant using benchmarks from Section 3:\n"
400
+ " df['risk_flag'] = pd.cut(df['rate'], bins=[0,3,6,100], labels=['GREEN','AMBER','RED'])\n\n"
401
+
402
+ "7. Sort final result by the primary risk metric descending (worst first).\n\n"
403
+
404
+ "8. In JSON, the code string must use \\n for newlines and escape all internal quotes.\n"
405
+ " Do not put raw newlines inside the JSON string value.\n\n"
406
+
407
+ "9. Keep code focused. No print statements. No plots. No file I/O.\n\n"
408
+
409
+ # ================================================================
410
+ # SECTION 8: JSON OUTPUT FORMAT
411
+ # ================================================================
412
+ "================================================================\n"
413
+ "SECTION 8: OUTPUT FORMAT — RETURN ONLY THIS JSON, NOTHING ELSE\n"
414
+ "================================================================\n\n"
415
+
416
+ "{\n"
417
+ ' "requirements": [\n'
418
+ ' {\n'
419
+ ' "sequence": 1,\n'
420
+ ' "title": "Short descriptive title",\n'
421
+ ' "description": "What this analysis does, why it answers the question, what the output table shows",\n'
422
+ ' "code": "# pandas code here\\nresult_1 = ..."\n'
423
+ ' }\n'
424
+ ' ]\n'
425
+ "}\n\n"
426
+
427
+ "User Question: " + question
428
+ )
429
+
430
+ messages = [
431
+ {
432
+ "role": "system",
433
+ "content": (
434
+ "You are a senior credit risk analyst who writes pandas code for portfolio analytics. "
435
+ "You MUST return ONLY valid JSON with no text before or after it. "
436
+ "Always name final result variables exactly result_1, result_2, or result_3. "
437
+ "booking_vintage and reporting_month are string columns in YYYY-MM format — never use .dt on them. "
438
+ "Always use master_df when you need both segment and performance data."
439
+ )
440
+ },
441
+ {"role": "user", "content": prompt}
442
+ ]
443
+
444
+ response = client.chat.completions.create(
445
+ model=HF_CODE_MODEL_ID,
446
+ messages=messages,
447
+ max_tokens=2048,
448
+ temperature=0.1,
449
+ top_p=0.95
450
+ )
451
+
452
+ response_text = (
453
+ response.choices[0].message.content
454
+ if hasattr(response, "choices")
455
+ else str(response)
456
+ )
457
+
458
+ # Extract JSON
459
+ spec = _extract_json(response_text)
460
+
461
+ if not spec:
462
+ return {
463
+ "success": False,
464
+ "requirements": [],
465
+ "error": f"Failed to parse JSON from LLM response: {response_text[:200]}",
466
+ "raw_response": response_text
467
+ }
468
+
469
+ requirements = spec.get("requirements", [])
470
+
471
+ if not requirements:
472
+ return {
473
+ "success": False,
474
+ "requirements": [],
475
+ "error": f"LLM returned no requirements. Response keys: {list(spec.keys())}",
476
+ "raw_response": response_text[:300]
477
+ }
478
+
479
+ print(f"[DEBUG] Generated {len(requirements)} requirements for question: {question[:80]}")
480
+ for i, req in enumerate(requirements, 1):
481
+ print(f" Req {i}: {req.get('title')}")
482
+
483
+ return {
484
+ "success": True,
485
+ "requirements": requirements,
486
+ "error": None
487
+ }
488
+
489
+
490
+ # ---------------------------------------------------
491
+ # STEP 2: CODE EXECUTION (LOOPED)
492
+ # ---------------------------------------------------
493
+
494
+ def execute_requirement_code(
495
+ code: str,
496
+ acq: pd.DataFrame,
497
+ perf: pd.DataFrame,
498
+ master_df: pd.DataFrame,
499
+ requirement_num: int,
500
+ namespace: dict | None = None
501
+ ):
502
+ """
503
+ Safely execute generated pandas code for a single requirement.
504
+
505
+ FIXES:
506
+ - FIX 1: Single namespace dict passed to exec() so variable assignments
507
+ are reliably captured (Python quirk with separate globals/locals).
508
+ - FIX 2: Smart result detection — checks named keys first, then scans
509
+ for any new DataFrame/Series, then any non-None new variable.
510
+ - FIX 3: _fix_pandas_compatibility() called before exec().
511
+ """
512
+
513
+ # FIX 3: Apply pandas compatibility patches BEFORE executing
514
+ code = _fix_pandas_compatibility(code)
515
+
516
+ # FIX 1: Merge everything into ONE dict so exec() writes back correctly.
517
+ # When you pass separate globals + locals to exec(), Python's bytecode
518
+ # compiler uses STORE_FAST which writes to an internal frame and does NOT
519
+ # update the locals dict you passed in — so result variables always come
520
+ # back None. Using a single namespace avoids this entirely.
521
+ if namespace is None:
522
+ namespace = {
523
+ "pd": pd,
524
+ "generate_metric_view": generate_metric_view,
525
+ "__builtins__": __builtins__,
526
+ # Data available to generated code
527
+ "acq": acq,
528
+ "perf": perf,
529
+ "master_df": master_df,
530
+ }
531
+ else:
532
+ # Keep the shared namespace populated with core helpers and data frames
533
+ namespace.setdefault("pd", pd)
534
+ namespace.setdefault("generate_metric_view", generate_metric_view)
535
+ namespace.setdefault("__builtins__", __builtins__)
536
+ namespace.setdefault("acq", acq)
537
+ namespace.setdefault("perf", perf)
538
+ namespace.setdefault("master_df", master_df)
539
+
540
+ # Snapshot of keys before exec so we can detect newly created variables
541
+ keys_before = set(namespace.keys())
542
+
543
+ try:
544
+ print(f"[DEBUG] Executing requirement {requirement_num}...")
545
+ print(f"[DEBUG] Code preview: {code[:120].strip()}...")
546
+
547
+ exec(code, namespace) # FIX 1: single namespace
548
+
549
+ # FIX 2: Smart result detection — three priority tiers
550
+
551
+ # --- Tier 1: expected named result variables ---
552
+ result = None
553
+ expected_keys = [
554
+ f"result_{requirement_num}",
555
+ "final_result",
556
+ "result",
557
+ ]
558
+ for key in expected_keys:
559
+ if key in namespace and namespace[key] is not None:
560
+ result = namespace[key]
561
+ print(f"[DEBUG] Found result in expected variable: '{key}'")
562
+ break
563
+
564
+ # --- Tier 2: any NEW DataFrame or Series created during exec ---
565
+ if result is None:
566
+ new_keys = set(namespace.keys()) - keys_before
567
+ for key in new_keys:
568
+ val = namespace[key]
569
+ if isinstance(val, (pd.DataFrame, pd.Series)) and val is not None:
570
+ result = val
571
+ print(f"[DEBUG] Found result by scanning new DataFrame/Series: '{key}'")
572
+ break
573
+
574
+ # --- Tier 3: any new non-None, non-private variable ---
575
+ if result is None:
576
+ new_keys = set(namespace.keys()) - keys_before
577
+ for key in sorted(new_keys): # sorted for determinism
578
+ if key.startswith("_"):
579
+ continue
580
+ val = namespace[key]
581
+ if val is not None:
582
+ result = val
583
+ print(f"[DEBUG] Fallback: found result in new variable: '{key}'")
584
+ break
585
+
586
+ if result is None:
587
+ result = "Code executed successfully but no result variable was found in namespace."
588
+
589
+ print(f"[DEBUG] Req {requirement_num} success. Result type: {type(result).__name__}")
590
+ return {
591
+ "success": True,
592
+ "result": result,
593
+ "error": None
594
+ }
595
+
596
+ except Exception as e:
597
+ import traceback
598
+ tb = traceback.format_exc()
599
+ print(f"[DEBUG] Req {requirement_num} FAILED: {str(e)}")
600
+ print(f"[DEBUG] Traceback:\n{tb}")
601
+ return {
602
+ "success": False,
603
+ "result": None,
604
+ "error": str(e)
605
+ }
606
+
607
+
608
+ def execute_all_requirements(
609
+ requirements: list,
610
+ acq: pd.DataFrame,
611
+ perf: pd.DataFrame,
612
+ master_df: pd.DataFrame
613
+ ):
614
+ """
615
+ Execute all requirements sequentially, building context.
616
+ """
617
+
618
+ print(f"[DEBUG] Starting execution of {len(requirements)} requirements")
619
+ all_results = []
620
+ context_text = ""
621
+
622
+ # Reuse one namespace so variables created in earlier requirements remain
623
+ # available to later requirements (for example, result_1 -> requirement 2).
624
+ shared_namespace = {
625
+ "pd": pd,
626
+ "generate_metric_view": generate_metric_view,
627
+ "__builtins__": __builtins__,
628
+ "acq": acq,
629
+ "perf": perf,
630
+ "master_df": master_df,
631
+ }
632
+
633
+ for i, req in enumerate(requirements, 1):
634
+ code = req.get("code", "")
635
+ description = req.get("description", "")
636
+ title = req.get("title", f"Analysis {i}")
637
+
638
+ exec_result = execute_requirement_code(
639
+ code,
640
+ acq,
641
+ perf,
642
+ master_df,
643
+ i,
644
+ namespace=shared_namespace,
645
+ )
646
+
647
+ all_results.append({
648
+ "sequence": i,
649
+ "title": title,
650
+ "description": description,
651
+ "code": code,
652
+ # "success" is what app.py checks via res.get("success")
653
+ # "execution_success" kept for backward compatibility
654
+ "success": exec_result["success"],
655
+ "execution_success": exec_result["success"],
656
+ "result": exec_result["result"],
657
+ "error": exec_result.get("error")
658
+ })
659
+
660
+ # Build context for interpretation
661
+ if exec_result["success"]:
662
+ context_text += f"\nAnalysis {i} ({title}):\n{str(exec_result['result'])}\n"
663
+ else:
664
+ context_text += f"\nAnalysis {i} ({title}) FAILED:\n{exec_result['error']}\n"
665
+
666
+ return all_results, context_text
667
+
668
+
669
+ # ---------------------------------------------------
670
+ # STEP 3: RESULT INTERPRETATION
671
+ # ---------------------------------------------------
672
+
673
+ def interpret_all_results(
674
+ question: str,
675
+ all_results: list,
676
+ context_text: str
677
+ ):
678
+ """
679
+ Senior risk analyst LLM interprets all results holistically.
680
+ """
681
+
682
+ print(f"[DEBUG] Interpreting results for {len(all_results)} analyses")
683
+ print(f"[DEBUG] Successful executions: {sum(1 for r in all_results if r.get('success'))}")
684
+
685
+ client = _get_hf_client()
686
+
687
+ # Format all analyses
688
+ analyses_text = ""
689
+ for res in all_results:
690
+ analyses_text += f"\n{'=' * 60}\n"
691
+ analyses_text += f"Analysis {res['sequence']}: {res['title']}\n"
692
+ analyses_text += f"Description: {res['description']}\n"
693
+ analyses_text += f"{'=' * 60}\n"
694
+ if res["success"]:
695
+ analyses_text += f"Result:\n{str(res['result'])}\n"
696
+ else:
697
+ analyses_text += f"Execution Error: {res['error']}\n"
698
+
699
+ prompt = (
700
+ "You are a senior retail credit risk analyst with 15+ years of portfolio management experience.\n\n"
701
+
702
+ "You have just run a set of pandas analyses on a credit portfolio. "
703
+ "Your task is to interpret the results and deliver a structured, specific, "
704
+ "actionable risk assessment. Do not repeat raw numbers from the tables — "
705
+ "interpret what they mean for the business.\n\n"
706
+
707
+ "RISK BENCHMARKS FOR REFERENCE:\n"
708
+ " 30+@3 : Green < 3% | Amber 3-6% | Red > 6%\n"
709
+ " 30+@6 : Green < 5% | Amber 5-9% | Red > 9%\n"
710
+ " 60+@6 : Green < 2% | Amber 2-4% | Red > 4%\n"
711
+ " Yr1 NCL: Green < 3% | Amber 3-6% | Red > 6%\n\n"
712
+
713
+ "STRUCTURE YOUR RESPONSE IN EXACTLY THESE 4 SECTIONS:\n\n"
714
+
715
+ "1. HEADLINE FINDING (2-3 sentences)\n"
716
+ " The single most important thing the data shows. Be specific — name the metric, "
717
+ " the segment or vintage, and the direction (improving/worsening).\n\n"
718
+
719
+ "2. KEY FINDINGS (3-5 bullet points)\n"
720
+ " Each bullet must:\n"
721
+ " - Name a specific segment, vintage, or metric (not generic statements)\n"
722
+ " - State the actual value and what benchmark zone it falls in (Green/Amber/Red)\n"
723
+ " - State whether it is improving, stable, or worsening vs the comparison period\n"
724
+ " Example: '• Broker channel 30+@3 is 7.8% (RED) — worsening by +2.1pp vs 2024, "
725
+ " suggesting deteriorating acquisition quality in this channel.'\n\n"
726
+
727
+ "3. RED FLAGS (list only if any metric is in Amber or Red zone)\n"
728
+ " For each red flag:\n"
729
+ " - Name the segment/vintage and metric\n"
730
+ " - State the value and benchmark zone\n"
731
+ " - Give one specific business hypothesis for why this is happening\n"
732
+ " If no red flags: write 'No red flags — all metrics within Green benchmarks.'\n\n"
733
+
734
+ "4. RECOMMENDATIONS (2-4 actionable items)\n"
735
+ " Each recommendation must be:\n"
736
+ " - Tied to a specific finding above (not generic advice)\n"
737
+ " - Actionable by a risk or credit team (tighten policy, adjust limit, investigate, monitor)\n"
738
+ " - Prioritised: label each as IMMEDIATE, SHORT-TERM, or MONITOR\n"
739
+ " Example: '[IMMEDIATE] Tighten credit bureau cut-off for Broker channel acquisitions — "
740
+ " 30+@3 at 7.8% exceeds Red threshold and is trending upward.'\n\n"
741
+
742
+ "User's Original Question:\n" + question + "\n\n"
743
+
744
+ "Analyses Performed:\n" + analyses_text + "\n\n"
745
+
746
+ "Provide your structured interpretation now:"
747
+ )
748
+
749
+ messages = [
750
+ {
751
+ "role": "system",
752
+ "content": (
753
+ "You are a senior credit risk analyst delivering a structured portfolio risk assessment. "
754
+ "Be specific — always name segments, vintages, and metrics by name. "
755
+ "Always reference benchmark zones (Green/Amber/Red). "
756
+ "Never give generic advice. Every recommendation must trace back to a specific data finding."
757
+ )
758
+ },
759
+ {"role": "user", "content": prompt}
760
+ ]
761
+
762
+ response = client.chat.completions.create(
763
+ model=HF_MODEL_ID,
764
+ messages=messages,
765
+ max_tokens=1024,
766
+ temperature=0.3,
767
+ top_p=0.95
768
+ )
769
+
770
+ interpretation = (
771
+ response.choices[0].message.content
772
+ if hasattr(response, "choices")
773
+ else str(response)
774
+ )
775
+ return interpretation
776
+
777
+
778
+ # ---------------------------------------------------
779
+ # MASTER ORCHESTRATOR FUNCTION
780
+ # ---------------------------------------------------
781
+
782
+ def run_deep_dive_analysis(
783
+ question: str,
784
+ acq: pd.DataFrame,
785
+ perf: pd.DataFrame,
786
+ master_df: pd.DataFrame
787
+ ):
788
+ """
789
+ End-to-end deep dive analysis:
790
+ 1. Break question into 1-3 structured requirements
791
+ 2. Generate code for each requirement
792
+ 3. Execute each requirement's code sequentially
793
+ 4. Synthesize results and provide senior analyst interpretation
794
+ """
795
+
796
+ print(f"\n[DEEP DIVE START] Question: {question}")
797
+ print(f"[DEBUG] Data shapes - Acq: {acq.shape}, Perf: {perf.shape}, Master: {master_df.shape}")
798
+
799
+ # Step 1: Generate requirements
800
+ print("[DEBUG] Step 1: Generating requirements...")
801
+ req_response = generate_analysis_requirements(question, acq, perf, master_df)
802
+
803
+ if not req_response["success"]:
804
+ return {
805
+ "success": False,
806
+ "question": question,
807
+ "requirements": [],
808
+ "all_results": [],
809
+ "interpretation": f"Failed to generate requirements: {req_response['error']}",
810
+ "error": req_response["error"]
811
+ }
812
+
813
+ requirements = req_response["requirements"][:3] # Cap at 3
814
+
815
+ # Step 2 & 3: Execute all requirements
816
+ print(f"[DEBUG] Step 2-3: Executing {len(requirements)} requirements...")
817
+ all_results, context_text = execute_all_requirements(requirements, acq, perf, master_df)
818
+
819
+ # Step 4: Interpret results
820
+ print("[DEBUG] Step 4: Interpreting all results...")
821
+ interpretation = interpret_all_results(question, all_results, context_text)
822
+ print("[DEEP DIVE END] Analysis complete\n")
823
+
824
+ return {
825
+ "success": True,
826
+ "question": question,
827
+ "requirements": requirements,
828
+ "all_results": all_results,
829
+ "interpretation": interpretation,
830
+ "error": None
831
+ }