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

feat: Enhance customer analysis with transaction intelligence, refine NBO logic, and update UI styling and project metadata.

Browse files
Files changed (2) hide show
  1. README.md +1 -1
  2. app.py +104 -180
README.md CHANGED
@@ -1,7 +1,7 @@
1
  ---
2
  title: Archon AI
3
  emoji: 🪙
4
- colorFrom: purple
5
  colorTo: gray
6
  sdk: gradio
7
  sdk_version: 6.4.0
 
1
  ---
2
  title: Archon AI
3
  emoji: 🪙
4
+ colorFrom: blue
5
  colorTo: gray
6
  sdk: gradio
7
  sdk_version: 6.4.0
app.py CHANGED
@@ -4,225 +4,149 @@ import numpy as np
4
  import gradio as gr
5
  import plotly.graph_objects as go
6
  from google import genai
 
7
 
8
- # --- CONFIG AI ---
9
  GOOGLE_API_KEY = os.getenv("GOOGLE_API_KEY")
10
- client_ai = None
11
- if GOOGLE_API_KEY:
12
- try:
13
- client_ai = genai.Client(api_key=GOOGLE_API_KEY)
14
- except:
15
- client_ai = None
16
-
17
- # --- UI STYLE: BANK NAGARI (LIGHT BLUE & GOLD) ---
18
  custom_css = """
19
  @import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;600;700;800&display=swap');
20
  body, .gradio-container { font-family: 'Plus Jakarta Sans', sans-serif !important; background-color: #FFFFFF !important; }
21
-
22
- .nagari-header {
23
- background: linear-gradient(135deg, #0514DE 0%, #82C3EB 100%);
24
- padding: 35px;
25
- border-radius: 15px;
26
- border-bottom: 6px solid #F7BD87;
27
- margin-bottom: 25px;
28
- text-align: center;
29
- }
30
-
31
- /* Memastikan teks ARCHON-AI BOLD PUTIH */
32
- .nagari-header h1 {
33
- color: #FFFFFF !important;
34
- font-weight: 800 !important;
35
- margin: 0;
36
- text-shadow: 2px 2px 4px rgba(0,0,0,0.2);
37
- }
38
-
39
- .report-card {
40
- background: #FFFFFF;
41
- border-radius: 15px;
42
- padding: 25px;
43
- border: 1.5px solid #E0EDF4;
44
- box-shadow: 0 4px 15px rgba(5, 20, 222, 0.05);
45
- }
46
-
47
- .status-card {
48
- background: #E0EDF4;
49
- padding: 20px;
50
- border-radius: 12px;
51
- border-left: 8px solid #0514DE;
52
- margin-bottom: 20px;
53
- }
54
-
55
- .nbo-box {
56
- background: #fffdf0;
57
- border: 2px solid #F7BD87;
58
- padding: 20px;
59
- border-radius: 10px;
60
- margin-top: 15px;
61
- }
62
  """
63
 
64
- class ArchonMasterEngine:
65
  def __init__(self):
66
  self.load_data()
67
 
68
  def load_data(self):
69
- try:
70
- # Proteksi ID error (C0014, dll) dengan sanitasi data awal
71
- self.df_txn = pd.read_csv('transactions.csv', parse_dates=['date']).fillna({"raw_description": "Transaksi Umum"})
72
- self.df_cust = pd.read_csv('customers.csv').fillna(0)
73
- self.df_bal = pd.read_csv('balances_revised.csv', parse_dates=['month']).fillna(0)
74
- self.df_rep = pd.read_csv('repayments_revised.csv', parse_dates=['due_date']).fillna("on_time")
75
- except Exception as e:
76
- print(f"File Error: {e}")
77
 
78
  def analyze(self, customer_id):
79
- # Sanitasi ID: Hapus spasi dan jadikan huruf besar
80
  cid = str(customer_id).strip().upper()
81
-
82
  u_txn = self.df_txn[self.df_txn['customer_id'] == cid].copy()
83
- u_bal = self.df_bal[self.df_bal['customer_id'] == cid].sort_values('month')
84
- u_rep = self.df_rep[self.df_rep['customer_id'] == cid]
85
- u_info_df = self.df_cust[self.df_cust['customer_id'] == cid]
86
-
87
- if u_txn.empty or u_info_df.empty: return None
88
 
89
- u_info = u_info_df.iloc[0]
90
 
91
- # --- FASE 4: DETERMINISTIC CALCULATION ---
92
- income_txn = u_txn[u_txn['transaction_type'] == 'credit']['amount'].sum()
93
- ref_income = max(income_txn, u_info['monthly_income'])
94
- expense = u_txn[u_txn['transaction_type'] == 'debit']['amount'].sum()
95
- er = min(expense / ref_income, 1.0) if ref_income > 0 else 1.0
96
 
97
- # Penentuan Discretionary Ratio (Estimasi Fase 2)
98
- disc_keywords = ['online', 'shopee', 'tokopedia', 'cafe', 'resto', 'cinema', 'travel', 'fashion', 'promo']
99
- disc_spending = u_txn[(u_txn['transaction_type'] == 'debit') &
100
- (u_txn['raw_description'].str.lower().str.contains('|'.join(disc_keywords)))]['amount'].sum()
101
- disc_ratio = disc_spending / expense if expense > 0 else 0
 
 
 
 
 
 
 
 
 
 
 
 
 
102
 
103
- # Scoring logic (Fase 4 - Bobot 30/20/20/20/10)
104
  er_s = 1.0 if er > 0.8 else (0.5 if er > 0.5 else 0.0)
105
  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
106
  od_s = 1.0 if (u_bal['min_balance'] <= 0).any() else 0.0
107
  mp_s = 1.0 if (u_rep['status'] == 'late').any() else 0.0
108
 
109
- score = (0.3 * er_s) + (0.2 * bt_s) + (0.2 * od_s) + (0.2 * mp_s) + 0.1
110
- risk_lv = "HIGH" if score >= 0.7 else ("MEDIUM" if score >= 0.4 else "LOW")
111
-
112
- return risk_lv, score, er, disc_ratio, u_bal, u_txn, expense, ref_income, mp_s
113
 
114
- def build_expert_nbo(self, risk_lv, score, er, disc_ratio, u_bal, expense, income, mp_s, cid, u_txn):
115
- # --- FASE 5: NBO DECISION ENGINE (LOGIKA ADAPTIF) ---
116
- msg = f"### 📊 LAPORAN INTELIJEN ARCHON\n"
117
- msg += f"Berdasarkan profil mutasi, tingkat resiliensi Bapak/Ibu berada di level **{risk_lv}** (Skor: {score:.2f}).\n\n"
118
-
119
- # 1. PENJELASAN METRIK (FASE 6)
120
- msg += f"**Analisis Arus Kas:**\n"
121
- msg += f"- **Rasio Pengeluaran ({er:.1%})**: Anda menghabiskan Rp{expense:,.0f} dari pendapatan Rp{income:,.0f}. "
122
- if er > 0.8: msg += "⚠️ Kondisi ini kritis bagi stabilitas jangka panjang."
123
-
124
- if not u_bal.empty:
125
- last_bal = u_bal.iloc[-1]['avg_balance']
126
- msg += f"\n- **Stabilitas Saldo**: Saldo rata-rata terakhir Rp{last_bal:,.0f}. "
127
- if len(u_bal) > 1 and last_bal < u_bal.iloc[-2]['avg_balance']: msg += "📉 Tren menurun terpantau."
128
-
129
- # 2. PENENTUAN AKSI NBO (FASE 5)
130
- action = ""
131
- reason = ""
132
-
133
  if risk_lv == "HIGH" or mp_s == 1:
134
- action = "Restructuring Suggestion"
135
- reason = "Terdeteksi tekanan pada pembayaran cicilan atau saldo kritis. Fokus pada penyelamatan likuiditas."
136
- elif disc_ratio > 0.6:
137
- action = "Spending Control"
138
- reason = "Pengeluaran gaya hidup (discretionary) melebihi 60%. Perlu pembatasan transaksi non-esensial segera."
139
- elif risk_lv == "MEDIUM" and disc_ratio > 0.4:
140
- action = "Budgeting Alert"
141
- reason = "Pola belanja mulai tidak stabil. Disarankan mengaktifkan fitur notifikasi budget."
142
- elif risk_lv == "LOW" and disc_ratio <= 0.3:
143
- action = "Promote Investment / Saving Boost"
144
- reason = "Kondisi finansial sangat prima dengan surplus dana. Waktunya memaksimalkan pertumbuhan aset."
145
  else:
146
- action = "Financial Education"
147
- reason = "Stabilitas terjaga, namun diperlukan literasi untuk pengelolaan cashflow yang lebih optimal."
148
-
149
- msg += f"\n\n<div class='nbo-box'>**🎯 REKOMENDASI AKSI (NBO):**\n"
150
- msg += f"**Tindakan**: {action}\n\n"
151
- msg += f"**Alasan**: {reason}</div>"
152
-
153
- # 3. GENERATIVE NBO (PARALLEL EXECUTION)
154
- if client_ai:
155
- merchants = u_txn.tail(2)['raw_description'].tolist()
156
- prompt = f"Advisor Bank Nagari: Nasabah {cid} risiko {risk_lv}, expense {er:.1%}, aksi {action}. Terakhir belanja di {merchants}. Beri saran sangat hangat & personal (panggil Bapak/Ibu), maks 2 kalimat."
157
- try:
158
- resp = client_ai.models.generate_content(model="gemini-1.5-flash", contents=prompt)
159
- msg += f"\n\n---\n**💡 SARAN VIRTUAL ADVISOR (AI):**\n{resp.text}"
160
- except: pass
161
-
162
- return msg
163
-
164
- def create_viz(self, u_bal, u_txn):
165
- # Grafik Inflow vs Outflow
166
- u_txn['m'] = u_txn['date'].dt.to_period('M').dt.to_timestamp()
167
- cf = u_txn.groupby(['m', 'transaction_type'])['amount'].sum().unstack().fillna(0)
168
- fig1 = go.Figure()
169
- fig1.add_trace(go.Bar(x=cf.index, y=cf.get('credit', 0), name='Pemasukan', marker_color='#82C3EB'))
170
- fig1.add_trace(go.Bar(x=cf.index, y=cf.get('debit', 0), name='Pengeluaran', marker_color='#0514DE'))
171
- fig1.update_layout(title="Inflow vs Outflow Bulanan", barmode='group', template='plotly_white')
172
-
173
- # Grafik Tren Saldo
174
- fig2 = go.Figure()
175
- fig2.add_trace(go.Scatter(x=u_bal['month'], y=u_bal['avg_balance'], name='Saldo Rata-rata', line=dict(color='#F7BD87', width=4)))
176
- fig2.update_layout(title="Grafik Tren Kesehatan Saldo", template='plotly_white')
177
- return fig1, fig2
178
-
179
- # --- UI EXECUTION ---
180
- engine = ArchonMasterEngine()
181
 
182
  def run_app(cust_id):
183
- res = engine.analyze(cust_id)
184
- if not res: return "## ❌ ID Tidak Valid", "Masukkan ID Customer yang terdaftar (C0001 - C0120).", None, None
 
 
 
185
 
186
- risk_lv, score, er, disc_ratio, u_bal, u_txn, exp, inc, mp_s = res
187
- report = engine.build_expert_nbo(risk_lv, score, er, disc_ratio, u_bal, exp, inc, mp_s, cust_id, u_txn)
188
- p1, p2 = engine.create_viz(u_bal, u_txn)
 
189
 
 
 
 
 
 
 
 
190
  color = "#ef4444" if risk_lv == "HIGH" else ("#f59e0b" if risk_lv == "MEDIUM" else "#10b981")
191
- status_html = f"""
192
- <div class='metric-box'>
193
- <h2 style='color: #0514DE; margin:0;'>Archon Insight Summary</h2>
194
- <div style='background:{color}; color:white; padding:5px 15px; border-radius:20px; font-weight:bold; display:inline-block; margin-top:10px;'>{risk_lv} RISK LEVEL</div>
195
- <p style='margin-top:15px; font-size:1.1em;'><b>Risk Score:</b> {score:.2f} | <b>Expense Ratio:</b> {er:.1%}</p>
196
- </div>
 
 
 
 
197
  """
198
- return status_html, report, p1, p2
 
 
199
 
200
  with gr.Blocks(css=custom_css) as demo:
201
- # Header Bold Putih
202
- gr.HTML("""
203
- <div class='nagari-header'>
204
- <h1>ARCHON-AI</h1>
205
- <p style='color: white !important; opacity: 0.9; margin: 5px 0 0 0;'>Pusat Intelijen Risiko & Resiliensi Finansial Nasabah</p>
206
- </div>
207
- """)
208
-
209
  with gr.Row():
210
  with gr.Column(scale=1):
211
- id_in = gr.Textbox(label="Input Customer ID", placeholder="C0001")
212
- btn = gr.Button("ANALYZE CUSTOMER", variant="primary")
213
  out_status = gr.HTML()
214
- gr.Markdown("---")
215
- gr.Markdown("ℹ️ **Interpretasi**: Skor risiko menggabungkan data mutasi saldo harian, rasio belanja, dan ketepatan cicilan nasabah secara otomatis.")
216
-
217
  with gr.Column(scale=2):
218
  with gr.Tabs():
219
- with gr.TabItem("Analysis & NBO Recommendation"):
220
- out_report = gr.Markdown(elem_classes="report-card")
221
- with gr.TabItem("Cashflow Insight"):
222
- plot_cf = gr.Plot()
223
- with gr.TabItem("Saving Trend"):
224
- plot_bal = gr.Plot()
225
-
226
- btn.click(fn=run_app, inputs=id_in, outputs=[out_status, out_report, plot_cf, plot_bal])
227
 
228
  demo.launch()
 
4
  import gradio as gr
5
  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()