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
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
|
| 39 |
def __init__(self):
|
| 40 |
self.load_data()
|
| 41 |
|
| 42 |
def load_data(self):
|
| 43 |
-
# FASE 1:
|
| 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 (
|
| 62 |
-
|
| 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 |
-
|
| 68 |
cat = str(row.get('merchant_category', '')).lower()
|
| 69 |
purp = str(row.get('purpose_code', '')).lower()
|
| 70 |
-
|
|
|
|
|
|
|
| 71 |
return 'essential'
|
| 72 |
return 'discretionary'
|
| 73 |
|
| 74 |
u_txn['expense_type'] = u_txn.apply(classify_exp, axis=1)
|
| 75 |
|
| 76 |
-
# --- FASE 3 & 4:
|
| 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 |
-
#
|
| 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.
|
| 90 |
|
| 91 |
-
# --- FASE 5: NBO ENGINE ---
|
| 92 |
if risk_lv == "HIGH" or mp_s == 1:
|
| 93 |
-
action = "Restructuring Suggestion"
|
| 94 |
-
|
| 95 |
-
steps = ["Penurunan bunga sementara", "Perpanjangan tenor", "Konsolidasi utang."]
|
| 96 |
elif er > 0.6:
|
| 97 |
-
action = "Spending Control"
|
| 98 |
-
|
| 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 |
-
|
| 103 |
-
steps = ["Deposito Nagari Gold", "Tabungan Berjangka", "Pilihan Reksa Dana."]
|
| 104 |
else:
|
| 105 |
-
action = "Financial Education"
|
| 106 |
-
|
| 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
|
| 112 |
-
#
|
| 113 |
-
txt = f"### ANALISIS EKSEKUTIF
|
| 114 |
-
txt += f"
|
| 115 |
|
| 116 |
-
txt += f"**Mengapa
|
| 117 |
-
txt += f"* **Rasio
|
| 118 |
-
txt += "Rasio di atas 80% sangat kritis
|
| 119 |
|
| 120 |
if not u_bal.empty:
|
| 121 |
-
txt += f"\n* **Analisis Saldo**: Saldo
|
| 122 |
-
txt += "
|
| 123 |
|
| 124 |
txt += f"\n\n<div class='nbo-box'>REKOMENDASI TINDAKAN (NBO): {action}\n\n"
|
| 125 |
-
txt += f"**
|
| 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.
|
| 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 (
|
| 137 |
-
f1.add_trace(go.Bar(x=cf.index, y=cf.get('debit', 0), name='Pengeluaran (
|
| 138 |
-
f1.update_layout(title="
|
| 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='
|
| 143 |
f2.update_layout(title="Kesehatan Pertumbuhan Saldo", template='plotly_white')
|
| 144 |
|
| 145 |
-
# 3.
|
| 146 |
-
exp_dist = u_txn[u_txn['transaction_type'] == 'debit'].groupby('expense_type')['amount'].sum()
|
| 147 |
-
|
| 148 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 149 |
|
| 150 |
return f1, f2, f3
|
| 151 |
|
| 152 |
# --- UI LOGIC ---
|
| 153 |
-
engine =
|
| 154 |
|
| 155 |
def run_app(cust_id):
|
| 156 |
res = engine.analyze(cust_id)
|
| 157 |
-
if not res: return "
|
| 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.
|
| 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;'>
|
| 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
|
| 168 |
-
<div class='health-badge'><b>
|
| 169 |
-
<div class='health-badge'><b>
|
| 170 |
-
<div class='health-badge'><b>
|
| 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.
|
| 189 |
plot_cf = gr.Plot()
|
| 190 |
-
gr.HTML("<div class='viz-explanation'><b>
|
| 191 |
-
|
| 192 |
gr.Markdown("---")
|
| 193 |
-
gr.Markdown("### 2. Tren
|
| 194 |
plot_bal = gr.Plot()
|
| 195 |
-
gr.HTML("<div class='viz-explanation'><b>
|
| 196 |
-
|
| 197 |
gr.Markdown("---")
|
| 198 |
-
gr.Markdown("### 3.
|
| 199 |
plot_dist = gr.Plot()
|
| 200 |
-
gr.HTML("<div class='viz-explanation'><b>
|
| 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 |
|