Download app.py from 1na37/testt: direct link, hf CLI and curl.
- Browser
- Download file 140 kB
-
https://huggingface.co/spaces/1na37/testt/resolve/main/app.py
- Command line
-
hf download hf://spaces/1na37/testt/app.py
-
curl -L -o app.py https://huggingface.co/spaces/1na37/testt/resolve/main/app.py
140 kB
| # ============================================================ | |
| # ๐ฆ SME Credit Risk Assessment โ FINAL (FIXED v6) | |
| # Final Project | AI Engineering Bootcamp Batch 10 | |
| # Author: 1na37 | |
| # ============================================================ | |
| # BASE: Doc6 (live version โ with GIF/music sidebar) | |
| # v4: _extract_adjustments_semantic() โ slider values from natural LLM text | |
| # v5: Orphan stripper Gen 1 (action-verb pattern: "coba naikkan X ke N") | |
| # + system prompt Rule 9 banning trailing sentences | |
| # v6 (this): Orphan stripper Gen 2 (Doc8 patterns after Rule 9 ban) | |
| # LLMs shifted from "coba naikkan X ke N" to: | |
| # - "aku bisa menyarankan untuk meningkatkan X ke N untuk mendapatkan Y" | |
| # - "Jadi, mari kita fokus pada cara meningkatkan X ke N" | |
| # - "Atau, kalau kamu ingin kembali ke topik kredit, aku bisa..." | |
| # NEW: strip_to_last_sentence() as final safety net โ handles any future | |
| # orphan variant by cutting back to last complete sentence if text ends | |
| # without sentence-ending punctuation (future-proofs against whack-a-mole). | |
| # RESTORED: Full CoT โ โกโขโฃ, all few-shot examples, memory badge | |
| # ============================================================ | |
| 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" | |
| ) | |
| # ============================================================ | |
| # TRANSLATION SYSTEM โ Latin Hindi (Hinglish transliteration) | |
| # ============================================================ | |
| 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)) | |
| # ============================================================ | |
| # SELECTBOX LABELS | |
| # ============================================================ | |
| 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)'} | |
| # ============================================================ | |
| # CSS STYLING | |
| # ============================================================ | |
| 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) | |
| # ============================================================ | |
| # HELPERS: Secrets + API Keys | |
| # ============================================================ | |
| 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() | |
| # ============================================================ | |
| # API CALL LOGGER | |
| # ============================================================ | |
| 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:] | |
| # ============================================================ | |
| # _set_status โ if/else instead of ternary (Python 3.13 compat) | |
| # ============================================================ | |
| 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 | |
| # ============================================================ | |
| # FORMAL TOOL CALLING SCHEMA (Agentic AI) | |
| # ============================================================ | |
| 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", | |
| ] | |
| # ============================================================ | |
| # LLM API CALLERS (with status + timing) | |
| # ============================================================ | |
| 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 = {} | |
| # Strip <think> blocks | |
| text = re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL).strip() | |
| # Extract + strip JSON tool-call objects {"field":..., "value":...} | |
| 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() | |
| # Extract [ADJUST: field=value] tags | |
| 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 | |
| # Strip the tag itself (leave the surrounding sentence intact) | |
| text = re.sub(r'\s*\[ADJUST:[^\]]+\]\s*', ' ', text).strip() | |
| # โโ ORPHAN TRAILING SENTENCE STRIPPER (v6) โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| # LLMs append unwanted recommendation sentences at the end of responses. | |
| # Two generations of patterns observed in production: | |
| # | |
| # Gen 1 (Doc7 - after v4 fix): action-verb style | |
| # "..., coba naikkan digital score ke 80 โ ini akan memberikan dampak..." | |
| # | |
| # Gen 2 (Doc8 - after Rule 9 ban): pivot-back-to-credit style | |
| # "...aku bisa membantu kamu dengan menyarankan untuk meningkatkan X ke N..." | |
| # "...Jadi, mari kita fokus pada membicarakan tentang cara meningkatkan X..." | |
| # "...Atau, kalau kamu ingin kembali ke topik kredit, aku bisa menyarankan..." | |
| # | |
| # STRATEGY: run both passes, then strip_to_last_sentence as final safety net. | |
| # โโ Pass 1: Gen 1 action-verb orphans โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| _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() | |
| # โโ Pass 2: Gen 2 pivot-back-to-credit orphans โโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| _pivot_patterns = [ | |
| # "aku bisa membantu kamu dengan menyarankan untuk meningkatkan X ke N untuk mendapatkan Y" | |
| 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 | |
| ), | |
| # "meningkatkan digital score kamu ke N untuk mendapatkan pinjaman..." | |
| 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 | |
| ), | |
| # "Jadi, mari kita fokus pada membicarakan tentang cara meningkatkan X ke N..." | |
| 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 | |
| ), | |
| # "Atau/Tapi, kalau kamu ingin kembali ke topik kredit..." | |
| 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 | |
| ), | |
| # "Tapi, kalau kamu adalah seorang pengusaha UMKM yang ingin mengajukan kredit, aku bisa..." | |
| 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() | |
| # โโ Pass 3: strip_to_last_sentence โ final safety net โโโโโโโโโโโโโโโโโโโโ | |
| # If text ends without sentence-ending punctuation (incomplete fragment), | |
| # cut back to the last complete sentence. Handles any new orphan variant. | |
| 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: # Don't cut more than 75% of response | |
| return t[:last_end + 1].strip() | |
| return t | |
| text = _strip_to_last_sentence(text) | |
| # Cleanup | |
| 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() | |
| # โโ Digital presence score โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| 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 | |
| # โโ Monthly cash flow โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| # Match "Rp NNjt" or "NNjt" near cash flow context | |
| 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) | |
| # Only update if it's a recommendation (different from current by >10%) | |
| if val_m != cur_cf and 1_000_000 <= val_m <= 500_000_000: | |
| adjustments['monthly_cash_flow'] = float(val_m) | |
| break | |
| # โโ Loan amount โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| 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: | |
| # Handle "128,6" (ID decimal) โ 128.6 โ 128_600_000 | |
| # Handle "128" (integer) โ 128 โ 128_000_000 | |
| 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 | |
| # โโ Duration / tenor โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| 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 | |
| # โโ Business age (only if explicitly recommended, not just mentioned) โโ | |
| 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 "" | |
| # ============================================================ | |
| # MEMORY PERSISTENCE | |
| # ============================================================ | |
| 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 [], "" | |
| # ============================================================ | |
| # MEMORY SUMMARIZATION | |
| # ============================================================ | |
| 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 | |
| # Early session state init | |
| for _k, _v in [('llm_status', ''), ('api_log', [])]: | |
| if _k not in st.session_state: | |
| st.session_state[_k] = _v | |
| # ============================================================ | |
| # SIDEBAR | |
| # ============================================================ | |
| 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.") | |
| # Live LLM status placeholder โ updated by _set_status() during calls | |
| _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)) | |
| # ============================================================ | |
| # LOAD MODEL & RAG | |
| # ============================================================ | |
| 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, {} | |
| 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() | |
| # ============================================================ | |
| # SESSION STATE INIT | |
| # ============================================================ | |
| _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 | |
| # ============================================================ | |
| # HELPER FUNCTIONS | |
| # ============================================================ | |
| 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 | |
| # ============================================================ | |
| # LLM HELPERS โ Narrative cascade | |
| # ============================================================ | |
| _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" | |
| # ============================================================ | |
| # NARRATIVE GENERATION | |
| # ============================================================ | |
| 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 | |
| # Static fallback | |
| 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' | |
| # ============================================================ | |
| # HELPER: top issue for fallback chat | |
| # ============================================================ | |
| 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!") | |
| # ============================================================ | |
| # AI CHAT โ Maya persona (FULL โ CoT + Few-shot + Tool Calling) | |
| # ============================================================ | |
| 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'} | |
| # โโ Memory summarization โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| recent_history, chat_summary = _summarize_history(history, lang) | |
| # โโ RAG context retrieval โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| 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 = "" | |
| # โโ Few-shot examples (RESTORED from Doc3) โโโโโโโโโโโโโโโโ | |
| 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 context block โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| 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" | |
| # โโ Chain-of-Thought instructions (RESTORED from Doc3) โโโโ | |
| 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. | |
| """ | |
| # โโ Full persona system prompt โโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| 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}) | |
| # โโ Call LLM with tool calling support โโโโโโโโโโโโโโโโโโโ | |
| response, tool_calls, _llm_src, _last_error = _call_chat_llm(messages) | |
| # Parse formal tool calls โ adjustments dict | |
| adjustments = _parse_tool_calls(tool_calls) | |
| # If LLM only returned tool calls (no text), convert to text | |
| if not response and tool_calls: | |
| response = _tool_call_to_text(tool_calls, lang) | |
| # โโ Smart Keyword Fallback (only if ALL LLMs failed) โโโโโ | |
| 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] | |
| # Helper: word-boundary check for short keywords (avoids 'hi' matching 'mempengaruhi') | |
| 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 | |
| # โ Who is Maya / greetings | |
| # FIXED: removed 'kamu','km' (too broad โ appears in almost every sentence) | |
| # FIXED: 'hi','hei','hai' use word-boundary via _kw helper | |
| 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?" | |
| ) | |
| # โก Quick profit / cuan intent โ CoT detects bisnis intent | |
| 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 | |
| # โฅ Digital score targeted โ MOVED UP before โข and โค | |
| # FIXED: was after โขimprove which had 'naik' catching 'naikin digital score' | |
| 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!" | |
| ) | |
| # โฉ Humor/absurd โ MOVED UP before โคloan | |
| # FIXED: 'kredit' in โคloan was catching "beruang... minta kredit bisa gak?" | |
| 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?" | |
| ) | |
| # โช 6-month action plan โ MOVED UP before โฃshap | |
| # FIXED: 'apa yang perlu' was triggering โฃshap via 'apa yang' substring | |
| 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]) | |
| # โข How to improve / lower PD โ triggers sliders | |
| 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]) | |
| ) | |
| # โฃ Why high score / SHAP factors โ CoT picks actual SHAP values | |
| # FIXED: removed 'apa yang' (matched 'berapa yang'), 'factor' (too broad) | |
| # Use specific phrases instead | |
| 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.') | |
| ) | |
| # โค Ideal loan amount โ trigger loan_rp slider | |
| # FIXED: removed 'besar' (too broad); โฅdigit and โฉhumor now come before this | |
| 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!" | |
| ) | |
| # โฆ Savings / cash flow topics | |
| 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) | |
| # โง Credit history | |
| 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." | |
| ) | |
| # โจ Business/SME/digital tips | |
| 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 | |
| # โซ Generic fallback | |
| 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 | |
| ) | |
| # โโ Final cleanup: clean response text + extract explicit [ADJUST:] tags โโ | |
| if response: | |
| response, extra_adj = _clean_response(response) | |
| for field, val in extra_adj.items(): | |
| if field not in adjustments: | |
| adjustments[field] = val | |
| # โโ Semantic fallback: parse numeric values from natural LLM text โโโโโโโโโ | |
| # Free-tier LLMs frequently ignore [ADJUST:] tag instructions even when | |
| # the system prompt says to use them. This guarantees slider updates work | |
| # even when the LLM gives a perfect natural-language recommendation but | |
| # forgets to embed the tags. Only fills fields not already captured above. | |
| 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 | |
| # ============================================================ | |
| # HEADER | |
| # ============================================================ | |
| 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 | |
| ) | |
| # ============================================================ | |
| # INPUT FORM | |
| # ============================================================ | |
| 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") | |
| # ============================================================ | |
| # EMPTY STATE (before first submission) | |
| # ============================================================ | |
| 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() | |
| # ============================================================ | |
| # PREDICTION โ with step-by-step progress bar | |
| # ============================================================ | |
| 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() | |
| # ============================================================ | |
| # Pull from session state | |
| # ============================================================ | |
| 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() | |
| # Re-generate narrative if language changed | |
| 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 = '' | |
| # ============================================================ | |
| # RESULTS BANNER + KPIs | |
| # ============================================================ | |
| 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 LOADING PROGRESS โ FIXED: always defined before use | |
| # ============================================================ | |
| _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() # Always defined here โ no conditional | |
| 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) # Show immediately before rendering first tab content | |
| # ============================================================ | |
| # TABS | |
| # ============================================================ | |
| 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) | |
| ]) | |
| # โโ TAB 1: SHAP โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| 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:'): | |
| # Render base64 directly โ avoids MediaFileStorage expiry | |
| 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) | |
| # โโ TAB 2: NARRATIVE โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| 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] | |
| ) | |
| # โโ TAB 3: AI CHAT โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| with t3: | |
| _tab_step(2) | |
| st.markdown( | |
| '<div class="sec-title">' + T("chat_title",lang) + '</div>', | |
| unsafe_allow_html=True | |
| ) | |
| # Memory badge โ shows when summary exists (after 8+ messages) | |
| 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 | |
| ) | |
| # Quick chip buttons | |
| 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("---") | |
| # Chat history display | |
| 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)) | |
| # Chat input + processing | |
| 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}) | |
| # Live status indicator while LLM runs | |
| _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() | |
| # Show error hint only if keys exist but all LLMs failed | |
| 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] | |
| ) | |
| # Append AI response | |
| 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','')) | |
| # Apply tool call / [ADJUST:] results to What-If sliders | |
| 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() | |
| # Clear chat button | |
| 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() | |
| # โโ TAB 4: WHAT-IF โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| with t4: | |
| _tab_step(3) | |
| 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() | |
| # โโ TAB 5: FORMULA โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ | |
| 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) | |
| # ============================================================ | |
| # All tabs rendered โ clear progress bar | |
| # ============================================================ | |
| _tab_step(5) | |
| _tab_prog_ph.empty() | |
| # ============================================================ | |
| # DOWNLOAD REPORT | |
| # ============================================================ | |
| 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" | |
| ) |