ZakyF commited on
Commit
77b155b
·
1 Parent(s): aaf8697

feat: Implement conditional AI client initialization, rename core engine, refine transaction classification with new essential list, update risk scoring details, enhance NBO actions and report narrative, and adjust visualization labels.

Browse files
Files changed (1) hide show
  1. app.py +66 -79
app.py CHANGED
@@ -7,40 +7,27 @@ from google import genai
7
 
8
  # --- CONFIG AI ---
9
  GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY")
10
- client_ai = genai.Client(api_key=GOOGLE_API_KEY)
11
 
12
- # --- UI STYLE BANK NAGARI PREMIUM ---
13
  custom_css = """
14
  @import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;600;700;800&display=swap');
15
  body, .gradio-container { font-family: 'Plus Jakarta Sans', sans-serif !important; background-color: #f8fafc !important; }
16
-
17
- .nagari-header {
18
- background: linear-gradient(135deg, #0514DE 0%, #82C3EB 100%);
19
- padding: 35px; border-radius: 15px; border-bottom: 6px solid #F7BD87;
20
- margin-bottom: 25px; text-align: center;
21
- }
22
  .nagari-header h1 { color: #FFFFFF !important; font-weight: 800 !important; margin: 0; font-size: 2.2em; }
23
-
24
- .card-sidebar {
25
- background: #E0EDF4; border-radius: 15px; padding: 25px;
26
- border: 1.5px solid #82C3EB; box-shadow: 0 4px 12px rgba(5, 20, 222, 0.1);
27
- }
28
  .health-badge { background: white; padding: 12px; border-radius: 8px; margin-bottom: 12px; border-left: 5px solid #0514DE; font-size: 0.95em; }
29
-
30
- .report-card {
31
- background: white; border-radius: 12px; padding: 30px;
32
- border: 1px solid #E2E8F0; line-height: 1.8; color: #1e293b;
33
- }
34
  .nbo-box { background: #fffdf0; border: 2px solid #F7BD87; padding: 20px; border-radius: 10px; margin-top: 20px; }
35
  .viz-explanation { background: #f1f5f9; padding: 15px; border-radius: 8px; margin-top: 10px; font-size: 0.9em; border-left: 4px solid #82C3EB; }
36
  """
37
 
38
- class ArchonExecutiveEngine:
39
  def __init__(self):
40
  self.load_data()
41
 
42
  def load_data(self):
43
- # FASE 1: Data Foundation
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')
@@ -58,117 +45,119 @@ class ArchonExecutiveEngine:
58
  if u_txn.empty or u_info_df.empty: return None
59
  u_info = u_info_df.iloc[0]
60
 
61
- # --- FASE 2: TRANSACTION INTELLIGENCE (Logic Only) ---
62
- essential_cats = {'groceries', 'utilities', 'transport', 'healthcare', 'education'}
63
- essential_purposes = {'bill_payment', 'salary_deduction', 'loan_repayment'}
64
- disc_cats = {'restaurant', 'cafe', 'entertainment', 'fashion', 'online_shopping', 'travel'}
65
 
66
  def classify_exp(row):
67
- desc = str(row.get('raw_description', '')).lower()
68
  cat = str(row.get('merchant_category', '')).lower()
69
  purp = str(row.get('purpose_code', '')).lower()
70
- if cat in essential_cats or purp in essential_purposes or any(k in desc for k in essential_cats):
 
 
71
  return 'essential'
72
  return 'discretionary'
73
 
74
  u_txn['expense_type'] = u_txn.apply(classify_exp, axis=1)
75
 
76
- # --- FASE 3 & 4: AGGREGATION & RISK ---
77
  inc_txn = u_txn[u_txn['transaction_type'] == 'credit']['amount'].sum()
78
  ref_inc = max(inc_txn, u_info['monthly_income'])
79
  exp_total = u_txn[u_txn['transaction_type'] == 'debit']['amount'].sum()
80
  er = min(exp_total / ref_inc, 1.0) if ref_inc > 0 else 1.0
81
 
82
- # Risk Weights (30/20/20/20/10)
83
  er_s = 1.0 if er > 0.8 else (0.5 if er > 0.5 else 0.0)
84
  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
85
  od_s = 1.0 if (u_bal['min_balance'] <= 0).any() else 0.0
86
  mp_s = 1.0 if (u_rep['status'] == 'late').any() else 0.0
87
 
 
88
  score = (0.3 * er_s) + (0.2 * bt_s) + (0.2 * od_s) + (0.2 * mp_s) + 0.1
89
- risk_lv = "HIGH" if score >= 0.7 else ("MEDIUM" if score >= 0.4 else "LOW")
90
 
91
- # --- FASE 5: NBO ENGINE ---
92
  if risk_lv == "HIGH" or mp_s == 1:
93
- action = "Restructuring Suggestion"
94
- desc = "Langkah darurat untuk mengatur ulang jadwal cicilan agar nasabah tidak gagal bayar."
95
- steps = ["Penurunan bunga sementara", "Perpanjangan tenor", "Konsolidasi utang."]
96
  elif er > 0.6:
97
- action = "Spending Control"
98
- desc = "Langkah strategis membatasi pengeluaran gaya hidup untuk menyelamatkan kas harian."
99
- steps = ["Limit QRIS harian Rp500rb", "Non-aktifkan auto-debit non-esensial", "Evaluasi belanja online."]
100
  elif risk_lv == "LOW":
101
- action = "Promote Investment"
102
- desc = "Optimalisasi kelebihan dana nasabah ke produk investasi yang memberikan bagi hasil."
103
- steps = ["Deposito Nagari Gold", "Tabungan Berjangka", "Pilihan Reksa Dana."]
104
  else:
105
- action = "Financial Education"
106
- desc = "Edukasi berkala agar nasabah bisa mempertahankan stabilitas keuangannya."
107
- steps = ["Modul Budgeting", "Tips belanja cerdas", "Webinar Perencanaan Keuangan."]
108
 
109
  return risk_lv, score, er, u_bal, u_txn, exp_total, ref_inc, action, desc, steps, er_s, bt_s, od_s, mp_s
110
 
111
- def create_narrative(self, risk_lv, score, er, u_bal, exp, inc, action, desc, steps, cid, u_txn):
112
- # Fase 6: Narrative Summary
113
- txt = f"### ANALISIS EKSEKUTIF ARCHON: NASABAH {cid}\n\n"
114
- txt += f"Hasil evaluasi hibrida menunjukkan tingkat resiliensi **{risk_lv}** (Skor: {score:.2f}).\n\n"
115
 
116
- txt += f"**Mengapa Hasil Ini Muncul?**\n"
117
- txt += f"* **Rasio Belanja ({er:.1%})**: Bapak/Ibu menggunakan Rp{exp:,.0f} dari pendapatan Rp{inc:,.0f}. "
118
- txt += "Rasio di atas 80% sangat kritis karena hampir tidak menyisakan dana darurat." if er > 0.8 else "Angka ini menunjukkan kontrol belanja yang sangat baik."
119
 
120
  if not u_bal.empty:
121
- txt += f"\n* **Analisis Saldo**: Saldo terakhir Rp{u_bal.iloc[-1]['avg_balance']:,.0f}. "
122
- txt += "Waspada, tren saldo sedang menurun, yang artinya nasabah sedang melakukan 'pemakanan tabungan'." if len(u_bal) > 1 and u_bal.iloc[-1]['avg_balance'] < u_bal.iloc[-2]['avg_balance'] else "Tren saldo tumbuh stabil."
123
 
124
  txt += f"\n\n<div class='nbo-box'>REKOMENDASI TINDAKAN (NBO): {action}\n\n"
125
- txt += f"**Bagaimana:** {desc}\n\n"
126
- txt += f"**Langkah-Langkah:**\n"
127
  for i, s in enumerate(steps, 1): txt += f"{i}. {s}\n"
128
  txt += f"</div>"
129
  return txt
130
 
131
  def create_viz(self, u_bal, u_txn):
132
- # 1. Arus Kas (Inflow vs Outflow)
133
  u_txn['m'] = u_txn['date'].dt.to_period('M').dt.to_timestamp()
134
  cf = u_txn.groupby(['m', 'transaction_type'])['amount'].sum().unstack().fillna(0)
135
  f1 = go.Figure()
136
- f1.add_trace(go.Bar(x=cf.index, y=cf.get('credit', 0), name='Pemasukan (Inflow)', marker_color='#82C3EB'))
137
- f1.add_trace(go.Bar(x=cf.index, y=cf.get('debit', 0), name='Pengeluaran (Outflow)', marker_color='#0514DE'))
138
- f1.update_layout(title="Laporan Arus Kas Bulanan", barmode='group', template='plotly_white')
139
 
140
  # 2. Tren Saldo
141
  f2 = go.Figure()
142
- f2.add_trace(go.Scatter(x=u_bal['month'], y=u_bal['avg_balance'], name='Saldo Rata-rata', line=dict(color='#F7BD87', width=4)))
143
  f2.update_layout(title="Kesehatan Pertumbuhan Saldo", template='plotly_white')
144
 
145
- # 3. Komposisi Belanja (New: Essential vs Discretionary)
146
- exp_dist = u_txn[u_txn['transaction_type'] == 'debit'].groupby('expense_type')['amount'].sum()
147
- f3 = go.Figure(data=[go.Pie(labels=exp_dist.index, values=exp_dist.values, hole=.4, marker_colors=['#0514DE', '#F7BD87'])])
148
- f3.update_layout(title="Komposisi Pengeluaran (Gaya Hidup vs Kebutuhan)")
 
 
 
 
 
 
 
149
 
150
  return f1, f2, f3
151
 
152
  # --- UI LOGIC ---
153
- engine = ArchonExecutiveEngine()
154
 
155
  def run_app(cust_id):
156
  res = engine.analyze(cust_id)
157
- if not res: return "## ❌ ID Tidak Valid", "Gunakan ID C0001 - C0120", None, None, None
158
 
159
  risk_lv, score, er, u_bal, u_txn, exp, inc, action, desc, steps, er_s, bt_s, od_s, mp_s = res
160
- report = engine.create_narrative(risk_lv, score, er, u_bal, exp, inc, action, desc, steps, cust_id, u_txn)
161
  p1, p2, p3 = engine.create_viz(u_bal, u_txn)
162
 
163
  color = "#ef4444" if risk_lv == "HIGH" else ("#f59e0b" if risk_lv == "MEDIUM" else "#10b981")
164
  sidebar = f"""
165
  <div class='card-sidebar'>
166
- <h2 style='color: #0514DE; margin:0;'>Summary Report</h2>
167
- <div style='background:{color}; color:white; padding:10px 20px; border-radius:30px; font-weight:bold; display:inline-block; margin:15px 0;'>{risk_lv} RISK LEVEL</div>
168
- <div class='health-badge'><b>Risk Score:</b> {score:.2f} / 1.00</div>
169
- <div class='health-badge'><b>Expense Ratio:</b> {er:.1%} {'⚠️' if er > 0.8 else ''}</div>
170
- <div class='health-badge'><b>Balance Trend:</b> {'🔻 Menurun' if bt_s == 1 else '🔺 Stabil'}</div>
171
- <div class='health-badge'><b>Credit History:</b> {'⚠️ Late' if mp_s == 1 else '✔️ Lancar'}</div>
172
  </div>
173
  """
174
  return sidebar, report, p1, p2, p3
@@ -185,19 +174,17 @@ with gr.Blocks(css=custom_css) as demo:
185
  with gr.Tab("Audit Summary"):
186
  out_report = gr.Markdown(elem_classes="report-card")
187
  with gr.Tab("Visual Analytics"):
188
- gr.Markdown("### 1. Analisis Arus Kas")
189
  plot_cf = gr.Plot()
190
- gr.HTML("<div class='viz-explanation'><b>Cara Baca:</b> Batang Muda (Pemasukan) harus lebih tinggi dari Batang Tua (Pengeluaran). Jika Batang Tua dominan, likuiditas nasabah dalam bahaya.</div>")
191
-
192
  gr.Markdown("---")
193
- gr.Markdown("### 2. Tren Pertumbuhan Saldo")
194
  plot_bal = gr.Plot()
195
- gr.HTML("<div class='viz-explanation'><b>Cara Baca:</b> Garis yang naik menunjukkan nasabah memiliki daya tahan (resiliensi) terhadap krisis ekonomi.</div>")
196
-
197
  gr.Markdown("---")
198
- gr.Markdown("### 3. Komposisi Gaya Hidup")
199
  plot_dist = gr.Plot()
200
- gr.HTML("<div class='viz-explanation'><b>Cara Baca:</b> Semakin besar porsi Biru (Essential), semakin sehat keuangan nasabah. Porsi Emas (Discretionary) yang besar menunjukkan gaya hidup boros.</div>")
201
 
202
  btn.click(fn=run_app, inputs=id_in, outputs=[out_side, out_report, plot_cf, plot_bal, plot_dist])
203
 
 
7
 
8
  # --- CONFIG AI ---
9
  GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY")
10
+ client_ai = genai.Client(api_key=GOOGLE_API_KEY) if GOOGLE_API_KEY else None
11
 
12
+ # --- UI STYLE: BANK NAGARI PREMIUM (BLUE-GOLD) ---
13
  custom_css = """
14
  @import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;600;700;800&display=swap');
15
  body, .gradio-container { font-family: 'Plus Jakarta Sans', sans-serif !important; background-color: #f8fafc !important; }
16
+ .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; }
 
 
 
 
 
17
  .nagari-header h1 { color: #FFFFFF !important; font-weight: 800 !important; margin: 0; font-size: 2.2em; }
18
+ .card-sidebar { background: #E0EDF4; border-radius: 15px; padding: 25px; border: 1.5px solid #82C3EB; box-shadow: 0 4px 12px rgba(5, 20, 222, 0.1); }
 
 
 
 
19
  .health-badge { background: white; padding: 12px; border-radius: 8px; margin-bottom: 12px; border-left: 5px solid #0514DE; font-size: 0.95em; }
20
+ .report-card { background: white; border-radius: 12px; padding: 30px; border: 1px solid #E2E8F0; line-height: 1.8; color: #1e293b; }
 
 
 
 
21
  .nbo-box { background: #fffdf0; border: 2px solid #F7BD87; padding: 20px; border-radius: 10px; margin-top: 20px; }
22
  .viz-explanation { background: #f1f5f9; padding: 15px; border-radius: 8px; margin-top: 10px; font-size: 0.9em; border-left: 4px solid #82C3EB; }
23
  """
24
 
25
+ class ArchonPrecisionEngine:
26
  def __init__(self):
27
  self.load_data()
28
 
29
  def load_data(self):
30
+ # FASE 1: DATA FOUNDATION [cite: 1, 3]
31
  try:
32
  self.df_txn = pd.read_csv('transactions.csv', parse_dates=['date']).sort_values('date')
33
  self.df_cust = pd.read_csv('customers.csv')
 
45
  if u_txn.empty or u_info_df.empty: return None
46
  u_info = u_info_df.iloc[0]
47
 
48
+ # --- FASE 2: TRANSACTION INTELLIGENCE (Refined Keywords) ---
49
+ essential_list = {'groceries', 'utilities', 'transport', 'healthcare', 'education', 'bill', 'loan', 'salary'}
 
 
50
 
51
  def classify_exp(row):
52
+ # Cek merchant_category, purpose_code, dan deskripsi secara case-insensitive [cite: 20, 21, 23]
53
  cat = str(row.get('merchant_category', '')).lower()
54
  purp = str(row.get('purpose_code', '')).lower()
55
+ desc = str(row.get('raw_description', '')).lower()
56
+
57
+ if any(k in cat for k in essential_list) or any(k in purp for k in essential_list) or any(k in desc for k in essential_list):
58
  return 'essential'
59
  return 'discretionary'
60
 
61
  u_txn['expense_type'] = u_txn.apply(classify_exp, axis=1)
62
 
63
+ # --- FASE 3 & 4: RISK SCORING (STRICT 30/20/20/20/10) [cite: 141-145] ---
64
  inc_txn = u_txn[u_txn['transaction_type'] == 'credit']['amount'].sum()
65
  ref_inc = max(inc_txn, u_info['monthly_income'])
66
  exp_total = u_txn[u_txn['transaction_type'] == 'debit']['amount'].sum()
67
  er = min(exp_total / ref_inc, 1.0) if ref_inc > 0 else 1.0
68
 
69
+ # Normalisasi Skor [cite: 132]
70
  er_s = 1.0 if er > 0.8 else (0.5 if er > 0.5 else 0.0)
71
  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
72
  od_s = 1.0 if (u_bal['min_balance'] <= 0).any() else 0.0
73
  mp_s = 1.0 if (u_rep['status'] == 'late').any() else 0.0
74
 
75
+ # Rumus Final Risk Score [cite: 147]
76
  score = (0.3 * er_s) + (0.2 * bt_s) + (0.2 * od_s) + (0.2 * mp_s) + 0.1
77
+ risk_lv = "HIGH" if score >= 0.70 else ("MEDIUM" if score >= 0.40 else "LOW")
78
 
79
+ # --- FASE 5: NBO ENGINE (Actionable Detail) [cite: 178] ---
80
  if risk_lv == "HIGH" or mp_s == 1:
81
+ action, desc = "Restructuring Suggestion", "Fokus penyelamatan kredit untuk menghindari gagal bayar."
82
+ steps = ["Penjadwalan ulang tenor", "Konsolidasi kewajiban", "Review bunga."] [cite: 220]
 
83
  elif er > 0.6:
84
+ action, desc = "Spending Control", "Pengendalian langsung pada pos belanja non-esensial."
85
+ steps = ["Setting limit QRIS Nagari", "Blokir sementara merchant gaya hidup", "Budgeting manual."] [cite: 207]
 
86
  elif risk_lv == "LOW":
87
+ action, desc = "Promote Investment", "Optimalisasi dana surplus ke produk aset produktif."
88
+ steps = ["Tabungan Berjangka", "Deposito Gold", "Investasi Reksa Dana."] [cite: 194]
 
89
  else:
90
+ action, desc = "Financial Education", "Edukasi berkala untuk stabilitas jangka panjang."
91
+ steps = ["Modul cerdas belanja", "Webinar Perencanaan", "Tips Cashflow."] [cite: 214]
 
92
 
93
  return risk_lv, score, er, u_bal, u_txn, exp_total, ref_inc, action, desc, steps, er_s, bt_s, od_s, mp_s
94
 
95
+ def build_report(self, risk_lv, score, er, u_bal, exp, inc, action, desc, steps, cid, u_txn):
96
+ # FASE 6: INSIGHT VISUALIZATION [cite: 280]
97
+ txt = f"### ANALISIS EKSEKUTIF: {risk_lv} RISK\n\n"
98
+ txt += f"**Identitas**: {cid} | **Skor Risiko**: {score:.2f}\n\n"
99
 
100
+ txt += f"**1. Mengapa Level Ini?**\n"
101
+ txt += f"* **Rasio Pengeluaran ({er:.1%})**: Anda menghabiskan Rp{exp:,.0f} dari pendapatan Rp{inc:,.0f}. "
102
+ txt += "Rasio di atas 80% sangat kritis." if er > 0.8 else "Angka ini menunjukkan manajemen yang sehat."
103
 
104
  if not u_bal.empty:
105
+ txt += f"\n* **Analisis Saldo**: Saldo saat ini Rp{u_bal.iloc[-1]['avg_balance']:,.0f}. "
106
+ txt += "Tren saldo sedang menurun (Drawdown)." if len(u_bal) > 1 and u_bal.iloc[-1]['avg_balance'] < u_bal.iloc[-2]['avg_balance'] else "Pertumbuhan saldo positif."
107
 
108
  txt += f"\n\n<div class='nbo-box'>REKOMENDASI TINDAKAN (NBO): {action}\n\n"
109
+ txt += f"**Langkah Implementasi:**\n"
 
110
  for i, s in enumerate(steps, 1): txt += f"{i}. {s}\n"
111
  txt += f"</div>"
112
  return txt
113
 
114
  def create_viz(self, u_bal, u_txn):
115
+ # 1. Cashflow
116
  u_txn['m'] = u_txn['date'].dt.to_period('M').dt.to_timestamp()
117
  cf = u_txn.groupby(['m', 'transaction_type'])['amount'].sum().unstack().fillna(0)
118
  f1 = go.Figure()
119
+ f1.add_trace(go.Bar(x=cf.index, y=cf.get('credit', 0), name='Pemasukan (Light Blue)', marker_color='#82C3EB'))
120
+ f1.add_trace(go.Bar(x=cf.index, y=cf.get('debit', 0), name='Pengeluaran (Deep Blue)', marker_color='#0514DE'))
121
+ f1.update_layout(title="Arus Kas Bulanan", barmode='group', template='plotly_white')
122
 
123
  # 2. Tren Saldo
124
  f2 = go.Figure()
125
+ f2.add_trace(go.Scatter(x=u_bal['month'], y=u_bal['avg_balance'], name='Rata-rata Saldo', line=dict(color='#F7BD87', width=4)))
126
  f2.update_layout(title="Kesehatan Pertumbuhan Saldo", template='plotly_white')
127
 
128
+ # 3. FIXED Pie Chart: Explicit Color Mapping
129
+ exp_dist = u_txn[u_txn['transaction_type'] == 'debit'].groupby('expense_type')['amount'].sum().reset_index()
130
+ color_map = {'essential': '#0514DE', 'discretionary': '#F7BD87'} # Biru=Essential, Emas=Disc
131
+
132
+ f3 = go.Figure(data=[go.Pie(
133
+ labels=exp_dist['expense_type'],
134
+ values=exp_dist['amount'],
135
+ hole=.4,
136
+ marker=dict(colors=[color_map[x] for x in exp_dist['expense_type']])
137
+ )])
138
+ f3.update_layout(title="Komposisi Gaya Hidup vs Kebutuhan")
139
 
140
  return f1, f2, f3
141
 
142
  # --- UI LOGIC ---
143
+ engine = ArchonPrecisionEngine()
144
 
145
  def run_app(cust_id):
146
  res = engine.analyze(cust_id)
147
+ if not res: return "❌ ID Tidak Valid", "Gunakan C0001 - C0120", None, None, None
148
 
149
  risk_lv, score, er, u_bal, u_txn, exp, inc, action, desc, steps, er_s, bt_s, od_s, mp_s = res
150
+ report = engine.build_report(risk_lv, score, er, u_bal, exp, inc, action, desc, steps, cust_id, u_txn)
151
  p1, p2, p3 = engine.create_viz(u_bal, u_txn)
152
 
153
  color = "#ef4444" if risk_lv == "HIGH" else ("#f59e0b" if risk_lv == "MEDIUM" else "#10b981")
154
  sidebar = f"""
155
  <div class='card-sidebar'>
156
+ <h2 style='color: #0514DE; margin:0;'>Archon Quick Look</h2>
157
+ <div style='background:{color}; color:white; padding:10px 20px; border-radius:30px; font-weight:bold; display:inline-block; margin:15px 0;'>{risk_lv} RISK</div>
158
+ <div class='health-badge'><b>Score:</b> {score:.2f} / 1.00</div>
159
+ <div class='health-badge'><b>ER:</b> {er:.1%} {'⚠️' if er > 0.8 else '✔️'}</div>
160
+ <div class='health-badge'><b>Trend:</b> {'🔻 Turun' if bt_s == 1 else '🔺 Stabil'}</div>
 
161
  </div>
162
  """
163
  return sidebar, report, p1, p2, p3
 
174
  with gr.Tab("Audit Summary"):
175
  out_report = gr.Markdown(elem_classes="report-card")
176
  with gr.Tab("Visual Analytics"):
177
+ gr.Markdown("### 1. Inflow vs Outflow")
178
  plot_cf = gr.Plot()
179
+ gr.HTML("<div class='viz-explanation'><b>Arus Kas:</b> Batang muda (Pemasukan) harus lebih tinggi dari batang tua (Pengeluaran).</div>")
 
180
  gr.Markdown("---")
181
+ gr.Markdown("### 2. Tren Saldo")
182
  plot_bal = gr.Plot()
183
+ gr.HTML("<div class='viz-explanation'><b>Resiliensi:</b> Garis naik menunjukkan nasabah kuat menghadapi krisis.</div>")
 
184
  gr.Markdown("---")
185
+ gr.Markdown("### 3. Gaya Hidup (Essential vs Discretionary)")
186
  plot_dist = gr.Plot()
187
+ gr.HTML("<div class='viz-explanation'><b>Warna:</b> Biru = Kebutuhan Pokok (Essential). Emas = Gaya Hidup (Discretionary).</div>")
188
 
189
  btn.click(fn=run_app, inputs=id_in, outputs=[out_side, out_report, plot_cf, plot_bal, plot_dist])
190