| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import streamlit as st |
| import pandas as pd |
| import numpy as np |
| import matplotlib |
| matplotlib.use('Agg') |
| import matplotlib.pyplot as plt |
| import base64 |
| import time as _time |
| import shap, joblib, os, json, warnings, re, requests, io, hashlib |
| warnings.filterwarnings('ignore') |
|
|
| st.set_page_config( |
| page_title="SME Credit Risk AI", |
| page_icon="🏦", |
| layout="wide", |
| initial_sidebar_state="expanded" |
| ) |
|
|
| |
| |
| |
| TRANSLATIONS = { |
| 'sidebar_api': {'id':'🔑 API Keys (opsional)', 'en':'🔑 API Keys (optional)', 'hi':'🔑 API Keys (vaikalpik)'}, |
| 'sidebar_api_note': {'id':'Prioritas: OpenRouter → Groq → Smart Template', |
| 'en':'Priority: OpenRouter → Groq → Smart Template', |
| 'hi':'Praathamikta: OpenRouter → Groq → Smart Template'}, |
| 'sidebar_stack': {'id':'**📖 Teknologi**', 'en':'**📖 Stack**', 'hi':'**📖 Takneek**'}, |
| 'sidebar_footer': {'id':'Final Project · AI Bootcamp Batch 10 · 1na37', |
| 'en':'Final Project · AI Bootcamp Batch 10 · 1na37', |
| 'hi':'Final Project · AI Bootcamp Batch 10 · 1na37'}, |
| 'header_title': {'id':'🏦 Penilaian Risiko Kredit UKM berbasis AI', |
| 'en':'🏦 AI-Powered SME Credit Risk Assessment', |
| 'hi':'🏦 AI-Aadharit SME Credit Jokhim Ankaalan'}, |
| 'header_sub': {'id':'Eksplainable · Preskriptif · Kecerdasan Kredit Multibahasa', |
| 'en':'Explainable · Prescriptive · Multilingual Credit Intelligence', |
| 'hi':'Vyaakhyaatmak · Nirdeshak · Bahubhaashik Credit Gyaan'}, |
| 'form_title': {'id':'📋 Data Nasabah UKM', 'en':'📋 SME Applicant Data', 'hi':'📋 SME Aavedan Daata'}, |
| 'form_loan': {'id':'**💰 Data Pinjaman**', 'en':'**💰 Loan Details**', 'hi':'**💰 Rin Vivaran**'}, |
| 'form_financial': {'id':'**👤 Data Keuangan**', 'en':'**👤 Financial Data**', 'hi':'**👤 Vitteey Daata**'}, |
| 'form_sme': {'id':'**🏢 Profil UKM**', 'en':'**🏢 SME Profile**', 'hi':'**🏢 SME Parichay**'}, |
| 'f_loan_amt': {'id':'Jumlah Pinjaman (Rp)', 'en':'Loan Amount (Rp)', 'hi':'Rin Raashi (Rp)'}, |
| 'f_lgd': {'id':'LGD (Kerugian jika Gagal Bayar)','en':'LGD (Loss Given Default)', 'hi':'LGD (Chook par Haani)'}, |
| 'f_duration': {'id':'Durasi (bulan)', 'en':'Duration (months)', 'hi':'Avadhi (Maheene)'}, |
| 'f_credit_amt': {'id':'Jumlah Kredit (DM)', 'en':'Credit Amount (DM)', 'hi':'Credit Raashi (DM)'}, |
| 'f_purpose': {'id':'Tujuan Pinjaman', 'en':'Loan Purpose', 'hi':'Rin Ka Uddeshya'}, |
| 'f_age': {'id':'Usia', 'en':'Age', 'hi':'Aayu'}, |
| 'f_checking': {'id':'Rekening Giro', 'en':'Checking Account', 'hi':'Checking Khaata'}, |
| 'f_savings': {'id':'Tabungan', 'en':'Savings Account', 'hi':'Bachat Khaata'}, |
| 'f_credit_hist': {'id':'Riwayat Kredit', 'en':'Credit History', 'hi':'Credit Itihaas'}, |
| 'f_employment': {'id':'Lama Bekerja', 'en':'Employment Duration', 'hi':'Rozgaar Avadhi'}, |
| 'f_housing': {'id':'Status Tempat Tinggal', 'en':'Housing Status', 'hi':'Aawaas Sthiti'}, |
| 'f_installment': {'id':'Komitmen Cicilan (%)', 'en':'Installment Commitment (%)', 'hi':'Kist Pratibaddhata (%)'}, |
| 'f_digital': {'id':'Skor Kehadiran Digital', 'en':'Digital Presence Score', 'hi':'Digital Upasthiti Score'}, |
| 'f_social': {'id':'Media Sosial Bisnis', 'en':'Business Social Media', 'hi':'Vyapaar Social Media'}, |
| 'f_ecomm': {'id':'Volume E-commerce (Rp)', 'en':'E-commerce Volume (Rp)', 'hi':'E-commerce Volume (Rp)'}, |
| 'f_npwp': {'id':'NPWP (NPWP Aktif)', 'en':'NPWP (Tax ID)', 'hi':'NPWP (Tax Pehchaan)'}, |
| 'f_siup': {'id':'SIUP/NIB (Izin Usaha)', 'en':'SIUP/NIB (Business License)', 'hi':'SIUP/NIB (Vyapaar Laaisens)'}, |
| 'f_biz_age': {'id':'Umur Bisnis (tahun)', 'en':'Business Age (years)', 'hi':'Vyapaar Aayu (Saal)'}, |
| 'f_cashflow': {'id':'Cash Flow Bulanan (Rp)', 'en':'Monthly Cash Flow (Rp)', 'hi':'Maasik Naqad Pravaah (Rp)'}, |
| 'f_employees': {'id':'Jumlah Karyawan', 'en':'Number of Employees', 'hi':'Karmachaaree Sankhyaa'}, |
| 'f_submit': {'id':'🔍 Analisis Risiko Kredit', 'en':'🔍 Assess Credit Risk', 'hi':'🔍 Credit Jokhim Jaanchein'}, |
| 'kpi_pd': {'id':'Skor PD', 'en':'PD Score', 'hi':'PD Score'}, |
| 'kpi_el': {'id':'Ekspektasi Kerugian', 'en':'Expected Loss', 'hi':'Anumaanit Haani'}, |
| 'kpi_lgd': {'id':'LGD', 'en':'LGD', 'hi':'LGD'}, |
| 'kpi_ead': {'id':'EAD', 'en':'EAD', 'hi':'EAD'}, |
| 'kpi_pd_sub': {'id':'Prob. Gagal Bayar', 'en':'Prob. of Default', 'hi':'Chook ki Sambhaavna'}, |
| 'kpi_el_sub': {'id':'PD×LGD×EAD', 'en':'PD×LGD×EAD', 'hi':'PD×LGD×EAD'}, |
| 'kpi_lgd_sub': {'id':'Basel II', 'en':'Basel II', 'hi':'Basel II'}, |
| 'kpi_ead_sub': {'id':'Jumlah Pinjaman', 'en':'Loan Amount', 'hi':'Rin Raashi'}, |
| 'tab_shap': {'id':'🔍 SHAP', 'en':'🔍 SHAP', 'hi':'🔍 SHAP'}, |
| 'tab_narrative': {'id':'💬 Narasi AI', 'en':'💬 AI Narrative', 'hi':'💬 AI Vivarana'}, |
| 'tab_chat': {'id':'🤖 AI Chat', 'en':'🤖 AI Chat', 'hi':'🤖 AI Baatcheet'}, |
| 'tab_whatif': {'id':'🎮 What-If', 'en':'🎮 What-If', 'hi':'🎮 What-If'}, |
| 'tab_formula': {'id':'📐 Formula', 'en':'📐 Formula', 'hi':'📐 Sutra'}, |
| 'shap_title': {'id':'🔍 SHAP — Mengapa Skor Ini?', 'en':'🔍 SHAP — Why This Score?', 'hi':'🔍 SHAP — Yah Score Kyun?'}, |
| 'shap_caption': {'id':'🔴 Merah = meningkatkan risiko | 🔵 Biru = menurunkan risiko', |
| 'en':'🔴 Red = increases risk | 🔵 Blue = decreases risk', |
| 'hi':'🔴 Laal = Jokhim Badhaata | 🔵 Neela = Jokhim Ghataata'}, |
| 'shap_col_feature': {'id':'Fitur', 'en':'Feature', 'hi':'Visheshata'}, |
| 'shap_col_impact': {'id':'Dampak', 'en':'Impact', 'hi':'Prabhav'}, |
| 'shap_risk_up': {'id':'⬆ Risiko Naik', 'en':'⬆ Risk↑', 'hi':'⬆ Jokhim Badha'}, |
| 'shap_risk_down': {'id':'⬇ Risiko Turun', 'en':'⬇ Risk↓', 'hi':'⬇ Jokhim Ghata'}, |
| 'narr_title': {'id':'💬 Narasi AI', 'en':'💬 AI Narrative', 'hi':'💬 AI Vivarana'}, |
| 'narr_source': {'id':'Sumber', 'en':'Source', 'hi':'Srot'}, |
| 'n_risk_summary': {'id':'Ringkasan Risiko', 'en':'Risk Summary', 'hi':'Jokhim Saaraansh'}, |
| 'n_strengths': {'id':'Kekuatan Utama', 'en':'Key Strengths', 'hi':'Mukhy Shakti'}, |
| 'n_risks': {'id':'Faktor Risiko', 'en':'Risk Factors', 'hi':'Jokhim Kaarak'}, |
| 'n_recommendations': {'id':'Rekomendasi', 'en':'Recommendations', 'hi':'Sujhaav'}, |
| 'chat_title': {'id':'🤖 Tanya Maya — AI Advisor', 'en':'🤖 Ask Maya — AI Advisor', 'hi':'🤖 Maya Se Puchein — AI Salaahkaar'}, |
| 'chat_welcome': {'id':'Halo! Aku Maya, AI Credit Advisor kamu 😊 Tanya apa aja soal kredit, bisnis, atau keuangan UMKM — atau sekadar ngobrol!', |
| 'en':'Hey! I am Maya, your AI Credit Advisor 😊 Ask me anything about credit, business, or SME finance — or just chat!', |
| 'hi':'Namaste! Main Maya hoon, aapki AI Credit Advisor 😊 Credit, vyapaar ya finance ke baare mein kuch bhi puchein!'}, |
| 'chat_input': {'id':'Ketik pertanyaan kamu...', 'en':'Type your question...', 'hi':'Apna sawaal likhein...'}, |
| 'chat_clear': {'id':'🗑️ Hapus Chat', 'en':'🗑️ Clear Chat', 'hi':'🗑️ Chat Saaf Karein'}, |
| 'chat_updated': {'id':'Slider What-If sudah diupdate — cek tab What-If!', |
| 'en':'What-If sliders updated — check that tab!', |
| 'hi':'What-If sliders update hue — voh tab dekhein!'}, |
| 'chat_chip1': {'id':'💡 Cara turunkan skor?', 'en':'💡 How to lower my score?', 'hi':'💡 Score Kaise Kam Karein?'}, |
| 'chat_chip2': {'id':'❓ Faktor apa yang pengaruh?', 'en':'❓ What factors matter?', 'hi':'❓ Kaun Se Kaarak Zaroori Hain?'}, |
| 'chat_chip3': {'id':'💰 Pinjaman ideal berapa?', 'en':'💰 Ideal loan amount?', 'hi':'💰 Aadarsht Rin Raashi?'}, |
| 'chat_chip4': {'id':'🌐 Tips bisnis UMKM?', 'en':'🌐 SME business tips?', 'hi':'🌐 SME Vyapaar Tips?'}, |
| 'wi_title': {'id':'🎮 Simulasi What-If', 'en':'🎮 What-If Simulator', 'hi':'🎮 What-If Simulator'}, |
| 'wi_caption': {'id':'Geser slider untuk lihat dampak ke skor risiko secara real-time', |
| 'en':'Adjust sliders to see real-time impact on your risk score', |
| 'hi':'Slider badlein aur jokhim score par real-time prabhav dekhein'}, |
| 'wi_loan': {'id':'💰 Jumlah Pinjaman (Rp juta)', 'en':'💰 Loan Amount (Rp million)', 'hi':'💰 Rin Raashi (Rp juta)'}, |
| 'wi_duration': {'id':'📅 Durasi (bulan)', 'en':'📅 Duration (months)', 'hi':'📅 Avadhi (Maheene)'}, |
| 'wi_cashflow': {'id':'💵 Cash Flow Bulanan (Rp juta)','en':'💵 Monthly Cash Flow (Rp million)','hi':'💵 Maasik Naqad Pravaah (Rp juta)'}, |
| 'wi_digital': {'id':'📱 Skor Digital', 'en':'📱 Digital Score', 'hi':'📱 Digital Score'}, |
| 'wi_bizage': {'id':'🏢 Umur Bisnis (tahun)', 'en':'🏢 Business Age (years)', 'hi':'🏢 Vyapaar Aayu (Saal)'}, |
| 'wi_employees': {'id':'👥 Jumlah Karyawan', 'en':'👥 Employees', 'hi':'👥 Karmachaaree'}, |
| 'wi_status': {'id':'Status', 'en':'Status', 'hi':'Sthiti'}, |
| 'wi_chart_title': {'id':'Original vs What-If — Skor PD','en':'Original vs What-If — PD Score','hi':'Original vs What-If — PD Score'}, |
| 'wi_pd_down': {'id':'turun', 'en':'reduced', 'hi':'kam hua'}, |
| 'wi_pd_up': {'id':'naik', 'en':'increased', 'hi':'badha'}, |
| 'wi_pd_from': {'id':'dari', 'en':'from', 'hi':'se'}, |
| 'wi_no_change': {'id':'Perubahan minimal pada skor risiko','en':'Minimal change in risk score','hi':'Jokhim score mein nyoonatam badlaav'}, |
| 'wi_tips_title': {'id':'💡 Tips Optimasi Skor', 'en':'💡 Score Optimization Tips', 'hi':'💡 Score Sudhaar Tips'}, |
| 'wi_form_first': {'id':'Submit form terlebih dahulu', 'en':'Submit the form first', 'hi':'Pehle form submit karein'}, |
| 'wi_approved': {'id':'LAYAK', 'en':'APPROVED', 'hi':'SWIKAARY'}, |
| 'wi_review': {'id':'REVIEW', 'en':'REVIEW', 'hi':'SAMEEKSHA'}, |
| 'wi_highrisk': {'id':'RISIKO TINGGI', 'en':'HIGH RISK', 'hi':'UCHCH JOKHIM'}, |
| 'form_formula_title':{'id':'📐 Ekspektasi Kerugian — Basel II','en':'📐 Expected Loss — Basel II','hi':'📐 Anumaanit Haani — Basel II'}, |
| 'formula_def': { |
| 'id':'**Definisi:**\n- **PD** — Probabilitas Gagal Bayar\n- **LGD** — Kerugian jika Gagal Bayar (40%)\n- **EAD** — Eksposur saat Gagal Bayar\n- **EL** — Ekspektasi Kerugian', |
| 'en':'**Definitions:**\n- **PD** — Probability of Default\n- **LGD** — Loss Given Default (40%)\n- **EAD** — Exposure at Default\n- **EL** — Expected Loss', |
| 'hi':'**Paribhaashaen:**\n- **PD** — Chook ki Sambhaavna\n- **LGD** — Chook par Haani (40%)\n- **EAD** — Chook par Exposure\n- **EL** — Anumaanit Haani'}, |
| 'formula_komponen': {'id':'Komponen', 'en':'Component', 'hi':'Ghatak'}, |
| 'formula_nilai': {'id':'Nilai', 'en':'Value', 'hi':'Moolya'}, |
| 'formula_low': {'id':'Rendah', 'en':'Low', 'hi':'Kam'}, |
| 'formula_medium': {'id':'Sedang', 'en':'Medium', 'hi':'Madhyam'}, |
| 'formula_high': {'id':'Tinggi', 'en':'High', 'hi':'Uchch'}, |
| 'download_btn': {'id':'📄 Download Laporan', 'en':'📄 Download Report', 'hi':'📄 Report Download Karein'}, |
| 'download_file': {'id':'laporan_risiko_kredit.txt', 'en':'credit_risk_report.txt', 'hi':'credit_risk_report.txt'}, |
| 'spinner': {'id':'🧠 Menganalisis...', 'en':'🧠 Analyzing...', 'hi':'🧠 Vishleshan ho raha hai...'}, |
| 'no_model': {'id':'Model tidak ditemukan. Jalankan train_colab.py lalu upload folder model/ ke HF Space ini.', |
| 'en':'Model not found. Run train_colab.py, then upload the model/ folder to this HF Space.', |
| 'hi':'Model nahi mila. train_colab.py chalayen, phir model/ folder upload karein.'}, |
| 'or_fallback': {'id':'menggunakan fallback', 'en':'using fallback', 'hi':'fallback use kar raha hai'}, |
| 'risk_approved': {'id':'🟢 LAYAK', 'en':'🟢 APPROVED', 'hi':'🟢 YOGYA'}, |
| 'risk_review': {'id':'🟡 PERLU REVIEW', 'en':'🟡 REVIEW', 'hi':'🟡 SAMEEKSHA'}, |
| 'risk_high': {'id':'🔴 BERISIKO TINGGI', 'en':'🔴 HIGH RISK', 'hi':'🔴 UCHCH JOKHIM'}, |
| 'empty_ensemble': {'id':'Ensemble', 'en':'Ensemble', 'hi':'Ensemble'}, |
| 'empty_xai': {'id':'XAI', 'en':'XAI', 'hi':'XAI'}, |
| 'empty_narrative': {'id':'Narasi', 'en':'Narrative', 'hi':'Vivarana'}, |
| 'empty_chat': {'id':'AI Chat', 'en':'AI Chat', 'hi':'AI Baatcheet'}, |
| } |
|
|
| def T(key: str, lang: str) -> str: |
| entry = TRANSLATIONS.get(key, {}) |
| return entry.get(lang, entry.get('en', key)) |
|
|
| |
| |
| |
| CHECKING_OPTS = ['<0', '0<=X<200', '>=200', 'no checking'] |
| CHECKING_LABELS = { |
| '<0': {'id':'Saldo Negatif / Defisit (< 0 DM)', 'en':'Negative Balance (< 0 DM)', 'hi':'Negative Bakaya (< 0 DM)'}, |
| '0<=X<200': {'id':'Saldo Rendah (0 – 200 DM)', 'en':'Low Balance (0 – 200 DM)', 'hi':'Kam Bakaya (0 – 200 DM)'}, |
| '>=200': {'id':'Saldo Sehat (≥ 200 DM) ✅', 'en':'Healthy Balance (≥ 200 DM) ✅', 'hi':'Swasth Bakaya (≥ 200 DM) ✅'}, |
| 'no checking': {'id':'Tidak Punya Rekening Giro', 'en':'No Checking Account', 'hi':'Koi Checking Khaata Nahi'}, |
| } |
| CREDIT_H_OPTS = ['no credits/all paid','all paid','existing paid','delayed previously','critical/other existing credit'] |
| CREDIT_H_LABELS = { |
| 'no credits/all paid': {'id':'Belum pernah kredit / Semua lunas', 'en':'No credits / All paid off', 'hi':'Koi Credit Nahi / Sab Chuka'}, |
| 'all paid': {'id':'Semua kredit terbayar lunas ✅', 'en':'All credits fully paid ✅', 'hi':'Sabhi Credit Poori Tarah Chuke ✅'}, |
| 'existing paid': {'id':'Kredit aktif — cicilan lancar tepat waktu ✅', 'en':'Active credit — payments on time ✅', 'hi':'Active Credit — Samay Par Bhugtaan ✅'}, |
| 'delayed previously': {'id':'Pernah terlambat bayar di masa lalu ⚠️', 'en':'Previously delayed payments ⚠️', 'hi':'Pehle Vilambh Se Bhugtaan ⚠️'}, |
| 'critical/other existing credit': {'id':'Kredit bermasalah / Ada kredit di bank lain 🔴','en':'Critical / Existing credit at other bank 🔴','hi':'Gambheer Credit / Anya Bank Mein 🔴'}, |
| } |
| PURPOSE_OPTS = ['new car','used car','furniture/equipment','radio/tv','domestic appliance','repairs','education','vacation','retraining','business','other'] |
| PURPOSE_LABELS = { |
| 'new car': {'id':'Mobil Baru 🚗', 'en':'New Car 🚗', 'hi':'Nayi Gaaree 🚗'}, |
| 'used car': {'id':'Mobil Bekas 🚙', 'en':'Used Car 🚙', 'hi':'Purani Gaaree 🚙'}, |
| 'furniture/equipment': {'id':'Furnitur / Peralatan 🪑', 'en':'Furniture / Equipment 🪑', 'hi':'Furniture / Upakaran 🪑'}, |
| 'radio/tv': {'id':'Elektronik (Radio / TV) 📺', 'en':'Electronics (Radio/TV) 📺', 'hi':'Electronics (Radio/TV) 📺'}, |
| 'domestic appliance': {'id':'Peralatan Rumah Tangga 🏠', 'en':'Domestic Appliances 🏠', 'hi':'Gharelu Upakaran 🏠'}, |
| 'repairs': {'id':'Renovasi / Perbaikan 🔧', 'en':'Repairs / Renovation 🔧', 'hi':'Marammat / Naveenikaran 🔧'}, |
| 'education': {'id':'Pendidikan 🎓', 'en':'Education 🎓', 'hi':'Shiksha 🎓'}, |
| 'vacation': {'id':'Liburan / Wisata ✈️', 'en':'Vacation / Travel ✈️', 'hi':'Chutti / Yaatraa ✈️'}, |
| 'retraining': {'id':'Pelatihan / Kursus 📚', 'en':'Retraining / Course 📚', 'hi':'Punar Prashikshan / Course 📚'}, |
| 'business': {'id':'Modal Usaha / Bisnis 🏢', 'en':'Business Capital 🏢', 'hi':'Vyaapaar Pooji 🏢'}, |
| 'other': {'id':'Lainnya', 'en':'Other', 'hi':'Anya'}, |
| } |
| SAVINGS_OPTS = ['<100','100<=X<500','500<=X<1000','>=1000','no known savings'] |
| SAVINGS_LABELS = { |
| '<100': {'id':'Tabungan Sangat Tipis (< 100 DM)', 'en':'Very Low Savings (< 100 DM)', 'hi':'Bahut Kam Bachat (< 100 DM)'}, |
| '100<=X<500': {'id':'Tabungan Kecil (100 – 500 DM)', 'en':'Small Savings (100 – 500 DM)', 'hi':'Chhoti Bachat (100 – 500 DM)'}, |
| '500<=X<1000': {'id':'Tabungan Sedang (500 – 1.000 DM)', 'en':'Medium Savings (500 – 1,000 DM)','hi':'Madhyam Bachat (500 – 1,000 DM)'}, |
| '>=1000': {'id':'Tabungan Kuat (≥ 1.000 DM) 💪', 'en':'Strong Savings (≥ 1,000 DM) 💪','hi':'Mazboot Bachat (≥ 1,000 DM) 💪'}, |
| 'no known savings': {'id':'Tidak Ada Tabungan Tercatat', 'en':'No Known Savings', 'hi':'Koi Jaani Bachat Nahi'}, |
| } |
| EMPLOY_OPTS = ['unemployed','<1','1<=X<4','4<=X<7','>=7'] |
| EMPLOY_LABELS = { |
| 'unemployed': {'id':'Tidak Bekerja / Pengangguran', 'en':'Unemployed', 'hi':'Berozgaar'}, |
| '<1': {'id':'Bekerja Kurang dari 1 Tahun', 'en':'Employed Less than 1 Year', 'hi':'1 Saal Se Kam Rozgaar'}, |
| '1<=X<4': {'id':'Bekerja 1 – 4 Tahun', 'en':'Employed 1 – 4 Years', 'hi':'1 – 4 Saal Rozgaar'}, |
| '4<=X<7': {'id':'Bekerja 4 – 7 Tahun', 'en':'Employed 4 – 7 Years', 'hi':'4 – 7 Saal Rozgaar'}, |
| '>=7': {'id':'Bekerja 7+ Tahun (Senior) 🏅', 'en':'Employed 7+ Years (Senior) 🏅', 'hi':'7+ Saal Rozgaar (Senior) 🏅'}, |
| } |
| HOUSING_OPTS = ['rent','free','own'] |
| HOUSING_LABELS = { |
| 'rent': {'id':'Sewa / Kontrak 🏘️', 'en':'Renting 🏘️', 'hi':'Kiraaye Par 🏘️'}, |
| 'free': {'id':'Tinggal Gratis (Bersama Keluarga)', 'en':'Free (Living with Family)', 'hi':'Muft (Parivaar Ke Saath)'}, |
| 'own': {'id':'Milik Sendiri 🏠', 'en':'Own Property 🏠', 'hi':'Apni Sampatti 🏠'}, |
| } |
| LANG_LABELS = {'id':'🇮🇩 Bahasa Indonesia','en':'🇬🇧 English','hi':'🇮🇳 Hindi (Roman)'} |
|
|
| |
| |
| |
| st.markdown(""" |
| <style> |
| @import url('https://fonts.googleapis.com/css2?family=Plus+Jakarta+Sans:wght@400;500;600;700;800&display=swap'); |
| html,body,[class*="css"]{font-family:'Plus Jakarta Sans',sans-serif;} |
| .header-wrap{background:linear-gradient(135deg,#0F1B40 0%,#1A2C6B 60%,#2a4494 100%); |
| padding:2.5rem 2rem;border-radius:16px;text-align:center;color:white;margin-bottom:2rem; |
| box-shadow:0 8px 32px rgba(26,44,107,0.3);} |
| .header-wrap h1{font-size:2rem;font-weight:800;margin:0;letter-spacing:-0.5px;} |
| .header-wrap p{font-size:0.95rem;opacity:0.75;margin-top:0.4rem;} |
| .badge{display:inline-block;padding:0.25rem 0.8rem;border-radius:20px; |
| font-size:0.72rem;font-weight:700;margin:0 3px;} |
| .b-blue{background:rgba(99,179,237,0.2);color:#90cdf4;border:1px solid rgba(99,179,237,0.3);} |
| .b-green{background:rgba(72,187,120,0.2);color:#9ae6b4;border:1px solid rgba(72,187,120,0.3);} |
| .b-yellow{background:rgba(246,173,85,0.2);color:#fbd38d;border:1px solid rgba(246,173,85,0.3);} |
| .risk-banner{padding:1.5rem;border-radius:12px;text-align:center; |
| font-weight:800;font-size:1.6rem;color:white;margin-bottom:1rem;} |
| .risk-low{background:linear-gradient(135deg,#11998e,#38ef7d);} |
| .risk-med{background:linear-gradient(135deg,#f7971e,#ffd200);color:#333;} |
| .risk-high{background:linear-gradient(135deg,#cb2d3e,#ef473a);} |
| .kpi-box{background:white;border-radius:12px;padding:1.1rem 1rem; |
| text-align:center;border:1px solid #e8ecf4; |
| box-shadow:0 2px 10px rgba(26,44,107,0.07);min-height:100px;} |
| .kpi-lbl{font-size:0.72rem;font-weight:600;text-transform:uppercase; |
| letter-spacing:.5px;color:#6b7280;} |
| .kpi-val{font-size:1.6rem;font-weight:800;color:#1a2c6b;line-height:1.2;margin-top:3px;} |
| .kpi-sub{font-size:0.72rem;color:#9ca3af;margin-top:2px;} |
| .narrative-box{background:#f8faff;border-left:4px solid #4A7FD4; |
| border-radius:8px;padding:1.2rem 1.5rem;line-height:1.75; |
| font-size:0.93rem;color:#2d3748;} |
| .sec-title{font-size:1rem;font-weight:700;color:#1a2c6b; |
| padding-bottom:.4rem;border-bottom:2px solid #4A7FD4;margin-bottom:1rem;} |
| .chat-bubble-user{background:linear-gradient(135deg,#1A2C6B,#2a4494); |
| color:white;padding:0.7rem 1rem;border-radius:16px 16px 4px 16px; |
| margin:0.4rem 0 0.4rem 15%;font-size:0.88rem;line-height:1.5;} |
| .chat-bubble-ai{background:white;color:#2d3748;padding:0.7rem 1rem; |
| border-radius:16px 16px 16px 4px;margin:0.4rem 15% 0.4rem 0; |
| font-size:0.88rem;line-height:1.6;border:1px solid #e8ecf4; |
| box-shadow:0 2px 8px rgba(26,44,107,0.06);} |
| .memory-badge{background:#f0f4ff;border:1px solid #c7d7f8;border-radius:8px; |
| padding:0.4rem 0.8rem;font-size:0.75rem;color:#4a6fa5;margin-bottom:0.5rem;} |
| </style> |
| """, unsafe_allow_html=True) |
|
|
| |
| |
| |
| def _read_secret(name: str) -> str: |
| try: |
| val = st.secrets[name] |
| if val: |
| return str(val).strip() |
| except Exception: |
| pass |
| return os.environ.get(name, "").strip() |
|
|
| def _init_api_keys(): |
| if "api_keys_loaded" not in st.session_state: |
| st.session_state.openrouter_key = _read_secret("OPENROUTER_API_KEY") |
| st.session_state.groq_key = _read_secret("GROQ_API_KEY") |
| st.session_state.api_keys_loaded = True |
|
|
| _init_api_keys() |
|
|
| |
| |
| |
| def _log_api(step: str, src: str, ok: bool, ms: int): |
| if 'api_log' not in st.session_state: |
| st.session_state.api_log = [] |
| st.session_state.api_log.append({'step': step, 'src': src, 'ok': ok, 'ms': ms}) |
| st.session_state.api_log = st.session_state.api_log[-20:] |
|
|
| |
| |
| |
| def _set_status(msg: str): |
| st.session_state['llm_status'] = msg |
| ph = st.session_state.get('_llm_ph') |
| if ph: |
| if msg: |
| try: |
| ph.info(msg, icon="⏳") |
| except Exception: |
| pass |
| else: |
| try: |
| ph.empty() |
| except Exception: |
| pass |
|
|
| |
| |
| |
| MAYA_TOOLS = [ |
| { |
| "type": "function", |
| "function": { |
| "name": "adjust_whatif_slider", |
| "description": ( |
| "Adjust a What-If simulator parameter to demonstrate improvement potential. " |
| "Use this whenever recommending a specific value change to the user's risk profile." |
| ), |
| "parameters": { |
| "type": "object", |
| "properties": { |
| "field": { |
| "type": "string", |
| "enum": ["digital_presence_score","business_age_years","num_employees", |
| "monthly_cash_flow","duration","loan_rp"], |
| "description": "The What-If field to adjust" |
| }, |
| "value": {"type": "number", "description": "The recommended value for the field"}, |
| "reason": {"type": "string", "description": "Brief explanation of why this change reduces credit risk"} |
| }, |
| "required": ["field", "value", "reason"] |
| } |
| } |
| } |
| ] |
|
|
| _OR_TOOL_MODELS = [ |
| "google/gemini-2.0-flash-exp:free", |
| "qwen/qwen3-32b:free", |
| "meta-llama/llama-3.3-70b-instruct:free", |
| "qwen/qwen2.5-72b-instruct:free", |
| "mistralai/mistral-7b-instruct:free", |
| ] |
| _GROQ_TOOL_MODELS = [ |
| "llama-3.3-70b-versatile", |
| "llama-3.1-8b-instant", |
| "gemma2-9b-it", |
| ] |
|
|
| |
| |
| |
| def call_openrouter_tools(messages, api_key, tools=None): |
| if not api_key: |
| return None, [], "No OpenRouter API key" |
| last_error = None |
| for m in _OR_TOOL_MODELS: |
| short = m.split("/")[-1].replace(":free","") |
| _set_status("OpenRouter → " + short + "...") |
| t0 = _time.time() |
| try: |
| payload = {"model": m, "messages": messages, "max_tokens": 700, "temperature": 0.7} |
| if tools: |
| payload["tools"] = tools |
| payload["tool_choice"] = "auto" |
| resp = requests.post( |
| "https://openrouter.ai/api/v1/chat/completions", |
| headers={ |
| "Authorization": "Bearer " + api_key, |
| "Content-Type": "application/json", |
| "HTTP-Referer": "https://huggingface.co", |
| "X-Title": "SME Credit Risk AI" |
| }, |
| json=payload, |
| timeout=15, |
| ) |
| ms = int((_time.time() - t0) * 1000) |
| if resp.status_code == 200: |
| choice = resp.json()['choices'][0]['message'] |
| text = choice.get('content') or '' |
| tool_calls = choice.get('tool_calls') or [] |
| _log_api("Chat", "OR/" + short, True, ms) |
| _set_status('') |
| return text, tool_calls, None |
| last_error = "HTTP " + str(resp.status_code) + " (" + short + "): " + resp.text[:120] |
| _log_api("Chat", "OR/" + short, False, ms) |
| except requests.exceptions.Timeout: |
| ms = int((_time.time() - t0) * 1000) |
| last_error = "Timeout (" + short + ")" |
| _log_api("Chat", "OR/" + short, False, ms) |
| _set_status("Timeout " + short + "...") |
| except Exception as e: |
| ms = int((_time.time() - t0) * 1000) |
| last_error = short + ": " + str(e)[:100] |
| _log_api("Chat", "OR/" + short, False, ms) |
| _set_status('') |
| return None, [], last_error |
|
|
| def call_groq_tools(messages, api_key, tools=None): |
| if not api_key: |
| return None, [], "No Groq API key" |
| last_error = None |
| for m in _GROQ_TOOL_MODELS: |
| _set_status("Groq → " + m + "...") |
| t0 = _time.time() |
| try: |
| payload = {"model": m, "messages": messages, "max_tokens": 700, "temperature": 0.7} |
| if tools: |
| payload["tools"] = tools |
| payload["tool_choice"] = "auto" |
| resp = requests.post( |
| "https://api.groq.com/openai/v1/chat/completions", |
| headers={ |
| "Authorization": "Bearer " + api_key, |
| "Content-Type": "application/json" |
| }, |
| json=payload, |
| timeout=12, |
| ) |
| ms = int((_time.time() - t0) * 1000) |
| if resp.status_code == 200: |
| choice = resp.json()['choices'][0]['message'] |
| text = choice.get('content') or '' |
| tool_calls = choice.get('tool_calls') or [] |
| _log_api("Chat", "Groq/" + m, True, ms) |
| _set_status('') |
| return text, tool_calls, None |
| last_error = "HTTP " + str(resp.status_code) + " (" + m + "): " + resp.text[:120] |
| _log_api("Chat", "Groq/" + m, False, ms) |
| except requests.exceptions.Timeout: |
| ms = int((_time.time() - t0) * 1000) |
| last_error = "Timeout (" + m + ")" |
| _log_api("Chat", "Groq/" + m, False, ms) |
| _set_status("Timeout " + m + "...") |
| except Exception as e: |
| ms = int((_time.time() - t0) * 1000) |
| last_error = m + ": " + str(e)[:100] |
| _log_api("Chat", "Groq/" + m, False, ms) |
| _set_status('') |
| return None, [], last_error |
|
|
| def _clean_response(text: str) -> tuple: |
| if not text: |
| return text, {} |
| adjustments = {} |
| |
| text = re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL).strip() |
|
|
| |
| json_pattern = re.compile( |
| r'\{[^{}]*"field"\s*:\s*"([^"]+)"[^{}]*"value"\s*:\s*([\d.]+)[^{}]*\}', |
| re.DOTALL |
| ) |
| valid_fields = { |
| 'digital_presence_score','business_age_years','num_employees', |
| 'monthly_cash_flow','duration','loan_rp' |
| } |
| for m in json_pattern.finditer(text): |
| field, val = m.group(1), float(m.group(2)) |
| if field in valid_fields: |
| adjustments[field] = val |
| text = json_pattern.sub('', text).strip() |
|
|
| |
| for m in re.finditer(r'\[ADJUST:\s*(\w+)\s*=\s*([\d.]+)\]', text): |
| field, val = m.group(1), float(m.group(2)) |
| if field not in adjustments: |
| adjustments[field] = val |
| |
| text = re.sub(r'\s*\[ADJUST:[^\]]+\]\s*', ' ', text).strip() |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| |
| _orphan_id = re.compile( |
| r'[,\s]*coba\s+(?:naikkan|tingkatkan|optimalkan|kurangi|pertahankan|daftarkan|perbaiki)\s+' |
| r'[\w\s]+\s+ke\s+[\w\s./0-9]+(?:\s*[—–-]\s*[^.]*)?\.?\s*$', |
| re.IGNORECASE | re.DOTALL |
| ) |
| _orphan_id2 = re.compile( |
| r'[,\n]\s*(?:naikkan|tingkatkan|optimalkan|kurangi|pertahankan|daftarkan|perbaiki)\s+' |
| r'[\w\s]+\s+ke\s+[\w\s./0-9]+(?:\s*[—–-]\s*[^.]*)?\.?\s*$', |
| re.IGNORECASE | re.DOTALL |
| ) |
| _orphan_en = re.compile( |
| r'[,\s]*(?:try\s+)?(?:raise|increase|lower|reduce|optimize|improve|register)\s+' |
| r'[\w\s]+\s+to\s+[\w\s./0-9]+(?:\s*[—–-]\s*[^.]*)?\.?\s*$', |
| re.IGNORECASE | re.DOTALL |
| ) |
| for _pat in [_orphan_id, _orphan_id2, _orphan_en]: |
| text = _pat.sub('', text).strip() |
|
|
| |
| _pivot_patterns = [ |
| |
| re.compile( |
| r',?\s*aku\s+bisa\s+(?:membantu\s+kamu\s+)?dengan\s+menyarankan\s+untuk\s+meningkatkan\s+' |
| r'[\w\s]+\s+ke\s+\d+\s+untuk\s+mendapatkan[^.]*\.?\s*$', |
| re.IGNORECASE | re.DOTALL |
| ), |
| |
| re.compile( |
| r',?\s*(?:untuk\s+)?meningkatkan\s+(?:digital\s+)?(?:score|presence|skor)\s+kamu\s+ke\s+\d+' |
| r'\s+untuk\s+mendapatkan[^.]*\.?\s*$', |
| re.IGNORECASE | re.DOTALL |
| ), |
| |
| re.compile( |
| r'[.\n]\s*(?:jadi|dan|tapi|,)?\s*(?:mari\s+kita\s+)?fokus\s+pada\s+' |
| r'(?:membicarakan|cara)\s+(?:tentang\s+)?(?:cara\s+)?meningkatkan\s+[\w\s]+\s+ke\s+\d+[^.]*\.?\s*$', |
| re.IGNORECASE | re.DOTALL |
| ), |
| |
| re.compile( |
| r'[.\n]\s*(?:atau|tapi|jadi|dan|,)?\s*kalau\s+kamu\s+(?:ingin|mau|bisa)\s+kembali\s+ke\s+' |
| r'topik\s+(?:kredit|keuangan|bisnis)[^.]*\.?\s*$', |
| re.IGNORECASE | re.DOTALL |
| ), |
| |
| re.compile( |
| r',?\s*tapi,?\s+kalau\s+kamu\s+(?:adalah\s+seorang|ingin\s+mengajukan)\s+[^.]{0,120}\.?\s*$', |
| re.IGNORECASE | re.DOTALL |
| ), |
| ] |
| for _pat in _pivot_patterns: |
| text = _pat.sub('', text).strip() |
|
|
| |
| |
| |
| def _strip_to_last_sentence(t): |
| t = t.strip() |
| if not t or t[-1] in '.!?': |
| return t |
| last_end = max(t.rfind('.'), t.rfind('!'), t.rfind('?')) |
| if last_end > len(t) * 0.25: |
| return t[:last_end + 1].strip() |
| return t |
| text = _strip_to_last_sentence(text) |
|
|
| |
| text = re.sub(r' +', ' ', text) |
| text = re.sub(r' ([,.])', r'\1', text) |
| text = re.sub(r'\n\*\*\s*\*\*\s*$', '', text, flags=re.MULTILINE).strip() |
| text = re.sub(r'\n{3,}', '\n\n', text).strip() |
| text = re.sub(r'[,\s—–-]+$', '', text).strip() |
| return text, adjustments |
|
|
| def _extract_adjustments_semantic(text: str, raw_input: dict) -> dict: |
| """ |
| FALLBACK: parse numeric slider values from natural LLM text. |
| |
| Free-tier LLMs (gemini-flash, llama-3, etc.) frequently ignore [ADJUST:] tag |
| instructions and respond in plain natural language. This function detects |
| numeric recommendations in the response text and maps them to What-If fields. |
| |
| Called AFTER _clean_response — only fills in fields not already captured |
| by explicit [ADJUST:] tags or formal tool calls. |
| |
| Examples handled: |
| "naikkan digital score ke 75" → digital_presence_score=75 |
| "digital score jadi 80" → digital_presence_score=80 |
| "Rp 25jt/bln" near "cash flow" → monthly_cash_flow=25_000_000 |
| "pinjaman ideal Rp 128jt" → loan_rp=128_000_000 |
| "tenor 36 bulan" → duration=36 |
| """ |
| if not text: |
| return {} |
| adjustments = {} |
| low = text.lower() |
|
|
| |
| for pat in [ |
| r'digital\s+score\s+(?:ke|jadi|to|→|->|=)\s*(\d+)', |
| r'naikkan\s+digital\s+(?:score\s+)?(?:ke|jadi|to)\s*(\d+)', |
| r'raise\s+digital\s+(?:score\s+)?to\s*(\d+)', |
| r'digital\s+(?:ke|jadi)\s*(\d+)', |
| r'digital\s+presence\s+(?:score\s+)?(?:ke|jadi|to)\s*(\d+)', |
| ]: |
| m = re.search(pat, low) |
| if m: |
| val = int(m.group(1)) |
| if 1 <= val <= 100: |
| adjustments['digital_presence_score'] = float(val) |
| break |
|
|
| |
| |
| for pat in [ |
| r'cash\s+flow\s+(?:ke|jadi|to|→)\s*rp\s*(\d+)\s*(?:jt|juta|m\b)', |
| r'(?:optimal|target|naikkan)\s+cash\s+flow.*?rp\s*(\d+)\s*(?:jt|juta|m\b)', |
| r'rp\s*(\d+)\s*(?:jt|juta)\s*/\s*(?:bln|bulan|month)', |
| r'cash\s+flow\s+.*?(\d+)\s*(?:jt|juta)\s*/\s*(?:bln|bulan|month)', |
| r'cash\s+flow.*?rp\s*(\d+)\s*(?:jt|juta)', |
| ]: |
| m = re.search(pat, low) |
| if m: |
| val_m = int(m.group(1)) * 1_000_000 |
| cur_cf = raw_input.get('monthly_cash_flow', 0) |
| |
| if val_m != cur_cf and 1_000_000 <= val_m <= 500_000_000: |
| adjustments['monthly_cash_flow'] = float(val_m) |
| break |
|
|
| |
| for pat in [ |
| r'pinjaman\s+(?:ideal|aman|safe|maksimal|max)\s+.*?rp\s*(\d+(?:[,.]\d+)?)\s*(?:jt|juta|m\b)', |
| r'rp\s*(\d+(?:[,.]\d+)?)\s*(?:jt|juta)\s+(?:lebih aman|masih aman|safe|ideal)', |
| r'batas\s+aman\s+.*?rp\s*(\d+(?:[,.]\d+)?)\s*(?:jt|juta)', |
| r'ideal\s+loan\s+.*?rp\s*(\d+(?:[,.]\d+)?)\s*(?:jt|juta|m\b)', |
| r'max(?:imal)?\s+.*?rp\s*(\d+(?:[,.]\d+)?)\s*(?:jt|juta|m\b)', |
| ]: |
| m = re.search(pat, low) |
| if m: |
| raw_val = m.group(1) |
| try: |
| |
| |
| if ',' in raw_val or '.' in raw_val: |
| val_f = float(raw_val.replace(',', '.')) |
| else: |
| val_f = float(raw_val) |
| val_m = int(val_f * 1_000_000) |
| cur_loan = raw_input.get('loan_rp', 50e6) |
| if val_m != cur_loan and 5_000_000 <= val_m <= 500_000_000: |
| adjustments['loan_rp'] = float(val_m) |
| break |
| except ValueError: |
| pass |
|
|
| |
| for pat in [ |
| r'tenor\s+(\d+)\s*bulan', |
| r'duration\s+(\d+)\s*months?', |
| r'perpanjang\s+tenor\s+(?:ke|jadi|to)\s*(\d+)', |
| ]: |
| m = re.search(pat, low) |
| if m: |
| val = int(m.group(1)) |
| cur_dur = raw_input.get('duration', 24) |
| if val != cur_dur and 4 <= val <= 72: |
| adjustments['duration'] = float(val) |
| break |
|
|
| |
| for pat in [ |
| r'bangun\s+bisnis\s+(?:selama\s+)?(\d+)\s*tahun', |
| r'business\s+age\s+(?:to|ke|jadi)\s*(\d+)', |
| ]: |
| m = re.search(pat, low) |
| if m: |
| val = int(m.group(1)) |
| if 1 <= val <= 20: |
| adjustments['business_age_years'] = float(val) |
| break |
|
|
| return adjustments |
|
|
|
|
| def _call_chat_llm(messages): |
| """Cascade: OR tools → Groq tools → OR no-tools → Groq no-tools.""" |
| _or = st.session_state.get("openrouter_key", "") |
| _grq = st.session_state.get("groq_key", "") |
| if _or: |
| text, tool_calls, err = call_openrouter_tools(messages, _or, MAYA_TOOLS) |
| if text or tool_calls: |
| return text, tool_calls, 'OpenRouter (tool-calling)', None |
| if _grq: |
| text, tool_calls, err = call_groq_tools(messages, _grq, MAYA_TOOLS) |
| if text or tool_calls: |
| return text, tool_calls, 'Groq (tool-calling)', None |
| if _or: |
| text, tool_calls, err = call_openrouter_tools(messages, _or, tools=None) |
| if text: |
| return text, [], 'OpenRouter', err |
| if _grq: |
| text, tool_calls, err = call_groq_tools(messages, _grq, tools=None) |
| if text: |
| return text, [], 'Groq', err |
| return None, [], None, "No LLM available" |
|
|
| def _parse_tool_calls(tool_calls): |
| """Parse formal tool_calls from LLM response into adjustments dict.""" |
| adjustments = {} |
| for tc in (tool_calls or []): |
| try: |
| fn = tc.get('function', {}) |
| if fn.get('name') == 'adjust_whatif_slider': |
| args = json.loads(fn.get('arguments', '{}')) |
| field = args.get('field') |
| value = args.get('value') |
| if field and value is not None: |
| adjustments[field] = float(value) |
| except Exception: |
| pass |
| return adjustments |
|
|
| def _tool_call_to_text(tool_calls, lang): |
| """Convert tool calls to human-readable confirmation text.""" |
| if not tool_calls: |
| return "" |
| field_labels = { |
| 'digital_presence_score': {'id':'Digital Score','en':'Digital Score','hi':'Digital Score'}, |
| 'business_age_years': {'id':'Umur Bisnis', 'en':'Business Age', 'hi':'Vyapaar Aayu'}, |
| 'num_employees': {'id':'Karyawan', 'en':'Employees', 'hi':'Karmachaaree'}, |
| 'monthly_cash_flow': {'id':'Cash Flow', 'en':'Cash Flow', 'hi':'Naqad Pravaah'}, |
| 'duration': {'id':'Tenor', 'en':'Duration', 'hi':'Avadhi'}, |
| 'loan_rp': {'id':'Pinjaman', 'en':'Loan', 'hi':'Rin'}, |
| } |
| parts = [] |
| for tc in tool_calls: |
| try: |
| fn = tc.get('function', {}) |
| if fn.get('name') == 'adjust_whatif_slider': |
| args = json.loads(fn.get('arguments', '{}')) |
| field = args.get('field', '') |
| value = args.get('value', 0) |
| reason = args.get('reason', '') |
| fl = field_labels.get(field, {}).get(lang, field) |
| if field in ('monthly_cash_flow', 'loan_rp'): |
| val_str = "Rp " + str(int(float(value) / 1e6)) + "jt" |
| else: |
| val_str = str(int(float(value))) |
| reason_str = " — " + reason if reason else "" |
| parts.append("Rekomendasi ubah **" + fl + "** ke **" + val_str + "**" + reason_str + ".") |
| except Exception: |
| pass |
| if parts: |
| suffix = { |
| 'id': 'What-If sliders sudah diupdate!', |
| 'en': 'What-If sliders updated!', |
| 'hi': 'What-If sliders update ho gaye!', |
| }[lang] |
| return '\n'.join(parts) + '\n\n' + suffix |
| return "" |
|
|
| |
| |
| |
| def _get_session_id(): |
| if 'session_id' not in st.session_state: |
| import random |
| st.session_state.session_id = hashlib.md5( |
| (str(_time.time()) + str(random.random())).encode() |
| ).hexdigest()[:10] |
| return st.session_state.session_id |
|
|
| def _save_chat_memory(history, summary=''): |
| try: |
| sid = _get_session_id() |
| data = {"history": history[-30:], "summary": summary} |
| with open("/tmp/chat_mem_" + sid + ".json", 'w', encoding='utf-8') as f: |
| json.dump(data, f, ensure_ascii=False) |
| except Exception: |
| pass |
|
|
| def _load_chat_memory(): |
| try: |
| sid = _get_session_id() |
| path = "/tmp/chat_mem_" + sid + ".json" |
| if os.path.exists(path): |
| with open(path, encoding='utf-8') as f: |
| data = json.load(f) |
| return data.get("history", []), data.get("summary", "") |
| except Exception: |
| pass |
| return [], "" |
|
|
| |
| |
| |
| def _summarize_history(history, lang): |
| if len(history) <= 8: |
| return history, st.session_state.get('chat_summary', '') |
| old_turns = history[:-6] |
| recent_turns = history[-6:] |
| prev_summary = st.session_state.get('chat_summary', '') |
| lang_str = {'id':'Bahasa Indonesia','en':'English','hi':'Hindi'} |
| prompt_lines = [ |
| "Summarize this conversation in 3-4 sentences in " + lang_str.get(lang,'Bahasa Indonesia') + ".", |
| "Preserve: key questions asked, recommendations given, any slider values suggested.", |
| ] |
| if prev_summary: |
| prompt_lines.append("Previous summary to extend: " + prev_summary) |
| prompt_lines.append("") |
| for m in old_turns: |
| prompt_lines.append(m['role'].upper() + ": " + m['content'][:160]) |
| r, _, _ = _call_llm([{"role": "user", "content": '\n'.join(prompt_lines)}]) |
| fallback = { |
| 'id': "[Ringkasan " + str(len(old_turns)//2) + " topik sebelumnya tidak tersedia]", |
| 'en': "[Summary of " + str(len(old_turns)//2) + " earlier topics unavailable]", |
| 'hi': "[Pehle ke " + str(len(old_turns)//2) + " vishay ka saaraansh upalabdh nahi]", |
| } |
| new_summary = r.strip() if r else (prev_summary or fallback[lang]) |
| st.session_state.chat_summary = new_summary |
| return recent_turns, new_summary |
|
|
| |
| for _k, _v in [('llm_status', ''), ('api_log', [])]: |
| if _k not in st.session_state: |
| st.session_state[_k] = _v |
|
|
| |
| |
| |
| with st.sidebar: |
| st.markdown("## 🏦 SME Credit Risk AI") |
| st.markdown("---") |
|
|
| if 'lang_sel' not in st.session_state: |
| st.session_state.lang_sel = 'id' |
| lang = st.radio( |
| "🌐 Language / Bahasa / Roman Hindi", |
| ['id','en','hi'], |
| format_func=lambda x: LANG_LABELS[x], |
| key='lang_sel' |
| ) |
| st.markdown("---") |
|
|
| openrouter_key = st.session_state.openrouter_key |
| groq_key = st.session_state.groq_key |
| st.caption( |
| "🔑 **OpenRouter:** " + ('✅' if openrouter_key else '❌') + |
| " **Groq:** " + ('✅' if groq_key else '❌') |
| ) |
| if not any([openrouter_key, groq_key]): |
| st.warning("⚠️ Tidak ada API key. Tambahkan OPENROUTER_API_KEY / GROQ_API_KEY di HF Secrets.") |
|
|
| |
| _llm_ph = st.empty() |
| st.session_state['_llm_ph'] = _llm_ph |
| if st.session_state.get('llm_status'): |
| try: |
| _llm_ph.info(st.session_state.llm_status, icon="⏳") |
| except Exception: |
| pass |
| st.markdown("---") |
|
|
| st.markdown("### 📡 API Tracker") |
| _active_status = st.session_state.get('llm_status', '') |
| if _active_status: |
| st.markdown( |
| "<div style='background:#fff3cd;border:1px solid #ffc107;border-radius:6px;" |
| "padding:0.3rem 0.6rem;font-size:0.75rem;color:#856404;margin-bottom:0.4rem'>" |
| "⏳ " + _active_status + "</div>", |
| unsafe_allow_html=True |
| ) |
| if st.session_state.api_log: |
| for entry in st.session_state.api_log[-5:]: |
| icon = "✅" if entry['ok'] else "❌" |
| st.caption(icon + " `" + entry['step'] + "` — " + entry['src'] + " (" + str(entry['ms']) + "ms)") |
| else: |
| st.caption("_Belum ada aktivitas API_") |
| if st.button("🗑️ Reset Log", key="reset_api_log", use_container_width=True): |
| st.session_state.api_log = [] |
| st.rerun() |
| st.markdown("---") |
|
|
| st.markdown(T('sidebar_stack', lang)) |
| stack_items = { |
| 'id': "- 🤖 XGBoost + LightGBM + RF\n- 🔍 SHAP Eksplainabilitas\n- 💬 Narasi AI (OpenRouter + Groq)\n- 🤖 Maya AI Chat + Tool Calling\n- 🧠 Chain-of-Thought + Few-shot\n- 💾 Memory Summarization\n- 🌍 ID / EN / HI", |
| 'en': "- 🤖 XGBoost + LightGBM + RF\n- 🔍 SHAP Explainability\n- 💬 AI Narrative (OpenRouter + Groq)\n- 🤖 Maya AI Chat + Tool Calling\n- 🧠 Chain-of-Thought + Few-shot\n- 💾 Memory Summarization\n- 🌍 ID / EN / HI", |
| 'hi': "- 🤖 XGBoost + LightGBM + RF\n- 🔍 SHAP Vyaakhyaa\n- 💬 AI Vivarana (OpenRouter + Groq)\n- 🤖 Maya AI Chat + Tool Calling\n- 🧠 Chain-of-Thought + Few-shot\n- 💾 Memory Saaraansh\n- 🌍 ID / EN / HI", |
| } |
| st.markdown(stack_items[lang]) |
| st.caption(T('sidebar_footer', lang)) |
|
|
| |
| |
| |
| @st.cache_resource(show_spinner="Loading AI model...") |
| def load_model(): |
| d = 'model' |
| try: |
| ensemble = joblib.load(d + '/ensemble_model.pkl') |
| xgb_model = joblib.load(d + '/xgb_model.pkl') |
| scaler = joblib.load(d + '/scaler.pkl') |
| feature_names = joblib.load(d + '/feature_names.pkl') |
| explainer = shap.TreeExplainer(xgb_model) |
| meta = {} |
| if os.path.exists(d + '/metadata.json'): |
| with open(d + '/metadata.json') as f: |
| meta = json.load(f) |
| return ensemble, scaler, feature_names, explainer, meta |
| except FileNotFoundError: |
| return None, None, None, None, {} |
|
|
| @st.cache_resource(show_spinner="Loading knowledge base...") |
| def load_rag_index(): |
| try: |
| from rag import load_index |
| return load_index(kb_path="knowledge_base", index_path="/tmp/kb_index.pkl") |
| except ImportError: |
| st.sidebar.warning("rag.py not found. RAG disabled.") |
| return None |
| except Exception as e: |
| st.sidebar.warning("RAG load failed: " + str(e)) |
| return None |
|
|
| ensemble, scaler, feature_names, explainer, meta = load_model() |
| rag_index = load_rag_index() |
|
|
| if rag_index: |
| chunk_count = len(rag_index.get('chunks', [])) |
| st.sidebar.success("Knowledge base: " + str(chunk_count) + " chunks") |
| else: |
| st.sidebar.warning("Knowledge base not loaded. RAG disabled.") |
|
|
| if ensemble is None: |
| st.markdown( |
| '<div class="header-wrap"><h1>' + T("header_title", lang) + '</h1></div>', |
| unsafe_allow_html=True |
| ) |
| st.error(T('no_model', lang)) |
| st.stop() |
|
|
| |
| |
| |
| _defaults = dict( |
| result=None, shap_vals=None, raw_input=None, |
| narrative='', llm_src='', narrative_lang='', |
| chat_history=[], shap_png=None, |
| wi_dig=None, wi_biz=None, wi_emp=None, |
| wi_cash=None, wi_dur=None, wi_loan=None, |
| chat_summary='', memory_loaded=False, |
| llm_status='', api_log=[], |
| ) |
| for k, v in _defaults.items(): |
| if k not in st.session_state: |
| st.session_state[k] = v |
|
|
| if not st.session_state.memory_loaded: |
| loaded_history, loaded_summary = _load_chat_memory() |
| if loaded_history and not st.session_state.chat_history: |
| st.session_state.chat_history = loaded_history |
| if loaded_summary and not st.session_state.chat_summary: |
| st.session_state.chat_summary = loaded_summary |
| st.session_state.memory_loaded = True |
|
|
| |
| |
| |
| def preprocess(raw, scaler, feature_names): |
| df = pd.DataFrame([raw]) |
| ohe = pd.get_dummies(df) |
| for col in feature_names: |
| if col not in ohe.columns: |
| ohe[col] = 0 |
| ohe = ohe[feature_names].apply(pd.to_numeric, errors='coerce').fillna(0) |
| return pd.DataFrame(scaler.transform(ohe), columns=feature_names) |
|
|
| def risk_result(pd_score, loan_rp, lgd=0.40): |
| el = pd_score * lgd * loan_rp |
| if pd_score < 0.20: |
| css, color = 'risk-low', '#11998e' |
| cat = {l: T('risk_approved', l) for l in ['id','en','hi']} |
| elif pd_score < 0.50: |
| css, color = 'risk-med', '#f7971e' |
| cat = {l: T('risk_review', l) for l in ['id','en','hi']} |
| else: |
| css, color = 'risk-high', '#e74c3c' |
| cat = {l: T('risk_high', l) for l in ['id','en','hi']} |
| return dict(pd=pd_score, el=el, lgd=lgd, ead=loan_rp, css=css, color=color, cat=cat) |
|
|
| def shap_summary(vals, names, n=5): |
| idx = np.argsort(np.abs(vals))[-n:][::-1] |
| return '\n'.join([ |
| "- " + names[i] + ": " + ('increases' if vals[i] > 0 else 'decreases') + |
| " risk (SHAP=" + str(round(vals[i], 3)) + ")" |
| for i in idx |
| ]) |
|
|
| def make_shap_png(shap_vals, feature_names): |
| """Returns base64 data URI — avoids Streamlit MediaFileStorage expiry.""" |
| sv = np.array(shap_vals) |
| names = list(feature_names) |
| n = min(12, len(sv)) |
| idx = np.argsort(np.abs(sv))[-n:] |
| fig, ax = plt.subplots(figsize=(9, 5)) |
| colors = ['#ef4444' if sv[i] > 0 else '#3b82f6' for i in idx] |
| ax.barh(range(n), sv[idx], color=colors, height=0.6) |
| ax.set_yticks(range(n)) |
| ax.set_yticklabels([names[i] for i in idx], fontsize=8) |
| ax.axvline(0, color='black', linewidth=0.8) |
| ax.set_xlabel('SHAP value') |
| ax.set_title('Feature Impact — SHAP', fontweight='bold', fontsize=10) |
| plt.tight_layout() |
| buf = io.BytesIO() |
| fig.savefig(buf, format='png', dpi=130, bbox_inches='tight') |
| plt.close(fig) |
| buf.seek(0) |
| b64 = base64.b64encode(buf.read()).decode() |
| return 'data:image/png;base64,' + b64 |
|
|
| |
| |
| |
| _OR_FREE_MODELS = [ |
| "google/gemini-2.0-flash-exp:free", |
| "qwen/qwen3-32b:free", |
| "meta-llama/llama-3.3-70b-instruct:free", |
| "qwen/qwen2.5-72b-instruct:free", |
| "deepseek/deepseek-chat-v3-0324:free", |
| "microsoft/phi-4:free", |
| "mistralai/mistral-7b-instruct:free", |
| ] |
| _GROQ_FREE_MODELS = [ |
| "llama-3.3-70b-versatile", |
| "llama-3.1-8b-instant", |
| "gemma2-9b-it", |
| "mixtral-8x7b-32768", |
| ] |
|
|
| def call_openrouter(messages, api_key, model=None): |
| if not api_key: |
| return None, "No OpenRouter API key" |
| models_to_try = [model] if model else _OR_FREE_MODELS |
| last_error = None |
| for m in models_to_try: |
| t0 = _time.time() |
| try: |
| resp = requests.post( |
| "https://openrouter.ai/api/v1/chat/completions", |
| headers={ |
| "Authorization": "Bearer " + api_key, |
| "Content-Type": "application/json", |
| "HTTP-Referer": "https://huggingface.co", |
| "X-Title": "SME Credit Risk AI" |
| }, |
| json={"model": m, "messages": messages, "max_tokens": 600, "temperature": 0.75}, |
| timeout=25, |
| ) |
| ms = int((_time.time() - t0) * 1000) |
| if resp.status_code == 200: |
| short = m.split('/')[-1].replace(':free','') |
| _log_api("Narasi", "OR/" + short, True, ms) |
| return resp.json()['choices'][0]['message']['content'], None |
| short = m.split('/')[-1].replace(':free','') |
| last_error = "HTTP " + str(resp.status_code) + " (" + m + "): " + resp.text[:150] |
| _log_api("Narasi", "OR/" + short, False, ms) |
| except requests.exceptions.Timeout: |
| ms = int((_time.time() - t0) * 1000) |
| last_error = "Timeout (" + m + ")" |
| short = m.split('/')[-1].replace(':free','') |
| _log_api("Narasi", "OR/" + short, False, ms) |
| except Exception as e: |
| ms = int((_time.time() - t0) * 1000) |
| last_error = m + ": " + str(e)[:100] |
| short = m.split('/')[-1].replace(':free','') |
| _log_api("Narasi", "OR/" + short, False, ms) |
| return None, last_error |
|
|
| def call_groq(messages, api_key): |
| if not api_key: |
| return None, "No Groq API key" |
| last_error = None |
| for m in _GROQ_FREE_MODELS: |
| t0 = _time.time() |
| try: |
| resp = requests.post( |
| "https://api.groq.com/openai/v1/chat/completions", |
| headers={ |
| "Authorization": "Bearer " + api_key, |
| "Content-Type": "application/json" |
| }, |
| json={"model": m, "messages": messages, "max_tokens": 600, "temperature": 0.75}, |
| timeout=20, |
| ) |
| ms = int((_time.time() - t0) * 1000) |
| if resp.status_code == 200: |
| _log_api("Narasi", "Groq/" + m, True, ms) |
| return resp.json()['choices'][0]['message']['content'], None |
| last_error = "HTTP " + str(resp.status_code) + " (" + m + "): " + resp.text[:150] |
| _log_api("Narasi", "Groq/" + m, False, ms) |
| except requests.exceptions.Timeout: |
| ms = int((_time.time() - t0) * 1000) |
| last_error = "Timeout (" + m + ")" |
| _log_api("Narasi", "Groq/" + m, False, ms) |
| except Exception as e: |
| ms = int((_time.time() - t0) * 1000) |
| last_error = m + ": " + str(e)[:100] |
| _log_api("Narasi", "Groq/" + m, False, ms) |
| return None, last_error |
|
|
| def _call_llm(messages): |
| """Narrative LLM: OpenRouter free → Groq free → None.""" |
| _or = st.session_state.get("openrouter_key", "") |
| _grq = st.session_state.get("groq_key", "") |
| last_err = None |
| if _or: |
| r, last_err = call_openrouter(messages, _or) |
| if r: |
| model_used = _OR_FREE_MODELS[0].split("/")[-1].replace(":free","") |
| return r, 'OpenRouter (' + model_used + ')', None |
| if _grq: |
| r, last_err = call_groq(messages, _grq) |
| if r: |
| model_used = _GROQ_FREE_MODELS[0] |
| return r, 'Groq (' + model_used + ')', None |
| return None, None, last_err or "No API key configured" |
|
|
| |
| |
| |
| def get_narrative(shap_vals, feature_names, result, lang, raw_input): |
| pd_pct = result['pd'] * 100 |
| factors = shap_summary(shap_vals, feature_names, 5) |
| lang_str = {'id':'Bahasa Indonesia profesional','en':'Professional English','hi':'Hindi'} |
| prompt = ( |
| "You are a senior credit analyst for Indonesian SME lending.\n" |
| "Reply ONLY in " + lang_str.get(lang,'English') + ".\n" |
| "Applicant: PD=" + str(round(pd_pct,1)) + "%, EL=Rp " + str(int(result['el'])) + ", " |
| "BizAge=" + str(raw_input.get('business_age_years')) + "yr, " |
| "Employees=" + str(raw_input.get('num_employees')) + ", " |
| "DigitalScore=" + str(raw_input.get('digital_presence_score')) + "/100, " |
| "CashFlow=Rp " + str(int(raw_input.get('monthly_cash_flow',0))) + "/mo, " |
| "NPWP=" + ('yes' if raw_input.get('has_npwp') else 'no') + ", " |
| "SIUP=" + ('yes' if raw_input.get('has_siup') else 'no') + "\n" |
| "Top SHAP factors:\n" + factors + "\n" |
| "Write: 1) " + T('n_risk_summary',lang) + " (1-2 sentences) " |
| "2) " + T('n_strengths',lang) + " (2-3 bullets) " |
| "3) " + T('n_risks',lang) + " (2-3 bullets) " |
| "4) " + T('n_recommendations',lang) + " (2-3 specific steps). " |
| "Be data-driven and reference actual values." |
| ) |
| msgs = [{"role": "user", "content": prompt}] |
| r, src, _ = _call_llm(msgs) |
| if r: |
| return r, src |
|
|
| |
| biz = raw_input.get('business_age_years', 0) |
| emp = raw_input.get('num_employees', 0) |
| dig = raw_input.get('digital_presence_score', 0) |
| cf = raw_input.get('monthly_cash_flow', 0) |
| npwp = raw_input.get('has_npwp', 0) |
| siup = raw_input.get('has_siup', 0) |
| dur = raw_input.get('duration', 24) |
| loan = raw_input.get('loan_rp', 50e6) |
| ch = raw_input.get('credit_history', '') |
|
|
| def _t(i, e, h): |
| return {'id': i, 'en': e, 'hi': h}[lang] |
|
|
| if pd_pct < 20: |
| status = _t('risiko rendah — **direkomendasikan disetujui**', |
| 'low risk — **recommended for approval**', |
| 'kam jokhim — **swikriti anushansit**') |
| elif pd_pct < 50: |
| status = _t('risiko sedang — **perlu review**', |
| 'moderate risk — **review required**', |
| 'madhyam jokhim — **sameeksha zaroori**') |
| else: |
| status = _t('risiko tinggi — **butuh jaminan/penolakan**', |
| 'high risk — **collateral or rejection recommended**', |
| 'uchch jokhim — **zamanat ya aswikriti anushansit**') |
|
|
| s = [] |
| if biz >= 5: |
| s.append(_t("Bisnis " + str(int(biz)) + " tahun — track record terbukti", |
| str(int(biz)) + "-year business — proven track record", |
| str(int(biz)) + " saal ka vyapaar — siddh track record")) |
| elif biz >= 3: |
| s.append(_t("Bisnis " + str(int(biz)) + " tahun — melewati fase startup", |
| str(int(biz)) + "-year business — past startup phase", |
| str(int(biz)) + " saal ka vyapaar — startup charan paar")) |
| if emp >= 10: |
| s.append(_t(str(emp) + " karyawan — skala usaha signifikan", |
| str(emp) + " employees — significant scale", |
| str(emp) + " karmachaaree — mahatvapurn paimaana")) |
| elif emp >= 5: |
| s.append(_t(str(emp) + " karyawan — tim solid", |
| str(emp) + " employees — solid team", |
| str(emp) + " karmachaaree — mazboot team")) |
| if dig >= 70: |
| s.append(_t("Digital score " + str(dig) + "/100 — kehadiran online sangat aktif", |
| "Digital score " + str(dig) + "/100 — very active online presence", |
| "Digital score " + str(dig) + "/100 — bahut active online upasthiti")) |
| elif dig >= 50: |
| s.append(_t("Digital score " + str(dig) + "/100 — kehadiran digital cukup", |
| "Digital score " + str(dig) + "/100 — decent digital presence", |
| "Digital score " + str(dig) + "/100 — theek-thaak online")) |
| if cf >= 15e6: |
| s.append(_t("Cash flow Rp " + str(int(cf/1e6)) + "jt/bln — mendukung kemampuan bayar", |
| "Cash flow Rp " + str(int(cf/1e6)) + "M/month — supports repayment", |
| "Naqad pravaah Rp " + str(int(cf/1e6)) + "M/maah — bhugtaan samarthan")) |
| if npwp: |
| s.append(_t("NPWP terverifikasi — kepatuhan pajak terbukti", |
| "NPWP verified — tax compliance proven", |
| "NPWP satthapit — kar anupalan siddh")) |
| if siup: |
| s.append(_t("SIUP/NIB aktif — legalitas usaha terpenuhi", |
| "SIUP/NIB active — business license verified", |
| "SIUP/NIB sakriy — vyapaar laaisens satthapit")) |
| if 'existing paid' in ch or 'all paid' in ch: |
| s.append(_t("Riwayat kredit baik — tidak ada tunggakan", |
| "Good credit history — no defaults", |
| "Achha credit itihaas — koi chook nahi")) |
|
|
| r_list = [] |
| if biz < 2: |
| r_list.append(_t("Bisnis sangat baru (" + str(int(biz)) + " thn) — belum ada track record", |
| "Very young business (" + str(int(biz)) + " yr) — no track record", |
| "Bahut naya vyapaar (" + str(int(biz)) + " saal) — koi track record nahi")) |
| elif biz < 3: |
| r_list.append(_t("Bisnis " + str(int(biz)) + " thn — masih di fase awal", |
| str(int(biz)) + "-year business — early growth stage", |
| str(int(biz)) + " saal ka vyapaar — prarambhik charan")) |
| if dig < 30: |
| r_list.append(_t("Digital score rendah (" + str(dig) + "/100) — minim online", |
| "Low digital score (" + str(dig) + "/100) — minimal presence", |
| "Kam digital score (" + str(dig) + "/100) — nyoonatam upasthiti")) |
| elif dig < 50: |
| r_list.append(_t("Digital score " + str(dig) + "/100 — di bawah rata-rata", |
| "Digital score " + str(dig) + "/100 — below average", |
| "Digital score " + str(dig) + "/100 — ausat se neeche")) |
| if cf < 5e6: |
| r_list.append(_t("Cash flow Rp " + str(int(cf/1e6)) + "jt/bln — kemampuan bayar diragukan", |
| "Cash flow Rp " + str(int(cf/1e6)) + "M/month — repayment questionable", |
| "Naqad pravaah Rp " + str(int(cf/1e6)) + "M/maah — bhugtaan sandehaapad")) |
| elif cf < 10e6: |
| r_list.append(_t("Cash flow Rp " + str(int(cf/1e6)) + "jt/bln — rasio cicilan mungkin ketat", |
| "Cash flow Rp " + str(int(cf/1e6)) + "M/month — installment ratio tight", |
| "Naqad pravaah Rp " + str(int(cf/1e6)) + "M/maah — kist anupaat tang")) |
| if not npwp: |
| r_list.append(_t("NPWP belum ada — wajib untuk pinjaman formal", |
| "No NPWP — required for formal loans", |
| "NPWP nahi — aupchaarik rin ke liye zaroori")) |
| if not siup: |
| r_list.append(_t("SIUP/NIB belum ada — legalitas perlu diperkuat", |
| "No SIUP/NIB — legality needs strengthening", |
| "SIUP/NIB nahi — vaaneeya ko mazboot karna hoga")) |
| if 'delayed' in ch or 'critical' in ch: |
| r_list.append(_t("Riwayat kredit bermasalah — sinyal negatif kuat", |
| "Problematic credit history — strong negative signal", |
| "Samasyaagrast credit itihaas — nkaraatmak sanket")) |
| est_inst = loan / dur if dur > 0 else loan |
| if cf > 0 and (est_inst / cf) > 0.40: |
| r_list.append(_t( |
| "Rasio cicilan " + str(int(est_inst/cf*100)) + "% — di atas batas aman 40%", |
| "Installment ratio " + str(int(est_inst/cf*100)) + "% — exceeds 40% safe limit", |
| "Kist anupaat " + str(int(est_inst/cf*100)) + "% — 40% seema se adhik" |
| )) |
|
|
| rec = [] |
| if not npwp: |
| rec.append(_t("Urus NPWP — daftar di pajak.go.id", |
| "Register NPWP — via pajak.go.id", |
| "NPWP register karein — pajak.go.id par")) |
| if dig < 60: |
| rec.append(_t("Naikkan digital score ke 60+ — Google Business + marketplace aktif", |
| "Raise digital score to 60+ — Google Business + active marketplace", |
| "Digital score 60+ karein — Google Business + marketplace")) |
| if cf < 15e6: |
| rec.append(_t("Optimalkan cash flow — dokumentasikan semua pemasukan", |
| "Optimize cash flow — document all income streams", |
| "Naqad pravaah optimize karein — sabhi aay document karein")) |
| if loan > 0 and dur > 0 and cf > 0 and (loan / dur / cf) > 0.40: |
| safe = cf * dur * 0.35 |
| rec.append(_t( |
| "Pertimbangkan pinjaman Rp " + str(int(safe/1e6)) + "jt — lebih aman", |
| "Consider loan of Rp " + str(int(safe/1e6)) + "M — safer option", |
| "Rp " + str(int(safe/1e6)) + "M ka rin sochein — surakshit vikalp" |
| )) |
| rec.append(_t("Gunakan tab What-If — simulasikan sebelum mengajukan ulang", |
| "Use the What-If tab — simulate before reapplying", |
| "What-If tab use karein — dobara apply se pehle simulate karein")) |
|
|
| if not s: |
| s = [_t("Profil sedang dievaluasi", "Profile under evaluation", "Parichay mulyankan mein")] |
| if not r_list: |
| r_list = [_t("Tidak ada risiko signifikan", |
| "No significant risk factors detected", |
| "Koi mahatvapurn jokhim nahi")] |
|
|
| el_fmt = result['el'] / 1e6 |
| intro = _t( |
| "Skor PD **" + str(round(pd_pct,1)) + "%** menunjukkan " + status + |
| ". Expected Loss diestimasi **Rp " + str(round(el_fmt,2)) + "jt** (PD × LGD " + |
| str(int(result['lgd']*100)) + "% × EAD Rp " + str(int(result['ead']/1e6)) + "jt).", |
| "PD score of **" + str(round(pd_pct,1)) + "%** indicates " + status + |
| ". Expected Loss estimated at **Rp " + str(round(el_fmt,2)) + "M** (PD × LGD " + |
| str(int(result['lgd']*100)) + "% × EAD Rp " + str(int(result['ead']/1e6)) + "M).", |
| "PD score **" + str(round(pd_pct,1)) + "%** darshata hai " + status + |
| ". Anumaanit haani **Rp " + str(round(el_fmt,2)) + "M** (PD × LGD " + |
| str(int(result['lgd']*100)) + "% × EAD Rp " + str(int(result['ead']/1e6)) + "M)." |
| ) |
| txt = ( |
| "**" + T('n_risk_summary', lang) + "**\n" + intro + "\n\n" |
| "**" + T('n_strengths', lang) + "**\n" + '\n'.join(s[:3]) + |
| "\n\n**" + T('n_risks', lang) + "**\n" + '\n'.join(r_list[:3]) + |
| "\n\n**" + T('n_recommendations', lang) + "**\n" + '\n'.join(rec[:3]) |
| ) |
| return txt, 'Smart Template' |
|
|
| |
| |
| |
| def _get_top_issue(raw_input, pd_pct, lang): |
| dig = raw_input.get('digital_presence_score', 0) |
| cf = raw_input.get('monthly_cash_flow', 0) |
| npwp = raw_input.get('has_npwp', 0) |
| biz = raw_input.get('business_age_years', 0) |
|
|
| def _t(i, e, h): |
| return {'id': i, 'en': e, 'hi': h}[lang] |
|
|
| if not npwp: |
| return _t("prioritas utama: **urus NPWP** dulu di pajak.go.id", |
| "top priority: **register NPWP** at pajak.go.id", |
| "mukhya prathamikta: **NPWP register karein** pajak.go.id par") |
| elif dig < 40: |
| return _t("**digital score " + str(dig) + "/100** adalah area paling kritis untuk ditingkatkan", |
| "**digital score " + str(dig) + "/100** is the most critical area to improve", |
| "**digital score " + str(dig) + "/100** sabse mahatvapurn sudhaar kshetra hai") |
| elif cf < 10e6: |
| return _t("**cash flow Rp " + str(int(cf/1e6)) + "jt/bln** perlu dioptimalkan", |
| "**cash flow Rp " + str(int(cf/1e6)) + "M/month** needs optimization", |
| "**naqad pravaah Rp " + str(int(cf/1e6)) + "M/maah** optimize zaroori hai") |
| elif biz < 2: |
| return _t("bisnis yang baru **" + str(int(biz)) + " tahun** jadi faktor risiko utama", |
| "**" + str(int(biz)) + "-year** business age is the main risk factor", |
| "**" + str(int(biz)) + " saal** ka vyapaar mukhya jokhim kaarak hai") |
| elif pd_pct < 20: |
| return _t("profil kamu sudah sangat bagus!", |
| "your profile is already excellent!", |
| "aapka parichay pehle se behtareen hai!") |
| else: |
| return _t("beberapa area bisa diperbaiki — tanya aku lebih spesifik!", |
| "several areas can be improved — ask me specifically!", |
| "kai kshetra sudhaare jaa sakte hain — vishesh roop se puchein!") |
|
|
| |
| |
| |
| def get_chat_response(user_msg, history, result, raw_input, shap_vals, feature_names, lang, rag_index=None): |
| pd_pct = result['pd'] * 100 |
| factors = shap_summary(shap_vals, feature_names, 5) |
| lang_full = {'id': 'Bahasa Indonesia', 'en': 'English', 'hi': 'Hindi'} |
|
|
| |
| recent_history, chat_summary = _summarize_history(history, lang) |
|
|
| |
| context = "" |
| if rag_index: |
| try: |
| from rag import retrieve |
| retrieved = retrieve(user_msg, rag_index, k=2) |
| if retrieved: |
| context = "\nINFORMASI TAMBAHAN DARI KNOWLEDGE BASE:\n" + retrieved + "\n" |
| except Exception: |
| context = "" |
|
|
| |
| fewshot = """ |
| CONTOH DIALOG MAYA (few-shot — ikuti gaya & kualitas ini): |
| --- |
| User: "Gimana cara turunin skor PD saya?" |
| Maya: "Berdasarkan data kamu, 3 prioritas utama: 1) **Naikkan digital score ke 75+** via Google Business & marketplace aktif [ADJUST: digital_presence_score=75] — ini faktor SHAP terbesar. 2) **Optimalkan cash flow ke Rp 25jt/bln** [ADJUST: monthly_cash_flow=25000000] — dokumentasikan semua pemasukan, pisah rekening. 3) **Urus NPWP** kalau belum ada. Mau lihat simulasinya di tab What-If?" |
| --- |
| User: "Kenapa skor saya tinggi banget?" |
| Maya: "Dua driver terbesar menurut SHAP: **business_age_years** (SHAP=+0.234) — bisnis muda = risiko lebih tinggi di mata model, dan **digital_presence_score** (SHAP=+0.189) — bank pakai ini sebagai proxy kredibilitas online. Fokus perbaiki keduanya dulu, dampaknya paling signifikan!" |
| --- |
| User: "Pinjaman berapa yang paling aman buat saya?" |
| Maya: "Dengan cash flow Rp 15jt/bln dan tenor 24 bln, batas aman kamu adalah **Rp 126jt** (rasio cicilan 35%). Pinjaman kamu sekarang Rp 50jt — masih aman! Kalau mau tambah, pastikan cash flow naik dulu." |
| --- |
| """ |
|
|
| |
| summary_block = "" |
| if chat_summary: |
| lbl = { |
| 'id': 'Ringkasan percakapan sebelumnya', |
| 'en': 'Earlier conversation summary', |
| 'hi': 'Pehle ki baatcheet ka saaraansh', |
| } |
| summary_block = "\n" + lbl.get(lang, lbl['en']) + ":\n" + chat_summary + "\n" |
|
|
| |
| cot_block = """ |
| CARA BERPIKIR — CHAIN OF THOUGHT (lakukan ini secara SILENT sebelum menjawab): |
| ① Apa yang sebenarnya ditanyakan user? (bukan hanya kata-katanya — cari intent sebenarnya) |
| ② Data pemohon mana yang paling relevan? (PD score, cash flow, digital score, NPWP, dll) |
| ③ Apa saran paling KONKRET & berdampak tinggi untuk UMKM spesifik ini? |
| ④ Apakah perlu adjust slider What-If? Jika ya, embed tag [ADJUST: field=value] LANGSUNG di dalam kalimat rekomendasi. |
| → Tulis HANYA jawaban akhir — jangan tampilkan proses berpikir di output. |
| """ |
|
|
| |
| system = ( |
| "Kamu adalah Maya, AI Credit Advisor yang cerdas, hangat, dan sedikit kasual untuk UMKM Indonesia.\n" |
| "Kepribadian: ramah tapi profesional, suka pakai analogi sederhana, TIDAK kaku, bisa ngobrol bebas, humoris kalau situasinya santai.\n" |
| "Bahasa: gunakan " + lang_full.get(lang,'Bahasa Indonesia') + " yang natural — boleh semi-formal, boleh pakai slang ringan (dong/nih/ya/banget/sih/kan/kok).\n\n" |
| "PEMAHAMAN BAHASA INDONESIA:\n" |
| "- Pahami kata ambigu/homofon dengan tepat sesuai konteks percakapan:\n" |
| " * 'beruang' bisa = hewan beruang (bear) ATAU ber-uang (punya uang) — baca konteks!\n" |
| " * 'bisa' bisa = mampu ATAU racun ular\n" |
| " * 'bunga' bisa = bunga tanaman ATAU bunga kredit\n" |
| "- Kalau pesannya lucu/absurd, ikuti humor-nya dengan ringan sambil tetap hubungkan ke konteks kredit UMKM.\n\n" |
| "DATA PEMOHON SAAT INI:\n" |
| "- PD Score: " + str(round(pd_pct,1)) + "% → " + ('LAYAK' if pd_pct < 20 else 'PERLU REVIEW' if pd_pct < 50 else 'RISIKO TINGGI') + "\n" |
| "- Expected Loss: Rp " + str(round(result['el']/1e6, 2)) + "M\n" |
| "- Umur Bisnis: " + str(raw_input.get('business_age_years')) + " tahun | Karyawan: " + str(raw_input.get('num_employees')) + " orang\n" |
| "- Digital Score: " + str(raw_input.get('digital_presence_score')) + "/100\n" |
| "- Cash Flow: Rp " + str(round(raw_input.get('monthly_cash_flow',0)/1e6, 1)) + "M/bulan\n" |
| "- NPWP: " + ('ada' if raw_input.get('has_npwp') else 'belum ada') + " | SIUP: " + ('ada' if raw_input.get('has_siup') else 'belum ada') + "\n" |
| "- Pinjaman: Rp " + str(int(raw_input.get('loan_rp',50e6)/1e6)) + "M | Tenor: " + str(raw_input.get('duration',24)) + " bulan\n" |
| "- Riwayat Kredit: " + str(raw_input.get('credit_history')) + "\n\n" |
| "FAKTOR RISIKO UTAMA (SHAP):\n" + factors + "\n" |
| + context |
| + summary_block |
| + cot_block |
| + fewshot |
| + "ATURAN MENJAWAB — BACA SEMUA DENGAN SEKSAMA:\n" |
| "1. Jawab BEBAS — tidak harus soal kredit. Kalau ditanya soal diri sendiri, perkenalkan sebagai Maya.\n" |
| "2. Kalau relevan, selalu hubungkan ke data pemohon dengan menyebut angka aktualnya.\n" |
| "3. Berikan saran KONKRET dan SPESIFIK, bukan generik.\n" |
| "4. Maksimal 150 kata kecuali diminta lebih panjang.\n" |
| "5. FORMAT TAG [ADJUST:] — WAJIB IKUTI ATURAN INI:\n" |
| " BENAR: Embed tag langsung di akhir kalimat saran:\n" |
| " 'Coba naikkan digital score ke 75 [ADJUST: digital_presence_score=75] — dampaknya paling besar.'\n" |
| " SALAH: Menulis kalimat saran TANPA tag, lalu menambah baris terpisah\n" |
| " SALAH: Menulis tag di baris sendiri di akhir response\n" |
| " SALAH BESAR: Menulis ulang rekomendasi di baris terpisah setelah selesai\n" |
| " RULE: Setiap rekomendasi bernilai angka = SATU kalimat + SATU tag [ADJUST:] inline. TIDAK LEBIH.\n" |
| " RULE: JANGAN pernah tulis JSON object {...} di response.\n" |
| " RULE: Field valid: digital_presence_score(1-100), business_age_years(1-20),\n" |
| " num_employees(1-50), monthly_cash_flow(angka Rp), duration(4-72), loan_rp(angka Rp)\n" |
| "6. JANGAN pernah balik ke template. Jawab seperti manusia cerdas.\n" |
| "7. Baca context percakapan sebelumnya sebelum menjawab — jaga konsistensi topik.\n" |
| "8. AKHIRI response dengan kalimat lengkap. Jangan tambahkan apapun setelah kalimat terakhir.\n" |
| "9. LARANGAN KERAS — JANGAN PERNAH LAKUKAN INI:\n" |
| " ❌ Menambah kalimat 'coba naikkan X ke N — ...' di AKHIR response sebagai penutup\n" |
| " ❌ Menambah kalimat 'raise/increase X to N — ...' di akhir response\n" |
| " ❌ Mengulang rekomendasi yang sudah ada di badan response sebagai trailing sentence\n" |
| " PENJELASAN: Kalau kamu sudah embed [ADJUST:] di dalam kalimat yang relevan, STOP.\n" |
| " Jangan append kalimat rekomendasi baru di akhir. Response harus berakhir dengan\n" |
| " kalimat natural yang sesuai dengan topik yang ditanyakan, bukan dengan action item.\n" |
| ) |
|
|
| messages = [{"role": "system", "content": system}] |
| for m in recent_history[-8:]: |
| messages.append({"role": m['role'], "content": m['content']}) |
| messages.append({"role": "user", "content": user_msg}) |
|
|
| |
| response, tool_calls, _llm_src, _last_error = _call_chat_llm(messages) |
|
|
| |
| adjustments = _parse_tool_calls(tool_calls) |
|
|
| |
| if not response and tool_calls: |
| response = _tool_call_to_text(tool_calls, lang) |
|
|
| |
| if response is None: |
| low = user_msg.lower() |
| dig = raw_input.get('digital_presence_score', 0) |
| cf = raw_input.get('monthly_cash_flow', 0) |
| npwp = raw_input.get('has_npwp', 0) |
| biz = raw_input.get('business_age_years', 0) |
|
|
| def _t(i, e, h): |
| return {'id': i, 'en': e, 'hi': h}[lang] |
|
|
| |
| def _kw(msg_low, word_list): |
| for w in word_list: |
| if len(w) <= 3: |
| if re.search(r'\b' + re.escape(w) + r'\b', msg_low): |
| return True |
| else: |
| if w in msg_low: |
| return True |
| return False |
|
|
| |
| |
| |
| if _kw(low, ['siapa','who are you','perkenalan','halo','hello','hei','hai','hi', |
| 'maya','nama kamu','you are']): |
| response = _t( |
| "Hei! Aku **Maya**, AI Credit Advisor kamu Aku dirancang untuk bantu kamu pahami skor kredit dan strategi bisnis UMKM. " |
| "Skor PD kamu sekarang **" + str(round(pd_pct,1)) + "%** — " + |
| ('sudah bagus banget!' if pd_pct < 20 else 'masih ada ruang untuk diperbaiki.') + |
| " Mau aku jelasin lebih detail atau ada yang mau ditanyain?", |
| "Hey! I'm **Maya**, your AI Credit Advisor. I help you understand your credit score and SME business strategy. " |
| "Your PD score is **" + str(round(pd_pct,1)) + "%** — " + |
| ('looking great!' if pd_pct < 20 else 'there is room to improve.') + |
| " Anything you want to ask?", |
| "Namaste! Main **Maya** hoon, aapki AI Credit Advisor. " |
| "Aapka PD score **" + str(round(pd_pct,1)) + "%** hai — " + |
| ('bahut badhiya!' if pd_pct < 20 else 'sudhaar ki gunjaish hai.') + |
| " Kuch poochna hai?" |
| ) |
|
|
| |
| elif any(w in low for w in ['cuan','profit','untung','laba','cepet cuan', |
| 'penghasilan cepat','cara cepet','duit cepet']): |
| response = _t( |
| "Cuan cepet? Sah-sah aja! Tapi di kredit, yang bikin bank percaya bukan seberapa cepat, tapi **konsistensi**. " |
| "Dari profil kamu (cash flow Rp " + str(int(cf/1e6)) + "jt/bln, digital " + str(dig) + "/100):\n\n" |
| "1. **Aktifin marketplace** → cash flow naik [ADJUST: monthly_cash_flow=25000000]\n" |
| "2. **Google Business aktif** → digital score naik [ADJUST: digital_presence_score=75]\n" |
| "3. **Dokumentasikan semua pemasukan** — pisah rekening bisnis & pribadi", |
| "Quick profit? Totally valid! But in credit, **consistency** matters more. " |
| "From your profile (CF Rp " + str(int(cf/1e6)) + "M/mo, digital " + str(dig) + "/100):\n\n" |
| "1. Active marketplace → CF up [ADJUST: monthly_cash_flow=25000000]\n" |
| "2. Google Business → digital up [ADJUST: digital_presence_score=75]\n" |
| "3. Document all income streams", |
| "Jaldi munafa? **Niyamitata** zaroori hai.\n" |
| "1) Marketplace active karein [ADJUST: monthly_cash_flow=25000000] " |
| "2) Google Business [ADJUST: digital_presence_score=75] 3) Aay document karein" |
| ) |
| adjustments['digital_presence_score'] = 75 |
| adjustments['monthly_cash_flow'] = 25000000 |
|
|
| |
| |
| elif any(w in low for w in ['digital score','skor digital','digital presence', |
| 'naikin digital','naik digital','digital jadi','digital ke']): |
| target = 80 |
| try: |
| nums = re.findall(r'\d+', user_msg) |
| if nums: |
| target = int(nums[-1]) |
| except Exception: |
| pass |
| target = max(1, min(100, target)) |
| adjustments['digital_presence_score'] = target |
| cur_dig = raw_input.get('digital_presence_score', 0) |
| response = _t( |
| "Digital score **" + str(cur_dig) + " → " + str(target) + "** sudah aku set di What-If!\n\n" |
| "Cara naik digital score:\n" |
| "1. **Google Business Profile** — verifikasi & lengkapi info\n" |
| "2. **Aktif di marketplace** — Tokopedia/Shopee/TikTok Shop\n" |
| "3. **Media sosial konsisten** — posting minimal 3x/minggu\n\n" |
| "Cek tab What-If untuk lihat dampak ke PD!", |
| "Digital score **" + str(cur_dig) + " → " + str(target) + "** set in What-If!\n\n" |
| "Ways to improve:\n" |
| "1. **Google Business Profile** — verify & complete\n" |
| "2. **Active marketplace** — Tokopedia/Shopee/TikTok Shop\n" |
| "3. **Consistent social media** — post 3x/week\n\n" |
| "Check the What-If tab for PD impact!", |
| "Digital score **" + str(cur_dig) + " → " + str(target) + "** What-If mein set!\n\n" |
| "Sudhaar ke tarike:\n" |
| "1. Google Business Profile verify karein\n" |
| "2. Marketplace active rahein\n" |
| "3. Social media niyamit rahein\n\n" |
| "What-If tab mein PD prabhav dekhein!" |
| ) |
|
|
| |
| |
| elif any(w in low for w in ['beruang','maling','jerapah','babi','kucing','lucu', |
| 'absurd','random','hewan','binatang','gajah','dinosaurus']): |
| response = _t( |
| "Haha, oke oke! 😄 Tapi balik ke topik serius — bahkan si beruang pun butuh skor kredit bagus buat pinjam madu dari bank!\n\n" |
| "Profil kamu: PD **" + str(round(pd_pct,1)) + "%** — " + |
| ('udah bagus banget nih!' if pd_pct < 20 else 'masih ada yang bisa diperbaiki.') + |
| " Ada yang mau ditanyain soal kredit atau bisnis?", |
| "Haha, fair enough! 😄 But back to business — even a bear needs good credit to borrow honey from the bank!\n\n" |
| "Your profile: PD **" + str(round(pd_pct,1)) + "%** — " + |
| ('already looking great!' if pd_pct < 20 else 'some room to improve.') + |
| " Anything credit or business related?", |
| "Haha, theek hai! 😄 Lekin credit ki baat karein — bhaaloo ko bhi bank se madhu udhaarne ke liye achha score chahiye!\n\n" |
| "Aapka PD **" + str(round(pd_pct,1)) + "%** — " + |
| ('pehle se badhiya!' if pd_pct < 20 else 'kuch sudhaar ho sakta hai.') + |
| " Kuch aur poochna hai?" |
| ) |
|
|
| |
| |
| elif any(w in low for w in ['6 bulan','rencana','plan','action plan','ke depan', |
| 'persiapan','apply lagi','sebelum apply']): |
| rec_items = [] |
| if not npwp: |
| rec_items.append(_t( |
| "**Bulan 1**: Urus NPWP di pajak.go.id (gratis, 1-3 hari)", |
| "**Month 1**: Register NPWP at pajak.go.id (free, 1-3 days)", |
| "**Maah 1**: NPWP register karein pajak.go.id par" |
| )) |
| if dig < 60: |
| rec_items.append(_t( |
| "**Bulan 1-2**: Aktifkan Google Business + daftar marketplace", |
| "**Month 1-2**: Activate Google Business + register on marketplace", |
| "**Maah 1-2**: Google Business activate + marketplace register karein" |
| )) |
| if cf < 15e6: |
| rec_items.append(_t( |
| "**Bulan 2-4**: Pisah rekening bisnis, dokumentasi semua pemasukan", |
| "**Month 2-4**: Separate business account, document all income", |
| "**Maah 2-4**: Vyapaar khaata alag karein, sabhi aay document karein" |
| )) |
| rec_items.append(_t( |
| "**Bulan 5-6**: Simulasikan ulang di What-If, ajukan kredit jika PD < 20%", |
| "**Month 5-6**: Re-simulate in What-If, apply when PD < 20%", |
| "**Maah 5-6**: What-If mein re-simulate karein, PD < 20% ho tab apply karein" |
| )) |
| response = _t( |
| "Action plan 6 bulan buat kamu (PD sekarang **" + str(round(pd_pct,1)) + "%**):\n\n", |
| "6-month action plan (current PD **" + str(round(pd_pct,1)) + "%**):\n\n", |
| "6 maah ka plan (abhi PD **" + str(round(pd_pct,1)) + "%**):\n\n" |
| ) + '\n'.join(rec_items[:4]) |
|
|
| |
| elif any(w in low for w in ['improve','better','lower','reduce','tingkatkan','kurangi', |
| 'turunkan','cara','gimana','bagaimana','naik','turun', |
| 'meningkat','naikkan','optimalkan']): |
| tips = [] |
| if not npwp: |
| tips.append(_t( |
| "1. **Urus NPWP** — wajib untuk pinjaman formal, daftar di pajak.go.id", |
| "1. **Register NPWP** — required for formal loans, apply at pajak.go.id", |
| "1. **NPWP register karein** — aupchaarik rin ke liye zaroori" |
| )) |
| adjustments['has_npwp'] = 1 |
| if dig < 75: |
| n = '2' if not npwp else '1' |
| tips.append(_t( |
| n + ". **Naikkan Digital Score dari " + str(dig) + " ke 75+** — aktif di Google Business, Tokopedia/Shopee", |
| n + ". **Raise Digital Score from " + str(dig) + " to 75+** — Google Business, marketplace", |
| n + ". **Digital Score " + str(dig) + " se 75+ karein** — Google Business, marketplace" |
| )) |
| adjustments['digital_presence_score'] = 75 |
| if cf < 25e6: |
| n = str(len(tips) + 1) |
| tips.append(_t( |
| n + ". **Optimalkan cash flow dari Rp " + str(int(cf/1e6)) + "jt ke 25jt+/bln** — diversifikasi produk", |
| n + ". **Grow cash flow from Rp " + str(int(cf/1e6)) + "M to 25M+/month**", |
| n + ". **Naqad pravaah Rp " + str(int(cf/1e6)) + "M se 25M+ karein**" |
| )) |
| adjustments['monthly_cash_flow'] = 25000000 |
| if not tips: |
| tips.append(_t( |
| "Profil kamu sudah solid dengan PD " + str(round(pd_pct,1)) + "%! Coba simulasikan di tab What-If.", |
| "Your profile is solid at PD " + str(round(pd_pct,1)) + "%! Try the What-If tab.", |
| "Aapka parichay PD " + str(round(pd_pct,1)) + "% ke saath mazboot hai!" |
| )) |
| response = ( |
| _t( |
| "Untuk turunkan PD dari **" + str(round(pd_pct,1)) + "%**, fokus ke:\n\n", |
| "To lower PD from **" + str(round(pd_pct,1)) + "%**, focus on:\n\n", |
| "PD **" + str(round(pd_pct,1)) + "%** kam karne ke liye:\n\n" |
| ) |
| + '\n'.join(tips[:3]) |
| ) |
|
|
| |
| |
| |
| elif any(w in low for w in ['why','kenapa','mengapa','shap','pengaruh','faktor risiko', |
| 'driver','jelasin','jelaskan','explain','definisi', |
| 'kenapa skor','mengapa skor','yang mempengaruhi','apa penyebab']): |
| ti = int(np.argsort(np.abs(shap_vals))[-1]) |
| ti2 = int(np.argsort(np.abs(shap_vals))[-2]) |
| fn = feature_names[ti] |
| if 'business_age' in fn: |
| analogi = _t( |
| " — ibarat pengalaman kerja, makin lama makin dipercaya", |
| " — like work experience, longer = more trustworthy", |
| " — kaam ke anubhav ki tarah, zyada = zyada bharosemand" |
| ) |
| elif 'cash_flow' in fn: |
| analogi = _t( |
| " — ibarat gaji bulanan kamu, makin besar makin mudah bayar cicilan", |
| " — like your monthly income, higher = easier to repay", |
| " — maasik aay ki tarah, zyada = kist bhrna aasaan" |
| ) |
| elif 'digital' in fn: |
| analogi = _t( |
| " — ibarat 'nilai reputasi online' bisnis kamu di mata bank", |
| " — your business's 'online reputation score' in the bank's eyes", |
| " — bank ki nazar mein vyapaar ka 'online pratishtha score'" |
| ) |
| else: |
| analogi = "" |
| response = _t( |
| "Dua faktor terbesar yang drive skor PD **" + str(round(pd_pct,1)) + "%** kamu:\n\n" |
| "1. **" + fn + "**" + analogi + "\n → " + |
| ('meningkatkan' if shap_vals[ti] > 0 else 'menurunkan') + |
| " risiko (SHAP=" + str(round(shap_vals[ti],3)) + ")\n\n" |
| "2. **" + feature_names[ti2] + "**\n → " + |
| ('meningkatkan' if shap_vals[ti2] > 0 else 'menurunkan') + |
| " risiko (SHAP=" + str(round(shap_vals[ti2],3)) + ")\n\n" |
| + ('Skor bagus! Kedua faktor ini justru mendukung kelayakan kamu.' if pd_pct < 20 |
| else 'Fokus perbaiki faktor pertama dulu — dampaknya paling besar.'), |
| "Two biggest drivers of your PD **" + str(round(pd_pct,1)) + "%**:\n\n" |
| "1. **" + fn + "**" + analogi + "\n → " + |
| ('increases' if shap_vals[ti] > 0 else 'decreases') + |
| " risk (SHAP=" + str(round(shap_vals[ti],3)) + ")\n\n" |
| "2. **" + feature_names[ti2] + "**\n → " + |
| ('increases' if shap_vals[ti2] > 0 else 'decreases') + |
| " risk (SHAP=" + str(round(shap_vals[ti2],3)) + ")\n\n" |
| + ('Great score! Both factors support your eligibility.' if pd_pct < 20 |
| else 'Focus on the first factor — it has the biggest impact.'), |
| "Aapke PD **" + str(round(pd_pct,1)) + "%** ke do mukhya kaarak:\n\n" |
| "1. **" + fn + "**" + analogi + "\n → " + |
| ('badhata' if shap_vals[ti] > 0 else 'ghataata') + |
| " jokhim (SHAP=" + str(round(shap_vals[ti],3)) + ")\n\n" |
| "2. **" + feature_names[ti2] + "**\n → " + |
| ('badhata' if shap_vals[ti2] > 0 else 'ghataata') + |
| " jokhim (SHAP=" + str(round(shap_vals[ti2],3)) + ")\n\n" |
| + ('Badhiya score! Dono kaarak yogyata ka samarthan karte hain.' if pd_pct < 20 |
| else 'Pehle pehle kaarak sudhaarein — sabse bada prabhav.') |
| ) |
|
|
| |
| |
| elif any(w in low for w in ['pinjaman','loan','kredit','berapa','amount', |
| 'limit','ideal','rekomendasi pinjaman']): |
| if cf > 0: |
| dur_val = raw_input.get('duration', 24) |
| safe = cf * dur_val * 0.35 |
| cur = raw_input.get('loan_rp', 50e6) |
| if cur > safe: |
| adjustments['loan_rp'] = safe |
| response = _t( |
| "Berdasarkan cash flow kamu **Rp " + str(int(cf/1e6)) + "jt/bulan** dan tenor " + |
| str(int(dur_val)) + " bulan, pinjaman ideal maksimal **Rp " + str(int(safe/1e6)) + |
| "jt** (rasio cicilan 35%).\n\nPinjaman kamu sekarang Rp " + str(int(cur/1e6)) + "jt — " + |
| ('masih aman, good job!' if cur <= safe |
| else 'melebihi batas aman. Pertimbangkan kurangi ke Rp ' + str(int(safe/1e6)) + 'jt atau perpanjang tenor.'), |
| "Based on your cash flow **Rp " + str(int(cf/1e6)) + "M/month** and " + |
| str(int(dur_val)) + "-month tenure, ideal loan is max **Rp " + str(int(safe/1e6)) + |
| "M** (35% installment ratio).\n\nCurrent loan Rp " + str(int(cur/1e6)) + "M — " + |
| ('within safe limits, good job!' if cur <= safe |
| else 'exceeds safe limit. Consider reducing to Rp ' + str(int(safe/1e6)) + 'M.'), |
| "Aapke naqad pravaah **Rp " + str(int(cf/1e6)) + "M/maah** aur " + |
| str(int(dur_val)) + " maah ke aadhaar par adhiktam rin **Rp " + str(int(safe/1e6)) + |
| "M** (35% kist anupaat).\n\nVartamaan rin Rp " + str(int(cur/1e6)) + "M — " + |
| ('surakshit!' if cur <= safe else 'seema se adhik.') |
| ) |
| else: |
| response = _t( |
| "Isi cash flow bulanan di form dulu ya, biar aku bisa kasih rekomendasi akurat!", |
| "Fill in your monthly cash flow first for an accurate recommendation!", |
| "Sahi sujhaav ke liye pehle maasik naqad pravaah bharein!" |
| ) |
|
|
| |
| elif any(w in low for w in ['tabungan','saving','nabung','menabung','savings', |
| 'uang','keuangan','finance','financial','simpan']): |
| response = _t( |
| "Soal tabungan dan cash flow kamu (saat ini **Rp " + str(int(cf/1e6)) + "jt/bln**):\n\n" |
| "3 cara praktis tingkatkan tabungan UMKM:\n" |
| "1. Pisahkan rekening bisnis & pribadi — biar tidak tercampur\n" |
| "2. Sisihkan minimal 10-15% dari omzet setiap bulan secara otomatis\n" |
| "3. Dokumentasikan semua pemasukan — ini juga naikkan digital score!\n\n" |
| "Tabungan naik → cash flow lebih sehat → PD turun", |
| "About savings and your cash flow (currently **Rp " + str(int(cf/1e6)) + "M/month**):\n\n" |
| "3 practical SME savings tips:\n" |
| "1. Separate business & personal bank accounts\n" |
| "2. Auto-transfer 10-15% of monthly revenue to savings\n" |
| "3. Document all income streams — this also boosts digital score!\n\n" |
| "More savings → healthier cash flow → lower PD", |
| "Bachat aur naqad pravaah (**Rp " + str(int(cf/1e6)) + "M/maah**) ke baare mein:\n\n" |
| "3 vyaavhaarik tarike:\n" |
| "1. Vyapaar aur vyaktigat khaate alag rakhein\n" |
| "2. Maasik aay ka 10-15% bachaen\n" |
| "3. Sabhi aay document karein — digital score bhi badhega!\n\n" |
| "Zyada bachat → swasth naqad pravaah → PD kam" |
| ) |
| adjustments['monthly_cash_flow'] = int(cf * 1.3) |
|
|
| |
|
|
| elif any(w in low for w in ['riwayat kredit','credit history','kredit history', |
| 'credit record','riwayat','history kredit']): |
| ch_val = raw_input.get('credit_history', '-') |
| response = _t( |
| "**Riwayat kredit** = catatan sejarah bayar utangmu — ibarat rapor keuangan.\n\n" |
| "Status kamu: **" + ch_val + "**\n\n" |
| "Dampak ke skor kredit:\n" |
| "- All paid / Existing paid → sinyal positif\n" |
| "- Delayed previously → bank waspada\n" |
| "- Critical → risiko naik signifikan\n\n" |
| "Cara bangun riwayat bagus: bayar cicilan tepat waktu, jangan ambil pinjaman melebihi kemampuan bayar.", |
| "**Credit history** = your track record of paying debts — your financial report card.\n\n" |
| "Your status: **" + ch_val + "**\n\n" |
| "Score impact:\n" |
| "- All paid / Existing paid → positive signal\n" |
| "- Delayed previously → bank is cautious\n" |
| "- Critical → significant risk increase\n\n" |
| "Build good history: pay on time, don't borrow beyond your repayment capacity.", |
| "**Credit itihaas** = karz bhugtaan ka record — vitteey report card.\n\n" |
| "Aapki sthiti: **" + ch_val + "**\n\n" |
| "Score prabhav:\n" |
| "- All paid / Existing paid → sakaraatmak sanket\n" |
| "- Delayed previously → bank saavdhan\n" |
| "- Critical → jokhim zyada\n\n" |
| "Samay par bhugtaan karein, kshamta se adhik rin na lein." |
| ) |
|
|
| |
| elif any(w in low for w in ['tips','bisnis','usaha','umkm','sme','digital','marketplace', |
| 'online','ecommerce','strategi','business tips']): |
| response = _t( |
| "Tips bisnis UMKM buat kamu (PD " + str(round(pd_pct,1)) + "%):\n\n" |
| "1. **Digital hadir** — daftar Google Business Profile, aktif di Tokopedia/Shopee/TikTok Shop\n" |
| " (Digital score kamu " + str(dig) + "/100 — masih bisa naik!)\n" |
| "2. **Pisah keuangan** — rekening bisnis terpisah dari pribadi\n" |
| "3. **Dokumentasi rutin** — catat semua transaksi, ini bukti ke bank\n" |
| "4. **Legalitas** — NPWP & SIUP/NIB buka akses ke KUR & kredit formal\n\n" |
| "Mau aku simulasikan dampaknya ke skor di tab What-If?", |
| "SME business tips for you (PD " + str(round(pd_pct,1)) + "%):\n\n" |
| "1. **Go digital** — Google Business Profile, Tokopedia/Shopee/TikTok Shop\n" |
| " (Your digital score " + str(dig) + "/100 — room to improve!)\n" |
| "2. **Separate finances** — dedicated business bank account\n" |
| "3. **Document everything** — track all transactions as proof for banks\n" |
| "4. **Get legal** — NPWP & SIUP/NIB unlock KUR & formal credit\n\n" |
| "Want me to simulate the impact in the What-If tab?", |
| "SME vyapaar tips (PD " + str(round(pd_pct,1)) + "%):\n\n" |
| "1. **Digital upasthiti** — Google Business Profile, Tokopedia/Shopee\n" |
| " (Aapka digital score " + str(dig) + "/100 — sudhaar ki gunjaish!)\n" |
| "2. **Alag vitteey khaata** — vyapaar ka alag bank khaata\n" |
| "3. **Sab document karein** — sabhi laanden-denden ka record\n" |
| "4. **Kanooni rahein** — NPWP & SIUP/NIB KUR unlock karta hai\n\n" |
| "Kya main What-If tab mein prabhav simulate karun?" |
| ) |
| if dig < 75: |
| adjustments['digital_presence_score'] = 75 |
|
|
| |
| else: |
| top_issue = _get_top_issue(raw_input, pd_pct, lang) |
| has_key = any([st.session_state.get("openrouter_key"), st.session_state.get("groq_key")]) |
| err_note = "\n\nLLM error: " + str(_last_error)[:80] if (has_key and _last_error) else "" |
| response = _t( |
| "Pertanyaan menarik! Untuk ini aku butuh koneksi AI.\n\n" |
| "Yang bisa aku kasih tahu: PD kamu **" + str(round(pd_pct,1)) + "%** dan " + top_issue + "\n\n" |
| "Coba tanya yang lebih spesifik: cara turunkan skor, faktor risiko, pinjaman ideal, tips tabungan, atau riwayat kredit." + err_note, |
| "Great question! For this I need an AI connection.\n\n" |
| "What I can share: your PD is **" + str(round(pd_pct,1)) + "%** and " + top_issue + "\n\n" |
| "Try asking specifically: how to lower score, risk factors, ideal loan, savings tips, or credit history." + err_note, |
| "Accha sawaal! Iske liye AI connection chahiye.\n\n" |
| "Aapka PD **" + str(round(pd_pct,1)) + "%** hai aur " + top_issue + "\n\n" |
| "Vishesh roop se puchein: score kaise kam karein, jokhim kaarak, ideal rin, bachat tips." + err_note |
| ) |
|
|
| |
| if response: |
| response, extra_adj = _clean_response(response) |
| for field, val in extra_adj.items(): |
| if field not in adjustments: |
| adjustments[field] = val |
|
|
| |
| |
| |
| |
| |
| if response and raw_input: |
| sem_adj = _extract_adjustments_semantic(response, raw_input) |
| for field, val in sem_adj.items(): |
| if field not in adjustments: |
| adjustments[field] = val |
|
|
| return response or "...", adjustments, _last_error |
|
|
| |
| |
| |
| roc_b = ("<span class='badge b-blue'>ROC-AUC: " + str(meta['roc_auc']) + "</span>") if meta.get('roc_auc') else "" |
| ks_b = ("<span class='badge b-green'>KS: " + str(meta['ks_stat']) + "</span>") if meta.get('ks_stat') else "" |
| st.markdown( |
| "<div class='header-wrap'>" |
| "<h1>" + T('header_title',lang) + "</h1>" |
| "<p>" + T('header_sub',lang) + "</p>" |
| "<div style='margin-top:0.8rem'>" |
| "<span class='badge b-blue'>XGBoost + LightGBM + RF</span>" |
| "<span class='badge b-green'>SHAP XAI</span>" |
| "<span class='badge b-yellow'>LLM Narrative</span>" |
| "<span class='badge b-blue'>Maya + Tool Calling</span>" |
| "<span class='badge b-green'>CoT + Few-shot</span>" |
| "<span class='badge b-yellow'>Memory</span>" |
| + roc_b + ks_b + |
| "</div></div>", |
| unsafe_allow_html=True |
| ) |
|
|
| |
| |
| |
| st.markdown('<div class="sec-title">' + T("form_title",lang) + '</div>', unsafe_allow_html=True) |
| with st.form("credit_form"): |
| c1, c2, c3 = st.columns(3) |
| with c1: |
| st.markdown(T('form_loan', lang)) |
| loan_rp = st.number_input(T('f_loan_amt',lang), 5_000_000, 500_000_000, 50_000_000, 5_000_000, format="%d") |
| lgd_val = st.slider(T('f_lgd',lang), 0.20, 0.80, 0.40, 0.05) |
| duration = st.slider(T('f_duration',lang), 4, 72, 24) |
| credit_amt = st.number_input(T('f_credit_amt',lang), 250, 20000, 2500, 250) |
| purpose = st.selectbox(T('f_purpose',lang), PURPOSE_OPTS, index=9, |
| format_func=lambda x: PURPOSE_LABELS[x][lang]) |
| with c2: |
| st.markdown(T('form_financial', lang)) |
| age = st.slider(T('f_age',lang), 18, 75, 35) |
| checking = st.selectbox(T('f_checking',lang), CHECKING_OPTS, |
| format_func=lambda x: CHECKING_LABELS[x][lang]) |
| savings = st.selectbox(T('f_savings',lang), SAVINGS_OPTS, index=4, |
| format_func=lambda x: SAVINGS_LABELS[x][lang]) |
| credit_hist = st.selectbox(T('f_credit_hist',lang), CREDIT_H_OPTS, index=2, |
| format_func=lambda x: CREDIT_H_LABELS[x][lang]) |
| employment = st.selectbox(T('f_employment',lang), EMPLOY_OPTS, index=2, |
| format_func=lambda x: EMPLOY_LABELS[x][lang]) |
| housing = st.selectbox(T('f_housing',lang), HOUSING_OPTS, index=2, |
| format_func=lambda x: HOUSING_LABELS[x][lang]) |
| installment_c = st.slider(T('f_installment',lang), 1, 4, 2) |
| with c3: |
| st.markdown(T('form_sme', lang)) |
| digital_score = st.slider(T('f_digital',lang), 1, 100, 50) |
| has_social = st.toggle(T('f_social',lang), True) |
| ecomm_vol = st.number_input(T('f_ecomm',lang), 0, 100_000_000, 5_000_000, 1_000_000) |
| has_npwp = st.toggle(T('f_npwp',lang), True) |
| has_siup = st.toggle(T('f_siup',lang), True) |
| biz_age = st.slider(T('f_biz_age',lang), 1, 20, 5) |
| cash_flow = st.number_input(T('f_cashflow',lang), 0, 100_000_000, 15_000_000, 1_000_000) |
| num_emp = st.slider(T('f_employees',lang), 1, 50, 5) |
| submitted = st.form_submit_button(T('f_submit',lang), use_container_width=True, type="primary") |
|
|
| |
| |
| |
| if not submitted and st.session_state.result is None: |
| st.markdown("<br>", unsafe_allow_html=True) |
| for col, (icon, lbl_k, val) in zip(st.columns(4), [ |
| ('🤖','empty_ensemble','XGB+LGBM+RF'), |
| ('🔍','empty_xai','SHAP'), |
| ('💬','empty_narrative','Multi-LLM'), |
| ('🤖','empty_chat','Maya AI'), |
| ]): |
| with col: |
| st.markdown( |
| '<div class="kpi-box"><div style="font-size:1.8rem">' + icon + '</div>' |
| '<div class="kpi-lbl">' + T(lbl_k,lang) + '</div>' |
| '<div style="font-size:.85rem;font-weight:700;color:#1a2c6b">' + val + '</div></div>', |
| unsafe_allow_html=True |
| ) |
| if meta: |
| st.markdown("<br>", unsafe_allow_html=True) |
| for col, (lbl, key) in zip(st.columns(4), [ |
| ('ROC-AUC','roc_auc'),('KS Stat','ks_stat'),('CV Mean','cv_mean'),('Features','n_features') |
| ]): |
| with col: |
| st.markdown( |
| '<div class="kpi-box"><div class="kpi-lbl">' + lbl + '</div>' |
| '<div class="kpi-val">' + str(meta.get(key,"—")) + '</div></div>', |
| unsafe_allow_html=True |
| ) |
| st.stop() |
|
|
| |
| |
| |
| if submitted: |
| raw = { |
| 'checking_status': checking, 'duration': duration, 'credit_history': credit_hist, |
| 'purpose': purpose, 'credit_amount': credit_amt, 'savings_status': savings, |
| 'employment': employment, 'installment_commitment': installment_c, |
| 'personal_status': 'male single', 'other_parties': 'none', 'residence_since': 3, |
| 'property_magnitude': 'real estate', 'age': age, 'other_payment_plans': 'none', |
| 'housing': housing, 'existing_credits': 1, 'job': 'skilled', 'num_dependents': 1, |
| 'own_telephone': 'yes', 'foreign_worker': 'yes', |
| 'digital_presence_score': digital_score, 'has_social_media': int(has_social), |
| 'ecommerce_monthly_volume': ecomm_vol, 'has_npwp': int(has_npwp), 'has_siup': int(has_siup), |
| 'business_age_years': float(biz_age), 'monthly_cash_flow': float(cash_flow), |
| 'num_employees': num_emp, 'loan_rp': loan_rp, |
| } |
|
|
| _step_labels = { |
| 'id': [ |
| '🔢 Memproses data input...', |
| '🤖 Menjalankan model ensemble...', |
| '🔍 Menghitung SHAP values...', |
| '💬 Menghasilkan narasi AI...', |
| '✅ Selesai!', |
| ], |
| 'en': [ |
| '🔢 Processing input data...', |
| '🤖 Running ensemble model...', |
| '🔍 Computing SHAP values...', |
| '💬 Generating AI narrative...', |
| '✅ Done!', |
| ], |
| 'hi': [ |
| '🔢 Input data process ho raha hai...', |
| '🤖 Ensemble model chal raha hai...', |
| '🔍 SHAP values compute ho rahe hain...', |
| '💬 AI vivarana ban raha hai...', |
| '✅ Taiyaar!', |
| ], |
| } |
| _steps = _step_labels.get(lang, _step_labels['id']) |
| _prog_ph = st.empty() |
|
|
| def _show_step(idx): |
| pct = int((idx / (len(_steps) - 1)) * 100) |
| _prog_ph.progress(pct, text=_steps[idx]) |
|
|
| try: |
| _show_step(0) |
| X_in = preprocess(raw, scaler, feature_names) |
|
|
| _show_step(1) |
| pd_score = float(ensemble.predict_proba(X_in)[0][1]) |
| result = risk_result(pd_score, loan_rp, lgd_val) |
|
|
| _show_step(2) |
| try: |
| sv = explainer(X_in) |
| shap_vals = sv[0].values |
| if not isinstance(shap_vals, np.ndarray) or len(shap_vals) == 0: |
| raise ValueError("SHAP values empty or invalid") |
| shap_png = make_shap_png(shap_vals, feature_names) |
| except Exception as shap_err: |
| shap_vals = np.zeros(len(feature_names)) |
| shap_png = None |
| st.warning( |
| "⚠️ SHAP explainer error: " + str(shap_err)[:120] + |
| " — SHAP tab akan menampilkan nilai 0." |
| ) |
|
|
| _show_step(3) |
| narrative, llm_src = get_narrative(shap_vals, feature_names, result, lang, raw) |
|
|
| _show_step(4) |
| st.session_state.result = result |
| st.session_state.shap_vals = shap_vals |
| st.session_state.raw_input = raw |
| st.session_state.narrative = narrative |
| st.session_state.llm_src = llm_src |
| st.session_state.narrative_lang = lang |
| st.session_state.shap_png = shap_png |
| st.session_state.chat_history = [] |
| st.session_state.chat_summary = '' |
| for k in ['wi_dig','wi_biz','wi_emp','wi_cash','wi_dur','wi_loan']: |
| st.session_state[k] = None |
| _save_chat_memory([], '') |
|
|
| _prog_ph.empty() |
|
|
| except Exception as e: |
| _prog_ph.empty() |
| st.error("Error: " + str(e)) |
| st.stop() |
|
|
| |
| |
| |
| result = st.session_state.result |
| shap_vals = st.session_state.shap_vals |
| raw_input = st.session_state.raw_input |
| narrative = st.session_state.narrative |
| llm_src = st.session_state.llm_src |
|
|
| if result is None: |
| st.stop() |
|
|
| |
| if result is not None and raw_input is not None and st.session_state.narrative_lang != lang: |
| with st.spinner(T('spinner', lang)): |
| narrative, llm_src = get_narrative(shap_vals, feature_names, result, lang, raw_input) |
| st.session_state.narrative = narrative |
| st.session_state.llm_src = llm_src |
| st.session_state.narrative_lang = lang |
| st.session_state.chat_history = [] |
| st.session_state.chat_summary = '' |
|
|
| |
| |
| |
| st.markdown("---") |
| st.markdown( |
| "<div class='risk-banner " + result['css'] + "'>" |
| + result['cat'][lang] + |
| "<div style='font-size:.95rem;font-weight:500;opacity:.85;margin-top:.3rem'>" |
| + T('kpi_pd',lang) + ": " + str(round(result['pd']*100,1)) + "%" |
| "</div></div>", |
| unsafe_allow_html=True |
| ) |
|
|
| for col, (lbl_k, val, sub_k, color) in zip(st.columns(4), [ |
| ('kpi_pd', str(round(result['pd']*100,1)) + "%", 'kpi_pd_sub', result['color']), |
| ('kpi_el', "Rp " + str(round(result['el']/1e6,2)) + "jt", 'kpi_el_sub', "#e74c3c"), |
| ('kpi_lgd', str(int(result['lgd']*100)) + "%", 'kpi_lgd_sub', "#1a2c6b"), |
| ('kpi_ead', "Rp " + str(int(result['ead']/1e6)) + "jt", 'kpi_ead_sub', "#1a2c6b"), |
| ]): |
| with col: |
| st.markdown( |
| '<div class="kpi-box"><div class="kpi-lbl">' + T(lbl_k,lang) + '</div>' |
| '<div class="kpi-val" style="color:' + color + ';font-size:1.4rem">' + val + '</div>' |
| '<div class="kpi-sub">' + T(sub_k,lang) + '</div></div>', |
| unsafe_allow_html=True |
| ) |
| st.markdown("<br>", unsafe_allow_html=True) |
|
|
| |
| |
| |
| _tab_load_steps = { |
| 'id': [ |
| '🔍 Menyiapkan tab SHAP...', |
| '💬 Menyiapkan Narasi AI...', |
| '🤖 Menyiapkan AI Chat...', |
| '🎮 Menyiapkan What-If...', |
| '📐 Menyiapkan Formula...', |
| '✅ Semua tab siap!', |
| ], |
| 'en': [ |
| '🔍 Preparing SHAP tab...', |
| '💬 Preparing AI Narrative...', |
| '🤖 Preparing AI Chat...', |
| '🎮 Preparing What-If...', |
| '📐 Preparing Formula...', |
| '✅ All tabs ready!', |
| ], |
| 'hi': [ |
| '🔍 SHAP tab taiyaar ho raha hai...', |
| '💬 AI Vivarana taiyaar ho raha hai...', |
| '🤖 AI Chat taiyaar ho raha hai...', |
| '🎮 What-If taiyaar ho raha hai...', |
| '📐 Sutra taiyaar ho raha hai...', |
| '✅ Sabhi tab taiyaar!', |
| ], |
| } |
| _tls = _tab_load_steps.get(lang, _tab_load_steps['id']) |
| _tab_prog_ph = st.empty() |
|
|
| def _tab_step(n): |
| """Update tab loading progress. Safe to call at any time.""" |
| try: |
| pct = int(n / 5 * 100) |
| _tab_prog_ph.progress(pct, text=_tls[n]) |
| except Exception: |
| pass |
|
|
| _tab_step(0) |
|
|
| |
| |
| |
| t1, t2, t3, t4, t5 = st.tabs([ |
| T('tab_shap',lang), T('tab_narrative',lang), |
| T('tab_chat',lang), T('tab_whatif',lang), T('tab_formula',lang) |
| ]) |
|
|
| |
| with t1: |
| _tab_step(0) |
| st.markdown('<div class="sec-title">' + T("shap_title",lang) + '</div>', unsafe_allow_html=True) |
| _shap_all_zero = (shap_vals is not None and np.all(shap_vals == 0)) |
| if _shap_all_zero: |
| st.warning( |
| "⚠️ **SHAP tidak tersedia** — model XGBoost (`xgb_model.pkl`) mungkin belum di-upload atau " |
| "tidak kompatibel dengan SHAP TreeExplainer. " |
| "Pastikan `model/xgb_model.pkl` ada di HF Space dan di-train ulang jika perlu.\n\n" |
| "Skor PD dan narasi tetap valid (menggunakan ensemble model)." |
| ) |
| else: |
| if st.session_state.get("shap_png"): |
| _spng = st.session_state.shap_png |
| if isinstance(_spng, str) and _spng.startswith('data:'): |
| |
| st.markdown( |
| '<img src="' + _spng + '" style="width:100%;border-radius:8px;">', |
| unsafe_allow_html=True |
| ) |
| elif _spng: |
| st.image(_spng, use_container_width=True) |
| st.caption(T('shap_caption', lang)) |
| top_idx = np.argsort(np.abs(shap_vals))[-10:][::-1] |
| st.dataframe(pd.DataFrame({ |
| T('shap_col_feature',lang): [feature_names[i] for i in top_idx], |
| 'SHAP': [round(shap_vals[i],4) for i in top_idx], |
| T('shap_col_impact',lang): [ |
| T('shap_risk_up',lang) if shap_vals[i] > 0 else T('shap_risk_down',lang) |
| for i in top_idx |
| ], |
| }), use_container_width=True, hide_index=True) |
|
|
| |
| with t2: |
| _tab_step(1) |
| flag = {'id':'🇮🇩','en':'🇬🇧','hi':'🇮🇳'}[lang] |
| st.markdown( |
| '<div class="sec-title">' + T("narr_title",lang) + ' ' + flag + '</div>', |
| unsafe_allow_html=True |
| ) |
| st.caption(T('narr_source',lang) + ": " + (llm_src if llm_src else "—")) |
| if narrative and narrative.strip(): |
| fmt = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', narrative.replace('\n','<br>')) |
| st.markdown('<div class="narrative-box">' + fmt + '</div>', unsafe_allow_html=True) |
| else: |
| narrative_fb, src_fb = get_narrative(shap_vals, feature_names, result, lang, raw_input) |
| st.session_state.narrative = narrative_fb |
| st.session_state.llm_src = src_fb |
| fmt = re.sub(r'\*\*(.+?)\*\*', r'<strong>\1</strong>', narrative_fb.replace('\n','<br>')) |
| st.markdown('<div class="narrative-box">' + fmt + '</div>', unsafe_allow_html=True) |
| _or_key = st.session_state.get("openrouter_key","") |
| _grq_key = st.session_state.get("groq_key","") |
| with st.expander("LLM Debug Info"): |
| st.code( |
| "OR key : " + ('OK ' + _or_key[:12] + '...' if _or_key else 'missing') + "\n" |
| "Groq key : " + ('OK ' + _grq_key[:12] + '...' if _grq_key else 'missing') + "\n" |
| "Source : " + str(src_fb) + "\n" |
| "OR model : " + _OR_FREE_MODELS[0] + "\n" |
| "Groq mdl : " + _GROQ_FREE_MODELS[0] |
| ) |
|
|
| |
| with t3: |
| _tab_step(2) |
| st.markdown( |
| '<div class="sec-title">' + T("chat_title",lang) + '</div>', |
| unsafe_allow_html=True |
| ) |
|
|
| |
| if st.session_state.get('chat_summary'): |
| n_turns = len(st.session_state.chat_history) |
| mem_lbl = { |
| 'id': '🧠 Memory aktif · ' + str(n_turns) + ' pesan tersimpan', |
| 'en': '🧠 Memory active · ' + str(n_turns) + ' messages saved', |
| 'hi': '🧠 Memory active · ' + str(n_turns) + ' sandesh save hue', |
| } |
| st.markdown( |
| '<div class="memory-badge">' + mem_lbl.get(lang, mem_lbl["en"]) + '</div>', |
| unsafe_allow_html=True |
| ) |
|
|
| |
| chips = [T('chat_chip1',lang), T('chat_chip2',lang), T('chat_chip3',lang), T('chat_chip4',lang)] |
| chip_cols = st.columns(len(chips)) |
| chip_clicked = None |
| for i, (col, chip) in enumerate(zip(chip_cols, chips)): |
| with col: |
| if st.button(chip, key="chip_" + str(i), use_container_width=True): |
| chip_clicked = chip |
| st.markdown("---") |
|
|
| |
| if st.session_state.chat_history: |
| html_chat = "" |
| for msg in st.session_state.chat_history: |
| content = re.sub( |
| r'\*\*(.+?)\*\*', r'<strong>\1</strong>', |
| msg['content'].replace('\n','<br>') |
| ) |
| cls = "chat-bubble-user" if msg['role'] == 'user' else "chat-bubble-ai" |
| icon = "👤" if msg['role'] == 'user' else "🤖" |
| html_chat += '<div class="' + cls + '">' + icon + ' ' + content + '</div>' |
| st.markdown(html_chat, unsafe_allow_html=True) |
| else: |
| st.info("👋 " + T('chat_welcome', lang)) |
|
|
| |
| user_input = st.chat_input(T('chat_input', lang)) |
| to_process = chip_clicked or user_input |
|
|
| if to_process: |
| st.session_state.chat_history.append({'role': 'user', 'content': to_process}) |
|
|
| |
| _chat_status_ph = st.empty() |
| _spinner_label = { |
| 'id': '🤔 Maya sedang berpikir...', |
| 'en': '🤔 Maya is thinking...', |
| 'hi': '🤔 Maya soch rahi hai...', |
| }.get(lang, '🤔 Maya sedang berpikir...') |
|
|
| with st.spinner(_spinner_label): |
| _chat_status_ph.info( |
| { |
| 'id': '⏳ Menghubungi LLM — bisa 5–15 detik tergantung model yang dipilih...', |
| 'en': '⏳ Connecting to LLM — may take 5–15 seconds depending on the model...', |
| 'hi': '⏳ LLM se connect ho raha hai — model ke hisaab se 5–15 second lag sakte hain...', |
| }[lang], |
| icon="⏳" |
| ) |
| ai_resp, adjustments, _debug_err = get_chat_response( |
| to_process, |
| st.session_state.chat_history, |
| result, raw_input, shap_vals, feature_names, lang, |
| rag_index=rag_index |
| ) |
| _chat_status_ph.empty() |
|
|
| |
| if _debug_err and any([ |
| st.session_state.get("openrouter_key"), |
| st.session_state.get("groq_key") |
| ]): |
| st.warning( |
| "Semua LLM gagal, menggunakan Smart Fallback. Error: " + str(_debug_err)[:100] |
| ) |
|
|
| |
| st.session_state.chat_history.append({'role': 'assistant', 'content': ai_resp}) |
| _save_chat_memory(st.session_state.chat_history, st.session_state.get('chat_summary','')) |
|
|
| |
| adj_map = { |
| 'digital_presence_score': 'wi_dig', |
| 'business_age_years': 'wi_biz', |
| 'num_employees': 'wi_emp', |
| 'monthly_cash_flow': 'wi_cash', |
| 'duration': 'wi_dur', |
| 'loan_rp': 'wi_loan', |
| } |
| if adjustments: |
| for field, val in adjustments.items(): |
| if field in adj_map: |
| st.session_state[adj_map[field]] = val |
| st.success("💡 " + T('chat_updated', lang)) |
|
|
| st.rerun() |
|
|
| |
| if st.session_state.chat_history: |
| if st.button(T('chat_clear', lang)): |
| st.session_state.chat_history = [] |
| st.session_state.chat_summary = '' |
| _save_chat_memory([], '') |
| st.rerun() |
|
|
| |
| with t4: |
| _tab_step(3) |
|
|
| @st.fragment |
| def render_whatif(): |
| _lang = st.session_state.get("lang_sel","id") |
| result_f = st.session_state.result |
| raw_input_f = st.session_state.raw_input |
| if result_f is None or raw_input_f is None: |
| st.info(T('wi_form_first', _lang)) |
| return |
|
|
| st.markdown( |
| '<div class="sec-title">' + T("wi_title",_lang) + '</div>', |
| unsafe_allow_html=True |
| ) |
| st.caption("💡 " + T('wi_caption', _lang)) |
|
|
| d_dig = int(st.session_state.wi_dig or raw_input_f.get('digital_presence_score',50)) |
| d_biz = int(st.session_state.wi_biz or raw_input_f.get('business_age_years',5)) |
| d_emp = int(st.session_state.wi_emp or raw_input_f.get('num_employees',5)) |
| d_cash = int((st.session_state.wi_cash or raw_input_f.get('monthly_cash_flow',15e6))//1e6) |
| d_dur = int(st.session_state.wi_dur or raw_input_f.get('duration',24)) |
| d_loan = int((st.session_state.wi_loan or raw_input_f.get('loan_rp',50e6))//1e6) |
|
|
| wc1, wc2 = st.columns(2) |
| with wc1: |
| wi_loan = st.slider(T('wi_loan',_lang), 5, 500, d_loan, 5) |
| wi_dur = st.slider(T('wi_duration',_lang), 4, 72, d_dur) |
| wi_cash = st.slider(T('wi_cashflow',_lang), 1, 100, d_cash) |
| with wc2: |
| wi_dig = st.slider(T('wi_digital',_lang), 1, 100, d_dig) |
| wi_biz = st.slider(T('wi_bizage',_lang), 1, 20, d_biz) |
| wi_emp = st.slider(T('wi_employees',_lang),1, 50, d_emp) |
|
|
| try: |
| wi_raw = { |
| **raw_input_f, |
| 'duration': wi_dur, |
| 'digital_presence_score': wi_dig, |
| 'business_age_years': float(wi_biz), |
| 'monthly_cash_flow': float(wi_cash * 1e6), |
| 'num_employees': wi_emp, |
| } |
| X_wi = preprocess(wi_raw, scaler, feature_names) |
| wi_pd = float(ensemble.predict_proba(X_wi)[0][1]) |
| wi_res = risk_result(wi_pd, wi_loan * 1e6, result_f['lgd']) |
| d_pd = wi_pd - result_f['pd'] |
| d_el = wi_res['el'] - result_f['el'] |
| _log_api("What-If", "Local Model", True, 0) |
|
|
| r1, r2, r3 = st.columns(3) |
| with r1: |
| st.metric(T('kpi_pd',_lang), str(round(wi_pd*100,1)) + "%", |
| str(round(d_pd*100,1)) + "pp", delta_color="inverse") |
| with r2: |
| st.metric(T('kpi_el',_lang), "Rp " + str(round(wi_res['el']/1e6,2)) + "jt", |
| "Rp " + str(round(d_el/1e6,2)) + "jt", delta_color="inverse") |
| with r3: |
| icon_m = {'risk-low':'🟢','risk-med':'🟡','risk-high':'🔴'} |
| label_m = { |
| 'risk-low': T('wi_approved',_lang), |
| 'risk-med': T('wi_review',_lang), |
| 'risk-high': T('wi_highrisk',_lang), |
| } |
| st.metric(T('wi_status',_lang), |
| icon_m[wi_res['css']] + " " + label_m[wi_res['css']]) |
|
|
| fig2, ax = plt.subplots(figsize=(7, 2.5)) |
| bars = ax.barh( |
| ['Original','What-If'], |
| [result_f['pd']*100, wi_pd*100], |
| color=[result_f['color'], wi_res['color']], |
| height=0.4 |
| ) |
| ax.axvline(20, color='#f7971e', ls='--', lw=1.5, label='20%') |
| ax.axvline(50, color='#e74c3c', ls='--', lw=1.5, label='50%') |
| for bar, v in zip(bars, [result_f['pd']*100, wi_pd*100]): |
| ax.text(bar.get_width()+0.5, bar.get_y()+bar.get_height()/2, |
| str(round(v,1)) + "%", va='center', fontweight='bold', fontsize=11) |
| ax.set_xlim(0, 110) |
| ax.legend(fontsize=8) |
| ax.grid(axis='x', alpha=0.3) |
| ax.set_title(T('wi_chart_title',_lang), fontweight='bold') |
| plt.tight_layout() |
| buf2 = io.BytesIO() |
| fig2.savefig(buf2, format='png', dpi=130, bbox_inches='tight') |
| plt.close(fig2) |
| buf2.seek(0) |
| _wi_b64 = base64.b64encode(buf2.read()).decode() |
| st.markdown( |
| '<img src="data:image/png;base64,' + _wi_b64 + '" style="width:100%;border-radius:8px;">', |
| unsafe_allow_html=True |
| ) |
|
|
| if wi_pd < result_f['pd'] - 0.005: |
| st.success( |
| "PD " + T('wi_pd_down',_lang) + " **" + |
| str(round((result_f['pd']-wi_pd)*100,1)) + "pp** (" + |
| T('wi_pd_from',_lang) + " " + str(round(result_f['pd']*100,1)) + |
| "% → " + str(round(wi_pd*100,1)) + "%)" |
| ) |
| elif wi_pd > result_f['pd'] + 0.005: |
| st.warning( |
| "PD " + T('wi_pd_up',_lang) + " **" + |
| str(round((wi_pd-result_f['pd'])*100,1)) + "pp** (" + |
| T('wi_pd_from',_lang) + " " + str(round(result_f['pd']*100,1)) + |
| "% → " + str(round(wi_pd*100,1)) + "%)" |
| ) |
| else: |
| st.info("↔️ " + T('wi_no_change',_lang)) |
|
|
| def _t(i, e, h): |
| return {'id': i, 'en': e, 'hi': h}[_lang] |
|
|
| with st.expander(T('wi_tips_title', _lang)): |
| tips = [] |
| if wi_dig < 70: |
| tips.append(_t( |
| "📱 Naikkan Digital Score ke 70+ — marketplace & Google Business", |
| "📱 Raise Digital Score to 70+ — marketplace & Google Business", |
| "📱 Digital Score 70+ karein — marketplace & Google Business" |
| )) |
| if wi_cash < 20: |
| tips.append(_t( |
| "💵 Target cash flow Rp 20jt+/bulan", |
| "💵 Target Rp 20M+/month cash flow", |
| "💵 Naqad pravaah Rp 20M+/maah target" |
| )) |
| if wi_biz < 3: |
| tips.append(_t( |
| "🏢 Bisnis < 3 thn lebih berisiko — bangun track record", |
| "🏢 Business < 3 yr is riskier — build track record", |
| "🏢 3 saal se kam vyapaar — track record banaen" |
| )) |
| if wi_emp < 5: |
| tips.append(_t( |
| "👥 Tambah karyawan = skala bisnis lebih sehat", |
| "👥 More employees signals healthy scale", |
| "👥 Zyada karmachaaree = swasth paimaana" |
| )) |
| if not tips: |
| tips.append(_t( |
| "🌟 Profil sudah optimal!", |
| "🌟 Profile already well-optimized!", |
| "🌟 Parichay pehle se anukoolit!" |
| )) |
| for tip in tips: |
| st.markdown(tip) |
|
|
| except Exception as e: |
| st.error("Error: " + str(e)) |
|
|
| render_whatif() |
|
|
| |
| with t5: |
| _tab_step(4) |
| st.markdown( |
| '<div class="sec-title">' + T("form_formula_title",lang) + '</div>', |
| unsafe_allow_html=True |
| ) |
| st.latex(r"EL = PD \times LGD \times EAD") |
| st.latex( |
| "EL = " + str(round(result['pd'],4)) + r" \times " + str(round(result['lgd'],2)) |
| + r" \times Rp\," + "{:,}".format(int(result['ead'])) |
| + r" = Rp\," + "{:,}".format(int(result['el'])) |
| ) |
| cf1, cf2 = st.columns(2) |
| with cf1: |
| st.markdown(T('formula_def', lang)) |
| with cf2: |
| cat_lbl = ( |
| T('formula_low',lang) if result['pd'] < .2 else |
| T('formula_medium',lang) if result['pd'] < .5 else |
| T('formula_high',lang) |
| ) |
| st.markdown( |
| "| " + T('formula_komponen',lang) + " | " + T('formula_nilai',lang) + " |\n" |
| "|--|--|\n" |
| "| PD | " + str(round(result['pd']*100,1)) + "% (" + cat_lbl + ") |\n" |
| "| LGD | " + str(int(result['lgd']*100)) + "% |\n" |
| "| EAD | Rp " + str(int(result['ead']/1e6)) + "jt |\n" |
| "| **EL** | **Rp " + str(round(result['el']/1e6,2)) + "jt** |" |
| ) |
| _log_api("Formula", "Local", True, 0) |
|
|
| |
| |
| |
| _tab_step(5) |
| _tab_prog_ph.empty() |
|
|
| |
| |
| |
| st.markdown("---") |
| report_txt = ( |
| "SME CREDIT RISK REPORT — 1na37 AI · Batch 10\n" + "="*50 + "\n" |
| + T('kpi_pd',lang) + ": " + str(round(result['pd']*100,1)) + "% → " + result['cat'][lang] + "\n" |
| + T('kpi_el',lang) + ": Rp " + "{:,}".format(int(result['el'])) + "\n" |
| "LGD: " + str(int(result['lgd']*100)) + "% (Basel II)\n" |
| "EAD: Rp " + "{:,}".format(int(result['ead'])) + "\n" |
| + "="*50 + "\n" |
| + T('narr_title',lang) + "\n" + narrative + "\n" |
| + "="*50 + "\n" |
| "TOP SHAP\n" + shap_summary(shap_vals, feature_names, 5) + "\n" |
| + "="*50 + "\n" |
| "DISCLAIMER: Educational use only. Not financial advice." |
| ) |
| st.download_button( |
| T('download_btn', lang), |
| data=report_txt, |
| file_name=T('download_file', lang), |
| mime="text/plain" |
| ) |