1na37 commited on
Commit
b94adc1
Β·
verified Β·
1 Parent(s): 4869bee

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +217 -42
app.py CHANGED
@@ -10,6 +10,7 @@ import numpy as np
10
  import matplotlib
11
  matplotlib.use('Agg')
12
  import matplotlib.pyplot as plt
 
13
  import shap, joblib, os, json, warnings, re, requests, io, hashlib
14
  warnings.filterwarnings('ignore')
15
 
@@ -401,7 +402,8 @@ def call_groq_tools(messages, api_key, tools=None):
401
  def _clean_response(text: str) -> tuple[str, dict]:
402
  """
403
  Strip all LLM artifacts from response text and extract any embedded adjustments.
404
- Handles: <think>…</think> blocks, raw JSON objects {"field":…}, [ADJUST:…] tags.
 
405
  Returns (clean_text, adjustments_dict).
406
  """
407
  if not text:
@@ -412,17 +414,16 @@ def _clean_response(text: str) -> tuple[str, dict]:
412
  text = re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL).strip()
413
 
414
  # ── Parse & strip inline JSON objects (tool-call leak) ────────
415
- # Matches {"field": "...", "value": ..., "reason": "..."} anywhere in text
416
  json_pattern = re.compile(
417
  r'\{[^{}]*"field"\s*:\s*"([^"]+)"[^{}]*"value"\s*:\s*([\d.]+)[^{}]*\}',
418
  re.DOTALL
419
  )
 
 
 
 
420
  for m in json_pattern.finditer(text):
421
  field, val = m.group(1), float(m.group(2))
422
- valid_fields = {
423
- 'digital_presence_score', 'business_age_years', 'num_employees',
424
- 'monthly_cash_flow', 'duration', 'loan_rp'
425
- }
426
  if field in valid_fields:
427
  adjustments[field] = val
428
  text = json_pattern.sub('', text).strip()
@@ -434,18 +435,37 @@ def _clean_response(text: str) -> tuple[str, dict]:
434
  adjustments[field] = val
435
  text = re.sub(r'\[ADJUST:[^\]]+\]', '', text).strip()
436
 
437
- # ── Strip trailing standalone slider-hint lines (LLM sometimes writes
438
- # "Naikkan X ke Y" or "Tingkatkan cash flow ke ..." as a bare last line
439
- # after the ADJUST tag has already been extracted and removed)
440
- # Pattern: last 1-2 lines that are short (<= 80 chars) and match
441
- # "Naikkan/Tingkatkan/Pertahankan/Kurangi ... ke ..." with no sentence context.
442
- _standalone = re.compile(
443
- r'\n+((?:Naikkan|Tingkatkan|Pertahankan|Kurangi|Raise|Increase|Maintain|Reduce|Lower)'
444
- r'[^.\n]{5,70}(?:ke|to|juta|M|score)[^.\n]{0,40})\s*$',
445
- re.IGNORECASE
 
 
 
 
 
 
 
446
  )
447
- text = _standalone.sub('', text).strip()
448
-
 
 
 
 
 
 
 
 
 
 
 
 
449
  text = re.sub(r'\n{3,}', '\n\n', text).strip()
450
 
451
  return text, adjustments
@@ -706,7 +726,6 @@ _defaults = dict(
706
  chat_history=[], shap_png=None,
707
  wi_dig=None, wi_biz=None, wi_emp=None,
708
  wi_cash=None, wi_dur=None, wi_loan=None,
709
- # β‘‘ Memory persistence
710
  chat_summary='',
711
  memory_loaded=False,
712
  )
@@ -982,7 +1001,6 @@ def _get_top_issue(raw_input, pd_pct, lang):
982
 
983
  # ============================================================
984
  # AI CHAT β€” Maya persona
985
- # β‘’ Few-shot + β‘£ Chain-of-Thought + β‘  Tool Calling + β‘’ Memory
986
  # ============================================================
987
  def get_chat_response(user_msg, history, result, raw_input, shap_vals, feature_names, lang, rag_index=None):
988
  pd_pct = result['pd'] * 100
@@ -1003,18 +1021,18 @@ def get_chat_response(user_msg, history, result, raw_input, shap_vals, feature_n
1003
  except Exception:
1004
  context = ""
1005
 
1006
- # ── β‘‘ Few-shot examples (explicit Q&A demonstrations) ─────
1007
  fewshot = """
1008
  CONTOH DIALOG MAYA (few-shot β€” ikuti gaya & kualitas ini):
1009
  ---
1010
  User: "Gimana cara turunin skor PD saya?"
1011
- Maya: "Berdasarkan data kamu, 3 prioritas utama: 1) **Naikkan digital score** ke 75+ via Google Business & marketplace aktif β€” ini faktor SHAP terbesar. 2) **Optimalkan cash flow** β€” dokumentasikan semua pemasukan, pisah rekening bisnis & pribadi. 3) **Urus NPWP** kalau belum ada. Mau saya simulasikan dampaknya ke skor sekarang?"
1012
  ---
1013
  User: "Kenapa skor saya tinggi banget?"
1014
  Maya: "Dua driver terbesar menurut SHAP: **business_age_years** (SHAP=+0.234) β€” bisnis muda = risiko lebih tinggi di mata model, dan **digital_presence_score** (SHAP=+0.189) β€” bank pakai ini sebagai proxy kredibilitas online. Fokus perbaiki keduanya dulu, dampaknya paling signifikan!"
1015
  ---
1016
  User: "Pinjaman berapa yang paling aman buat saya?"
1017
- Maya: "Dengan cash flow Rp 15jt/bln dan tenor 24 bln, batas aman kamu adalah **Rp 126jt** (rasio cicilan 35%). Pinjaman kamu sekarang Rp 50jt β€” βœ… masih aman! Kalau mau tambah, pastikan cash flow naik dulu biar rasio tetap sehat."
1018
  ---
1019
  """
1020
 
@@ -1024,13 +1042,13 @@ Maya: "Dengan cash flow Rp 15jt/bln dan tenor 24 bln, batas aman kamu adalah **R
1024
  lbl = {'id': 'πŸ“ Ringkasan percakapan sebelumnya', 'en': 'πŸ“ Earlier conversation summary', 'hi': 'πŸ“ ΰ€ͺΰ₯‚ΰ€°ΰ₯ΰ€΅ ΰ€΅ΰ€Ύΰ€°ΰ₯ΰ€€ΰ€Ύΰ€²ΰ€Ύΰ€ͺ ΰ€Έΰ€Ύΰ€°ΰ€Ύΰ€‚ΰ€Ά'}
1025
  summary_block = f"\n{lbl.get(lang, lbl['en'])}:\n{chat_summary}\n"
1026
 
1027
- # ── β‘£ Chain-of-Thought instructions ─────────────────────────
1028
  cot_block = """
1029
  CARA BERPIKIR β€” CHAIN OF THOUGHT (lakukan ini secara SILENT sebelum menjawab):
1030
  β‘  Apa yang sebenarnya ditanyakan user? (bukan hanya kata-katanya β€” cari intent sebenarnya)
1031
  β‘‘ Data pemohon mana yang paling relevan? (PD score, cash flow, digital score, NPWP, dll)
1032
  β‘’ Apa saran paling KONKRET & berdampak tinggi untuk UMKM spesifik ini?
1033
- β‘£ Apakah perlu adjust slider What-If? Jika ya, gunakan tool adjust_whatif_slider dengan field & value spesifik.
1034
  β†’ Tulis HANYA jawaban akhir β€” jangan tampilkan proses berpikir di output.
1035
  """
1036
 
@@ -1058,21 +1076,27 @@ CARA BERPIKIR β€” CHAIN OF THOUGHT (lakukan ini secara SILENT sebelum menjawab):
1058
  f"{summary_block}"
1059
  + cot_block
1060
  + fewshot +
1061
- f"ATURAN MENJAWAB:\n"
1062
  f"1. Jawab BEBAS β€” tidak harus soal kredit. Kalau ditanya soal diri sendiri, perkenalkan sebagai Maya.\n"
1063
  f"2. Kalau relevan, selalu hubungkan ke data pemohon dengan menyebut angka aktualnya.\n"
1064
  f"3. Berikan saran KONKRET dan SPESIFIK, bukan generik.\n"
1065
  f"4. Maksimal 150 kata kecuali diminta lebih panjang.\n"
1066
- f"5. Jika merekomendasikan nilai spesifik, EMBED tag `[ADJUST: field=value]` LANGSUNG di dalam kalimat saran.\n"
1067
- f" BENAR: 'Coba naikkan digital score-mu ke 75 [ADJUST: digital_presence_score=75] β€” dampaknya paling besar.'\n"
1068
- f" SALAH: 'Saran: naikkan digital score.\\nNaikkan digital score ke 75 [ADJUST: digital_presence_score=75]'\n"
1069
- f" SALAH: Menulis baris terpisah seperti 'Naikkan digital score ke 75' di akhir response.\n"
1070
- f" PENTING: Tag [ADJUST:] hanya boleh muncul SEKALI per field, INLINE di dalam kalimat, bukan di baris tersendiri.\n"
1071
- f" PENTING: Jangan pernah menulis JSON object {{...}} di response.\n"
1072
- f" Field valid: digital_presence_score(1-100), business_age_years(1-20),\n"
1073
- f" num_employees(1-50), monthly_cash_flow(angka Rp), duration(4-72), loan_rp(angka Rp)\n"
 
 
 
 
 
1074
  f"6. JANGAN pernah balik ke template. Jawab seperti manusia cerdas.\n"
1075
  f"7. Baca context percakapan sebelumnya sebelum menjawab β€” jaga konsistensi topik.\n"
 
1076
  )
1077
 
1078
  messages = [{"role": "system", "content": system}]
@@ -1080,7 +1104,7 @@ CARA BERPIKIR β€” CHAIN OF THOUGHT (lakukan ini secara SILENT sebelum menjawab):
1080
  messages.append({"role": m['role'], "content": m['content']})
1081
  messages.append({"role": "user", "content": user_msg})
1082
 
1083
- # ── β‘  Formal Tool Calling via _call_chat_llm ──────────────
1084
  response, tool_calls, _llm_src, _last_error = _call_chat_llm(messages)
1085
 
1086
  # Parse tool_calls for adjustments (formal schema)
@@ -1120,14 +1144,14 @@ CARA BERPIKIR β€” CHAIN OF THOUGHT (lakukan ini secara SILENT sebelum menjawab):
1120
  adjustments['has_npwp'] = 1
1121
  if dig < 75:
1122
  n = '2' if not npwp else '1'
1123
- tips.append(_t(f"{n}. **Naikkan Digital Score** dari {dig} ke 75+ β€” aktif di Google Business, Tokopedia/Shopee",
1124
- f"{n}. **Raise Digital Score** from {dig} to 75+ β€” Google Business, marketplace",
1125
  f"{n}. **ΰ€‘ΰ€Ώΰ€œΰ€Ώΰ€Ÿΰ€² ΰ€Έΰ₯ΰ€•ΰ₯‹ΰ€°** {dig} ΰ€Έΰ₯‡ 75+ ΰ€•ΰ€°ΰ₯‡ΰ€‚"))
1126
  adjustments['digital_presence_score'] = 75
1127
  if cf < 25e6:
1128
  n = str(len(tips)+1)
1129
- tips.append(_t(f"{n}. **Optimalkan cash flow** dari Rp {cf/1e6:.0f}jt ke 25jt+/bln β€” diversifikasi produk",
1130
- f"{n}. **Grow cash flow** from Rp {cf/1e6:.0f}M to 25M+/month",
1131
  f"{n}. **ΰ€¨ΰ€•ΰ€¦ ΰ€ͺΰ₯ΰ€°ΰ€΅ΰ€Ύΰ€Ή** Rp {cf/1e6:.0f}M ΰ€Έΰ₯‡ 25M+ ΰ€•ΰ€°ΰ₯‡ΰ€‚"))
1132
  adjustments['monthly_cash_flow'] = 25000000
1133
  if not tips:
@@ -1281,7 +1305,7 @@ CARA BERPIKIR β€” CHAIN OF THOUGHT (lakukan ini secara SILENT sebelum menjawab):
1281
  f"ΰ€†ΰ€ͺΰ€•ΰ€Ύ PD **{pd_pct:.1f}%** ΰ€Ήΰ₯ˆ ΰ€”ΰ€° {top_issue}"
1282
  )
1283
 
1284
- # Final cleanup: strip think-tags, leaked JSON, ADJUST tags
1285
  if response:
1286
  response, extra_adj = _clean_response(response)
1287
  for field, val in extra_adj.items():
@@ -1291,6 +1315,155 @@ CARA BERPIKIR β€” CHAIN OF THOUGHT (lakukan ini secara SILENT sebelum menjawab):
1291
  return response or "...", adjustments, _last_error
1292
 
1293
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1294
  # ============================================================
1295
  # HEADER
1296
  # ============================================================
@@ -1406,7 +1579,6 @@ if submitted:
1406
  st.session_state.chat_history=[]
1407
  st.session_state.chat_summary=''
1408
  for k in ['wi_dig','wi_biz','wi_emp','wi_cash','wi_dur','wi_loan']: st.session_state[k]=None
1409
- # Clear persisted memory on new submission
1410
  _save_chat_memory([], '')
1411
  except Exception as e:
1412
  st.error(f"Error: {e}"); st.stop()
@@ -1451,6 +1623,11 @@ for col,(lbl_k,val,sub_k,color) in zip(st.columns(4),[
1451
  f'<div class="kpi-sub">{T(sub_k,lang)}</div></div>', unsafe_allow_html=True)
1452
  st.markdown("<br>", unsafe_allow_html=True)
1453
 
 
 
 
 
 
1454
  # ============================================================
1455
  # TABS
1456
  # ============================================================
@@ -1482,7 +1659,6 @@ with t2:
1482
  with t3:
1483
  st.markdown(f'<div class="sec-title">{T("chat_title",lang)}</div>', unsafe_allow_html=True)
1484
 
1485
- # Show memory summary badge if exists
1486
  if st.session_state.get('chat_summary'):
1487
  n_turns = len(st.session_state.chat_history)
1488
  lbl = {'id': f'🧠 Memory aktif Β· {n_turns} pesan tersimpan',
@@ -1520,7 +1696,6 @@ with t3:
1520
  st.warning(f"⚠️ Semua LLM gagal, menggunakan Smart Fallback. Error: `{_debug_err[:100]}`")
1521
  st.session_state.chat_history.append({'role':'assistant','content':ai_resp})
1522
 
1523
- # β‘‘ Persist memory after each turn
1524
  _save_chat_memory(
1525
  st.session_state.chat_history,
1526
  st.session_state.get('chat_summary', '')
 
10
  import matplotlib
11
  matplotlib.use('Agg')
12
  import matplotlib.pyplot as plt
13
+ import streamlit.components.v1 as components
14
  import shap, joblib, os, json, warnings, re, requests, io, hashlib
15
  warnings.filterwarnings('ignore')
16
 
 
402
  def _clean_response(text: str) -> tuple[str, dict]:
403
  """
404
  Strip all LLM artifacts from response text and extract any embedded adjustments.
405
+ Handles: <think>…</think>, raw JSON objects, [ADJUST:…] tags, and trailing
406
+ standalone recommendation lines that duplicate inline suggestions.
407
  Returns (clean_text, adjustments_dict).
408
  """
409
  if not text:
 
414
  text = re.sub(r'<think>.*?</think>', '', text, flags=re.DOTALL).strip()
415
 
416
  # ── Parse & strip inline JSON objects (tool-call leak) ────────
 
417
  json_pattern = re.compile(
418
  r'\{[^{}]*"field"\s*:\s*"([^"]+)"[^{}]*"value"\s*:\s*([\d.]+)[^{}]*\}',
419
  re.DOTALL
420
  )
421
+ valid_fields = {
422
+ 'digital_presence_score', 'business_age_years', 'num_employees',
423
+ 'monthly_cash_flow', 'duration', 'loan_rp'
424
+ }
425
  for m in json_pattern.finditer(text):
426
  field, val = m.group(1), float(m.group(2))
 
 
 
 
427
  if field in valid_fields:
428
  adjustments[field] = val
429
  text = json_pattern.sub('', text).strip()
 
435
  adjustments[field] = val
436
  text = re.sub(r'\[ADJUST:[^\]]+\]', '', text).strip()
437
 
438
+ # ── FIX: Strip ALL trailing standalone action-lines ──────────
439
+ # Only strip when there's meaningful content before the trailing line,
440
+ # i.e. the response has more than 1 non-empty line. This prevents
441
+ # incorrectly stripping single-line responses that start with action words.
442
+ _action_line = re.compile(
443
+ r'\n[ \t]*(?:'
444
+ # Indonesian
445
+ r'(?:Naikkan|Tingkatkan|Pertahankan|Kurangi|Optimalkan|Perbaiki|Tambahkan|Pastikan|Coba|Daftarkan)'
446
+ r'|'
447
+ # English
448
+ r'(?:Raise|Increase|Maintain|Reduce|Lower|Improve|Try|Optimize|Register|Ensure)'
449
+ r'|'
450
+ # Hindi
451
+ r'(?:ΰ€¬ΰ€’ΰ€Όΰ€Ύΰ€ΰ€‚|ΰ€˜ΰ€Ÿΰ€Ύΰ€ΰ€‚|ΰ€Έΰ₯ΰ€§ΰ€Ύΰ€°ΰ₯‡ΰ€‚|ΰ€ͺΰ€‚ΰ€œΰ₯€ΰ€•ΰ₯ƒΰ€€|ΰ€Έΰ₯ΰ€¨ΰ€Ώΰ€Άΰ₯ΰ€šΰ€Ώΰ€€)'
452
+ r')[^\n]{3,90}$',
453
+ re.IGNORECASE | re.MULTILINE
454
  )
455
+ # Run up to 5 passes; guard: only strip if text has >1 non-empty line
456
+ for _ in range(5):
457
+ non_empty_lines = [l for l in text.split('\n') if l.strip()]
458
+ if len(non_empty_lines) <= 1:
459
+ break # Don't strip the only content line
460
+ new_text = _action_line.sub('', text).rstrip()
461
+ if new_text == text:
462
+ break
463
+ text = new_text
464
+
465
+ # ── Strip markdown bold artifacts left on their own line ─────
466
+ text = re.sub(r'\n\*\*\s*\*\*\s*$', '', text, flags=re.MULTILINE).strip()
467
+
468
+ # ── Collapse excessive blank lines ────────────────────────────
469
  text = re.sub(r'\n{3,}', '\n\n', text).strip()
470
 
471
  return text, adjustments
 
726
  chat_history=[], shap_png=None,
727
  wi_dig=None, wi_biz=None, wi_emp=None,
728
  wi_cash=None, wi_dur=None, wi_loan=None,
 
729
  chat_summary='',
730
  memory_loaded=False,
731
  )
 
1001
 
1002
  # ============================================================
1003
  # AI CHAT β€” Maya persona
 
1004
  # ============================================================
1005
  def get_chat_response(user_msg, history, result, raw_input, shap_vals, feature_names, lang, rag_index=None):
1006
  pd_pct = result['pd'] * 100
 
1021
  except Exception:
1022
  context = ""
1023
 
1024
+ # ── Few-shot examples ─────────────────────────────────────
1025
  fewshot = """
1026
  CONTOH DIALOG MAYA (few-shot β€” ikuti gaya & kualitas ini):
1027
  ---
1028
  User: "Gimana cara turunin skor PD saya?"
1029
+ Maya: "Berdasarkan data kamu, 3 prioritas utama: 1) **Naikkan digital score ke 75+** via Google Business & marketplace aktif [ADJUST: digital_presence_score=75] β€” ini faktor SHAP terbesar. 2) **Optimalkan cash flow ke Rp 25jt/bln** [ADJUST: monthly_cash_flow=25000000] β€” dokumentasikan semua pemasukan, pisah rekening. 3) **Urus NPWP** kalau belum ada. Mau lihat simulasinya di tab What-If?"
1030
  ---
1031
  User: "Kenapa skor saya tinggi banget?"
1032
  Maya: "Dua driver terbesar menurut SHAP: **business_age_years** (SHAP=+0.234) β€” bisnis muda = risiko lebih tinggi di mata model, dan **digital_presence_score** (SHAP=+0.189) β€” bank pakai ini sebagai proxy kredibilitas online. Fokus perbaiki keduanya dulu, dampaknya paling signifikan!"
1033
  ---
1034
  User: "Pinjaman berapa yang paling aman buat saya?"
1035
+ Maya: "Dengan cash flow Rp 15jt/bln dan tenor 24 bln, batas aman kamu adalah **Rp 126jt** (rasio cicilan 35%). Pinjaman kamu sekarang Rp 50jt β€” βœ… masih aman! Kalau mau tambah, pastikan cash flow naik dulu."
1036
  ---
1037
  """
1038
 
 
1042
  lbl = {'id': 'πŸ“ Ringkasan percakapan sebelumnya', 'en': 'πŸ“ Earlier conversation summary', 'hi': 'πŸ“ ΰ€ͺΰ₯‚ΰ€°ΰ₯ΰ€΅ ΰ€΅ΰ€Ύΰ€°ΰ₯ΰ€€ΰ€Ύΰ€²ΰ€Ύΰ€ͺ ΰ€Έΰ€Ύΰ€°ΰ€Ύΰ€‚ΰ€Ά'}
1043
  summary_block = f"\n{lbl.get(lang, lbl['en'])}:\n{chat_summary}\n"
1044
 
1045
+ # ── Chain-of-Thought instructions ─────────────────────────
1046
  cot_block = """
1047
  CARA BERPIKIR β€” CHAIN OF THOUGHT (lakukan ini secara SILENT sebelum menjawab):
1048
  β‘  Apa yang sebenarnya ditanyakan user? (bukan hanya kata-katanya β€” cari intent sebenarnya)
1049
  β‘‘ Data pemohon mana yang paling relevan? (PD score, cash flow, digital score, NPWP, dll)
1050
  β‘’ Apa saran paling KONKRET & berdampak tinggi untuk UMKM spesifik ini?
1051
+ β‘£ Apakah perlu adjust slider What-If? Jika ya, embed tag [ADJUST: field=value] LANGSUNG di dalam kalimat rekomendasi.
1052
  β†’ Tulis HANYA jawaban akhir β€” jangan tampilkan proses berpikir di output.
1053
  """
1054
 
 
1076
  f"{summary_block}"
1077
  + cot_block
1078
  + fewshot +
1079
+ f"ATURAN MENJAWAB β€” BACA SEMUA DENGAN SEKSAMA:\n"
1080
  f"1. Jawab BEBAS β€” tidak harus soal kredit. Kalau ditanya soal diri sendiri, perkenalkan sebagai Maya.\n"
1081
  f"2. Kalau relevan, selalu hubungkan ke data pemohon dengan menyebut angka aktualnya.\n"
1082
  f"3. Berikan saran KONKRET dan SPESIFIK, bukan generik.\n"
1083
  f"4. Maksimal 150 kata kecuali diminta lebih panjang.\n"
1084
+ f"5. FORMAT TAG [ADJUST:] β€” WAJIB IKUTI ATURAN INI:\n"
1085
+ f" βœ… BENAR: Embed tag langsung di akhir kalimat saran:\n"
1086
+ f" 'Coba naikkan digital score ke 75 [ADJUST: digital_presence_score=75] β€” dampaknya paling besar.'\n"
1087
+ f" ❌ SALAH: Menulis kalimat saran TANPA tag, lalu menambah baris terpisah:\n"
1088
+ f" 'Coba naikkan digital score ke 75.\\nNaikkan digital score ke 75 [ADJUST: ...]'\n"
1089
+ f" ❌ SALAH: Menulis tag di baris sendiri di akhir response:\n"
1090
+ f" '...skor kamu akan membaik.\\n[ADJUST: digital_presence_score=75]'\n"
1091
+ f" ❌ SALAH BESAR: Menulis ulang rekomendasi di baris terpisah setelah selesai:\n"
1092
+ f" '...semua sudah dijelaskan di atas.\\nNaikkan digital score ke 75'\n"
1093
+ f" RULE: Setiap rekomendasi bernilai angka = SATU kalimat + SATU tag [ADJUST:] inline. TIDAK LEBIH.\n"
1094
+ f" RULE: JANGAN pernah tulis JSON object {{...}} di response.\n"
1095
+ f" RULE: Field valid: digital_presence_score(1-100), business_age_years(1-20),\n"
1096
+ f" num_employees(1-50), monthly_cash_flow(angka Rp), duration(4-72), loan_rp(angka Rp)\n"
1097
  f"6. JANGAN pernah balik ke template. Jawab seperti manusia cerdas.\n"
1098
  f"7. Baca context percakapan sebelumnya sebelum menjawab β€” jaga konsistensi topik.\n"
1099
+ f"8. AKHIRI response dengan kalimat lengkap. Jangan tambahkan apapun setelah kalimat terakhir.\n"
1100
  )
1101
 
1102
  messages = [{"role": "system", "content": system}]
 
1104
  messages.append({"role": m['role'], "content": m['content']})
1105
  messages.append({"role": "user", "content": user_msg})
1106
 
1107
+ # ── Formal Tool Calling via _call_chat_llm ────────────────
1108
  response, tool_calls, _llm_src, _last_error = _call_chat_llm(messages)
1109
 
1110
  # Parse tool_calls for adjustments (formal schema)
 
1144
  adjustments['has_npwp'] = 1
1145
  if dig < 75:
1146
  n = '2' if not npwp else '1'
1147
+ tips.append(_t(f"{n}. **Naikkan Digital Score dari {dig} ke 75+** β€” aktif di Google Business, Tokopedia/Shopee",
1148
+ f"{n}. **Raise Digital Score from {dig} to 75+** β€” Google Business, marketplace",
1149
  f"{n}. **ΰ€‘ΰ€Ώΰ€œΰ€Ώΰ€Ÿΰ€² ΰ€Έΰ₯ΰ€•ΰ₯‹ΰ€°** {dig} ΰ€Έΰ₯‡ 75+ ΰ€•ΰ€°ΰ₯‡ΰ€‚"))
1150
  adjustments['digital_presence_score'] = 75
1151
  if cf < 25e6:
1152
  n = str(len(tips)+1)
1153
+ tips.append(_t(f"{n}. **Optimalkan cash flow dari Rp {cf/1e6:.0f}jt ke 25jt+/bln** β€” diversifikasi produk",
1154
+ f"{n}. **Grow cash flow from Rp {cf/1e6:.0f}M to 25M+/month**",
1155
  f"{n}. **ΰ€¨ΰ€•ΰ€¦ ΰ€ͺΰ₯ΰ€°ΰ€΅ΰ€Ύΰ€Ή** Rp {cf/1e6:.0f}M ΰ€Έΰ₯‡ 25M+ ΰ€•ΰ€°ΰ₯‡ΰ€‚"))
1156
  adjustments['monthly_cash_flow'] = 25000000
1157
  if not tips:
 
1305
  f"ΰ€†ΰ€ͺΰ€•ΰ€Ύ PD **{pd_pct:.1f}%** ΰ€Ήΰ₯ˆ ΰ€”ΰ€° {top_issue}"
1306
  )
1307
 
1308
+ # Final cleanup: strip think-tags, leaked JSON, ADJUST tags, trailing lines
1309
  if response:
1310
  response, extra_adj = _clean_response(response)
1311
  for field, val in extra_adj.items():
 
1315
  return response or "...", adjustments, _last_error
1316
 
1317
 
1318
+ # ============================================================
1319
+ # FLOATING DRAGGABLE GIF + SOUND PER TAB
1320
+ # ============================================================
1321
+ def render_media_manager():
1322
+ """
1323
+ Inject floating draggable GIFs + audio per tab.
1324
+ Tab index mapping:
1325
+ 0 = SHAP β†’ 1 GIF (crying cat) + idk.mp3
1326
+ 3 = What-If β†’ 3 GIFs (dancing chibi) + shake.mp3
1327
+ others β†’ semua tersembunyi, audio pause
1328
+ """
1329
+ html = """
1330
+ <div id="media-container">
1331
+ <!-- ── AUDIO ─────────────────────────────────────── -->
1332
+ <audio id="audio-shap" loop preload="auto" src="/static/idk.mp3"></audio>
1333
+ <audio id="audio-whatif" loop preload="auto" src="/static/shake.mp3"></audio>
1334
+
1335
+ <!-- ── GIFs: SHAP tab ────────────────────────────── -->
1336
+ <div id="gif-shap" class="drag-gif" style="bottom:20px;left:20px;display:none;">
1337
+ <img src="https://media1.tenor.com/m/8606581414273585609/crying-cat-no-msgs-kitty-meme-screaming.gif" width="140" draggable="false">
1338
+ <div class="drag-close" onclick="document.getElementById('gif-shap').style.display='none'">βœ•</div>
1339
+ </div>
1340
+
1341
+ <!-- ── GIFs: What-If tab ─────────────────────────── -->
1342
+ <div id="gif-wi-1" class="drag-gif" style="bottom:20px;left:20px;display:none;">
1343
+ <img src="https://media1.tenor.com/m/20429653/chibi-haachama-hololive-dancing-squareofthelightones-akai-haato.gif" width="110" draggable="false">
1344
+ <div class="drag-close" onclick="document.getElementById('gif-wi-1').style.display='none'">βœ•</div>
1345
+ </div>
1346
+ <div id="gif-wi-2" class="drag-gif" style="bottom:20px;left:170px;display:none;">
1347
+ <img src="https://media1.tenor.com/m/13376873318217365775/evernight-hsr-lonely-lonely-i-guess-im-lonely-dance.gif" width="110" draggable="false">
1348
+ <div class="drag-close" onclick="document.getElementById('gif-wi-2').style.display='none'">βœ•</div>
1349
+ </div>
1350
+ <div id="gif-wi-3" class="drag-gif" style="bottom:20px;left:320px;display:none;">
1351
+ <img src="https://media1.tenor.com/m/129497420709683859/anime-chibi-azazal-silly-jumping.gif" width="110" draggable="false">
1352
+ <div class="drag-close" onclick="document.getElementById('gif-wi-3').style.display='none'">βœ•</div>
1353
+ </div>
1354
+ </div>
1355
+
1356
+ <style>
1357
+ .drag-gif {
1358
+ position: fixed;
1359
+ z-index: 9999;
1360
+ cursor: move;
1361
+ user-select: none;
1362
+ filter: drop-shadow(0 4px 12px rgba(0,0,0,0.4));
1363
+ transition: opacity 0.2s;
1364
+ }
1365
+ .drag-gif:hover { opacity: 0.92; }
1366
+ .drag-gif img { display: block; border-radius: 8px; pointer-events: none; }
1367
+ .drag-close {
1368
+ position: absolute;
1369
+ top: -8px; right: -8px;
1370
+ background: #e74c3c;
1371
+ color: white;
1372
+ border-radius: 50%;
1373
+ width: 20px; height: 20px;
1374
+ font-size: 11px;
1375
+ line-height: 20px;
1376
+ text-align: center;
1377
+ cursor: pointer;
1378
+ font-weight: 700;
1379
+ box-shadow: 0 2px 6px rgba(0,0,0,0.3);
1380
+ transition: transform 0.15s;
1381
+ }
1382
+ .drag-close:hover { transform: scale(1.2); }
1383
+ </style>
1384
+
1385
+ <script>
1386
+ (function () {
1387
+ 'use strict';
1388
+
1389
+ // ── Draggable logic ───────────────────────────────────────
1390
+ function makeDraggable(el) {
1391
+ let ox = 0, oy = 0, mx = 0, my = 0;
1392
+ el.addEventListener('mousedown', function (e) {
1393
+ if (e.target.classList.contains('drag-close')) return;
1394
+ e.preventDefault();
1395
+ mx = e.clientX; my = e.clientY;
1396
+ document.addEventListener('mousemove', drag);
1397
+ document.addEventListener('mouseup', stopDrag);
1398
+ });
1399
+ function drag(e) {
1400
+ ox = mx - e.clientX; oy = my - e.clientY;
1401
+ mx = e.clientX; my = e.clientY;
1402
+ el.style.top = (el.offsetTop - oy) + 'px';
1403
+ el.style.left = (el.offsetLeft - ox) + 'px';
1404
+ el.style.bottom = 'auto';
1405
+ }
1406
+ function stopDrag() {
1407
+ document.removeEventListener('mousemove', drag);
1408
+ document.removeEventListener('mouseup', stopDrag);
1409
+ }
1410
+ }
1411
+ document.querySelectorAll('.drag-gif').forEach(makeDraggable);
1412
+
1413
+ // ── Audio + GIF per-tab controller ───────────────────────
1414
+ const audShap = document.getElementById('audio-shap');
1415
+ const audWhatif = document.getElementById('audio-whatif');
1416
+ const allGifs = document.querySelectorAll('.drag-gif');
1417
+
1418
+ function stopAll() {
1419
+ allGifs.forEach(g => g.style.display = 'none');
1420
+ [audShap, audWhatif].forEach(a => { a.pause(); a.currentTime = 0; });
1421
+ }
1422
+
1423
+ window.setActiveTab = function (idx) {
1424
+ stopAll();
1425
+ if (idx === 0) { // SHAP tab
1426
+ document.getElementById('gif-shap').style.display = 'block';
1427
+ audShap.play().catch(() => {});
1428
+ } else if (idx === 3) { // What-If tab
1429
+ ['gif-wi-1','gif-wi-2','gif-wi-3'].forEach(id => {
1430
+ document.getElementById(id).style.display = 'block';
1431
+ });
1432
+ audWhatif.play().catch(() => {});
1433
+ }
1434
+ };
1435
+
1436
+ // ── Auto-detect active Streamlit tab ─────────────────────
1437
+ // Streamlit renders tabs as <li role="tab" aria-selected="true">
1438
+ function detectTab() {
1439
+ const tabs = document.querySelectorAll('[role="tab"]');
1440
+ for (let i = 0; i < tabs.length; i++) {
1441
+ if (tabs[i].getAttribute('aria-selected') === 'true') {
1442
+ window.setActiveTab(i);
1443
+ return;
1444
+ }
1445
+ }
1446
+ }
1447
+
1448
+ // Watch for Streamlit rerenders via MutationObserver
1449
+ const observer = new MutationObserver(function (mutations) {
1450
+ for (const m of mutations) {
1451
+ for (const n of m.addedNodes) {
1452
+ if (n.nodeType === 1) { detectTab(); return; }
1453
+ }
1454
+ }
1455
+ });
1456
+ observer.observe(document.body, { childList: true, subtree: true });
1457
+
1458
+ // Initial detection after short delay (Streamlit needs time to render tabs)
1459
+ setTimeout(detectTab, 800);
1460
+ setTimeout(detectTab, 2000);
1461
+ })();
1462
+ </script>
1463
+ """
1464
+ components.html(html, height=0, scrolling=False)
1465
+
1466
+
1467
  # ============================================================
1468
  # HEADER
1469
  # ============================================================
 
1579
  st.session_state.chat_history=[]
1580
  st.session_state.chat_summary=''
1581
  for k in ['wi_dig','wi_biz','wi_emp','wi_cash','wi_dur','wi_loan']: st.session_state[k]=None
 
1582
  _save_chat_memory([], '')
1583
  except Exception as e:
1584
  st.error(f"Error: {e}"); st.stop()
 
1623
  f'<div class="kpi-sub">{T(sub_k,lang)}</div></div>', unsafe_allow_html=True)
1624
  st.markdown("<br>", unsafe_allow_html=True)
1625
 
1626
+ # ============================================================
1627
+ # FLOATING MEDIA (inject once, JS auto-detects active tab)
1628
+ # ============================================================
1629
+ render_media_manager()
1630
+
1631
  # ============================================================
1632
  # TABS
1633
  # ============================================================
 
1659
  with t3:
1660
  st.markdown(f'<div class="sec-title">{T("chat_title",lang)}</div>', unsafe_allow_html=True)
1661
 
 
1662
  if st.session_state.get('chat_summary'):
1663
  n_turns = len(st.session_state.chat_history)
1664
  lbl = {'id': f'🧠 Memory aktif Β· {n_turns} pesan tersimpan',
 
1696
  st.warning(f"⚠️ Semua LLM gagal, menggunakan Smart Fallback. Error: `{_debug_err[:100]}`")
1697
  st.session_state.chat_history.append({'role':'assistant','content':ai_resp})
1698
 
 
1699
  _save_chat_memory(
1700
  st.session_state.chat_history,
1701
  st.session_state.get('chat_summary', '')