ZakyF commited on
Commit
0e5c866
·
1 Parent(s): 8cb9c5e

feat: Enhance risk analysis with new risk drivers, update UI styling, and add comprehensive reporting with visual analytics and integrated AI advice.

Browse files
Files changed (1) hide show
  1. app.py +130 -87
app.py CHANGED
@@ -6,147 +6,190 @@ import plotly.graph_objects as go
6
  from google import genai
7
  from datetime import timedelta
8
 
9
- # --- CONFIG & AI ---
10
  GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY")
11
- client_ai = genai.Client(api_key=GOOGLE_API_KEY) if GOOGLE_API_KEY else None
12
 
13
- # --- UI STYLING ---
 
14
  custom_css = """
15
  @import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;600;700;800&display=swap');
16
- body, .gradio-container { font-family: 'Plus Jakarta Sans', sans-serif !important; background-color: #FFFFFF !important; }
17
- .nagari-header { background: linear-gradient(135deg, #0514DE 0%, #82C3EB 100%); padding: 35px; border-radius: 15px; border-bottom: 6px solid #F7BD87; margin-bottom: 25px; text-align: center; }
18
- .nagari-header h1 { color: #FFFFFF !important; font-weight: 800 !important; margin: 0; }
19
- .card { background: #FFFFFF; border-radius: 12px; padding: 20px; border: 1px solid #E0EDF4; box-shadow: 0 4px 10px rgba(0,0,0,0.05); }
20
- .status-pill { padding: 5px 15px; border-radius: 20px; color: white; font-weight: bold; font-size: 0.9em; }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
21
  """
22
 
23
- class ArchonBankNagari:
24
  def __init__(self):
25
  self.load_data()
26
 
27
  def load_data(self):
28
- # FASE 1: DATA FOUNDATION
29
- self.df_txn = pd.read_csv('transactions.csv', parse_dates=['date']).sort_values('date')
30
- self.df_cust = pd.read_csv('customers.csv')
31
- self.df_bal = pd.read_csv('balances_revised.csv', parse_dates=['month']).sort_values('month')
32
- self.df_rep = pd.read_csv('repayments_revised.csv', parse_dates=['due_date'])
33
- self.df_off = pd.read_csv('offers.csv')
 
 
34
 
35
  def analyze(self, customer_id):
36
  cid = str(customer_id).strip().upper()
37
  u_txn = self.df_txn[self.df_txn['customer_id'] == cid].copy()
38
- u_bal = self.df_bal[self.df_bal['customer_id'] == cid].copy()
39
- u_rep = self.df_rep[self.df_rep['customer_id'] == cid].copy()
40
- u_info = self.df_cust[self.df_cust['customer_id'] == cid].iloc[0] if cid in self.df_cust['customer_id'].values else None
41
 
42
- if u_txn.empty or u_info is None: return None
 
43
 
44
- # --- FASE 2: TRANSACTION INTELLIGENCE ---
45
- # Mapping Expense Type
46
  essential_cats = {'groceries', 'utilities', 'transport', 'healthcare', 'education'}
47
  u_txn['expense_type'] = u_txn['raw_description'].apply(lambda x: 'essential' if any(k in x.lower() for k in essential_cats) else 'discretionary')
48
 
49
- # Risk Spending Flag (Rolling 30 days median)
50
  u_txn = u_txn.set_index('date').sort_index()
51
  u_txn['rolling_median'] = u_txn['amount'].rolling('30D').median()
52
  u_txn['risk_spending_flag'] = ((u_txn['expense_type'] == 'discretionary') & (u_txn['amount'] > u_txn['rolling_median'])).astype(int)
53
  u_txn = u_txn.reset_index()
54
 
55
- # Behavior Signal
56
- q75 = u_txn['amount'].quantile(0.75)
57
- def get_signal(row):
58
- if row['expense_type'] == 'discretionary' and row['amount'] > q75: return 'impulsive'
59
- return 'normal'
60
- u_txn['behavior_signal'] = u_txn.apply(get_signal, axis=1)
61
-
62
- # --- FASE 3 & 4: AGGREGATION & RISK ---
63
- income = u_txn[u_txn['transaction_type'] == 'credit']['amount'].sum()
64
  expense = u_txn[u_txn['transaction_type'] == 'debit']['amount'].sum()
65
- ref_inc = max(income, u_info['monthly_income'])
66
- er = min(expense / ref_inc, 1.0)
67
 
68
- # Risk Scoring (30/20/20/20/10)
69
  er_s = 1.0 if er > 0.8 else (0.5 if er > 0.5 else 0.0)
70
  bt_s = 1.0 if len(u_bal) >= 2 and u_bal.iloc[-1]['avg_balance'] < u_bal.iloc[-2]['avg_balance'] else 0.0
71
  od_s = 1.0 if (u_bal['min_balance'] <= 0).any() else 0.0
72
  mp_s = 1.0 if (u_rep['status'] == 'late').any() else 0.0
73
 
74
- final_score = (0.3 * er_s) + (0.2 * bt_s) + (0.2 * od_s) + (0.2 * mp_s) + 0.1
75
- risk_lv = "HIGH" if final_score >= 0.7 else ("MEDIUM" if final_score >= 0.4 else "LOW")
76
-
 
 
 
 
 
 
77
  # --- FASE 5: NBO ENGINE ---
78
- disc_ratio = u_txn[u_txn['expense_type'] == 'discretionary']['amount'].sum() / expense if expense > 0 else 0
 
 
79
  if risk_lv == "HIGH" or mp_s == 1:
80
  action, reason = "Restructuring Suggestion", "repayment_risk_detected"
81
- elif er > 0.6:
82
  action, reason = "Spending Control", "high_discretionary_spending"
83
- elif risk_lv == "LOW" and disc_ratio < 0.3:
84
  action, reason = "Promote Investment", "surplus_balance"
85
  else:
86
  action, reason = "Financial Education", "stable_cashflow"
87
 
88
- return risk_lv, final_score, er, u_bal, u_txn, action, reason
89
 
90
- def get_ai_narrative(self, risk_lv, er, cid, u_txn):
91
- if not client_ai: return "Koneksi Advisor AI tidak tersedia."
92
- tx = u_txn.tail(2)['raw_description'].tolist()
93
- prompt = f"Advisor Bank Nagari: Nasabah {cid} ({risk_lv} risk, expense {er:.1%}). Terakhir belanja di {tx}. Beri 1 saran hangat personal (Bapak/Ibu) maks 3 kalimat."
94
- try:
95
- return client_ai.models.generate_content(model="gemini-1.5-flash", contents=prompt).text
96
- except: return "Kami menyarankan Bapak/Ibu untuk menjaga rasio pengeluaran agar tetap stabil bulan ini."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
97
 
98
- # --- UI ---
99
- engine = ArchonBankNagari()
100
 
101
  def run_app(cust_id):
102
- data = engine.analyze(cust_id)
103
- if not data: return "## ❌ ID Tidak Valid", "Gunakan C0001 - C0120", None, None
104
-
105
- risk_lv, score, er, u_bal, u_txn, action, reason = data
106
- advice = engine.get_ai_narrative(risk_lv, er, cust_id, u_txn)
107
 
108
- # Graphs
109
- f1 = go.Figure()
110
- f1.add_trace(go.Scatter(x=u_bal['month'], y=u_bal['avg_balance'], name='Avg Balance', line=dict(color='#0514DE', width=4)))
111
- f1.update_layout(title="Trend Saldo (Fase 6)", template="plotly_white")
112
 
113
- f2 = go.Figure()
114
- u_txn['m'] = u_txn['date'].dt.to_period('M').dt.to_timestamp()
115
- cf = u_txn.groupby(['m', 'transaction_type'])['amount'].sum().unstack().fillna(0)
116
- f2.add_trace(go.Bar(x=cf.index, y=cf.get('credit', 0), name='Inflow', marker_color='#82C3EB'))
117
- f2.add_trace(go.Bar(x=cf.index, y=cf.get('debit', 0), name='Outflow', marker_color='#0514DE'))
118
- f2.update_layout(title="Inflow vs Outflow", barmode='group', template='plotly_white')
119
-
120
  color = "#ef4444" if risk_lv == "HIGH" else ("#f59e0b" if risk_lv == "MEDIUM" else "#10b981")
121
- report = f"""
122
- ### 🛡️ HASIL AUDIT ARCHON-AI
123
- - **Risk Score**: {score:.2f} ({risk_lv} RISK)
124
- - **Expense Ratio**: {er:.1%}
125
-
126
- **🎯 REKOMENDASI NBO:**
127
- **{action}** ({reason})
128
-
129
- **💡 SARAN VIRTUAL ADVISOR:**
130
- {advice}
131
  """
132
-
133
- status_html = f"<div style='background:{color}; color:white; padding:15px; border-radius:10px; text-align:center;'><h2>STATUS: {risk_lv}</h2></div>"
134
- return status_html, report, f1, f2
135
 
136
  with gr.Blocks(css=custom_css) as demo:
137
- gr.HTML("<div class='nagari-header'><h1>ARCHON-AI</h1></div>")
 
138
  with gr.Row():
139
  with gr.Column(scale=1):
140
- id_in = gr.Textbox(label="Customer ID")
141
- btn = gr.Button("ANALYZE", variant="primary")
142
  out_status = gr.HTML()
 
143
  with gr.Column(scale=2):
144
  with gr.Tabs():
145
- with gr.TabItem("Audit & Advice"): out_report = gr.Markdown()
146
- with gr.TabItem("Visual Trends"):
147
- gr.Plot(label="Balance")
148
- gr.Plot(label="Cashflow")
 
149
 
150
- btn.click(fn=run_app, inputs=id_in, outputs=[out_status, out_report, gr.Plot(), gr.Plot()])
151
 
152
  demo.launch()
 
6
  from google import genai
7
  from datetime import timedelta
8
 
9
+ # --- INITIALIZATION ---
10
  GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY")
11
+ ai_client = genai.Client(api_key=GOOGLE_API_KEY) if GOOGLE_API_KEY else None
12
 
13
+ # --- UI STYLE: BANK NAGARI PREMIUM ---
14
+ # Palette: #0514DE (Deep Blue), #82C3EB (Light Blue), #F7BD87 (Gold), #FFFFFF (White)
15
  custom_css = """
16
  @import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;600;700;800&display=swap');
17
+ body, .gradio-container { font-family: 'Plus Jakarta Sans', sans-serif !important; background-color: #f8fafc !important; }
18
+
19
+ .nagari-header {
20
+ background: linear-gradient(135deg, #0514DE 0%, #82C3EB 100%);
21
+ padding: 35px; border-radius: 15px; border-bottom: 6px solid #F7BD87;
22
+ margin-bottom: 25px; text-align: center; box-shadow: 0 10px 15px rgba(5, 20, 222, 0.15);
23
+ }
24
+
25
+ .nagari-header h1 {
26
+ color: #FFFFFF !important; font-weight: 800 !important; margin: 0; font-size: 2.2em; letter-spacing: 1px;
27
+ }
28
+
29
+ .card-overview { background: #FFFFFF; border-radius: 12px; padding: 25px; border: 1px solid #e2e8f0; box-shadow: 0 1px 3px rgba(0,0,0,0.1); }
30
+
31
+ .report-section { background: #FFFFFF; border-radius: 12px; padding: 30px; border-left: 8px solid #0514DE; min-height: 400px; }
32
+
33
+ .status-badge { padding: 8px 20px; border-radius: 30px; color: white; font-weight: 700; display: inline-block; margin-bottom: 15px; }
34
+
35
+ .advice-container { background: #fffdf0; border: 1px solid #F7BD87; padding: 20px; border-radius: 10px; margin-top: 20px; }
36
  """
37
 
38
+ class ArchonNagariEngine:
39
  def __init__(self):
40
  self.load_data()
41
 
42
  def load_data(self):
43
+ # FASE 1: DATA FOUNDATION (Single Source of Truth)
44
+ try:
45
+ self.df_txn = pd.read_csv('transactions.csv', parse_dates=['date']).sort_values('date')
46
+ self.df_cust = pd.read_csv('customers.csv')
47
+ self.df_bal = pd.read_csv('balances_revised.csv', parse_dates=['month']).sort_values('month')
48
+ self.df_rep = pd.read_csv('repayments_revised.csv', parse_dates=['due_date'])
49
+ except Exception as e:
50
+ print(f"Data Loading Error: {e}")
51
 
52
  def analyze(self, customer_id):
53
  cid = str(customer_id).strip().upper()
54
  u_txn = self.df_txn[self.df_txn['customer_id'] == cid].copy()
55
+ u_bal = self.df_bal[self.df_bal['customer_id'] == cid].sort_values('month')
56
+ u_rep = self.df_rep[self.df_rep['customer_id'] == cid]
57
+ u_info_list = self.df_cust[self.df_cust['customer_id'] == cid]
58
 
59
+ if u_txn.empty or u_info_df_empty := u_info_list.empty: return None
60
+ u_info = u_info_list.iloc[0]
61
 
62
+ # --- FASE 2: TRANSACTION INTELLIGENCE (Non-ML Rules) ---
 
63
  essential_cats = {'groceries', 'utilities', 'transport', 'healthcare', 'education'}
64
  u_txn['expense_type'] = u_txn['raw_description'].apply(lambda x: 'essential' if any(k in x.lower() for k in essential_cats) else 'discretionary')
65
 
66
+ # Risk Spending Flag (Rolling 30D Median)
67
  u_txn = u_txn.set_index('date').sort_index()
68
  u_txn['rolling_median'] = u_txn['amount'].rolling('30D').median()
69
  u_txn['risk_spending_flag'] = ((u_txn['expense_type'] == 'discretionary') & (u_txn['amount'] > u_txn['rolling_median'])).astype(int)
70
  u_txn = u_txn.reset_index()
71
 
72
+ # --- FASE 3 & 4: RISK LABELING (30/20/20/20/10) ---
73
+ income_txn = u_txn[u_txn['transaction_type'] == 'credit']['amount'].sum()
74
+ ref_income = max(income_txn, u_info['monthly_income'])
 
 
 
 
 
 
75
  expense = u_txn[u_txn['transaction_type'] == 'debit']['amount'].sum()
76
+ er = min(expense / ref_income, 1.0)
 
77
 
 
78
  er_s = 1.0 if er > 0.8 else (0.5 if er > 0.5 else 0.0)
79
  bt_s = 1.0 if len(u_bal) >= 2 and u_bal.iloc[-1]['avg_balance'] < u_bal.iloc[-2]['avg_balance'] else 0.0
80
  od_s = 1.0 if (u_bal['min_balance'] <= 0).any() else 0.0
81
  mp_s = 1.0 if (u_rep['status'] == 'late').any() else 0.0
82
 
83
+ risk_score = (0.3 * er_s) + (0.2 * bt_s) + (0.2 * od_s) + (0.2 * mp_s) + 0.1
84
+ risk_lv = "HIGH" if risk_score >= 0.7 else ("MEDIUM" if risk_score >= 0.4 else "LOW")
85
+
86
+ # Risk Drivers
87
+ drivers = []
88
+ if er_s >= 0.5: drivers.append("HIGH_EXPENSE_RATIO")
89
+ if bt_s == 1.0: drivers.append("DECLINING_BALANCE")
90
+ if od_s == 1.0: drivers.append("OVERDRAFT_HISTORY")
91
+
92
  # --- FASE 5: NBO ENGINE ---
93
+ disc_spending = u_txn[u_txn['expense_type'] == 'discretionary']['amount'].sum()
94
+ disc_ratio = disc_spending / expense if expense > 0 else 0
95
+
96
  if risk_lv == "HIGH" or mp_s == 1:
97
  action, reason = "Restructuring Suggestion", "repayment_risk_detected"
98
+ elif er > 0.6 or disc_ratio > 0.5:
99
  action, reason = "Spending Control", "high_discretionary_spending"
100
+ elif risk_lv == "LOW":
101
  action, reason = "Promote Investment", "surplus_balance"
102
  else:
103
  action, reason = "Financial Education", "stable_cashflow"
104
 
105
+ return risk_lv, risk_score, er, disc_ratio, u_bal, u_txn, expense, ref_income, action, reason, drivers
106
 
107
+ def get_summary_report(self, risk_lv, score, er, u_bal, expense, income, action, reason, drivers, cid, u_txn):
108
+ # FASE 6: EXPLAINABLE SUMMARY (CLEAN & PROFESSIONAL)
109
+ msg = f"### 📊 LAPORAN ANALISIS RESILIENSI: {risk_lv} RISK\n\n"
110
+
111
+ msg += f"**1. Evaluasi Parameter Risiko (Fase 4)**\n"
112
+ msg += f"* **Indeks Risiko**: {score:.2f} (Skala 0-1). Perhitungan menggunakan bobot perbankan Nagari.\n"
113
+ msg += f"* **Faktor Pemicu (Drivers)**: {', '.join(drivers) if drivers else 'Normal'}\n"
114
+ msg += f"* **Efisiensi Anggaran**: {er:.1%}. Bapak/Ibu mengalokasikan Rp{expense:,.0f} dari total daya beli Rp{income:,.0f}.\n\n"
115
+
116
+ msg += f"**2. Profil Kesehatan Saldo (Fase 3)**\n"
117
+ if not u_bal.empty:
118
+ last_bal = u_bal.iloc[-1]['avg_balance']
119
+ msg += f"* **Saldo Rata-rata**: Rp{last_bal:,.0f}.\n"
120
+ msg += f"* **Status Tren**: {'Terdeteksi tren penurunan saldo harian.' if len(u_bal) > 1 and last_bal < u_bal.iloc[-2]['avg_balance'] else 'Pertumbuhan saldo terpantau stabil.'}\n\n"
121
+
122
+ msg += f"**3. Rekomendasi Tindakan (NBO Engine - Fase 5)**\n"
123
+ msg += f"* **Aksi Rekomendasi**: **{action}**\n"
124
+ msg += f"* **Dasar Keputusan**: {reason}\n"
125
+
126
+ # Adaptive AI Advice
127
+ if ai_client:
128
+ try:
129
+ tx_last = u_txn.tail(2)['raw_description'].tolist()
130
+ prompt = f"Advisor Bank Nagari: Nasabah {cid} risiko {risk_lv}, pengeluaran {er:.1%}. Terakhir belanja di {tx_last}. Beri 1 saran hangat personal (Bapak/Ibu) maks 3 kalimat."
131
+ resp = ai_client.models.generate_content(model="gemini-1.5-flash", contents=prompt)
132
+ msg += f"\n---\n**💡 SARAN VIRTUAL ADVISOR (GEN-AI):**\n_{resp.text}_"
133
+ except: pass
134
+
135
+ return msg
136
+
137
+ def create_plots(self, u_bal, u_txn):
138
+ # FASE 6: VISUAL ANALYTICS
139
+ u_txn['m'] = u_txn['date'].dt.to_period('M').dt.to_timestamp()
140
+ cf = u_txn.groupby(['m', 'transaction_type'])['amount'].sum().unstack().fillna(0)
141
+
142
+ f1 = go.Figure()
143
+ f1.add_trace(go.Bar(x=cf.index, y=cf.get('credit', 0), name='Pemasukan', marker_color='#82C3EB'))
144
+ f1.add_trace(go.Bar(x=cf.index, y=cf.get('debit', 0), name='Pengeluaran', marker_color='#0514DE'))
145
+ f1.update_layout(title="Laporan Arus Kas (Inflow vs Outflow)", barmode='group', template='plotly_white')
146
+
147
+ f2 = go.Figure()
148
+ f2.add_trace(go.Scatter(x=u_bal['month'], y=u_bal['avg_balance'], name='Saldo Rata-rata', line=dict(color='#F7BD87', width=4)))
149
+ f2.add_trace(go.Bar(x=u_bal['month'], y=u_bal['min_balance'], name='Saldo Minimum', marker_color='#E0EDF4', opacity=0.5))
150
+ f2.update_layout(title="Tren Pertumbuhan & Saldo Minimum", template='plotly_white')
151
+
152
+ return f1, f2
153
 
154
+ # --- UI LOGIC ---
155
+ engine = ArchonNagariEngine()
156
 
157
  def run_app(cust_id):
158
+ res = engine.analyze(cust_id)
159
+ if not res: return "## ❌ ID Tidak Valid", "Mohon masukkan ID C0001 - C0120", None, None
 
 
 
160
 
161
+ risk_lv, score, er, dr, u_bal, u_txn, exp, inc, action, reason, drivers = res
162
+ report = engine.get_summary_report(risk_lv, score, er, u_bal, exp, inc, action, reason, drivers, cust_id, u_txn)
163
+ p1, p2 = engine.create_plots(u_bal, u_txn)
 
164
 
 
 
 
 
 
 
 
165
  color = "#ef4444" if risk_lv == "HIGH" else ("#f59e0b" if risk_lv == "MEDIUM" else "#10b981")
166
+ status_html = f"""
167
+ <div class='card-overview'>
168
+ <h2 style='color: #0514DE; margin:0;'>Dashboard Ringkasan</h2>
169
+ <div class='status-badge' style='background:{color}; margin-top:15px;'>{risk_lv} RISK LEVEL</div>
170
+ <p style='margin-top:10px;'><b>Risk Score:</b> {score:.2f} / 1.00</p>
171
+ <p><b>Expense Ratio:</b> {er:.1%}</p>
172
+ </div>
 
 
 
173
  """
174
+ return status_html, report, p1, p2
 
 
175
 
176
  with gr.Blocks(css=custom_css) as demo:
177
+ gr.HTML("<div class='nagari-header'><h1>🛡️ **ARCHON-AI**: BANK NAGARI</h1></div>")
178
+
179
  with gr.Row():
180
  with gr.Column(scale=1):
181
+ id_in = gr.Textbox(label="Customer ID", placeholder="Masukkan ID (contoh: C0005)")
182
+ btn = gr.Button("JALANKAN ANALISIS", variant="primary")
183
  out_status = gr.HTML()
184
+
185
  with gr.Column(scale=2):
186
  with gr.Tabs():
187
+ with gr.Tab("Audit Summary"):
188
+ out_report = gr.Markdown(elem_classes="report-section")
189
+ with gr.Tab("Visual Analytics"):
190
+ plot_cf = gr.Plot(label="Cashflow Insight")
191
+ plot_bal = gr.Plot(label="Balance History")
192
 
193
+ btn.click(fn=run_app, inputs=id_in, outputs=[out_status, out_report, plot_cf, plot_bal])
194
 
195
  demo.launch()