Update app.py
Browse files
app.py
CHANGED
|
@@ -255,7 +255,7 @@ html,body,[class*="css"]{font-family:'Plus Jakarta Sans',sans-serif;}
|
|
| 255 |
""", unsafe_allow_html=True)
|
| 256 |
|
| 257 |
# ============================================================
|
| 258 |
-
# HELPER: Baca secret dari HF Secrets
|
| 259 |
# ============================================================
|
| 260 |
def _read_secret(name: str) -> str:
|
| 261 |
try:
|
|
@@ -266,6 +266,21 @@ def _read_secret(name: str) -> str:
|
|
| 266 |
pass
|
| 267 |
return os.environ.get(name, "").strip()
|
| 268 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 269 |
# ============================================================
|
| 270 |
# SIDEBAR
|
| 271 |
# ============================================================
|
|
@@ -282,19 +297,18 @@ with st.sidebar:
|
|
| 282 |
)
|
| 283 |
st.markdown("---")
|
| 284 |
|
| 285 |
-
#
|
| 286 |
-
openrouter_key =
|
| 287 |
-
gemini_key =
|
| 288 |
-
groq_key =
|
| 289 |
|
| 290 |
-
# Tampilkan status key (โ
/โ)
|
| 291 |
st.caption(
|
| 292 |
f"๐ **OpenRouter:** {'โ
' if openrouter_key else 'โ'} "
|
| 293 |
f"**Gemini:** {'โ
' if gemini_key else 'โ'} "
|
| 294 |
f"**Groq:** {'โ
' if groq_key else 'โ'}"
|
| 295 |
)
|
| 296 |
if not any([openrouter_key, gemini_key, groq_key]):
|
| 297 |
-
st.warning("โ ๏ธ Tidak ada API key terdeteksi. Chat
|
| 298 |
|
| 299 |
st.markdown("---")
|
| 300 |
st.markdown(T('sidebar_stack', lang))
|
|
@@ -329,7 +343,7 @@ def load_model():
|
|
| 329 |
def load_rag_index():
|
| 330 |
try:
|
| 331 |
from rag import load_index
|
| 332 |
-
return load_index(kb_path="knowledge_base", index_path="kb_index.pkl")
|
| 333 |
except ImportError:
|
| 334 |
st.sidebar.warning("โ ๏ธ rag.py not found. RAG disabled.")
|
| 335 |
return None
|
|
@@ -388,7 +402,10 @@ def risk_result(pd_score, loan_rp, lgd=0.40):
|
|
| 388 |
|
| 389 |
def shap_summary(vals, names, n=5):
|
| 390 |
idx = np.argsort(np.abs(vals))[-n:][::-1]
|
| 391 |
-
return '\n'.join([
|
|
|
|
|
|
|
|
|
|
| 392 |
|
| 393 |
def make_shap_png(shap_vals, feature_names):
|
| 394 |
sv = np.array(shap_vals); names = list(feature_names); n = min(12,len(sv))
|
|
@@ -403,28 +420,95 @@ def make_shap_png(shap_vals, feature_names):
|
|
| 403 |
buf = io.BytesIO(); fig.savefig(buf, format='png', dpi=130, bbox_inches='tight')
|
| 404 |
plt.close(fig); buf.seek(0); return buf.read()
|
| 405 |
|
| 406 |
-
|
| 407 |
-
|
| 408 |
-
|
| 409 |
-
|
| 410 |
-
|
| 411 |
-
|
| 412 |
-
|
| 413 |
-
|
| 414 |
-
|
| 415 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 416 |
|
| 417 |
# ============================================================
|
| 418 |
-
# NARRATIVE GENERATION
|
| 419 |
# ============================================================
|
| 420 |
-
def get_narrative(shap_vals, feature_names, result, lang, raw_input
|
| 421 |
-
|
| 422 |
-
pd_pct = result['pd']*100
|
| 423 |
factors = shap_summary(shap_vals, feature_names, 5)
|
| 424 |
-
lang_str = {'id':'Bahasa Indonesia profesional','en':'Professional English','hi':'Hindi'}
|
| 425 |
prompt = (
|
| 426 |
f"You are a senior credit analyst for Indonesian SME lending.\n"
|
| 427 |
-
f"Reply ONLY in {lang_str.get(lang,'English')}.\n"
|
| 428 |
f"Applicant: PD={pd_pct:.1f}%, EL=Rp {result['el']:,.0f}, "
|
| 429 |
f"BizAge={raw_input.get('business_age_years')}yr, Employees={raw_input.get('num_employees')}, "
|
| 430 |
f"DigitalScore={raw_input.get('digital_presence_score')}/100, "
|
|
@@ -438,25 +522,11 @@ def get_narrative(shap_vals, feature_names, result, lang, raw_input,
|
|
| 438 |
f"4) {T('n_recommendations',lang)} (2-3 specific steps). "
|
| 439 |
f"Be data-driven and reference actual values."
|
| 440 |
)
|
| 441 |
-
msgs = [{"role":"user","content":prompt}]
|
| 442 |
-
|
| 443 |
-
|
| 444 |
-
if r: return r, '๐ค OpenRouter (Claude Haiku)'
|
| 445 |
-
if gemini_key:
|
| 446 |
-
try:
|
| 447 |
-
import google.generativeai as genai
|
| 448 |
-
genai.configure(api_key=gemini_key)
|
| 449 |
-
r = genai.GenerativeModel('gemini-1.5-flash').generate_content(prompt)
|
| 450 |
-
return r.text, '๐ค Gemini Flash'
|
| 451 |
-
except: pass
|
| 452 |
-
if groq_key:
|
| 453 |
-
try:
|
| 454 |
-
from groq import Groq
|
| 455 |
-
r = Groq(api_key=groq_key).chat.completions.create(messages=msgs, model='llama3-8b-8192')
|
| 456 |
-
return r.choices[0].message.content, '๐ค Groq (Llama3)'
|
| 457 |
-
except: pass
|
| 458 |
|
| 459 |
-
# Static fallback
|
| 460 |
biz=raw_input.get('business_age_years',0); emp=raw_input.get('num_employees',0)
|
| 461 |
dig=raw_input.get('digital_presence_score',0); cf=raw_input.get('monthly_cash_flow',0)
|
| 462 |
npwp=raw_input.get('has_npwp',0); siup=raw_input.get('has_siup',0)
|
|
@@ -553,12 +623,10 @@ def _get_top_issue(raw_input, pd_pct, lang):
|
|
| 553 |
# ============================================================
|
| 554 |
# AI CHAT โ Maya persona, dengan RAG
|
| 555 |
# ============================================================
|
| 556 |
-
def get_chat_response(user_msg, history, result, raw_input, shap_vals,
|
| 557 |
-
|
| 558 |
-
rag_index=None):
|
| 559 |
-
pd_pct = result['pd']*100
|
| 560 |
factors = shap_summary(shap_vals, feature_names, 5)
|
| 561 |
-
lang_full = {'id':'Bahasa Indonesia','en':'English','hi':'Hindi'}
|
| 562 |
|
| 563 |
# RAG context
|
| 564 |
context = ""
|
|
@@ -571,7 +639,6 @@ def get_chat_response(user_msg, history, result, raw_input, shap_vals,
|
|
| 571 |
except Exception:
|
| 572 |
context = ""
|
| 573 |
|
| 574 |
-
# System prompt: Maya persona, cerdas, tidak kaku
|
| 575 |
system = (
|
| 576 |
f"Kamu adalah Maya, AI Credit Advisor yang cerdas, hangat, dan sedikit kasual untuk UMKM Indonesia.\n"
|
| 577 |
f"Kepribadian: ramah tapi profesional, suka pakai analogi sederhana, TIDAK kaku, bisa ngobrol bebas.\n"
|
|
@@ -589,7 +656,7 @@ def get_chat_response(user_msg, history, result, raw_input, shap_vals,
|
|
| 589 |
f"{context}\n"
|
| 590 |
f"ATURAN MENJAWAB:\n"
|
| 591 |
f"1. Jawab BEBAS โ tidak harus soal kredit. Kalau ditanya soal diri sendiri, perkenalkan sebagai Maya.\n"
|
| 592 |
-
f"2. Kalau relevan, selalu hubungkan ke data pemohon
|
| 593 |
f"3. Berikan saran KONKRET dan SPESIFIK, bukan generik.\n"
|
| 594 |
f"4. Maksimal 150 kata kecuali diminta lebih panjang.\n"
|
| 595 |
f"5. Kalau ada saran slider: [ADJUST: field=value]\n"
|
|
@@ -598,32 +665,19 @@ def get_chat_response(user_msg, history, result, raw_input, shap_vals,
|
|
| 598 |
f"6. JANGAN pernah balik ke template. Jawab seperti manusia cerdas.\n"
|
| 599 |
)
|
| 600 |
|
| 601 |
-
messages = [{"role":"system","content":system}]
|
| 602 |
-
for m in history[-8:]: messages.append({"role":m['role'],"content":m['content']})
|
| 603 |
-
messages.append({"role":"user","content":user_msg})
|
| 604 |
|
| 605 |
-
|
| 606 |
-
|
| 607 |
-
|
| 608 |
-
|
| 609 |
-
|
| 610 |
-
import google.generativeai as genai
|
| 611 |
-
genai.configure(api_key=gemini_key)
|
| 612 |
-
flat = system+"\n\n"+"\n".join([
|
| 613 |
-
f"{'User' if m['role']=='user' else 'Assistant'}: {m['content']}"
|
| 614 |
-
for m in messages[1:]])
|
| 615 |
-
r = genai.GenerativeModel('gemini-1.5-flash').generate_content(flat)
|
| 616 |
-
response = r.text
|
| 617 |
-
except: pass
|
| 618 |
-
if response is None and groq_key:
|
| 619 |
-
try:
|
| 620 |
-
from groq import Groq
|
| 621 |
-
r = Groq(api_key=groq_key).chat.completions.create(
|
| 622 |
-
messages=messages, model='llama3-8b-8192', max_tokens=400)
|
| 623 |
-
response = r.choices[0].message.content
|
| 624 |
-
except: pass
|
| 625 |
|
| 626 |
-
|
|
|
|
|
|
|
| 627 |
if response is None:
|
| 628 |
low = user_msg.lower()
|
| 629 |
def _t(i,e,h): return {'id':i,'en':e,'hi':h}[lang]
|
|
@@ -632,62 +686,80 @@ def get_chat_response(user_msg, history, result, raw_input, shap_vals,
|
|
| 632 |
npwp = raw_input.get('has_npwp', 0)
|
| 633 |
biz = raw_input.get('business_age_years', 0)
|
| 634 |
|
| 635 |
-
#
|
| 636 |
-
if any(w in low for w in ['siapa','kamu','km','who are you','nama','
|
| 637 |
response = _t(
|
| 638 |
f"Hei! Aku **Maya**, AI Credit Advisor kamu ๐ Aku dirancang untuk bantu kamu pahami skor kredit dan strategi bisnis UMKM. "
|
| 639 |
f"Skor PD kamu sekarang **{pd_pct:.1f}%** โ {'sudah bagus banget! ๐' if pd_pct<20 else 'masih ada ruang untuk diperbaiki.'} "
|
| 640 |
f"Mau aku jelasin lebih detail atau ada yang mau ditanyain?",
|
| 641 |
-
f"Hey! I'm **Maya**, your AI Credit Advisor ๐ I
|
| 642 |
f"Your PD score is **{pd_pct:.1f}%** โ {'looking great! ๐' if pd_pct<20 else 'there is room to improve.'} "
|
| 643 |
-
f"
|
| 644 |
f"เคจเคฎเคธเฅเคคเฅ! เคฎเฅเค **Maya** เคนเฅเค, เคเคชเคเฅ AI Credit Advisor ๐ "
|
| 645 |
f"เคเคชเคเคพ PD เคธเฅเคเฅเคฐ **{pd_pct:.1f}%** เคนเฅเฅค เคเฅเค เคชเฅเคเคจเคพ เคนเฅ?"
|
| 646 |
)
|
| 647 |
|
| 648 |
# Cara improve / turunkan skor
|
| 649 |
-
elif any(w in low for w in ['improve','better','lower','reduce','tingkatkan','kurangi','turunkan','cara','gimana','bagaimana','naik','turun','เคเฅเคธเฅ','เคเคฎ','เคธเฅเคงเคพเคฐ']):
|
| 650 |
tips = []
|
| 651 |
if not npwp:
|
| 652 |
-
tips.append(_t("1. **Urus NPWP** โ
|
| 653 |
"1. **Register NPWP** โ required for formal loans, apply at pajak.go.id",
|
| 654 |
"1. **NPWP เคชเคเคเฅเคเฅเคค เคเคฐเฅเค** โ เคเคชเคเคพเคฐเคฟเค เคเคฃ เคเฅ เคฒเคฟเค เค
เคจเคฟเคตเคพเคฐเฅเคฏ"))
|
| 655 |
if dig < 75:
|
| 656 |
-
|
| 657 |
-
|
| 658 |
-
f"{
|
|
|
|
| 659 |
if cf < 25e6:
|
| 660 |
-
|
| 661 |
-
|
| 662 |
-
f"{
|
|
|
|
| 663 |
if not tips:
|
| 664 |
-
tips.append(_t(f"Profil kamu sudah solid dengan PD {pd_pct:.1f}%! ๐ Coba simulasikan What-If
|
| 665 |
-
f"Your profile is
|
| 666 |
-
f"
|
| 667 |
-
response = _t(f"Untuk turunkan PD dari **{pd_pct:.1f}%**, fokus ke
|
| 668 |
-
|
| 669 |
-
|
| 670 |
-
|
| 671 |
-
# Kenapa / faktor
|
| 672 |
-
elif any(w in low for w in ['why','kenapa','mengapa','factor','faktor','shap','pengaruh','driver','เคเฅเคฏเฅเค','เคเคพเคฐเคฃ','apa yang']):
|
| 673 |
ti = int(np.argsort(np.abs(shap_vals))[-1])
|
| 674 |
ti2 = int(np.argsort(np.abs(shap_vals))[-2])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 675 |
response = _t(
|
| 676 |
f"Dua faktor terbesar yang drive skor PD **{pd_pct:.1f}%** kamu:\n\n"
|
| 677 |
-
f"1. **{
|
| 678 |
-
f"2. **{feature_names[ti2]}**
|
| 679 |
f"{'Skor bagus! Kedua faktor ini justru mendukung kelayakan kamu. ๐' if pd_pct<20 else 'Fokus perbaiki faktor pertama dulu โ dampaknya paling besar.'}",
|
| 680 |
f"Two biggest drivers of your PD **{pd_pct:.1f}%**:\n\n"
|
| 681 |
-
f"1. **{
|
| 682 |
-
f"2. **{feature_names[ti2]}**
|
| 683 |
-
f"{'Great score! Both factors support your eligibility. ๐' if pd_pct<20 else 'Focus on
|
| 684 |
f"PD **{pd_pct:.1f}%** เคเฅ เคฆเฅ เคฎเฅเคเฅเคฏ เคเคพเคฐเค:\n\n"
|
| 685 |
-
f"1. **{
|
| 686 |
f"2. **{feature_names[ti2]}** โ {'โฌ เคฌเคขเคผเคพเคคเคพ' if shap_vals[ti2]>0 else 'โฌ เคเคเคพเคคเคพ'} เคเฅเคเคฟเคฎ"
|
| 687 |
)
|
| 688 |
|
| 689 |
# Pinjaman ideal
|
| 690 |
-
elif any(w in low for w in ['pinjaman','loan','kredit','berapa','amount','besar','limit','เคเคฃ','เคเคฟเคคเคจเคพ']):
|
| 691 |
if cf > 0:
|
| 692 |
dur_val = raw_input.get('duration', 24)
|
| 693 |
safe = cf * dur_val * 0.35
|
|
@@ -697,39 +769,116 @@ def get_chat_response(user_msg, history, result, raw_input, shap_vals,
|
|
| 697 |
f"pinjaman ideal maksimal **Rp {safe/1e6:.0f}jt** (rasio cicilan 35%).\n\n"
|
| 698 |
f"Pinjaman kamu sekarang Rp {cur/1e6:.0f}jt โ "
|
| 699 |
f"{'โ
masih aman, good job!' if cur<=safe else f'โ ๏ธ melebihi batas aman. Pertimbangkan kurangi ke Rp {safe/1e6:.0f}jt atau perpanjang tenor.'}",
|
| 700 |
-
f"Based on your cash flow
|
| 701 |
f"ideal loan is max **Rp {safe/1e6:.0f}M** (35% installment ratio).\n\n"
|
| 702 |
f"Current loan Rp {cur/1e6:.0f}M โ "
|
| 703 |
-
f"{'โ
within safe limits, good job!' if cur<=safe else f'โ ๏ธ exceeds safe limit. Consider reducing to Rp {safe/1e6:.0f}M
|
| 704 |
-
f"เคจเคเคฆ เคชเฅเคฐเคตเคพเคน **Rp {cf/1e6:.0f}M/เคฎเคพเคน** เคเคฐ {int(dur_val)} เคฎเคพเคน เคเฅ เคเคงเคพเคฐ เคชเคฐ
|
| 705 |
-
f"
|
| 706 |
-
f"เคตเคฐเฅเคคเคฎเคพเคจ เคเคฃ Rp {cur/1e6:.0f}M โ "
|
| 707 |
-
f"{'โ
เคธเฅเคฐเคเฅเคทเคฟเคค เคธเฅเคฎเคพ เคฎเฅเคเฅค' if cur<=safe else 'โ ๏ธ เคธเฅเคฎเคพ เคธเฅ เค
เคงเคฟเคเฅค'}"
|
| 708 |
)
|
| 709 |
else:
|
| 710 |
-
response = _t("Isi cash flow bulanan di form dulu ya, biar aku bisa kasih rekomendasi
|
| 711 |
-
"Fill in your monthly cash flow
|
| 712 |
"เคธเคเฅเค เคธเฅเคเคพเคต เคเฅ เคฒเคฟเค เคชเคนเคฒเฅ เคฎเคพเคธเคฟเค เคจเคเคฆ เคชเฅเคฐเคตเคพเคน เคญเคฐเฅเค! ๐")
|
| 713 |
|
| 714 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 715 |
else:
|
| 716 |
top_issue = _get_top_issue(raw_input, pd_pct, lang)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 717 |
response = _t(
|
| 718 |
-
f"
|
| 719 |
-
f"Yang bisa aku kasih tahu
|
| 720 |
-
f"
|
| 721 |
-
f"
|
| 722 |
f"What I can share: your PD is **{pd_pct:.1f}%** and {top_issue}\n\n"
|
| 723 |
-
f"
|
| 724 |
-
f"
|
| 725 |
f"เคเคชเคเคพ PD **{pd_pct:.1f}%** เคนเฅ เคเคฐ {top_issue}"
|
| 726 |
)
|
| 727 |
|
|
|
|
| 728 |
adjustments = {}
|
| 729 |
for m in re.finditer(r'\[ADJUST:\s*(\w+)=([\d.]+)\]', response):
|
| 730 |
adjustments[m.group(1)] = float(m.group(2))
|
| 731 |
-
clean = re.sub(r'\[ADJUST:[^\]]+\]','',response).strip()
|
| 732 |
-
return clean, adjustments,
|
| 733 |
|
| 734 |
# ============================================================
|
| 735 |
# HEADER
|
|
@@ -836,8 +985,7 @@ if submitted:
|
|
| 836 |
result = risk_result(pd_score, loan_rp, lgd_val)
|
| 837 |
sv = explainer(X_in)
|
| 838 |
shap_vals = sv[0].values
|
| 839 |
-
narrative, llm_src = get_narrative(shap_vals, feature_names, result, lang, raw
|
| 840 |
-
openrouter_key, gemini_key, groq_key)
|
| 841 |
st.session_state.result=result; st.session_state.shap_vals=shap_vals
|
| 842 |
st.session_state.raw_input=raw; st.session_state.narrative=narrative
|
| 843 |
st.session_state.llm_src=llm_src; st.session_state.narrative_lang=lang
|
|
@@ -855,12 +1003,9 @@ llm_src = st.session_state.llm_src
|
|
| 855 |
if result is None: st.stop()
|
| 856 |
|
| 857 |
# Auto-regenerate narrative if language switched
|
| 858 |
-
if
|
| 859 |
-
and st.session_state.narrative_lang != lang):
|
| 860 |
with st.spinner(T('spinner', lang)):
|
| 861 |
-
narrative, llm_src = get_narrative(
|
| 862 |
-
shap_vals, feature_names, result, lang, raw_input,
|
| 863 |
-
openrouter_key, gemini_key, groq_key)
|
| 864 |
st.session_state.narrative = narrative
|
| 865 |
st.session_state.llm_src = llm_src
|
| 866 |
st.session_state.narrative_lang = lang
|
|
@@ -898,7 +1043,8 @@ t1,t2,t3,t4,t5 = st.tabs([T('tab_shap',lang),T('tab_narrative',lang),
|
|
| 898 |
# โโ TAB 1: SHAP โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 899 |
with t1:
|
| 900 |
st.markdown(f'<div class="sec-title">{T("shap_title",lang)}</div>', unsafe_allow_html=True)
|
| 901 |
-
if st.session_state.get("shap_png"):
|
|
|
|
| 902 |
st.caption(T('shap_caption',lang))
|
| 903 |
top_idx = np.argsort(np.abs(shap_vals))[-10:][::-1]
|
| 904 |
st.dataframe(pd.DataFrame({
|
|
@@ -919,50 +1065,60 @@ with t2:
|
|
| 919 |
with t3:
|
| 920 |
st.markdown(f'<div class="sec-title">{T("chat_title",lang)}</div>', unsafe_allow_html=True)
|
| 921 |
chips = [T('chat_chip1',lang),T('chat_chip2',lang),T('chat_chip3',lang),T('chat_chip4',lang)]
|
| 922 |
-
chip_cols=st.columns(len(chips)); chip_clicked=None
|
| 923 |
for i,(col,chip) in enumerate(zip(chip_cols,chips)):
|
| 924 |
with col:
|
| 925 |
-
if st.button(chip, key=f"chip_{i}", use_container_width=True): chip_clicked=chip
|
| 926 |
st.markdown("---")
|
| 927 |
if st.session_state.chat_history:
|
| 928 |
html=""
|
| 929 |
for msg in st.session_state.chat_history:
|
| 930 |
-
content=re.sub(r'\*\*(.+?)\*\*',r'<strong>\1</strong>',msg['content'].replace('\n','<br>'))
|
| 931 |
-
cls="chat-bubble-user" if msg['role']=='user' else "chat-bubble-ai"
|
| 932 |
-
icon="๐ค" if msg['role']=='user' else "๐ค"
|
| 933 |
-
html+=f'<div class="{cls}">{icon} {content}</div>'
|
| 934 |
st.markdown(html, unsafe_allow_html=True)
|
| 935 |
else:
|
| 936 |
st.info(f"๐ {T('chat_welcome',lang)}")
|
| 937 |
-
|
| 938 |
-
|
|
|
|
| 939 |
if to_process:
|
| 940 |
st.session_state.chat_history.append({'role':'user','content':to_process})
|
| 941 |
with st.spinner("๐ค ..."):
|
| 942 |
-
ai_resp,adjustments,_debug_err=get_chat_response(
|
| 943 |
-
to_process,st.session_state.chat_history,
|
| 944 |
-
|
| 945 |
rag_index=rag_index)
|
| 946 |
-
|
| 947 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 948 |
st.session_state.chat_history.append({'role':'assistant','content':ai_resp})
|
| 949 |
-
adj_map={
|
| 950 |
-
|
|
|
|
|
|
|
|
|
|
| 951 |
if adjustments:
|
| 952 |
-
for field,val in adjustments.items():
|
| 953 |
-
if field in adj_map: st.session_state[adj_map[field]]=val
|
| 954 |
st.success(f"๐ก {T('chat_updated',lang)}")
|
| 955 |
st.rerun()
|
| 956 |
if st.session_state.chat_history:
|
| 957 |
if st.button(T('chat_clear',lang)):
|
| 958 |
-
st.session_state.chat_history=[]; st.rerun()
|
| 959 |
|
| 960 |
# โโ TAB 4: What-If โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 961 |
with t4:
|
| 962 |
@st.fragment
|
| 963 |
def render_whatif():
|
| 964 |
-
_lang=st.session_state.get("lang_sel","id")
|
| 965 |
-
result_f=
|
|
|
|
| 966 |
if result_f is None or raw_input_f is None:
|
| 967 |
st.info(T('wi_form_first',_lang)); return
|
| 968 |
st.markdown(f'<div class="sec-title">{T("wi_title",_lang)}</div>', unsafe_allow_html=True)
|
|
@@ -973,44 +1129,51 @@ with t4:
|
|
| 973 |
d_cash = int((st.session_state.wi_cash or raw_input_f.get('monthly_cash_flow',15e6))//1e6)
|
| 974 |
d_dur = int(st.session_state.wi_dur or raw_input_f.get('duration',24))
|
| 975 |
d_loan = int((st.session_state.wi_loan or raw_input_f.get('loan_rp',50e6))//1e6)
|
| 976 |
-
wc1,wc2=st.columns(2)
|
| 977 |
with wc1:
|
| 978 |
-
wi_loan=st.slider(T('wi_loan',_lang),5,500,d_loan,5)
|
| 979 |
-
wi_dur
|
| 980 |
-
wi_cash=st.slider(T('wi_cashflow',_lang),1,100,d_cash)
|
| 981 |
with wc2:
|
| 982 |
-
wi_dig
|
| 983 |
-
wi_biz
|
| 984 |
-
wi_emp
|
| 985 |
try:
|
| 986 |
-
wi_raw={
|
| 987 |
-
|
| 988 |
-
|
| 989 |
-
|
| 990 |
-
|
| 991 |
-
|
| 992 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 993 |
with r1: st.metric(T('kpi_pd',_lang),f"{wi_pd*100:.1f}%",f"{d_pd*100:+.1f}pp",delta_color="inverse")
|
| 994 |
with r2: st.metric(T('kpi_el',_lang),f"Rp {wi_res['el']/1e6:.2f}jt",f"Rp {d_el/1e6:+.2f}jt",delta_color="inverse")
|
| 995 |
with r3:
|
| 996 |
-
icon_m={'risk-low':'๐ข','risk-med':'๐ก','risk-high':'๐ด'}
|
| 997 |
-
label_m={'risk-low':T('wi_approved',_lang),'risk-med':T('wi_review',_lang),'risk-high':T('wi_highrisk',_lang)}
|
| 998 |
st.metric(T('wi_status',_lang),f"{icon_m[wi_res['css']]} {label_m[wi_res['css']]}")
|
| 999 |
-
fig2,ax=plt.subplots(figsize=(7,2.5))
|
| 1000 |
-
bars=ax.barh(['Original','What-If'],[result_f['pd']*100,wi_pd*100],
|
| 1001 |
-
|
| 1002 |
ax.axvline(20,color='#f7971e',ls='--',lw=1.5,label='20%')
|
| 1003 |
ax.axvline(50,color='#e74c3c',ls='--',lw=1.5,label='50%')
|
| 1004 |
for bar,v in zip(bars,[result_f['pd']*100,wi_pd*100]):
|
| 1005 |
-
ax.text(bar.get_width()+0.5,bar.get_y()+bar.get_height()/2,
|
|
|
|
| 1006 |
ax.set_xlim(0,110); ax.legend(fontsize=8); ax.grid(axis='x',alpha=0.3)
|
| 1007 |
ax.set_title(T('wi_chart_title',_lang),fontweight='bold')
|
| 1008 |
plt.tight_layout()
|
| 1009 |
-
buf2=io.BytesIO(); fig2.savefig(buf2,format='png',dpi=130,bbox_inches='tight')
|
| 1010 |
plt.close(fig2); buf2.seek(0); st.image(buf2.read(),use_container_width=True)
|
| 1011 |
-
if wi_pd<result_f['pd']-0.005:
|
| 1012 |
st.success(f"โ
PD {T('wi_pd_down',_lang)} **{(result_f['pd']-wi_pd)*100:.1f}pp** ({T('wi_pd_from',_lang)} {result_f['pd']*100:.1f}% โ {wi_pd*100:.1f}%)")
|
| 1013 |
-
elif wi_pd>result_f['pd']+0.005:
|
| 1014 |
st.warning(f"โ ๏ธ PD {T('wi_pd_up',_lang)} **{(wi_pd-result_f['pd'])*100:.1f}pp** ({T('wi_pd_from',_lang)} {result_f['pd']*100:.1f}% โ {wi_pd*100:.1f}%)")
|
| 1015 |
else:
|
| 1016 |
st.info(f"โ๏ธ {T('wi_no_change',_lang)}")
|
|
@@ -1023,7 +1186,8 @@ with t4:
|
|
| 1023 |
if wi_emp<5: tips.append(_t("๐ฅ Tambah karyawan = skala bisnis lebih sehat","๐ฅ More employees signals healthy scale","๐ฅ เค
เคงเคฟเค เคเคฐเฅเคฎเคเคพเคฐเฅ = เคธเฅเคตเคธเฅเคฅ เคชเฅเคฎเคพเคจเคพ"))
|
| 1024 |
if not tips: tips.append(_t("๐ Profil sudah optimal!","๐ Profile already well-optimized!","๐ เคชเฅเคฐเฅเคซเคผเคพเคเคฒ เคชเคนเคฒเฅ เคธเฅ เค
เคจเฅเคเฅเคฒเคฟเคค!"))
|
| 1025 |
for tip in tips: st.markdown(tip)
|
| 1026 |
-
except Exception as e:
|
|
|
|
| 1027 |
render_whatif()
|
| 1028 |
|
| 1029 |
# โโ TAB 5: Formula โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
@@ -1031,15 +1195,17 @@ with t5:
|
|
| 1031 |
st.markdown(f'<div class="sec-title">{T("form_formula_title",lang)}</div>', unsafe_allow_html=True)
|
| 1032 |
st.latex(r"EL = PD \times LGD \times EAD")
|
| 1033 |
st.latex(rf"EL = {result['pd']:.4f} \times {result['lgd']:.2f} \times Rp\,{result['ead']:,.0f} = Rp\,{result['el']:,.0f}")
|
| 1034 |
-
cf1,cf2=st.columns(2)
|
| 1035 |
with cf1: st.markdown(T('formula_def',lang))
|
| 1036 |
with cf2:
|
| 1037 |
-
cat_lbl=(T('formula_low',lang) if result['pd']<.2 else T('formula_medium',lang) if result['pd']<.5 else T('formula_high',lang))
|
| 1038 |
-
st.markdown(
|
| 1039 |
-
|
| 1040 |
-
|
| 1041 |
-
|
| 1042 |
-
|
|
|
|
|
|
|
| 1043 |
|
| 1044 |
# ============================================================
|
| 1045 |
# DOWNLOAD REPORT
|
|
|
|
| 255 |
""", unsafe_allow_html=True)
|
| 256 |
|
| 257 |
# ============================================================
|
| 258 |
+
# HELPER: Baca secret dari HF Secrets
|
| 259 |
# ============================================================
|
| 260 |
def _read_secret(name: str) -> str:
|
| 261 |
try:
|
|
|
|
| 266 |
pass
|
| 267 |
return os.environ.get(name, "").strip()
|
| 268 |
|
| 269 |
+
|
| 270 |
+
def _init_api_keys():
|
| 271 |
+
"""
|
| 272 |
+
Load API keys sekali ke session_state saat startup.
|
| 273 |
+
Ini memastikan keys tetap tersedia di semua Streamlit reruns,
|
| 274 |
+
termasuk di dalam @st.fragment (What-If tab) yang punya scope berbeda.
|
| 275 |
+
"""
|
| 276 |
+
if "api_keys_loaded" not in st.session_state:
|
| 277 |
+
st.session_state.openrouter_key = _read_secret("OPENROUTER_API_KEY")
|
| 278 |
+
st.session_state.gemini_key = _read_secret("GEMINI_API_KEY")
|
| 279 |
+
st.session_state.groq_key = _read_secret("GROQ_API_KEY")
|
| 280 |
+
st.session_state.api_keys_loaded = True
|
| 281 |
+
|
| 282 |
+
_init_api_keys()
|
| 283 |
+
|
| 284 |
# ============================================================
|
| 285 |
# SIDEBAR
|
| 286 |
# ============================================================
|
|
|
|
| 297 |
)
|
| 298 |
st.markdown("---")
|
| 299 |
|
| 300 |
+
# Ambil dari session_state (sudah diinisialisasi di _init_api_keys)
|
| 301 |
+
openrouter_key = st.session_state.openrouter_key
|
| 302 |
+
gemini_key = st.session_state.gemini_key
|
| 303 |
+
groq_key = st.session_state.groq_key
|
| 304 |
|
|
|
|
| 305 |
st.caption(
|
| 306 |
f"๐ **OpenRouter:** {'โ
' if openrouter_key else 'โ'} "
|
| 307 |
f"**Gemini:** {'โ
' if gemini_key else 'โ'} "
|
| 308 |
f"**Groq:** {'โ
' if groq_key else 'โ'}"
|
| 309 |
)
|
| 310 |
if not any([openrouter_key, gemini_key, groq_key]):
|
| 311 |
+
st.warning("โ ๏ธ Tidak ada API key terdeteksi. Chat menggunakan Smart Fallback.")
|
| 312 |
|
| 313 |
st.markdown("---")
|
| 314 |
st.markdown(T('sidebar_stack', lang))
|
|
|
|
| 343 |
def load_rag_index():
|
| 344 |
try:
|
| 345 |
from rag import load_index
|
| 346 |
+
return load_index(kb_path="knowledge_base", index_path="/tmp/kb_index.pkl")
|
| 347 |
except ImportError:
|
| 348 |
st.sidebar.warning("โ ๏ธ rag.py not found. RAG disabled.")
|
| 349 |
return None
|
|
|
|
| 402 |
|
| 403 |
def shap_summary(vals, names, n=5):
|
| 404 |
idx = np.argsort(np.abs(vals))[-n:][::-1]
|
| 405 |
+
return '\n'.join([
|
| 406 |
+
f"- {names[i]}: {'increases' if vals[i]>0 else 'decreases'} risk (SHAP={vals[i]:.3f})"
|
| 407 |
+
for i in idx
|
| 408 |
+
])
|
| 409 |
|
| 410 |
def make_shap_png(shap_vals, feature_names):
|
| 411 |
sv = np.array(shap_vals); names = list(feature_names); n = min(12,len(sv))
|
|
|
|
| 420 |
buf = io.BytesIO(); fig.savefig(buf, format='png', dpi=130, bbox_inches='tight')
|
| 421 |
plt.close(fig); buf.seek(0); return buf.read()
|
| 422 |
|
| 423 |
+
# ============================================================
|
| 424 |
+
# LLM HELPERS โ OpenRouter dengan free model cascade
|
| 425 |
+
# ============================================================
|
| 426 |
+
|
| 427 |
+
# Model gratis OpenRouter, dicoba berurutan
|
| 428 |
+
_OR_FREE_MODELS = [
|
| 429 |
+
"meta-llama/llama-3.1-8b-instruct:free",
|
| 430 |
+
"google/gemini-2.0-flash-exp:free",
|
| 431 |
+
"mistralai/mistral-7b-instruct:free",
|
| 432 |
+
]
|
| 433 |
+
|
| 434 |
+
def call_openrouter(messages, api_key, model=None):
|
| 435 |
+
"""Coba free models berurutan sampai ada yang berhasil."""
|
| 436 |
+
if not api_key:
|
| 437 |
+
return None, "No API key"
|
| 438 |
+
models_to_try = [model] if model else _OR_FREE_MODELS
|
| 439 |
+
last_error = None
|
| 440 |
+
for m in models_to_try:
|
| 441 |
+
try:
|
| 442 |
+
resp = requests.post(
|
| 443 |
+
"https://openrouter.ai/api/v1/chat/completions",
|
| 444 |
+
headers={
|
| 445 |
+
"Authorization": f"Bearer {api_key}",
|
| 446 |
+
"Content-Type": "application/json",
|
| 447 |
+
"HTTP-Referer": "https://huggingface.co",
|
| 448 |
+
"X-Title": "SME Credit Risk AI",
|
| 449 |
+
},
|
| 450 |
+
json={"model": m, "messages": messages, "max_tokens": 600, "temperature": 0.75},
|
| 451 |
+
timeout=25,
|
| 452 |
+
)
|
| 453 |
+
if resp.status_code == 200:
|
| 454 |
+
return resp.json()['choices'][0]['message']['content'], None
|
| 455 |
+
last_error = f"HTTP {resp.status_code} ({m}): {resp.text[:150]}"
|
| 456 |
+
except requests.exceptions.Timeout:
|
| 457 |
+
last_error = f"Timeout ({m})"
|
| 458 |
+
except Exception as e:
|
| 459 |
+
last_error = f"{m}: {str(e)[:100]}"
|
| 460 |
+
return None, last_error
|
| 461 |
+
|
| 462 |
+
def _call_llm(messages, prompt_flat=None):
|
| 463 |
+
"""
|
| 464 |
+
Coba semua LLM dari session_state. Return (response_text, source_label, error).
|
| 465 |
+
messages = list[dict] format OpenAI
|
| 466 |
+
prompt_flat = string untuk Gemini (gabungan system+history)
|
| 467 |
+
"""
|
| 468 |
+
_or = st.session_state.get("openrouter_key", "")
|
| 469 |
+
_gem = st.session_state.get("gemini_key", "")
|
| 470 |
+
_grq = st.session_state.get("groq_key", "")
|
| 471 |
+
|
| 472 |
+
if _or:
|
| 473 |
+
r, err = call_openrouter(messages, _or)
|
| 474 |
+
if r: return r, f'๐ค OpenRouter ({_OR_FREE_MODELS[0].split("/")[-1]})', None
|
| 475 |
+
else:
|
| 476 |
+
err = None
|
| 477 |
+
|
| 478 |
+
if _gem:
|
| 479 |
+
try:
|
| 480 |
+
import google.generativeai as genai
|
| 481 |
+
genai.configure(api_key=_gem)
|
| 482 |
+
content = prompt_flat or "\n".join([
|
| 483 |
+
f"{'User' if m['role']=='user' else 'Assistant'}: {m['content']}"
|
| 484 |
+
for m in messages if m['role'] != 'system'
|
| 485 |
+
])
|
| 486 |
+
r = genai.GenerativeModel('gemini-1.5-flash').generate_content(content)
|
| 487 |
+
return r.text, '๐ค Gemini Flash', None
|
| 488 |
+
except Exception as e:
|
| 489 |
+
err = f"Gemini: {str(e)[:100]}"
|
| 490 |
+
|
| 491 |
+
if _grq:
|
| 492 |
+
try:
|
| 493 |
+
from groq import Groq
|
| 494 |
+
r = Groq(api_key=_grq).chat.completions.create(
|
| 495 |
+
messages=messages, model='llama3-8b-8192', max_tokens=500)
|
| 496 |
+
return r.choices[0].message.content, '๐ค Groq (Llama3)', None
|
| 497 |
+
except Exception as e:
|
| 498 |
+
err = f"Groq: {str(e)[:100]}"
|
| 499 |
+
|
| 500 |
+
return None, None, err
|
| 501 |
|
| 502 |
# ============================================================
|
| 503 |
+
# NARRATIVE GENERATION
|
| 504 |
# ============================================================
|
| 505 |
+
def get_narrative(shap_vals, feature_names, result, lang, raw_input):
|
| 506 |
+
pd_pct = result['pd'] * 100
|
|
|
|
| 507 |
factors = shap_summary(shap_vals, feature_names, 5)
|
| 508 |
+
lang_str = {'id': 'Bahasa Indonesia profesional', 'en': 'Professional English', 'hi': 'Hindi'}
|
| 509 |
prompt = (
|
| 510 |
f"You are a senior credit analyst for Indonesian SME lending.\n"
|
| 511 |
+
f"Reply ONLY in {lang_str.get(lang, 'English')}.\n"
|
| 512 |
f"Applicant: PD={pd_pct:.1f}%, EL=Rp {result['el']:,.0f}, "
|
| 513 |
f"BizAge={raw_input.get('business_age_years')}yr, Employees={raw_input.get('num_employees')}, "
|
| 514 |
f"DigitalScore={raw_input.get('digital_presence_score')}/100, "
|
|
|
|
| 522 |
f"4) {T('n_recommendations',lang)} (2-3 specific steps). "
|
| 523 |
f"Be data-driven and reference actual values."
|
| 524 |
)
|
| 525 |
+
msgs = [{"role": "user", "content": prompt}]
|
| 526 |
+
r, src, _ = _call_llm(msgs, prompt_flat=prompt)
|
| 527 |
+
if r: return r, src
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 528 |
|
| 529 |
+
# โโ Static fallback โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 530 |
biz=raw_input.get('business_age_years',0); emp=raw_input.get('num_employees',0)
|
| 531 |
dig=raw_input.get('digital_presence_score',0); cf=raw_input.get('monthly_cash_flow',0)
|
| 532 |
npwp=raw_input.get('has_npwp',0); siup=raw_input.get('has_siup',0)
|
|
|
|
| 623 |
# ============================================================
|
| 624 |
# AI CHAT โ Maya persona, dengan RAG
|
| 625 |
# ============================================================
|
| 626 |
+
def get_chat_response(user_msg, history, result, raw_input, shap_vals, feature_names, lang, rag_index=None):
|
| 627 |
+
pd_pct = result['pd'] * 100
|
|
|
|
|
|
|
| 628 |
factors = shap_summary(shap_vals, feature_names, 5)
|
| 629 |
+
lang_full = {'id': 'Bahasa Indonesia', 'en': 'English', 'hi': 'Hindi'}
|
| 630 |
|
| 631 |
# RAG context
|
| 632 |
context = ""
|
|
|
|
| 639 |
except Exception:
|
| 640 |
context = ""
|
| 641 |
|
|
|
|
| 642 |
system = (
|
| 643 |
f"Kamu adalah Maya, AI Credit Advisor yang cerdas, hangat, dan sedikit kasual untuk UMKM Indonesia.\n"
|
| 644 |
f"Kepribadian: ramah tapi profesional, suka pakai analogi sederhana, TIDAK kaku, bisa ngobrol bebas.\n"
|
|
|
|
| 656 |
f"{context}\n"
|
| 657 |
f"ATURAN MENJAWAB:\n"
|
| 658 |
f"1. Jawab BEBAS โ tidak harus soal kredit. Kalau ditanya soal diri sendiri, perkenalkan sebagai Maya.\n"
|
| 659 |
+
f"2. Kalau relevan, selalu hubungkan ke data pemohon dengan menyebut angka aktualnya.\n"
|
| 660 |
f"3. Berikan saran KONKRET dan SPESIFIK, bukan generik.\n"
|
| 661 |
f"4. Maksimal 150 kata kecuali diminta lebih panjang.\n"
|
| 662 |
f"5. Kalau ada saran slider: [ADJUST: field=value]\n"
|
|
|
|
| 665 |
f"6. JANGAN pernah balik ke template. Jawab seperti manusia cerdas.\n"
|
| 666 |
)
|
| 667 |
|
| 668 |
+
messages = [{"role": "system", "content": system}]
|
| 669 |
+
for m in history[-8:]: messages.append({"role": m['role'], "content": m['content']})
|
| 670 |
+
messages.append({"role": "user", "content": user_msg})
|
| 671 |
|
| 672 |
+
# Build flat prompt untuk Gemini
|
| 673 |
+
flat = system + "\n\n" + "\n".join([
|
| 674 |
+
f"{'User' if m['role']=='user' else 'Assistant'}: {m['content']}"
|
| 675 |
+
for m in messages[1:]
|
| 676 |
+
])
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 677 |
|
| 678 |
+
response, _src, _last_error = _call_llm(messages, prompt_flat=flat)
|
| 679 |
+
|
| 680 |
+
# โโ Smart Keyword Fallback (hanya jika semua LLM gagal) โโ
|
| 681 |
if response is None:
|
| 682 |
low = user_msg.lower()
|
| 683 |
def _t(i,e,h): return {'id':i,'en':e,'hi':h}[lang]
|
|
|
|
| 686 |
npwp = raw_input.get('has_npwp', 0)
|
| 687 |
biz = raw_input.get('business_age_years', 0)
|
| 688 |
|
| 689 |
+
# Salam / perkenalan / identitas
|
| 690 |
+
if any(w in low for w in ['siapa','kamu','km','who are you','nama','you are','maya','halo','hai','hei','hello','hi','perkenalan']):
|
| 691 |
response = _t(
|
| 692 |
f"Hei! Aku **Maya**, AI Credit Advisor kamu ๐ Aku dirancang untuk bantu kamu pahami skor kredit dan strategi bisnis UMKM. "
|
| 693 |
f"Skor PD kamu sekarang **{pd_pct:.1f}%** โ {'sudah bagus banget! ๐' if pd_pct<20 else 'masih ada ruang untuk diperbaiki.'} "
|
| 694 |
f"Mau aku jelasin lebih detail atau ada yang mau ditanyain?",
|
| 695 |
+
f"Hey! I'm **Maya**, your AI Credit Advisor ๐ I help you understand your credit score and SME business strategy. "
|
| 696 |
f"Your PD score is **{pd_pct:.1f}%** โ {'looking great! ๐' if pd_pct<20 else 'there is room to improve.'} "
|
| 697 |
+
f"Anything you want to ask?",
|
| 698 |
f"เคจเคฎเคธเฅเคคเฅ! เคฎเฅเค **Maya** เคนเฅเค, เคเคชเคเฅ AI Credit Advisor ๐ "
|
| 699 |
f"เคเคชเคเคพ PD เคธเฅเคเฅเคฐ **{pd_pct:.1f}%** เคนเฅเฅค เคเฅเค เคชเฅเคเคจเคพ เคนเฅ?"
|
| 700 |
)
|
| 701 |
|
| 702 |
# Cara improve / turunkan skor
|
| 703 |
+
elif any(w in low for w in ['improve','better','lower','reduce','tingkatkan','kurangi','turunkan','cara','gimana','bagaimana','naik','turun','เคเฅเคธเฅ','เคเคฎ','เคธเฅเคงเคพเคฐ','meningkat','naikkan','optimalkan']):
|
| 704 |
tips = []
|
| 705 |
if not npwp:
|
| 706 |
+
tips.append(_t("1. **Urus NPWP** โ wajib untuk pinjaman formal, daftar di pajak.go.id",
|
| 707 |
"1. **Register NPWP** โ required for formal loans, apply at pajak.go.id",
|
| 708 |
"1. **NPWP เคชเคเคเฅเคเฅเคค เคเคฐเฅเค** โ เคเคชเคเคพเคฐเคฟเค เคเคฃ เคเฅ เคฒเคฟเค เค
เคจเคฟเคตเคพเคฐเฅเคฏ"))
|
| 709 |
if dig < 75:
|
| 710 |
+
n = '2' if not npwp else '1'
|
| 711 |
+
tips.append(_t(f"{n}. **Naikkan Digital Score** dari {dig} ke 75+ โ aktif di Google Business, Tokopedia/Shopee [ADJUST: digital_presence_score=75]",
|
| 712 |
+
f"{n}. **Raise Digital Score** from {dig} to 75+ โ Google Business, marketplace [ADJUST: digital_presence_score=75]",
|
| 713 |
+
f"{n}. **เคกเคฟเคเคฟเคเคฒ เคธเฅเคเฅเคฐ** {dig} เคธเฅ 75+ เคเคฐเฅเค [ADJUST: digital_presence_score=75]"))
|
| 714 |
if cf < 25e6:
|
| 715 |
+
n = str(len(tips)+1)
|
| 716 |
+
tips.append(_t(f"{n}. **Optimalkan cash flow** dari Rp {cf/1e6:.0f}jt ke 25jt+/bln โ diversifikasi produk [ADJUST: monthly_cash_flow=25000000]",
|
| 717 |
+
f"{n}. **Grow cash flow** from Rp {cf/1e6:.0f}M to 25M+/month [ADJUST: monthly_cash_flow=25000000]",
|
| 718 |
+
f"{n}. **เคจเคเคฆ เคชเฅเคฐเคตเคพเคน** Rp {cf/1e6:.0f}M เคธเฅ 25M+ เคเคฐเฅเค [ADJUST: monthly_cash_flow=25000000]"))
|
| 719 |
if not tips:
|
| 720 |
+
tips.append(_t(f"Profil kamu sudah solid dengan PD {pd_pct:.1f}%! ๐ Coba simulasikan di tab What-If.",
|
| 721 |
+
f"Your profile is solid at PD {pd_pct:.1f}%! ๐ Try the What-If tab.",
|
| 722 |
+
f"เคชเฅเคฐเฅเคซเคผเคพเคเคฒ PD {pd_pct:.1f}% เคเฅ เคธเคพเคฅ เคฎเคเคฌเฅเคค เคนเฅ! ๐"))
|
| 723 |
+
response = (_t(f"Untuk turunkan PD dari **{pd_pct:.1f}%**, fokus ke:\n\n",
|
| 724 |
+
f"To lower PD from **{pd_pct:.1f}%**, focus on:\n\n",
|
| 725 |
+
f"PD **{pd_pct:.1f}%** เคเคฎ เคเคฐเคจเฅ เคเฅ เคฒเคฟเค:\n\n") + '\n'.join(tips[:3]))
|
| 726 |
+
|
| 727 |
+
# Kenapa / SHAP / jelasin faktor / analogi
|
| 728 |
+
elif any(w in low for w in ['why','kenapa','mengapa','factor','faktor','shap','pengaruh','driver','เคเฅเคฏเฅเค','เคเคพเคฐเคฃ','apa yang','jelasin','jelaskan','explain','definisi','definisikan','artinya','maksud','arti','anak','sederhana','simple','5 tahun','umur 5']):
|
| 729 |
ti = int(np.argsort(np.abs(shap_vals))[-1])
|
| 730 |
ti2 = int(np.argsort(np.abs(shap_vals))[-2])
|
| 731 |
+
fn = feature_names[ti]
|
| 732 |
+
# Analogi sederhana
|
| 733 |
+
if 'business_age' in fn:
|
| 734 |
+
analogi = _t(" โ ibarat pengalaman kerja, makin lama makin dipercaya",
|
| 735 |
+
" โ like work experience, longer = more trustworthy",
|
| 736 |
+
" โ เค
เคจเฅเคญเคต เคเฅ เคคเคฐเคน, เคฒเคเคฌเคพ = เค
เคงเคฟเค เคญเคฐเฅเคธเฅเคฎเคเคฆ")
|
| 737 |
+
elif 'cash_flow' in fn:
|
| 738 |
+
analogi = _t(" โ ibarat gaji bulanan kamu, makin besar makin mudah bayar cicilan",
|
| 739 |
+
" โ like your monthly income, higher = easier to repay",
|
| 740 |
+
" โ เคฎเคพเคธเคฟเค เคเคฏ เคเฅ เคคเคฐเคน, เค
เคงเคฟเค = เคเคฟเคธเฅเคค เคญเคฐเคจเคพ เคเคธเคพเคจ")
|
| 741 |
+
elif 'digital' in fn:
|
| 742 |
+
analogi = _t(" โ ibarat 'nilai reputasi online' bisnis kamu di mata bank",
|
| 743 |
+
" โ your business's 'online reputation score' in the bank's eyes",
|
| 744 |
+
" โ เคฌเฅเคเค เคเฅ เคจเคเคฐ เคฎเฅเค เคตเฅเคฏเคตเคธเคพเคฏ เคเฅ เคเคจเคฒเคพเคเคจ เคชเฅเคฐเคคเคฟเคทเฅเค เคพ")
|
| 745 |
+
else:
|
| 746 |
+
analogi = ""
|
| 747 |
response = _t(
|
| 748 |
f"Dua faktor terbesar yang drive skor PD **{pd_pct:.1f}%** kamu:\n\n"
|
| 749 |
+
f"1. **{fn}**{analogi}\n โ {'โฌ meningkatkan' if shap_vals[ti]>0 else 'โฌ menurunkan'} risiko (SHAP={shap_vals[ti]:.3f})\n\n"
|
| 750 |
+
f"2. **{feature_names[ti2]}**\n โ {'โฌ meningkatkan' if shap_vals[ti2]>0 else 'โฌ menurunkan'} risiko (SHAP={shap_vals[ti2]:.3f})\n\n"
|
| 751 |
f"{'Skor bagus! Kedua faktor ini justru mendukung kelayakan kamu. ๐' if pd_pct<20 else 'Fokus perbaiki faktor pertama dulu โ dampaknya paling besar.'}",
|
| 752 |
f"Two biggest drivers of your PD **{pd_pct:.1f}%**:\n\n"
|
| 753 |
+
f"1. **{fn}**{analogi}\n โ {'โฌ increases' if shap_vals[ti]>0 else 'โฌ decreases'} risk (SHAP={shap_vals[ti]:.3f})\n\n"
|
| 754 |
+
f"2. **{feature_names[ti2]}**\n โ {'โฌ increases' if shap_vals[ti2]>0 else 'โฌ decreases'} risk (SHAP={shap_vals[ti2]:.3f})\n\n"
|
| 755 |
+
f"{'Great score! Both factors support your eligibility. ๐' if pd_pct<20 else 'Focus on the first factor โ it has the biggest impact.'}",
|
| 756 |
f"PD **{pd_pct:.1f}%** เคเฅ เคฆเฅ เคฎเฅเคเฅเคฏ เคเคพเคฐเค:\n\n"
|
| 757 |
+
f"1. **{fn}**{analogi} โ {'โฌ เคฌเคขเคผเคพเคคเคพ' if shap_vals[ti]>0 else 'โฌ เคเคเคพเคคเคพ'} เคเฅเคเคฟเคฎ\n"
|
| 758 |
f"2. **{feature_names[ti2]}** โ {'โฌ เคฌเคขเคผเคพเคคเคพ' if shap_vals[ti2]>0 else 'โฌ เคเคเคพเคคเคพ'} เคเฅเคเคฟเคฎ"
|
| 759 |
)
|
| 760 |
|
| 761 |
# Pinjaman ideal
|
| 762 |
+
elif any(w in low for w in ['pinjaman','loan','kredit','berapa','amount','besar','limit','เคเคฃ','เคเคฟเคคเคจเคพ','ideal','rekomendasi pinjaman']):
|
| 763 |
if cf > 0:
|
| 764 |
dur_val = raw_input.get('duration', 24)
|
| 765 |
safe = cf * dur_val * 0.35
|
|
|
|
| 769 |
f"pinjaman ideal maksimal **Rp {safe/1e6:.0f}jt** (rasio cicilan 35%).\n\n"
|
| 770 |
f"Pinjaman kamu sekarang Rp {cur/1e6:.0f}jt โ "
|
| 771 |
f"{'โ
masih aman, good job!' if cur<=safe else f'โ ๏ธ melebihi batas aman. Pertimbangkan kurangi ke Rp {safe/1e6:.0f}jt atau perpanjang tenor.'}",
|
| 772 |
+
f"Based on your cash flow **Rp {cf/1e6:.0f}M/month** and {int(dur_val)}-month tenure, "
|
| 773 |
f"ideal loan is max **Rp {safe/1e6:.0f}M** (35% installment ratio).\n\n"
|
| 774 |
f"Current loan Rp {cur/1e6:.0f}M โ "
|
| 775 |
+
f"{'โ
within safe limits, good job!' if cur<=safe else f'โ ๏ธ exceeds safe limit. Consider reducing to Rp {safe/1e6:.0f}M.'}",
|
| 776 |
+
f"เคจเคเคฆ เคชเฅเคฐเคตเคพเคน **Rp {cf/1e6:.0f}M/เคฎเคพเคน** เคเคฐ {int(dur_val)} เคฎเคพเคน เคเฅ เคเคงเคพเคฐ เคชเคฐ เค
เคงเคฟเคเคคเคฎ เคเคฃ **Rp {safe/1e6:.0f}M**เฅค\n\n"
|
| 777 |
+
f"เคตเคฐเฅเคคเคฎเคพเคจ เคเคฃ Rp {cur/1e6:.0f}M โ {'โ
เคธเฅเคฐเคเฅเคทเคฟเคคเฅค' if cur<=safe else 'โ ๏ธ เคธเฅเคฎเคพ เคธเฅ เค
เคงเคฟเคเฅค'}"
|
|
|
|
|
|
|
| 778 |
)
|
| 779 |
else:
|
| 780 |
+
response = _t("Isi cash flow bulanan di form dulu ya, biar aku bisa kasih rekomendasi akurat! ๐",
|
| 781 |
+
"Fill in your monthly cash flow first for an accurate recommendation! ๐",
|
| 782 |
"เคธเคเฅเค เคธเฅเคเคพเคต เคเฅ เคฒเคฟเค เคชเคนเคฒเฅ เคฎเคพเคธเคฟเค เคจเคเคฆ เคชเฅเคฐเคตเคพเคน เคญเคฐเฅเค! ๐")
|
| 783 |
|
| 784 |
+
# Tabungan / menabung / saving
|
| 785 |
+
elif any(w in low for w in ['tabungan','saving','nabung','menabung','savings','uang','keuangan','finance','financial','menabung','simpan']):
|
| 786 |
+
response = _t(
|
| 787 |
+
f"Soal tabungan dan cash flow kamu (saat ini **Rp {cf/1e6:.0f}jt/bln**):\n\n"
|
| 788 |
+
f"๐ฐ **3 cara praktis tingkatkan tabungan UMKM:**\n"
|
| 789 |
+
f"1. Pisahkan rekening bisnis & pribadi โ biar tidak tercampur\n"
|
| 790 |
+
f"2. Sisihkan minimal 10โ15% dari omzet setiap bulan secara otomatis\n"
|
| 791 |
+
f"3. Dokumentasikan semua pemasukan โ ini juga naikkan digital score!\n\n"
|
| 792 |
+
f"Tabungan naik โ cash flow lebih sehat โ PD turun ๐ [ADJUST: monthly_cash_flow={int(cf*1.3)}]",
|
| 793 |
+
f"About savings and your cash flow (currently **Rp {cf/1e6:.0f}M/month**):\n\n"
|
| 794 |
+
f"๐ฐ **3 practical SME savings tips:**\n"
|
| 795 |
+
f"1. Separate business & personal bank accounts\n"
|
| 796 |
+
f"2. Auto-transfer 10โ15% of monthly revenue to savings\n"
|
| 797 |
+
f"3. Document all income streams โ this also boosts digital score!\n\n"
|
| 798 |
+
f"More savings โ healthier cash flow โ lower PD ๐ [ADJUST: monthly_cash_flow={int(cf*1.3)}]",
|
| 799 |
+
f"เคฌเคเคค เคเคฐ เคจเคเคฆ เคชเฅเคฐเคตเคพเคน (**Rp {cf/1e6:.0f}M/เคฎเคพเคน**) เคเฅ เคฌเคพเคฐเฅ เคฎเฅเค:\n\n"
|
| 800 |
+
f"๐ฐ **3 เคตเฅเคฏเคพเคตเคนเคพเคฐเคฟเค เคคเคฐเฅเคเฅ:**\n"
|
| 801 |
+
f"1. เคตเฅเคฏเคตเคธเคพเคฏ เคเคฐ เคตเฅเคฏเคเฅเคคเคฟเคเคค เคเคพเคคเฅ เค
เคฒเค เคฐเคเฅเค\n"
|
| 802 |
+
f"2. เคฎเคพเคธเคฟเค เคฐเคพเคเคธเฅเคต เคเคพ 10โ15% เคฌเคเคพเคเค\n"
|
| 803 |
+
f"3. เคธเคญเฅ เคเคฏ เคฆเคธเฅเคคเคพเคตเฅเคเคผเฅเคเฅเคค เคเคฐเฅเค\n\n"
|
| 804 |
+
f"เค
เคงเคฟเค เคฌเคเคค โ PD เคเคฎ ๐"
|
| 805 |
+
)
|
| 806 |
+
|
| 807 |
+
# Riwayat kredit / credit history
|
| 808 |
+
elif any(w in low for w in ['riwayat kredit','credit history','kredit history','credit record','riwayat','history kredit','riwayat pembayaran']):
|
| 809 |
+
ch_val = raw_input.get('credit_history', '-')
|
| 810 |
+
response = _t(
|
| 811 |
+
f"**Riwayat kredit** = catatan sejarah bayar utangmu โ ibarat rapor keuangan.\n\n"
|
| 812 |
+
f"Status kamu: **{ch_val}**\n\n"
|
| 813 |
+
f"๐ Dampak ke skor kredit:\n"
|
| 814 |
+
f"- *All paid / Existing paid* โ sinyal positif ๐\n"
|
| 815 |
+
f"- *Delayed previously* โ bank waspada โ ๏ธ\n"
|
| 816 |
+
f"- *Critical* โ risiko naik signifikan ๐ด\n\n"
|
| 817 |
+
f"Cara bangun riwayat bagus: bayar cicilan tepat waktu, jangan ambil pinjaman melebihi kemampuan bayar.",
|
| 818 |
+
f"**Credit history** = your track record of paying debts โ your financial report card.\n\n"
|
| 819 |
+
f"Your status: **{ch_val}**\n\n"
|
| 820 |
+
f"๐ Score impact:\n"
|
| 821 |
+
f"- *All paid / Existing paid* โ positive signal ๐\n"
|
| 822 |
+
f"- *Delayed previously* โ bank is cautious โ ๏ธ\n"
|
| 823 |
+
f"- *Critical* โ significant risk increase ๏ฟฝ๏ฟฝ๏ฟฝ๏ฟฝ\n\n"
|
| 824 |
+
f"Build good history: pay on time, don't borrow beyond your repayment capacity.",
|
| 825 |
+
f"**เคเฅเคฐเฅเคกเคฟเค เคเคคเคฟเคนเคพเคธ** = เคเคฃ เคญเฅเคเคคเคพเคจ เคเคพ เคฐเคฟเคเฅเคฐเฅเคกเฅค\n\nเคเคชเคเคพ เคธเฅเคคเคฐ: **{ch_val}**\n\nเคธเคฎเคฏ เคชเคฐ เคญเฅเคเคคเคพเคจ เคเคฐเฅเคเฅค"
|
| 826 |
+
)
|
| 827 |
+
|
| 828 |
+
# Tips bisnis UMKM
|
| 829 |
+
elif any(w in low for w in ['tips','bisnis','usaha','umkm','sme','digital','marketplace','online','ecommerce','strategi','business tips']):
|
| 830 |
+
response = _t(
|
| 831 |
+
f"๐ผ **Tips bisnis UMKM buat kamu (PD {pd_pct:.1f}%):**\n\n"
|
| 832 |
+
f"1. **Digital hadir** โ daftar Google Business Profile, aktif di Tokopedia/Shopee/TikTok Shop\n"
|
| 833 |
+
f" *(Digital score kamu {dig}/100 โ masih bisa naik!)*\n"
|
| 834 |
+
f"2. **Pisah keuangan** โ rekening bisnis terpisah dari pribadi\n"
|
| 835 |
+
f"3. **Dokumentasi rutin** โ catat semua transaksi, ini bukti ke bank\n"
|
| 836 |
+
f"4. **Legalitas** โ NPWP & SIUP/NIB buka akses ke KUR & kredit formal\n\n"
|
| 837 |
+
f"Mau aku simulasikan dampaknya ke skor di tab What-If?",
|
| 838 |
+
f"๐ผ **SME business tips for you (PD {pd_pct:.1f}%):**\n\n"
|
| 839 |
+
f"1. **Go digital** โ Google Business Profile, Tokopedia/Shopee/TikTok Shop\n"
|
| 840 |
+
f" *(Your digital score {dig}/100 โ room to improve!)*\n"
|
| 841 |
+
f"2. **Separate finances** โ dedicated business bank account\n"
|
| 842 |
+
f"3. **Document everything** โ track all transactions as proof for banks\n"
|
| 843 |
+
f"4. **Get legal** โ NPWP & SIUP/NIB unlock KUR & formal credit\n\n"
|
| 844 |
+
f"Want me to simulate the impact in the What-If tab?",
|
| 845 |
+
f"๐ผ **UMKM เคตเฅเคฏเคตเคธเคพเคฏ เคเคฟเคชเฅเคธ (PD {pd_pct:.1f}%):**\n\n"
|
| 846 |
+
f"1. เคกเคฟเคเคฟเคเคฒ เคเคชเคธเฅเคฅเคฟเคคเคฟ เคฌเคขเคผเคพเคเค โ Google Business, Marketplace\n"
|
| 847 |
+
f"2. เค
เคฒเค เคฌเฅเคเค เคเคพเคคเคพ เคฐเคเฅเค\n"
|
| 848 |
+
f"3. เคธเคญเฅ เคฒเฅเคจเคฆเฅเคจ เคฆเคธเฅเคคเคพเคตเฅเคเคผเฅเคเฅเคค เคเคฐเฅเค\n"
|
| 849 |
+
f"4. NPWP เคเคฐ SIUP/NIB เคชเฅเคฐเคพเคชเฅเคค เคเคฐเฅเค"
|
| 850 |
+
)
|
| 851 |
+
|
| 852 |
+
# Default: kontekstual, bukan template kaku
|
| 853 |
else:
|
| 854 |
top_issue = _get_top_issue(raw_input, pd_pct, lang)
|
| 855 |
+
has_key = any([
|
| 856 |
+
st.session_state.get("openrouter_key"),
|
| 857 |
+
st.session_state.get("gemini_key"),
|
| 858 |
+
st.session_state.get("groq_key"),
|
| 859 |
+
])
|
| 860 |
+
err_note = ""
|
| 861 |
+
if has_key and _last_error:
|
| 862 |
+
err_note = _t(f"\n\n_โ ๏ธ LLM error: `{_last_error[:80]}` โ pakai fallback_",
|
| 863 |
+
f"\n\n_โ ๏ธ LLM error: `{_last_error[:80]}` โ using fallback_",
|
| 864 |
+
f"\n\n_โ ๏ธ LLM เคคเฅเคฐเฅเคเคฟ: `{_last_error[:80]}`_")
|
| 865 |
response = _t(
|
| 866 |
+
f"Pertanyaan menarik! Untuk ini aku butuh koneksi AI.\n\n"
|
| 867 |
+
f"Yang bisa aku kasih tahu: PD kamu **{pd_pct:.1f}%** dan {top_issue}\n\n"
|
| 868 |
+
f"Coba tanya yang lebih spesifik: *cara turunkan skor*, *faktor risiko*, *pinjaman ideal*, *tips tabungan*, atau *riwayat kredit*.{err_note}",
|
| 869 |
+
f"Great question! For this I need an AI connection.\n\n"
|
| 870 |
f"What I can share: your PD is **{pd_pct:.1f}%** and {top_issue}\n\n"
|
| 871 |
+
f"Try asking specifically: *how to lower score*, *risk factors*, *ideal loan*, *savings tips*, or *credit history*.{err_note}",
|
| 872 |
+
f"เค
เคเฅเคเคพ เคชเฅเคฐเคถเฅเคจ! เคเคธเคเฅ เคฒเคฟเค AI เคเคจเฅเคเฅเคถเคจ เคเคพเคนเคฟเคเฅค\n\n"
|
| 873 |
f"เคเคชเคเคพ PD **{pd_pct:.1f}%** เคนเฅ เคเคฐ {top_issue}"
|
| 874 |
)
|
| 875 |
|
| 876 |
+
# Parse ADJUST instructions
|
| 877 |
adjustments = {}
|
| 878 |
for m in re.finditer(r'\[ADJUST:\s*(\w+)=([\d.]+)\]', response):
|
| 879 |
adjustments[m.group(1)] = float(m.group(2))
|
| 880 |
+
clean = re.sub(r'\[ADJUST:[^\]]+\]', '', response).strip()
|
| 881 |
+
return clean, adjustments, _last_error
|
| 882 |
|
| 883 |
# ============================================================
|
| 884 |
# HEADER
|
|
|
|
| 985 |
result = risk_result(pd_score, loan_rp, lgd_val)
|
| 986 |
sv = explainer(X_in)
|
| 987 |
shap_vals = sv[0].values
|
| 988 |
+
narrative, llm_src = get_narrative(shap_vals, feature_names, result, lang, raw)
|
|
|
|
| 989 |
st.session_state.result=result; st.session_state.shap_vals=shap_vals
|
| 990 |
st.session_state.raw_input=raw; st.session_state.narrative=narrative
|
| 991 |
st.session_state.llm_src=llm_src; st.session_state.narrative_lang=lang
|
|
|
|
| 1003 |
if result is None: st.stop()
|
| 1004 |
|
| 1005 |
# Auto-regenerate narrative if language switched
|
| 1006 |
+
if result is not None and raw_input is not None and st.session_state.narrative_lang != lang:
|
|
|
|
| 1007 |
with st.spinner(T('spinner', lang)):
|
| 1008 |
+
narrative, llm_src = get_narrative(shap_vals, feature_names, result, lang, raw_input)
|
|
|
|
|
|
|
| 1009 |
st.session_state.narrative = narrative
|
| 1010 |
st.session_state.llm_src = llm_src
|
| 1011 |
st.session_state.narrative_lang = lang
|
|
|
|
| 1043 |
# โโ TAB 1: SHAP โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 1044 |
with t1:
|
| 1045 |
st.markdown(f'<div class="sec-title">{T("shap_title",lang)}</div>', unsafe_allow_html=True)
|
| 1046 |
+
if st.session_state.get("shap_png"):
|
| 1047 |
+
st.image(st.session_state.shap_png, use_container_width=True)
|
| 1048 |
st.caption(T('shap_caption',lang))
|
| 1049 |
top_idx = np.argsort(np.abs(shap_vals))[-10:][::-1]
|
| 1050 |
st.dataframe(pd.DataFrame({
|
|
|
|
| 1065 |
with t3:
|
| 1066 |
st.markdown(f'<div class="sec-title">{T("chat_title",lang)}</div>', unsafe_allow_html=True)
|
| 1067 |
chips = [T('chat_chip1',lang),T('chat_chip2',lang),T('chat_chip3',lang),T('chat_chip4',lang)]
|
| 1068 |
+
chip_cols = st.columns(len(chips)); chip_clicked = None
|
| 1069 |
for i,(col,chip) in enumerate(zip(chip_cols,chips)):
|
| 1070 |
with col:
|
| 1071 |
+
if st.button(chip, key=f"chip_{i}", use_container_width=True): chip_clicked = chip
|
| 1072 |
st.markdown("---")
|
| 1073 |
if st.session_state.chat_history:
|
| 1074 |
html=""
|
| 1075 |
for msg in st.session_state.chat_history:
|
| 1076 |
+
content = re.sub(r'\*\*(.+?)\*\*',r'<strong>\1</strong>',msg['content'].replace('\n','<br>'))
|
| 1077 |
+
cls = "chat-bubble-user" if msg['role']=='user' else "chat-bubble-ai"
|
| 1078 |
+
icon = "๐ค" if msg['role']=='user' else "๐ค"
|
| 1079 |
+
html += f'<div class="{cls}">{icon} {content}</div>'
|
| 1080 |
st.markdown(html, unsafe_allow_html=True)
|
| 1081 |
else:
|
| 1082 |
st.info(f"๐ {T('chat_welcome',lang)}")
|
| 1083 |
+
|
| 1084 |
+
user_input = st.chat_input(T('chat_input',lang))
|
| 1085 |
+
to_process = chip_clicked or user_input
|
| 1086 |
if to_process:
|
| 1087 |
st.session_state.chat_history.append({'role':'user','content':to_process})
|
| 1088 |
with st.spinner("๐ค ..."):
|
| 1089 |
+
ai_resp, adjustments, _debug_err = get_chat_response(
|
| 1090 |
+
to_process, st.session_state.chat_history,
|
| 1091 |
+
result, raw_input, shap_vals, feature_names, lang,
|
| 1092 |
rag_index=rag_index)
|
| 1093 |
+
# Tampilkan warning hanya jika ada key tapi LLM tetap gagal
|
| 1094 |
+
if _debug_err and any([
|
| 1095 |
+
st.session_state.get("openrouter_key"),
|
| 1096 |
+
st.session_state.get("gemini_key"),
|
| 1097 |
+
st.session_state.get("groq_key"),
|
| 1098 |
+
]):
|
| 1099 |
+
st.warning(f"โ ๏ธ Semua LLM gagal, menggunakan Smart Fallback. Error: `{_debug_err[:100]}`")
|
| 1100 |
st.session_state.chat_history.append({'role':'assistant','content':ai_resp})
|
| 1101 |
+
adj_map = {
|
| 1102 |
+
'digital_presence_score':'wi_dig','business_age_years':'wi_biz',
|
| 1103 |
+
'num_employees':'wi_emp','monthly_cash_flow':'wi_cash',
|
| 1104 |
+
'duration':'wi_dur','loan_rp':'wi_loan'
|
| 1105 |
+
}
|
| 1106 |
if adjustments:
|
| 1107 |
+
for field, val in adjustments.items():
|
| 1108 |
+
if field in adj_map: st.session_state[adj_map[field]] = val
|
| 1109 |
st.success(f"๐ก {T('chat_updated',lang)}")
|
| 1110 |
st.rerun()
|
| 1111 |
if st.session_state.chat_history:
|
| 1112 |
if st.button(T('chat_clear',lang)):
|
| 1113 |
+
st.session_state.chat_history = []; st.rerun()
|
| 1114 |
|
| 1115 |
# โโ TAB 4: What-If โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
| 1116 |
with t4:
|
| 1117 |
@st.fragment
|
| 1118 |
def render_whatif():
|
| 1119 |
+
_lang = st.session_state.get("lang_sel","id")
|
| 1120 |
+
result_f = st.session_state.result
|
| 1121 |
+
raw_input_f = st.session_state.raw_input
|
| 1122 |
if result_f is None or raw_input_f is None:
|
| 1123 |
st.info(T('wi_form_first',_lang)); return
|
| 1124 |
st.markdown(f'<div class="sec-title">{T("wi_title",_lang)}</div>', unsafe_allow_html=True)
|
|
|
|
| 1129 |
d_cash = int((st.session_state.wi_cash or raw_input_f.get('monthly_cash_flow',15e6))//1e6)
|
| 1130 |
d_dur = int(st.session_state.wi_dur or raw_input_f.get('duration',24))
|
| 1131 |
d_loan = int((st.session_state.wi_loan or raw_input_f.get('loan_rp',50e6))//1e6)
|
| 1132 |
+
wc1,wc2 = st.columns(2)
|
| 1133 |
with wc1:
|
| 1134 |
+
wi_loan = st.slider(T('wi_loan',_lang),5,500,d_loan,5)
|
| 1135 |
+
wi_dur = st.slider(T('wi_duration',_lang),4,72,d_dur)
|
| 1136 |
+
wi_cash = st.slider(T('wi_cashflow',_lang),1,100,d_cash)
|
| 1137 |
with wc2:
|
| 1138 |
+
wi_dig = st.slider(T('wi_digital',_lang),1,100,d_dig)
|
| 1139 |
+
wi_biz = st.slider(T('wi_bizage',_lang),1,20,d_biz)
|
| 1140 |
+
wi_emp = st.slider(T('wi_employees',_lang),1,50,d_emp)
|
| 1141 |
try:
|
| 1142 |
+
wi_raw = {
|
| 1143 |
+
**raw_input_f,
|
| 1144 |
+
'duration':wi_dur,'digital_presence_score':wi_dig,
|
| 1145 |
+
'business_age_years':float(wi_biz),
|
| 1146 |
+
'monthly_cash_flow':float(wi_cash*1e6),
|
| 1147 |
+
'num_employees':wi_emp
|
| 1148 |
+
}
|
| 1149 |
+
X_wi = preprocess(wi_raw, scaler, feature_names)
|
| 1150 |
+
wi_pd = float(ensemble.predict_proba(X_wi)[0][1])
|
| 1151 |
+
wi_res = risk_result(wi_pd, wi_loan*1e6, result_f['lgd'])
|
| 1152 |
+
d_pd = wi_pd - result_f['pd']
|
| 1153 |
+
d_el = wi_res['el'] - result_f['el']
|
| 1154 |
+
r1,r2,r3 = st.columns(3)
|
| 1155 |
with r1: st.metric(T('kpi_pd',_lang),f"{wi_pd*100:.1f}%",f"{d_pd*100:+.1f}pp",delta_color="inverse")
|
| 1156 |
with r2: st.metric(T('kpi_el',_lang),f"Rp {wi_res['el']/1e6:.2f}jt",f"Rp {d_el/1e6:+.2f}jt",delta_color="inverse")
|
| 1157 |
with r3:
|
| 1158 |
+
icon_m = {'risk-low':'๐ข','risk-med':'๐ก','risk-high':'๐ด'}
|
| 1159 |
+
label_m = {'risk-low':T('wi_approved',_lang),'risk-med':T('wi_review',_lang),'risk-high':T('wi_highrisk',_lang)}
|
| 1160 |
st.metric(T('wi_status',_lang),f"{icon_m[wi_res['css']]} {label_m[wi_res['css']]}")
|
| 1161 |
+
fig2,ax = plt.subplots(figsize=(7,2.5))
|
| 1162 |
+
bars = ax.barh(['Original','What-If'],[result_f['pd']*100,wi_pd*100],
|
| 1163 |
+
color=[result_f['color'],wi_res['color']],height=0.4)
|
| 1164 |
ax.axvline(20,color='#f7971e',ls='--',lw=1.5,label='20%')
|
| 1165 |
ax.axvline(50,color='#e74c3c',ls='--',lw=1.5,label='50%')
|
| 1166 |
for bar,v in zip(bars,[result_f['pd']*100,wi_pd*100]):
|
| 1167 |
+
ax.text(bar.get_width()+0.5,bar.get_y()+bar.get_height()/2,
|
| 1168 |
+
f'{v:.1f}%',va='center',fontweight='bold',fontsize=11)
|
| 1169 |
ax.set_xlim(0,110); ax.legend(fontsize=8); ax.grid(axis='x',alpha=0.3)
|
| 1170 |
ax.set_title(T('wi_chart_title',_lang),fontweight='bold')
|
| 1171 |
plt.tight_layout()
|
| 1172 |
+
buf2 = io.BytesIO(); fig2.savefig(buf2,format='png',dpi=130,bbox_inches='tight')
|
| 1173 |
plt.close(fig2); buf2.seek(0); st.image(buf2.read(),use_container_width=True)
|
| 1174 |
+
if wi_pd < result_f['pd']-0.005:
|
| 1175 |
st.success(f"โ
PD {T('wi_pd_down',_lang)} **{(result_f['pd']-wi_pd)*100:.1f}pp** ({T('wi_pd_from',_lang)} {result_f['pd']*100:.1f}% โ {wi_pd*100:.1f}%)")
|
| 1176 |
+
elif wi_pd > result_f['pd']+0.005:
|
| 1177 |
st.warning(f"โ ๏ธ PD {T('wi_pd_up',_lang)} **{(wi_pd-result_f['pd'])*100:.1f}pp** ({T('wi_pd_from',_lang)} {result_f['pd']*100:.1f}% โ {wi_pd*100:.1f}%)")
|
| 1178 |
else:
|
| 1179 |
st.info(f"โ๏ธ {T('wi_no_change',_lang)}")
|
|
|
|
| 1186 |
if wi_emp<5: tips.append(_t("๐ฅ Tambah karyawan = skala bisnis lebih sehat","๐ฅ More employees signals healthy scale","๐ฅ เค
เคงเคฟเค เคเคฐเฅเคฎเคเคพเคฐเฅ = เคธเฅเคตเคธเฅเคฅ เคชเฅเคฎเคพเคจเคพ"))
|
| 1187 |
if not tips: tips.append(_t("๐ Profil sudah optimal!","๐ Profile already well-optimized!","๐ เคชเฅเคฐเฅเคซเคผเคพเคเคฒ เคชเคนเคฒเฅ เคธเฅ เค
เคจเฅเคเฅเคฒเคฟเคค!"))
|
| 1188 |
for tip in tips: st.markdown(tip)
|
| 1189 |
+
except Exception as e:
|
| 1190 |
+
st.error(f"Error: {e}")
|
| 1191 |
render_whatif()
|
| 1192 |
|
| 1193 |
# โโ TAB 5: Formula โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
|
|
|
|
| 1195 |
st.markdown(f'<div class="sec-title">{T("form_formula_title",lang)}</div>', unsafe_allow_html=True)
|
| 1196 |
st.latex(r"EL = PD \times LGD \times EAD")
|
| 1197 |
st.latex(rf"EL = {result['pd']:.4f} \times {result['lgd']:.2f} \times Rp\,{result['ead']:,.0f} = Rp\,{result['el']:,.0f}")
|
| 1198 |
+
cf1,cf2 = st.columns(2)
|
| 1199 |
with cf1: st.markdown(T('formula_def',lang))
|
| 1200 |
with cf2:
|
| 1201 |
+
cat_lbl = (T('formula_low',lang) if result['pd']<.2 else T('formula_medium',lang) if result['pd']<.5 else T('formula_high',lang))
|
| 1202 |
+
st.markdown(
|
| 1203 |
+
f"| {T('formula_komponen',lang)} | {T('formula_nilai',lang)} |\n|--|--|\n"
|
| 1204 |
+
f"| PD | {result['pd']*100:.1f}% ({cat_lbl}) |\n"
|
| 1205 |
+
f"| LGD | {result['lgd']*100:.0f}% |\n"
|
| 1206 |
+
f"| EAD | Rp {result['ead']/1e6:.0f}jt |\n"
|
| 1207 |
+
f"| **EL** | **Rp {result['el']/1e6:.2f}jt** |"
|
| 1208 |
+
)
|
| 1209 |
|
| 1210 |
# ============================================================
|
| 1211 |
# DOWNLOAD REPORT
|