ZakyF commited on
Commit
cc1a9d9
Β·
1 Parent(s): 06c006c

refactor: Reorganize Archon engine logic, simplify risk scoring and NBO, and revamp the report UI with new styles and an enhanced AI advisor.

Browse files
Files changed (1) hide show
  1. app.py +82 -94
app.py CHANGED
@@ -6,157 +6,145 @@ 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 STYLE BANK NAGARI ---
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: 30px; border-radius: 12px; border-bottom: 6px solid #F7BD87; text-align: center; margin-bottom: 20px; }
18
- .nagari-header h1 { color: #FFFFFF !important; font-weight: 800 !important; margin: 0; }
19
- .audit-card { background: white; border-radius: 12px; padding: 25px; border: 1px solid #E2E8F0; line-height: 1.8; }
20
- .status-card { background: #E0EDF4; padding: 20px; border-radius: 12px; border-left: 8px solid #0514DE; }
 
21
  """
22
 
23
- class ArchonEngine:
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
 
34
- def run_analysis(self, customer_id):
35
  cid = str(customer_id).strip().upper()
36
  u_txn = self.df_txn[self.df_txn['customer_id'] == cid].copy()
37
  u_bal = self.df_bal[self.df_bal['customer_id'] == cid].sort_values('month')
38
  u_rep = self.df_rep[self.df_rep['customer_id'] == cid]
39
- u_info_df = self.df_cust[self.df_cust['customer_id'] == cid]
40
 
41
- if u_txn.empty or u_info_df.empty: return None
42
- u_info = u_info_df.iloc[0]
43
 
44
- # --- FASE 2: TRANSACTION INTELLIGENCE ---
45
- # 1. 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
- # 2. Risk Spending Flag (Rolling 30D 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
- # 3. Behavior Signal (Normal, Impulsive, Recurring, Anomaly)
56
- q75 = u_txn['amount'].quantile(0.75)
57
- def get_behavior(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_behavior, axis=1)
61
-
62
- # --- FASE 3 & 4: RISK LABELING ---
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
  er_s = 1.0 if er > 0.8 else (0.5 if er > 0.5 else 0.0)
69
  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
70
  od_s = 1.0 if (u_bal['min_balance'] <= 0).any() else 0.0
71
  mp_s = 1.0 if (u_rep['status'] == 'late').any() else 0.0
72
- vol_s = 0.5 # Default stability score
73
 
74
- # Formula: 0.3*ER + 0.2*BT + 0.2*OD + 0.2*MP + 0.1*VOL
75
- risk_score = (0.3 * er_s) + (0.2 * bt_s) + (0.2 * od_s) + (0.2 * mp_s) + (0.1 * vol_s)
76
- risk_lv = "HIGH" if risk_score >= 0.7 else ("MEDIUM" if risk_score >= 0.4 else "LOW")
77
-
78
- # --- FASE 5: NBO ENGINE ---
79
- disc_ratio = u_txn[u_txn['expense_type'] == 'discretionary']['amount'].sum() / expense if expense > 0 else 0
80
- if risk_lv == "HIGH" or mp_s == 1:
81
- action, reason = "Restructuring Suggestion", "repayment_risk_detected"
82
- elif er > 0.6:
83
- action, reason = "Spending Control", "high_discretionary_spending"
84
- elif risk_lv == "LOW" and disc_ratio < 0.3:
85
- action, reason = "Promote Investment", "surplus_balance"
86
- else:
87
- action, reason = "Financial Education", "stable_cashflow"
88
-
89
- return risk_lv, risk_score, er, disc_ratio, u_bal, u_txn, action, reason
90
-
91
- def get_report(self, risk_lv, score, er, dr, u_bal, action, reason, cid, u_txn):
92
- # FASE 6: EXPLAINABLE SUMMARY (RAPI POIN-POIN)
93
- report = f"### πŸ“Š HASIL AUDIT FINANCIAL RESILIENCE\n\n"
94
- report += f"**1. Parameter Risiko Utama (Fase 4)**\n"
95
- report += f"* **Risk Level**: **{risk_lv}** (Skor: {score:.2f})\n"
96
- report += f"* **Expense Ratio**: {er:.1%}. Penggunaan dana terpantau {'kritis' if er > 0.8 else 'stabil'}.\n"
97
- report += f"* **Discretionary Ratio**: {dr:.1%}. Porsi belanja gaya hidup nasabah.\n\n"
98
 
99
- report += f"**2. Analisis Perilaku & Saldo (Fase 2 & 3)**\n"
100
- latest_avg = u_bal.iloc[-1]['avg_balance'] if not u_bal.empty else 0
101
- report += f"* **Kondisi Saldo**: Rp{latest_avg:,.0f}. {'Tren menurun' if len(u_bal) > 1 and latest_avg < u_bal.iloc[-2]['avg_balance'] else 'Tren positif'}.\n"
102
- report += f"* **Sinyal Dominan**: {u_txn['behavior_signal'].mode()[0].upper()}\n\n"
103
 
104
- report += f"**3. Rekomendasi Aksi (NBO Engine)**\n"
105
- report += f"* **Recommended Action**: **{action}**\n"
106
- report += f"* **Action Reason**: {reason.replace('_', ' ').title()}\n"
 
 
107
 
 
 
 
 
108
  if client_ai:
109
  try:
110
- tx = u_txn.tail(2)['raw_description'].tolist()
111
- prompt = f"Advisor Bank Nagari: Nasabah {cid} ({risk_lv} risk). Saran hangat personal (Bapak/Ibu) terkait {tx}, maks 2 kalimat."
112
- advice = client_ai.models.generate_content(model="gemini-1.5-flash", contents=prompt).text
113
- report += f"\n---\n**πŸ’‘ SARAN VIRTUAL ADVISOR:**\n_{advice}_"
114
  except: pass
115
 
 
116
  return report
117
 
118
  # --- UI LOGIC ---
119
- engine = ArchonEngine()
120
 
121
- def run_app(cust_id):
122
- data = engine.run_analysis(cust_id)
123
- if not data: return "## ❌ ID Tidak Valid", "Gunakan C0001 - C0120", None, None
124
 
125
- risk_lv, score, er, dr, u_bal, u_txn, action, reason = data
126
- report = engine.get_report(risk_lv, score, er, dr, u_bal, action, reason, cust_id, u_txn)
127
 
128
- # Graphs
 
129
  f1 = go.Figure()
130
- f1.add_trace(go.Scatter(x=u_bal['month'], y=u_bal['avg_balance'], name='Avg Balance', line=dict(color='#0514DE', width=4)))
131
- f1.update_layout(title="Tren Pertumbuhan Saldo", template="plotly_white", margin=dict(t=40, b=40, l=40, r=40))
132
 
133
  f2 = go.Figure()
134
  u_txn['m'] = u_txn['date'].dt.to_period('M').dt.to_timestamp()
135
  cf = u_txn.groupby(['m', 'transaction_type'])['amount'].sum().unstack().fillna(0)
136
- f2.add_trace(go.Bar(x=cf.index, y=cf.get('credit', 0), name='Income', marker_color='#82C3EB'))
137
- f2.add_trace(go.Bar(x=cf.index, y=cf.get('debit', 0), name='Expense', marker_color='#0514DE'))
138
- f2.update_layout(title="Arus Kas Bulanan", barmode='group', template='plotly_white', margin=dict(t=40, b=40, l=40, r=40))
139
 
140
- color = "#ef4444" if risk_lv == "HIGH" else ("#f59e0b" if risk_lv == "MEDIUM" else "#10b981")
141
- status_html = f"""<div class='status-card'><h2 style='color: #0514DE; margin:0;'>Ringkasan AI</h2><div style='background:{color}; color:white; padding:5px 15px; border-radius:20px; font-weight:bold; display:inline-block; margin-top:10px;'>{risk_lv} RISK</div></div>"""
142
 
143
- return status_html, report, f1, f2
144
 
145
  with gr.Blocks(css=custom_css) as demo:
146
  gr.HTML("<div class='nagari-header'><h1>ARCHON-AI</h1></div>")
 
147
  with gr.Row():
148
  with gr.Column(scale=1):
149
  id_in = gr.Textbox(label="Customer ID", placeholder="C0001")
150
  btn = gr.Button("ANALYZE CUSTOMER", variant="primary")
151
- status_out = gr.HTML()
 
152
  with gr.Column(scale=2):
153
  with gr.Tabs():
154
- with gr.Tab("Audit Report"):
155
- report_out = gr.Markdown(elem_classes="audit-card")
156
- with gr.Tab("Visual Analytics"):
157
- plot_cf = gr.Plot(label="Cashflow")
158
- plot_bal = gr.Plot(label="Balance")
159
-
160
- btn.click(fn=run_app, inputs=id_in, outputs=[status_out, report_out, plot_cf, plot_bal])
 
 
 
161
 
162
  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
  client_ai = genai.Client(api_key=GOOGLE_API_KEY) if GOOGLE_API_KEY else None
12
 
13
+ # --- UI STYLE BANK NAGARI PREMIUM ---
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; text-align: center; margin-bottom: 25px; }
18
+ .nagari-header h1 { color: #FFFFFF !important; font-weight: 800 !important; margin: 0; font-size: 2.2em; }
19
+ .report-card { background: #FFFFFF; border-radius: 12px; padding: 25px; border: 1.5px solid #E0EDF4; box-shadow: 0 4px 15px rgba(5, 20, 222, 0.05); margin-bottom: 20px; }
20
+ .section-header { color: #0514DE; border-bottom: 2px solid #F7BD87; padding-bottom: 5px; margin-bottom: 15px; font-weight: 700; }
21
+ .status-pill { padding: 10px 20px; border-radius: 30px; color: white; font-weight: 800; display: inline-block; font-size: 1.1em; }
22
  """
23
 
24
+ class ArchonNarrativeEngine:
25
  def __init__(self):
26
  self.load_data()
27
 
28
  def load_data(self):
29
+ # FASE 1: Foundation
30
  self.df_txn = pd.read_csv('transactions.csv', parse_dates=['date']).sort_values('date')
31
  self.df_cust = pd.read_csv('customers.csv')
32
  self.df_bal = pd.read_csv('balances_revised.csv', parse_dates=['month']).sort_values('month')
33
+ self.df_rep = pd.read_csv('repayments_revised.csv', parse_dates=['due_date']).fillna("on_time")
34
 
35
+ def run_full_pipeline(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].sort_values('month')
39
  u_rep = self.df_rep[self.df_rep['customer_id'] == cid]
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 & 3: INTELLIGENCE ---
45
+ income_txn = u_txn[u_txn['transaction_type'] == 'credit']['amount'].sum()
46
+ ref_income = max(income_txn, u_info['monthly_income'])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
47
  expense = u_txn[u_txn['transaction_type'] == 'debit']['amount'].sum()
48
+ er = min(expense / ref_income, 1.0)
 
49
 
50
+ # --- FASE 4: RISK SCORING (30/20/20/20/10) ---
51
  er_s = 1.0 if er > 0.8 else (0.5 if er > 0.5 else 0.0)
52
  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
53
  od_s = 1.0 if (u_bal['min_balance'] <= 0).any() else 0.0
54
  mp_s = 1.0 if (u_rep['status'] == 'late').any() else 0.0
 
55
 
56
+ score = (0.3 * er_s) + (0.2 * bt_s) + (0.2 * od_s) + (0.2 * mp_s) + 0.1
57
+ risk_lv = "HIGH" if score >= 0.7 else ("MEDIUM" if score >= 0.4 else "LOW")
58
+
59
+ # --- FASE 5: NBO ---
60
+ if risk_lv == "HIGH": action, nbo_desc = "Restructuring Suggestion", "Fokus pada penyelamatan kredit."
61
+ elif er > 0.6: action, nbo_desc = "Spending Control", "Batasi pengeluaran non-esensial."
62
+ else: action, nbo_desc = "Promote Saving/Investment", "Optimalkan surplus dana nasabah."
63
+
64
+ return risk_lv, score, er, u_bal, u_txn, expense, ref_income, action, nbo_desc
65
+
66
+ def build_narrative_report(self, risk_lv, score, er, u_bal, expense, income, action, nbo_desc, cid, u_txn):
67
+ # FASE 6: EXPLAINABLE SUMMARY & RINGKASAN AI
68
+ report = f"<div class='report-card'>"
69
+ report += f"<h3 class='section-header'>🌟 EXECUTIVE SUMMARY (RINGKASAN AI)</h3>"
70
+
71
+ # Ringkasan Naratif Otomatis
72
+ report += f"<p>Berdasarkan analisis menyeluruh terhadap data mutasi dan profil Bapak/Ibu ({cid}), sistem Archon menetapkan tingkat resiliensi finansial Anda berada pada kategori <b>{risk_lv}</b>. "
73
+ report += f"Hal ini disebabkan oleh kombinasi antara rasio pengeluaran sebesar {er:.1%} dan stabilitas saldo yang memerlukan perhatian khusus.</p>"
 
 
 
 
 
 
74
 
75
+ report += f"<h3 class='section-header'>πŸ” MENGAPA SKOR ANDA {score:.2f}?</h3>"
76
+ report += f"<ul>"
77
+ report += f"<li><b>Rasio Pengeluaran ({er:.1%})</b>: Artinya, dari setiap Rp1.000.000 yang Bapak/Ibu terima, sebanyak Rp{int(er*1000000):,} habis digunakan. {'Angka ini kritis karena melebihi batas aman 80%.' if er > 0.8 else 'Kondisi ini sehat karena di bawah batas aman.'}</li>"
 
78
 
79
+ if len(u_bal) > 1:
80
+ trend = "menurun" if u_bal.iloc[-1]['avg_balance'] < u_bal.iloc[-2]['avg_balance'] else "meningkat"
81
+ report += f"<li><b>Tren Saldo</b>: Saldo rata-rata Anda terpantau <b>{trend}</b>. Tren ini digunakan bank untuk melihat kemampuan Bapak/Ibu dalam menghadapi kebutuhan mendadak di masa depan.</li>"
82
+
83
+ report += f"</ul>"
84
 
85
+ report += f"<h3 class='section-header'>🎯 REKOMENDASI TINDAKAN (NBO)</h3>"
86
+ report += f"<p>Sistem merekomendasikan aksi: <b>{action}</b>. <br><i>Mengapa?</i> {nbo_desc}</p>"
87
+
88
+ # Gemini Section
89
  if client_ai:
90
  try:
91
+ tx = u_txn.tail(1)['raw_description'].iloc[0]
92
+ prompt = f"Advisor Bank Nagari: Nasabah {cid} risiko {risk_lv}, pengeluaran {er:.1%}. Terakhir belanja di {tx}. Beri saran hangat personal (Bapak/Ibu) maks 2 kalimat."
93
+ resp = client_ai.models.generate_content(model="gemini-1.5-flash", contents=prompt)
94
+ report += f"<div style='background:#f0f9ff; padding:15px; border-radius:10px; margin-top:10px; border:1px solid #bae6fd;'><b>πŸ’‘ Pesan Personal Advisor:</b><br>_{resp.text}_</div>"
95
  except: pass
96
 
97
+ report += "</div>"
98
  return report
99
 
100
  # --- UI LOGIC ---
101
+ engine = ArchonNarrativeEngine()
102
 
103
+ def process(cust_id):
104
+ res = engine.run_full_pipeline(cust_id)
105
+ if not res: return "## ❌ ID Tidak Ditemukan", "Coba gunakan C0001 - C0120", None, None
106
 
107
+ risk_lv, score, er, u_bal, u_txn, exp, inc, action, nbo_desc = res
108
+ narrative = engine.build_narrative_report(risk_lv, score, er, u_bal, exp, inc, action, nbo_desc, cust_id, u_txn)
109
 
110
+ # Graphs with Interpretation
111
+ color = "#0514DE"
112
  f1 = go.Figure()
113
+ f1.add_trace(go.Scatter(x=u_bal['month'], y=u_bal['avg_balance'], name='Rata-rata Saldo', line=dict(color='#F7BD87', width=4)))
114
+ f1.update_layout(title="Kesehatan Pertumbuhan Saldo", template="plotly_white")
115
 
116
  f2 = go.Figure()
117
  u_txn['m'] = u_txn['date'].dt.to_period('M').dt.to_timestamp()
118
  cf = u_txn.groupby(['m', 'transaction_type'])['amount'].sum().unstack().fillna(0)
119
+ f2.add_trace(go.Bar(x=cf.index, y=cf.get('credit', 0), name='Pemasukan', marker_color='#82C3EB'))
120
+ f2.add_trace(go.Bar(x=cf.index, y=cf.get('debit', 0), name='Pengeluaran', marker_color='#0514DE'))
121
+ f2.update_layout(title="Laporan Arus Kas", barmode='group', template='plotly_white')
122
 
123
+ status_color = "#ef4444" if risk_lv == "HIGH" else ("#f59e0b" if risk_lv == "MEDIUM" else "#10b981")
124
+ status_html = f"""<div style='text-align:center;'><span class='status-pill' style='background:{status_color}'>{risk_lv} RISK</span><br><p style='margin-top:10px;'>Risk Score: <b>{score:.2f}</b></p></div>"""
125
 
126
+ return status_html, narrative, f1, f2
127
 
128
  with gr.Blocks(css=custom_css) as demo:
129
  gr.HTML("<div class='nagari-header'><h1>ARCHON-AI</h1></div>")
130
+
131
  with gr.Row():
132
  with gr.Column(scale=1):
133
  id_in = gr.Textbox(label="Customer ID", placeholder="C0001")
134
  btn = gr.Button("ANALYZE CUSTOMER", variant="primary")
135
+ out_status = gr.HTML()
136
+
137
  with gr.Column(scale=2):
138
  with gr.Tabs():
139
+ with gr.Tab("πŸ“‹ Executive Briefing"):
140
+ out_report = gr.HTML()
141
+ with gr.Tab("πŸ“ˆ Visual Analytics"):
142
+ gr.Markdown("### Interpretasi Arus Kas\nBatang biru (Pengeluaran) tidak boleh sering melebihi batang muda (Pemasukan).")
143
+ plot_cf = gr.Plot()
144
+ gr.Markdown("---")
145
+ gr.Markdown("### Tren Saldo\nGrafik yang naik menunjukkan nasabah memiliki daya tahan terhadap krisis ekonomi.")
146
+ plot_bal = gr.Plot()
147
+
148
+ btn.click(fn=process, inputs=id_in, outputs=[out_status, out_report, plot_cf, plot_bal])
149
 
150
  demo.launch()