1na37 commited on
Commit
5ab724e
ยท
verified ยท
1 Parent(s): 767744c

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +344 -178
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 (tidak bocor ke UI)
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
- # Baca API key langsung dari secrets
286
- openrouter_key = _read_secret("OPENROUTER_API_KEY")
287
- gemini_key = _read_secret("GEMINI_API_KEY")
288
- groq_key = _read_secret("GROQ_API_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 akan menggunakan fallback.")
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([f"- {names[i]}: {'increases' if vals[i]>0 else 'decreases'} risk (SHAP={vals[i]:.3f})" for i in idx])
 
 
 
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
- def call_openrouter(messages, api_key, model="anthropic/claude-3-haiku-20240307"):
407
- if not api_key: return None, "No API key"
408
- try:
409
- resp = requests.post("https://openrouter.ai/api/v1/chat/completions",
410
- headers={"Authorization":f"Bearer {api_key}","Content-Type":"application/json",
411
- "HTTP-Referer":"https://huggingface.co","X-Title":"SME Credit Risk AI"},
412
- json={"model":model,"messages":messages,"max_tokens":600,"temperature":0.75}, timeout=20)
413
- if resp.status_code==200: return resp.json()['choices'][0]['message']['content'], None
414
- return None, f"HTTP {resp.status_code}: {resp.text[:200]}"
415
- except Exception as e: return None, str(e)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
416
 
417
  # ============================================================
418
- # NARRATIVE GENERATION (with LLM fallback)
419
  # ============================================================
420
- def get_narrative(shap_vals, feature_names, result, lang, raw_input,
421
- openrouter_key='', gemini_key='', groq_key=''):
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
- if openrouter_key:
443
- r, _ = call_openrouter(msgs, openrouter_key)
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 (ringkas, tidak template)
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
- feature_names, lang, openrouter_key='', gemini_key='', groq_key='',
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 di atas dengan menyebut angka aktualnya.\n"
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
- response=None; _or_error=None
606
- if openrouter_key:
607
- response, _or_error = call_openrouter(messages, openrouter_key)
608
- if response is None and gemini_key:
609
- try:
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
- # Fallback: natural, tidak template
 
 
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
- # Pertanyaan tentang identitas Maya
636
- if any(w in low for w in ['siapa','kamu','km','who are you','nama','nama kamu','you are','maya']):
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'm here to help you understand your credit score and SME business strategy. "
642
  f"Your PD score is **{pd_pct:.1f}%** โ€” {'looking great! ๐ŸŽ‰' if pd_pct<20 else 'there is room to improve.'} "
643
- f"Want me to explain more or do you have a question?",
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** โ€” ini wajib untuk pinjaman formal, daftar di pajak.go.id",
653
  "1. **Register NPWP** โ€” required for formal loans, apply at pajak.go.id",
654
  "1. **NPWP เคชเค‚เคœเฅ€เค•เฅƒเคค เค•เคฐเฅ‡เค‚** โ€” เค”เคชเคšเคพเคฐเคฟเค• เค‹เคฃ เค•เฅ‡ เคฒเคฟเค เค…เคจเคฟเคตเคพเคฐเฅเคฏ"))
655
  if dig < 75:
656
- tips.append(_t(f"{'2' if not npwp else '1'}. **Naikkan Digital Score** dari {dig} ke 75+ โ€” aktif di Google Business, Tokopedia/Shopee [ADJUST: digital_presence_score=75]",
657
- f"{'2' if not npwp else '1'}. **Raise Digital Score** from {dig} to 75+ โ€” Google Business, marketplace [ADJUST: digital_presence_score=75]",
658
- f"{'2' if not npwp else '1'}. **เคกเคฟเคœเคฟเคŸเคฒ เคธเฅเค•เฅ‹เคฐ** {dig} เคธเฅ‡ 75+ เค•เคฐเฅ‡เค‚ [ADJUST: digital_presence_score=75]"))
 
659
  if cf < 25e6:
660
- tips.append(_t(f"{'3' if (not npwp and dig<75) else '2'}. **Optimalkan cash flow** dari Rp {cf/1e6:.0f}jt ke 25jt+/bln โ€” diversifikasi produk [ADJUST: monthly_cash_flow=25000000]",
661
- f"{'3' if (not npwp and dig<75) else '2'}. **Grow cash flow** from Rp {cf/1e6:.0f}M to 25M+/month [ADJUST: monthly_cash_flow=25000000]",
662
- f"{'3' if (not npwp and dig<75) else '2'}. **เคจเค•เคฆ เคชเฅเคฐเคตเคพเคน** Rp {cf/1e6:.0f}M เคธเฅ‡ 25M+ เค•เคฐเฅ‡เค‚ [ADJUST: monthly_cash_flow=25000000]"))
 
663
  if not tips:
664
- tips.append(_t(f"Profil kamu sudah solid dengan PD {pd_pct:.1f}%! ๐ŸŽ‰ Coba simulasikan What-If untuk eksplorasi lebih.",
665
- f"Your profile is already solid at PD {pd_pct:.1f}%! ๐ŸŽ‰ Try the What-If tab.",
666
- f"เค†เคชเค•เฅ€ เคชเฅเคฐเฅ‹เคซเคผเคพเค‡เคฒ PD {pd_pct:.1f}% เค•เฅ‡ เคธเคพเคฅ เคชเคนเคฒเฅ‡ เคธเฅ‡ เคฎเคœเคฌเฅ‚เคค เคนเฅˆ! ๐ŸŽ‰"))
667
- response = _t(f"Untuk turunkan PD dari **{pd_pct:.1f}%**, fokus ke ini:\n\n",
668
- f"To lower PD from **{pd_pct:.1f}%**, focus on:\n\n",
669
- f"PD **{pd_pct:.1f}%** เค•เคฎ เค•เคฐเคจเฅ‡ เค•เฅ‡ เคฒเคฟเค:\n\n") + '\n'.join(tips[:3])
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. **{feature_names[ti]}** โ€” {'โฌ† meningkatkan' if shap_vals[ti]>0 else 'โฌ‡ menurunkan'} risiko (SHAP={shap_vals[ti]:.3f})\n"
678
- f"2. **{feature_names[ti2]}** โ€” {'โฌ† meningkatkan' if shap_vals[ti2]>0 else 'โฌ‡ menurunkan'} risiko (SHAP={shap_vals[ti2]:.3f})\n\n"
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. **{feature_names[ti]}** โ€” {'โฌ† increases' if shap_vals[ti]>0 else 'โฌ‡ decreases'} risk (SHAP={shap_vals[ti]:.3f})\n"
682
- f"2. **{feature_names[ti2]}** โ€” {'โฌ† increases' if shap_vals[ti2]>0 else 'โฌ‡ decreases'} risk (SHAP={shap_vals[ti2]:.3f})\n\n"
683
- f"{'Great score! Both factors support your eligibility. ๐ŸŽ‰' if pd_pct<20 else 'Focus on improving the first factor โ€” it has the biggest impact.'}",
684
  f"PD **{pd_pct:.1f}%** เค•เฅ‡ เคฆเฅ‹ เคฎเฅเค–เฅเคฏ เค•เคพเคฐเค•:\n\n"
685
- f"1. **{feature_names[ti]}** โ€” {'โฌ† เคฌเคขเคผเคพเคคเคพ' if shap_vals[ti]>0 else 'โฌ‡ เค˜เคŸเคพเคคเคพ'} เคœเฅ‹เค–เคฟเคฎ\n"
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 of **Rp {cf/1e6:.0f}M/month** and {int(dur_val)}-month tenure, "
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 or extending tenure.'}",
704
- f"เคจเค•เคฆ เคชเฅเคฐเคตเคพเคน **Rp {cf/1e6:.0f}M/เคฎเคพเคน** เค”เคฐ {int(dur_val)} เคฎเคพเคน เค•เฅ‡ เค†เคงเคพเคฐ เคชเคฐ, "
705
- f"เค…เคงเคฟเค•เคคเคฎ เค‹เคฃ **Rp {safe/1e6:.0f}M** (35% เค•เคฟเคธเฅเคค เค…เคจเฅเคชเคพเคค)เฅค\n\n"
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 yang akurat! ๐Ÿ˜Š",
711
- "Fill in your monthly cash flow in the form first for an accurate recommendation! ๐Ÿ˜Š",
712
  "เคธเคŸเฅ€เค• เคธเฅเคเคพเคต เค•เฅ‡ เคฒเคฟเค เคชเคนเคฒเฅ‡ เคฎเคพเคธเคฟเค• เคจเค•เคฆ เคชเฅเคฐเคตเคพเคน เคญเคฐเฅ‡เค‚! ๐Ÿ˜Š")
713
 
714
- # Default: tidak ke template, jawab kontekstual
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
715
  else:
716
  top_issue = _get_top_issue(raw_input, pd_pct, lang)
 
 
 
 
 
 
 
 
 
 
717
  response = _t(
718
- f"Hei! Aku Maya, AI Advisor kamu ๐Ÿ˜Š Untuk pertanyaan ini aku butuh koneksi AI โ€” coba tambah API key di sidebar.\n\n"
719
- f"Yang bisa aku kasih tahu sekarang: PD kamu **{pd_pct:.1f}%** dan {top_issue}\n\n"
720
- f"Mau tanya soal cara improve skor, faktor risiko, atau pinjaman ideal?",
721
- f"Hey! I'm Maya ๐Ÿ˜Š For this I need an AI connection โ€” try adding an API key in the sidebar.\n\n"
722
  f"What I can share: your PD is **{pd_pct:.1f}%** and {top_issue}\n\n"
723
- f"Want to ask about improving your score, risk factors, or ideal loan?",
724
- f"เคจเคฎเคธเฅเคคเฅ‡! เคฎเฅˆเค‚ Maya เคนเฅ‚เค ๐Ÿ˜Š เค‡เคธเค•เฅ‡ เคฒเคฟเค AI เค•เคจเฅ‡เค•เฅเคถเคจ เคšเคพเคนเคฟเค โ€” sidebar เคฎเฅ‡เค‚ API key เคœเฅ‹เคกเคผเฅ‡เค‚เฅค\n\n"
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, _or_error
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 (result is not None and raw_input is not None
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"): st.image(st.session_state.shap_png, use_container_width=True)
 
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
- user_input=st.chat_input(T('chat_input',lang))
938
- to_process=chip_clicked or user_input
 
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,result,raw_input,shap_vals,
944
- feature_names,lang,openrouter_key,gemini_key,groq_key,
945
  rag_index=rag_index)
946
- if _debug_err and openrouter_key:
947
- st.caption(f"โš ๏ธ OpenRouter: {_debug_err[:80]} โ€” {T('or_fallback',lang)}")
 
 
 
 
 
948
  st.session_state.chat_history.append({'role':'assistant','content':ai_resp})
949
- adj_map={'digital_presence_score':'wi_dig','business_age_years':'wi_biz',
950
- 'num_employees':'wi_emp','monthly_cash_flow':'wi_cash','duration':'wi_dur','loan_rp':'wi_loan'}
 
 
 
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=st.session_state.result; raw_input_f=st.session_state.raw_input
 
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 =st.slider(T('wi_duration',_lang),4,72,d_dur)
980
- wi_cash=st.slider(T('wi_cashflow',_lang),1,100,d_cash)
981
  with wc2:
982
- wi_dig =st.slider(T('wi_digital',_lang),1,100,d_dig)
983
- wi_biz =st.slider(T('wi_bizage',_lang),1,20,d_biz)
984
- wi_emp =st.slider(T('wi_employees',_lang),1,50,d_emp)
985
  try:
986
- wi_raw={**raw_input_f,'duration':wi_dur,'digital_presence_score':wi_dig,
987
- 'business_age_years':float(wi_biz),'monthly_cash_flow':float(wi_cash*1e6),'num_employees':wi_emp}
988
- X_wi=preprocess(wi_raw,scaler,feature_names)
989
- wi_pd=float(ensemble.predict_proba(X_wi)[0][1])
990
- wi_res=risk_result(wi_pd,wi_loan*1e6,result_f['lgd'])
991
- d_pd=wi_pd-result_f['pd']; d_el=wi_res['el']-result_f['el']
992
- r1,r2,r3=st.columns(3)
 
 
 
 
 
 
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
- color=[result_f['color'],wi_res['color']],height=0.4)
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,f'{v:.1f}%',va='center',fontweight='bold',fontsize=11)
 
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: st.error(f"Error: {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(f"| {T('formula_komponen',lang)} | {T('formula_nilai',lang)} |\n|--|--|\n"
1039
- f"| PD | {result['pd']*100:.1f}% ({cat_lbl}) |\n"
1040
- f"| LGD | {result['lgd']*100:.0f}% |\n"
1041
- f"| EAD | Rp {result['ead']/1e6:.0f}jt |\n"
1042
- f"| **EL** | **Rp {result['el']/1e6:.2f}jt** |")
 
 
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