irhamni commited on
Commit
1645b76
Β·
verified Β·
1 Parent(s): e2fcb76

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +405 -211
app.py CHANGED
@@ -2,11 +2,20 @@
2
  """
3
  IPLM 2025 β€” Final (Target Sampel 33.88% per Jenis) β€” TANPA Kinerja Relatif / Percentile
4
 
5
- βœ… Ringkasan (Jenis + Keseluruhan) kini MEMUAT:
6
- - Rata2_sub_koleksi, Rata2_sub_sdm, Rata2_sub_pelayanan, Rata2_sub_pengelolaan
7
- - Rata2_dim_kepatuhan, Rata2_dim_kinerja
8
-
9
- βœ… Tabel Ringkasan tsb menjadi RUJUKAN UTAMA LLM untuk narasi laporan Word.
 
 
 
 
 
 
 
 
 
10
  """
11
 
12
  import os
@@ -14,6 +23,7 @@ import re
14
  import time
15
  import tempfile
16
  from pathlib import Path
 
17
 
18
  import gradio as gr
19
  import numpy as np
@@ -42,7 +52,7 @@ except Exception:
42
  # 1) KONFIGURASI
43
  # ============================================================
44
 
45
- DATA_FILE = os.getenv("DATA_FILE", "DATA CLEAN GABUNGAN SANGGAH-TIDAK SANGGAH - ALL190226.xlsx")
46
  POP_KAB = os.getenv("POP_KAB", "Data_populasi_Kab_kota_fixed.xlsx")
47
  POP_PROV = os.getenv("POP_PROV", "Data_populasi_propinsi.xlsx")
48
  POP_KHUSUS = os.getenv("POP_KHUSUS", "Data_populasi_perp_khusus.xlsx")
@@ -79,7 +89,7 @@ def _disp_text(x):
79
  t = str(x).strip().upper()
80
  return " ".join(t.split())
81
 
82
- def pick_col(df, candidates):
83
  if df is None or df.empty:
84
  return None
85
  for c in candidates:
@@ -101,6 +111,7 @@ def coerce_num(val):
101
  t = t.replace("\u00a0", " ").replace("Rp", "").replace("%", "")
102
  t = re.sub(r"[^0-9,.\-]", "", t)
103
 
 
104
  if t.count(".") > 1 and t.count(",") == 1:
105
  t = t.replace(".", "").replace(",", ".")
106
  elif t.count(",") > 1 and t.count(".") == 1:
@@ -179,26 +190,16 @@ def safe_div(num, den):
179
  return float(num) / float(den)
180
 
181
  def faktor_penyesuaian_total(n_total: float, target_total: float) -> float:
 
 
 
 
182
  if target_total is None or pd.isna(target_total) or float(target_total) <= 0:
183
  return 1.0
184
  if n_total is None or pd.isna(n_total) or float(n_total) < 0:
185
  n_total = 0.0
186
  return float(min(float(n_total) / float(target_total), 1.0))
187
 
188
- def df_to_markdown_table(df: pd.DataFrame, max_rows: int = 50) -> str:
189
- if df is None or df.empty:
190
- return "(tabel kosong)"
191
- d = df.copy()
192
- if len(d) > max_rows:
193
- d = d.head(max_rows)
194
- for c in d.columns:
195
- if pd.api.types.is_float_dtype(d[c]):
196
- d[c] = d[c].map(lambda x: "" if pd.isna(x) else f"{float(x):.3f}")
197
- try:
198
- return d.to_markdown(index=False)
199
- except Exception:
200
- return d.to_string(index=False)
201
-
202
 
203
  # ============================================================
204
  # 3) INDIKATOR IPLM
@@ -272,11 +273,20 @@ def _mean_norm_cols(row, cols):
272
  return float(np.mean(vals)) if vals else 0.0
273
 
274
  def prepare_global(df_src: pd.DataFrame) -> pd.DataFrame:
 
 
 
 
 
 
 
 
275
  if df_src is None or df_src.empty:
276
  return df_src
277
 
278
  df = df_src.copy()
279
 
 
280
  rename_map = {}
281
  for col in df.columns:
282
  c = _canon(col)
@@ -294,6 +304,7 @@ def prepare_global(df_src: pd.DataFrame) -> pd.DataFrame:
294
  for c in available:
295
  df[c] = df[c].apply(coerce_num)
296
 
 
297
  for c in available:
298
  x = pd.to_numeric(df[c], errors="coerce").astype(float).values
299
  mask = ~np.isnan(x)
@@ -325,9 +336,27 @@ def prepare_global(df_src: pd.DataFrame) -> pd.DataFrame:
325
  # 5) CACHE LOADER (NO UPLOAD)
326
  # ============================================================
327
 
328
- _CACHE = {"key": None, "df_all": None, "df_raw": None, "pop_kab": None, "pop_prov": None, "pop_khusus": None, "meta": None, "info": None}
 
 
 
 
 
 
 
 
 
329
 
330
  def _parse_pop_khusus(path_xlsx: str) -> pd.DataFrame:
 
 
 
 
 
 
 
 
 
331
  df = pd.read_excel(path_xlsx)
332
  if df is None or df.empty:
333
  return pd.DataFrame()
@@ -349,6 +378,7 @@ def _parse_pop_khusus(path_xlsx: str) -> pd.DataFrame:
349
 
350
  rows = []
351
  current_prov = None
 
352
  for m, pval in zip(mix.tolist(), pop_series.tolist()):
353
  mm = _disp_text(m) or ""
354
  if mm == "":
@@ -357,10 +387,20 @@ def _parse_pop_khusus(path_xlsx: str) -> pd.DataFrame:
357
  if mm.startswith("PROVINSI "):
358
  prov_name = mm.replace("PROVINSI", "").strip()
359
  current_prov = prov_name
360
- rows.append({"LEVEL": "PROV", "Provinsi_Label": f"PROVINSI {prov_name}", "Kab_Kota_Label": None, "Pop_Total_Jenis": pval})
 
 
 
 
 
361
  continue
362
 
363
- rows.append({"LEVEL": "KAB", "Provinsi_Label": f"PROVINSI {current_prov}" if current_prov else None, "Kab_Kota_Label": mm, "Pop_Total_Jenis": pval})
 
 
 
 
 
364
 
365
  pop = pd.DataFrame(rows)
366
  if pop.empty:
@@ -372,13 +412,25 @@ def _parse_pop_khusus(path_xlsx: str) -> pd.DataFrame:
372
  return pop
373
 
374
  def load_default_files(force=False):
375
- key = (DATA_FILE, POP_KAB, POP_PROV, POP_KHUSUS, _mtime(DATA_FILE), _mtime(POP_KAB), _mtime(POP_PROV), _mtime(POP_KHUSUS))
 
 
 
 
 
 
 
 
 
 
 
 
376
  if (not force) and _CACHE["key"] == key and _CACHE["df_all"] is not None:
377
  return _CACHE["df_all"], _CACHE["df_raw"], _CACHE["pop_kab"], _CACHE["pop_prov"], _CACHE["pop_khusus"], _CACHE["meta"], _CACHE["info"]
378
 
379
  for p, label in [(DATA_FILE, "DM"), (POP_KAB, "POP_KAB"), (POP_PROV, "POP_PROV"), (POP_KHUSUS, "POP_KHUSUS")]:
380
  if not Path(p).exists():
381
- info = f"❌ File {label} tidak ditemukan: `{p}`"
382
  _CACHE.update({"key": key, "df_all": None, "df_raw": None, "pop_kab": None, "pop_prov": None, "pop_khusus": None, "meta": {}, "info": info})
383
  return None, None, None, None, None, {}, info
384
 
@@ -399,7 +451,7 @@ def load_default_files(force=False):
399
  if kew_col is None: missing.append("Kewenangan")
400
  if jenis_col is None: missing.append("Jenis Perpustakaan")
401
  if missing:
402
- info = f"❌ Kolom wajib tidak ditemukan di DM: {', '.join(missing)}"
403
  _CACHE.update({"key": key, "df_all": None, "df_raw": None, "pop_kab": None, "pop_prov": None, "pop_khusus": None, "meta": {}, "info": info})
404
  return None, None, None, None, None, {}, info
405
 
@@ -416,6 +468,7 @@ def load_default_files(force=False):
416
  df_raw["prov_key"] = df_raw["PROV_DISP"].apply(norm_prov_label)
417
  df_raw["kab_key"] = df_raw["KAB_DISP"].apply(norm_kab_label)
418
 
 
419
  if nama_col and nama_col in df_raw.columns:
420
  kcols = [prov_col, kab_col, kew_col, jenis_col, nama_col]
421
  else:
@@ -427,11 +480,12 @@ def load_default_files(force=False):
427
  df_raw = df_raw.drop_duplicates(subset=["_row_key"], keep="first").copy()
428
  after = len(df_raw)
429
 
 
430
  pk = pd.read_excel(POP_KAB)
431
  c_kab = pick_col(pk, ["KABUPATEN_KOTA","Kab/Kota","Kabupaten/Kota","KAB/KOTA","Kabupaten_Kota","kab_kota","kabupaten_kota"])
432
  c_prov = pick_col(pk, ["PROVINSI","Provinsi","provinsi"])
433
  if c_kab is None:
434
- info = "❌ POP_KAB: wajib ada kolom Kab/Kota."
435
  _CACHE.update({"key": key, "df_all": None, "df_raw": None, "pop_kab": None, "pop_prov": None, "pop_khusus": None, "meta": {}, "info": info})
436
  return None, None, None, None, None, {}, info
437
 
@@ -441,10 +495,11 @@ def load_default_files(force=False):
441
  pop_kab["kab_key"] = pop_kab["Kab_Kota_Label"].apply(norm_kab_label)
442
  pop_kab = pop_kab.groupby("kab_key", as_index=False).first()
443
 
 
444
  pp = pd.read_excel(POP_PROV)
445
  c_pr = pick_col(pp, ["Provinsi","PROVINSI","provinsi","Propinsi","PROPINSI","propinsi"])
446
  if c_pr is None:
447
- info = "❌ POP_PROV: wajib ada kolom Provinsi."
448
  _CACHE.update({"key": key, "df_all": None, "df_raw": None, "pop_kab": None, "pop_prov": None, "pop_khusus": None, "meta": {}, "info": info})
449
  return None, None, None, None, None, {}, info
450
 
@@ -453,10 +508,11 @@ def load_default_files(force=False):
453
  pop_prov["prov_key"] = pop_prov["Provinsi_Label"].apply(norm_prov_label)
454
  pop_prov = pop_prov.groupby("prov_key", as_index=False).first()
455
 
 
456
  try:
457
  pop_khusus = _parse_pop_khusus(POP_KHUSUS)
458
  except Exception as e:
459
- info = f"❌ POP_KHUSUS gagal dibaca: {repr(e)}"
460
  _CACHE.update({"key": key, "df_all": None, "df_raw": None, "pop_kab": None, "pop_prov": None, "pop_khusus": None, "meta": {}, "info": info})
461
  return None, None, None, None, None, {}, info
462
 
@@ -464,16 +520,25 @@ def load_default_files(force=False):
464
  meta = dict(prov_col=prov_col, kab_col=kab_col, kew_col=kew_col, jenis_col=jenis_col, nama_col=nama_col)
465
 
466
  info = (
467
- f"βœ… Mode NO UPLOAD (cache aktif)<br>"
468
- f"βœ… DM: <b>{fp.name}</b> | Baris: {before} β†’ dedup: {after}<br>"
469
- f"βœ… POP_KAB: <b>{Path(POP_KAB).name}</b> (n={len(pop_kab)})<br>"
470
- f"βœ… POP_PROV: <b>{Path(POP_PROV).name}</b> (n={len(pop_prov)})<br>"
471
- f"βœ… POP_KHUSUS: <b>{Path(POP_KHUSUS).name}</b> (n={len(pop_khusus)}) β€” (PROV row ikut dihitung)<br>"
472
- f"βœ… TARGET sampel per jenis: <b>{TARGET_RATIO*100:.2f}%</b><br>"
473
- f"πŸ•’ mtime: DM={time.ctime(_mtime(DATA_FILE))} | Kab={time.ctime(_mtime(POP_KAB))} | Prov={time.ctime(_mtime(POP_PROV))} | Khusus={time.ctime(_mtime(POP_KHUSUS))}"
474
  )
475
 
476
- _CACHE.update({"key": key, "df_all": df_all, "df_raw": df_raw, "pop_kab": pop_kab, "pop_prov": pop_prov, "pop_khusus": pop_khusus, "meta": meta, "info": info})
 
 
 
 
 
 
 
 
 
477
  return df_all, df_raw, pop_kab, pop_prov, pop_khusus, meta, info
478
 
479
 
@@ -481,7 +546,19 @@ def load_default_files(force=False):
481
  # 6) FAKTOR WILAYAH β€” PER JENIS (TARGET 33.88%)
482
  # ============================================================
483
 
484
- def build_faktor_wilayah_jenis(df_filtered: pd.DataFrame, pop_kab: pd.DataFrame, pop_prov: pd.DataFrame, pop_khusus: pd.DataFrame, kew_value: str):
 
 
 
 
 
 
 
 
 
 
 
 
485
  if df_filtered is None or df_filtered.empty:
486
  return pd.DataFrame()
487
 
@@ -493,6 +570,7 @@ def build_faktor_wilayah_jenis(df_filtered: pd.DataFrame, pop_kab: pd.DataFrame,
493
 
494
  jenis_list = ["sekolah", "umum", "khusus"]
495
 
 
496
  if "PROV" in kew_norm:
497
  key_col, label_col, label_name, mode = "prov_key", "PROV_DISP", "Provinsi", "PROV"
498
  base_pop = pop_prov.copy() if (pop_prov is not None and not pop_prov.empty) else pd.DataFrame()
@@ -507,7 +585,10 @@ def build_faktor_wilayah_jenis(df_filtered: pd.DataFrame, pop_kab: pd.DataFrame,
507
  base_pop = base_pop.set_index("kab_key") if (not base_pop.empty and "kab_key" in base_pop.columns) else pd.DataFrame().set_index(pd.Index([]))
508
 
509
  base_keys = df[[key_col, label_col]].drop_duplicates().rename(columns={key_col: "group_key", label_col: label_name})
510
- full = base_keys.assign(_tmp=1).merge(pd.DataFrame({"Jenis": jenis_list, "_tmp": 1}), on="_tmp").drop(columns="_tmp")
 
 
 
511
 
512
  cnt = (
513
  df.groupby([key_col, label_col, "_dataset"], dropna=False)
@@ -523,10 +604,12 @@ def build_faktor_wilayah_jenis(df_filtered: pd.DataFrame, pop_kab: pd.DataFrame,
523
  base_n["target_total_33_88_jenis"] = 0.0
524
  base_n["pop_total_jenis"] = 0.0
525
 
 
526
  if not base_pop.empty:
527
  if mode == "KAB":
528
  pop_sekolah = pd.to_numeric(base_pop.get("jumlah_populasi_sekolah", 0), errors="coerce").fillna(0.0)
529
  pop_umum = pd.to_numeric(base_pop.get("jumlah_populasi_umum", 0), errors="coerce").fillna(0.0)
 
530
  tgt_sekolah = pop_sekolah * float(TARGET_RATIO)
531
  tgt_umum = pop_umum * float(TARGET_RATIO)
532
  else:
@@ -535,6 +618,7 @@ def build_faktor_wilayah_jenis(df_filtered: pd.DataFrame, pop_kab: pd.DataFrame,
535
  slb = pd.to_numeric(base_pop.get("slb", 0), errors="coerce").fillna(0.0)
536
  pop_sekolah = sma + smk + slb
537
  tgt_sekolah = pop_sekolah * float(TARGET_RATIO)
 
538
  pop_umum = pd.to_numeric(base_pop.get("perpus_umum_prop", 0), errors="coerce").fillna(0.0)
539
  tgt_umum = pop_umum * float(TARGET_RATIO)
540
 
@@ -546,6 +630,7 @@ def build_faktor_wilayah_jenis(df_filtered: pd.DataFrame, pop_kab: pd.DataFrame,
546
  base_n.loc[m, "pop_total_jenis"] = base_n.loc[m, "group_key"].map(pop_umum).fillna(0.0).values
547
  base_n.loc[m, "target_total_33_88_jenis"] = base_n.loc[m, "group_key"].map(tgt_umum).fillna(0.0).values
548
 
 
549
  if pop_khusus is not None and not pop_khusus.empty:
550
  pk = pop_khusus.copy()
551
  pk["Pop_Total_Jenis"] = pd.to_numeric(pk.get("Pop_Total_Jenis", 0), errors="coerce").fillna(0.0)
@@ -609,6 +694,13 @@ def build_faktor_wilayah_jenis(df_filtered: pd.DataFrame, pop_kab: pd.DataFrame,
609
  # ============================================================
610
 
611
  def build_agg_wilayah_jenis(df_filtered: pd.DataFrame, faktor_wilayah_jenis: pd.DataFrame, kew_value: str):
 
 
 
 
 
 
 
612
  if df_filtered is None or df_filtered.empty:
613
  return pd.DataFrame()
614
 
@@ -627,7 +719,10 @@ def build_agg_wilayah_jenis(df_filtered: pd.DataFrame, faktor_wilayah_jenis: pd.
627
  jenis_list = ["sekolah", "umum", "khusus"]
628
 
629
  base_keys = df[[key_col, label_col]].drop_duplicates().rename(columns={key_col: "group_key", label_col: label_name})
630
- full = base_keys.assign(_tmp=1).merge(pd.DataFrame({"Jenis": jenis_list, "_tmp": 1}), on="_tmp").drop(columns="_tmp")
 
 
 
631
 
632
  agg_real = df.groupby([key_col, label_col, "_dataset"], dropna=False).agg(
633
  Jumlah=("Indeks_Dasar_0_100", "size"),
@@ -641,10 +736,12 @@ def build_agg_wilayah_jenis(df_filtered: pd.DataFrame, faktor_wilayah_jenis: pd.
641
  ).reset_index().rename(columns={key_col: "group_key", label_col: label_name, "_dataset": "Jenis"})
642
 
643
  agg_real["Jenis"] = agg_real["Jenis"].astype(str).str.lower().str.strip()
644
- agg = full.merge(agg_real, on=["group_key", label_name, "Jenis"], how="left")
645
 
646
- for c in ["Jumlah","Rata2_sub_koleksi","Rata2_sub_sdm","Rata2_sub_pelayanan","Rata2_sub_pengelolaan",
647
- "Rata2_dim_kepatuhan","Rata2_dim_kinerja","Indeks_Dasar_Agregat_0_100"]:
 
 
 
648
  if c in agg.columns:
649
  agg[c] = pd.to_numeric(agg[c], errors="coerce").fillna(0.0)
650
 
@@ -660,6 +757,7 @@ def build_agg_wilayah_jenis(df_filtered: pd.DataFrame, faktor_wilayah_jenis: pd.
660
  else:
661
  fw = faktor_wilayah_jenis.copy()
662
  fw["Jenis"] = fw["Jenis"].astype(str).str.lower().str.strip()
 
663
  keep = ["group_key", label_name, "Jenis",
664
  "faktor_penyesuaian_jenis", "target_total_33_88_jenis", "pop_total_jenis",
665
  "coverage_jenis_%", "gap_target33_88_jenis", "n_jenis"]
@@ -700,6 +798,11 @@ def build_agg_wilayah_jenis(df_filtered: pd.DataFrame, faktor_wilayah_jenis: pd.
700
  # ============================================================
701
 
702
  def build_agg_wilayah_total_from_jenis(agg_jenis: pd.DataFrame, faktor_wilayah_jenis: pd.DataFrame, kew_value: str):
 
 
 
 
 
703
  if agg_jenis is None or agg_jenis.empty:
704
  return pd.DataFrame()
705
 
@@ -711,15 +814,19 @@ def build_agg_wilayah_total_from_jenis(agg_jenis: pd.DataFrame, faktor_wilayah_j
711
  a["Jenis"] = a["Jenis"].astype(str).str.lower().str.strip()
712
 
713
  base_keys = a[["group_key", label_name]].drop_duplicates()
714
- full = base_keys.assign(_tmp=1).merge(pd.DataFrame({"Jenis": jenis_list, "_tmp": 1}), on="_tmp").drop(columns="_tmp")
 
 
 
715
 
716
- cols_present = [c for c in [
717
  "Jumlah",
718
  "Rata2_sub_koleksi","Rata2_sub_sdm","Rata2_sub_pelayanan","Rata2_sub_pengelolaan",
719
  "Rata2_dim_kepatuhan","Rata2_dim_kinerja",
720
  "Indeks_Dasar_Agregat_0_100",
721
  "Indeks_Final_Agregat_0_100",
722
- ] if c in a.columns]
 
723
 
724
  full = full.merge(
725
  a[["group_key", label_name, "Jenis"] + cols_present],
@@ -742,9 +849,11 @@ def build_agg_wilayah_total_from_jenis(agg_jenis: pd.DataFrame, faktor_wilayah_j
742
  Indeks_Final_Wilayah_0_100=("Indeks_Final_Agregat_0_100", "mean"),
743
  )
744
 
 
745
  if faktor_wilayah_jenis is not None and not faktor_wilayah_jenis.empty:
746
  fw = faktor_wilayah_jenis.copy()
747
  fw["Jenis"] = fw["Jenis"].astype(str).str.lower().str.strip()
 
748
  piv = fw.pivot_table(
749
  index=["group_key", label_name],
750
  columns="Jenis",
@@ -784,7 +893,8 @@ def build_agg_wilayah_total_from_jenis(agg_jenis: pd.DataFrame, faktor_wilayah_j
784
 
785
  out["coverage_target33_88_all_%"] = np.where(
786
  pd.to_numeric(out["target_total_33_88_all"], errors="coerce").fillna(0).values > 0,
787
- (pd.to_numeric(out["terkumpul_all"], errors="coerce").fillna(0).values / pd.to_numeric(out["target_total_33_88_all"], errors="coerce").fillna(0).values) * 100.0,
 
788
  0.0
789
  )
790
  out["coverage_target33_88_all_%"] = pd.to_numeric(out["coverage_target33_88_all_%"], errors="coerce").fillna(0.0).round(2)
@@ -805,22 +915,25 @@ def build_agg_wilayah_total_from_jenis(agg_jenis: pd.DataFrame, faktor_wilayah_j
805
 
806
 
807
  # ============================================================
808
- # 9) RINGKASAN (PER JENIS) + KESELURUHAN (MEMUAT Rata2_sub/dim)
809
  # ============================================================
810
 
811
  def build_summary_per_jenis(agg_jenis: pd.DataFrame, agg_total: pd.DataFrame):
 
 
 
 
 
 
 
 
 
 
 
812
  jenis_list = ["sekolah", "umum", "khusus"]
813
- metric_cols = [
814
- "Rata2_sub_koleksi",
815
- "Rata2_sub_sdm",
816
- "Rata2_sub_pelayanan",
817
- "Rata2_sub_pengelolaan",
818
- "Rata2_dim_kepatuhan",
819
- "Rata2_dim_kinerja",
820
- ]
821
 
822
  def _row_default(jenis):
823
- base = {
824
  "Jenis": jenis,
825
  "Jumlah_Wilayah": 0,
826
  "Total_Perpus": 0,
@@ -831,10 +944,13 @@ def build_summary_per_jenis(agg_jenis: pd.DataFrame, agg_total: pd.DataFrame):
831
  "Indeks_Dasar_0_100": 0.0,
832
  "Indeks_Final_Disesuaikan_0_100": 0.0,
833
  "Penyesuaian_Poin": 0.0,
 
 
 
 
 
 
834
  }
835
- for c in metric_cols:
836
- base[c] = 0.0
837
- return base
838
 
839
  rows_by_jenis = {j: _row_default(j) for j in jenis_list}
840
 
@@ -848,8 +964,13 @@ def build_summary_per_jenis(agg_jenis: pd.DataFrame, agg_total: pd.DataFrame):
848
  "Indeks_Final_Agregat_0_100",
849
  "pop_total_jenis",
850
  "target_total_33_88_jenis",
851
- ] + metric_cols
852
-
 
 
 
 
 
853
  for c in num_cols:
854
  if c in a.columns:
855
  a[c] = pd.to_numeric(a[c], errors="coerce").fillna(0.0)
@@ -860,15 +981,22 @@ def build_summary_per_jenis(agg_jenis: pd.DataFrame, agg_total: pd.DataFrame):
860
  continue
861
 
862
  jumlah_wilayah = int(sub.shape[0])
863
- terkumpul = int(pd.to_numeric(sub.get("Jumlah", 0), errors="coerce").fillna(0).sum())
864
- pop_total = int(pd.to_numeric(sub.get("pop_total_jenis", 0), errors="coerce").fillna(0).sum())
865
- target3388 = int(pd.to_numeric(sub.get("target_total_33_88_jenis", 0), errors="coerce").fillna(0).sum())
866
-
867
  coverage = (terkumpul / target3388 * 100.0) if target3388 > 0 else 0.0
868
- dasar = float(pd.to_numeric(sub.get("Indeks_Dasar_Agregat_0_100", 0), errors="coerce").fillna(0).mean())
869
- final = float(pd.to_numeric(sub.get("Indeks_Final_Agregat_0_100", 0), errors="coerce").fillna(0).mean())
870
 
871
- row = {
 
 
 
 
 
 
 
 
 
 
872
  "Jenis": jenis,
873
  "Jumlah_Wilayah": jumlah_wilayah,
874
  "Total_Perpus": terkumpul,
@@ -879,23 +1007,42 @@ def build_summary_per_jenis(agg_jenis: pd.DataFrame, agg_total: pd.DataFrame):
879
  "Indeks_Dasar_0_100": float(dasar),
880
  "Indeks_Final_Disesuaikan_0_100": float(final),
881
  "Penyesuaian_Poin": float(final - dasar),
 
 
 
 
 
 
882
  }
883
- for c in metric_cols:
884
- row[c] = float(pd.to_numeric(sub.get(c, 0), errors="coerce").fillna(0).mean())
885
-
886
- rows_by_jenis[jenis] = row
887
 
888
  rows = [rows_by_jenis[j] for j in jenis_list]
889
 
890
- def _avg3(colname: str) -> float:
891
- return (
892
- float(rows_by_jenis["sekolah"].get(colname, 0.0))
893
- + float(rows_by_jenis["umum"].get(colname, 0.0))
894
- + float(rows_by_jenis["khusus"].get(colname, 0.0))
895
- ) / 3.0
896
-
897
- dasar_all = _avg3("Indeks_Dasar_0_100")
898
- final_all = _avg3("Indeks_Final_Disesuaikan_0_100")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
899
 
900
  pop_all = int(rows_by_jenis["sekolah"]["Pop_Total_Jenis"]
901
  + rows_by_jenis["umum"]["Pop_Total_Jenis"]
@@ -917,7 +1064,7 @@ def build_summary_per_jenis(agg_jenis: pd.DataFrame, agg_total: pd.DataFrame):
917
  rows_by_jenis["khusus"]["Jumlah_Wilayah"])
918
  )
919
 
920
- row_all = {
921
  "Jenis": "keseluruhan",
922
  "Jumlah_Wilayah": jumlah_wilayah_all,
923
  "Total_Perpus": terkumpul_all,
@@ -928,24 +1075,27 @@ def build_summary_per_jenis(agg_jenis: pd.DataFrame, agg_total: pd.DataFrame):
928
  "Indeks_Dasar_0_100": float(dasar_all),
929
  "Indeks_Final_Disesuaikan_0_100": float(final_all),
930
  "Penyesuaian_Poin": float(final_all - dasar_all),
931
- }
932
- for c in metric_cols:
933
- row_all[c] = float(_avg3(c))
 
 
 
 
934
 
935
- rows.append(row_all)
936
  out = pd.DataFrame(rows)
937
 
938
  for c in ["Jumlah_Wilayah","Total_Perpus","Pop_Total_Jenis","Target33_88_Total_Jenis","Terkumpul_Jenis"]:
939
- if c in out.columns:
940
- out[c] = pd.to_numeric(out[c], errors="coerce").fillna(0).round(0).astype(int)
941
 
942
- for c in ["Coverage_Target33_88_Jenis_%","Indeks_Dasar_0_100","Indeks_Final_Disesuaikan_0_100","Penyesuaian_Poin"] + metric_cols:
943
- if c in out.columns:
944
- out[c] = pd.to_numeric(out[c], errors="coerce").fillna(0.0).round(3)
945
 
946
- for c in ["Indeks_Dasar_0_100","Indeks_Final_Disesuaikan_0_100","Penyesuaian_Poin","Coverage_Target33_88_Jenis_%"]:
947
- if c in out.columns:
948
- out[c] = pd.to_numeric(out[c], errors="coerce").fillna(0.0).round(2)
 
 
949
 
950
  return out
951
 
@@ -1036,11 +1186,17 @@ def build_verif_jenis(faktor_wilayah_jenis: pd.DataFrame, kew_value: str):
1036
 
1037
 
1038
  # ============================================================
1039
- # 12) BELL CURVE β€” Indeks Dasar per Entitas (per Jenis) + Hover Nama
1040
  # ============================================================
1041
 
1042
- def _make_bell_curve_entitas(dfp: pd.DataFrame, title: str, xcol: str = "Indeks_Dasar_0_100",
1043
- label_col: str = "nm_perpustakaan", hover_cols=None, min_points: int = 2):
 
 
 
 
 
 
1044
  fig = go.Figure()
1045
  fig.update_layout(
1046
  title=title,
@@ -1096,8 +1252,12 @@ def _make_bell_curve_entitas(dfp: pd.DataFrame, title: str, xcol: str = "Indeks_
1096
 
1097
  if len(x) < min_points:
1098
  x_single = float(x[0])
1099
- fig.add_trace(go.Scatter(x=[x_single], y=[0], mode="markers", showlegend=False,
1100
- hovertext=[hover_text[0]] if hover_text else None, hoverinfo="text"))
 
 
 
 
1101
  fig.add_vline(x=x_single, line_width=1, line_dash="dash",
1102
  annotation_text=f"Nilai: {x_single:.1f}", annotation_position="top")
1103
  fig.update_xaxes(range=[0, 100])
@@ -1114,8 +1274,12 @@ def _make_bell_curve_entitas(dfp: pd.DataFrame, title: str, xcol: str = "Indeks_
1114
  pdf = (1.0 / (sigma * np.sqrt(2 * np.pi))) * np.exp(-0.5 * ((xs - mu) / sigma) ** 2)
1115
 
1116
  fig.add_trace(go.Scatter(x=xs, y=pdf, mode="lines", name="Kurva Normal (fit)"))
1117
- fig.add_trace(go.Scatter(x=x, y=np.zeros_like(x), mode="markers", showlegend=False,
1118
- hovertext=hover_text if hover_text else None, hoverinfo="text"))
 
 
 
 
1119
 
1120
  q1, q2, q3 = np.percentile(x, [25, 50, 75])
1121
  for xv, lab in [(q1, "Q1"), (q2, "Q2 (Median)"), (q3, "Q3"), (mu, "Mean")]:
@@ -1142,37 +1306,42 @@ def _safe_first(df, col, default=0.0, where=None):
1142
  return float(pd.to_numeric(sub[col], errors="coerce").fillna(default).iloc[0])
1143
 
1144
  def compute_dashboard_kpis(summary_jenis: pd.DataFrame):
1145
- final_all = _safe_first(summary_jenis, "Indeks_Final_Disesuaikan_0_100", 0.0, where=summary_jenis["Jenis"].astype(str).str.lower().eq("keseluruhan"))
1146
- dasar_all = _safe_first(summary_jenis, "Indeks_Dasar_0_100", 0.0, where=summary_jenis["Jenis"].astype(str).str.lower().eq("keseluruhan"))
 
 
1147
  return {"final_all": final_all, "dasar_all": dasar_all}
1148
 
1149
  def build_kpi_markdown(summary_jenis: pd.DataFrame) -> str:
1150
  if summary_jenis is None or summary_jenis.empty:
1151
  return ""
 
1152
  k = compute_dashboard_kpis(summary_jenis)
1153
 
1154
  def fmt(x, nd=2):
1155
  return "NA" if pd.isna(x) else f"{x:.{nd}f}"
1156
 
 
 
1157
  return f"""
1158
  <div style="display:flex; gap:12px; flex-wrap:wrap;">
1159
  <div style="border:1px solid #333; border-radius:10px; padding:10px 12px; min-width:260px;">
1160
- <div style="opacity:0.8;">Indeks IPLM FINAL (Disesuaikan 33.88%)</div>
1161
  <div style="font-size:26px; font-weight:700;">{fmt(k["final_all"],2)}</div>
1162
- <div style="opacity:0.7;">Skor absolut (untuk akuntabilitas)</div>
1163
  </div>
1164
 
1165
  <div style="border:1px solid #333; border-radius:10px; padding:10px 12px; min-width:260px;">
1166
  <div style="opacity:0.8;">Indeks Dasar (Tanpa Penyesuaian)</div>
1167
  <div style="font-size:26px; font-weight:700;">{fmt(k["dasar_all"],2)}</div>
1168
- <div style="opacity:0.7;">Sebelum faktor kecukupan sampel</div>
1169
  </div>
1170
  </div>
1171
  """.strip()
1172
 
1173
 
1174
  # ============================================================
1175
- # 14) LLM + WORD (LLM RUJUKAN UTAMA = TABEL RINGKASAN)
1176
  # ============================================================
1177
 
1178
  _HF_CLIENT = None
@@ -1191,95 +1360,126 @@ def get_llm_client():
1191
  _HF_CLIENT = None
1192
  return None
1193
 
1194
- def generate_llm_analysis(summary_jenis, wilayah, kew):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1195
  client = get_llm_client()
1196
  if client is None or (not USE_LLM):
1197
  return "Analisis otomatis (LLM) tidak digunakan / tidak tersedia."
1198
 
1199
- ctx = f"Wilayah={wilayah} | Kewenangan={kew} | Target={TARGET_RATIO*100:.2f}%"
1200
- summary_md = df_to_markdown_table(summary_jenis, max_rows=10)
1201
 
1202
  prompt = f"""
1203
-
1204
  Anda adalah analis kebijakan perpustakaan di Indonesia.
1205
-
1206
- Tulis analisis berbasis DATA yang sepenuhnya merujuk pada
1207
- TABEL 'Ringkasan (Jenis + Keseluruhan)'.
1208
-
1209
- ATURAN:
1210
- 1. Semua angka harus diambil persis dari tabel.
1211
- 2. Dilarang membuat angka baru atau membulatkan ulang.
1212
- 3. Dilarang menggunakan percentile atau benchmarking eksternal.
1213
- 4. Hindari kata evaluatif seperti:
1214
- baik, buruk, optimal, kurang, memuaskan.
1215
- 5. Interpretasi hanya boleh berbasis relasi angka:
1216
- selisih absolut, rentang, perbandingan lebih besar/kecil,
1217
- perubahan numerik, kontribusi.
1218
-
1219
- WAJIB DISEBUT:
1220
- - Baris: keseluruhan, sekolah, umum, khusus
1221
- - Kolom:
1222
- Indeks_Dasar_0_100
1223
- Indeks_Final_Disesuaikan_0_100
1224
- Penyesuaian_Poin
1225
- Rata2_sub_koleksi
1226
- Rata2_sub_sdm
1227
- Rata2_sub_pelayanan
1228
- Rata2_sub_pengelolaan
1229
- Rata2_dim_kepatuhan
1230
- Rata2_dim_kinerja
1231
-
1232
- WAJIB DIJELASKAN:
1233
- Indeks_Final_Disesuaikan_0_100 merupakan hasil
1234
- Indeks_Dasar_0_100 dikalikan faktor kecukupan sampel
1235
- berdasarkan target 33.88% per jenis.
1236
- Perubahan antara dasar dan final adalah efek penyesuaian tersebut.
1237
-
1238
- FORMAT:
1239
- Tepat 3 paragraf.
1240
-
1241
- Paragraf 1:
1242
- Deskripsi numerik Indeks_Dasar_0_100 dan profil sub/dimensi
1243
- untuk keseluruhan dan tiga jenis.
1244
- Sebutkan selisih atau rentang angka bila relevan.
1245
-
1246
- Paragraf 2:
1247
- Deskripsi Indeks_Final_Disesuaikan_0_100 dan Penyesuaian_Poin.
1248
- Jelaskan perubahan absolutnya dan kaitkan dengan faktor kecukupan sampel.
1249
-
1250
- Paragraf 3:
1251
- Tampilkan opsi teknis peningkatan indeks IPLM
1252
- yang diturunkan langsung dari pola angka.
1253
- Contoh:
1254
- - Sub dengan nilai paling kecil β†’ area ekspansi program.
1255
- - Gap antar dimensi β†’ arah harmonisasi kebijakan.
1256
- - Jenis dengan penyesuaian negatif terbesar β†’ fokus peningkatan coverage.
1257
- Jangan gunakan bahasa normatif, tetap berbasis angka.
1258
 
1259
  Konteks:
1260
  {ctx}
1261
 
1262
- TABEL RINGKASAN:
1263
  {summary_md}
1264
- """.strip()
1265
-
1266
  """.strip()
1267
 
1268
  try:
1269
  resp = client.chat_completion(
1270
  model=LLM_MODEL_NAME,
1271
  messages=[
1272
- {"role":"system","content":"Tulis analisis ringkas, transparan, dan berbasis angka tabel."},
1273
  {"role":"user","content":prompt}
1274
  ],
1275
- max_tokens=700,
1276
- temperature=0.25,
1277
  top_p=0.9,
1278
  )
1279
  text = resp.choices[0].message.content.strip()
1280
  return text if text else "LLM mengembalikan respon kosong."
1281
  except Exception as e:
1282
- return f"⚠️ Error LLM: {repr(e)}"
1283
 
1284
  def generate_word_report(wilayah, summary_jenis, analysis_text):
1285
  if (not DOCX_AVAILABLE) or (Document is None):
@@ -1301,13 +1501,14 @@ def generate_word_report(wilayah, summary_jenis, analysis_text):
1301
  if pd.isna(v):
1302
  cells[i].text = ""
1303
  elif isinstance(v, (float, np.floating)):
1304
- cells[i].text = f"{float(v):.3f}"
 
1305
  elif isinstance(v, (int, np.integer)):
1306
  cells[i].text = str(int(v))
1307
  else:
1308
  cells[i].text = str(v)
1309
 
1310
- doc.add_heading("Analisis (LLM)", level=2)
1311
  for p in (analysis_text or "").split("\n"):
1312
  if p.strip():
1313
  doc.add_paragraph(p.strip())
@@ -1321,7 +1522,7 @@ def generate_word_report(wilayah, summary_jenis, analysis_text):
1321
  # 15) CORE RUN
1322
  # ============================================================
1323
 
1324
- def _empty_outputs(msg="⚠️ Data belum siap."):
1325
  empty = pd.DataFrame()
1326
  empty_fig = go.Figure()
1327
  return (
@@ -1335,7 +1536,7 @@ def _empty_outputs(msg="⚠️ Data belum siap."):
1335
  def run_calc(prov_value, kab_value, kew_value, df_all, df_raw, pop_kab, pop_prov, pop_khusus, meta):
1336
  try:
1337
  if df_all is None or df_all.empty or df_raw is None or df_raw.empty:
1338
- return _empty_outputs("⚠️ Data belum ter-load. Pastikan file tersedia di repo/server.")
1339
 
1340
  df = df_all.copy()
1341
  if prov_value and prov_value != "(Semua)":
@@ -1353,17 +1554,16 @@ def run_calc(prov_value, kab_value, kew_value, df_all, df_raw, pop_kab, pop_prov
1353
  agg_jenis_full = build_agg_wilayah_jenis(df, faktor_wilayah_jenis, kew_norm)
1354
  agg_total = build_agg_wilayah_total_from_jenis(agg_jenis_full, faktor_wilayah_jenis, kew_norm)
1355
 
1356
- # βœ… Ringkasan sekarang sudah mengandung Rata2_sub* dan Rata2_dim*
1357
  summary_jenis = build_summary_per_jenis(agg_jenis_full, agg_total)
1358
  verif_total = build_verif_jenis(faktor_wilayah_jenis, kew_norm)
1359
  detail_view = attach_final_to_detail(df, agg_total, meta, kew_norm)
1360
 
1361
- # UI agg_jenis: tampil sampai indeks dasar + juga rata2 sub/dim
1362
  if agg_jenis_full is None or agg_jenis_full.empty:
1363
  agg_jenis_view = agg_jenis_full
1364
  else:
1365
  kew_norm2 = str(kew_norm).upper()
1366
- label_name = "Kab/Kota" if ("KAB" in kew_norm2 or "KOTA" in kew_norm2) else ("Provinsi" if "PROV" in kew_norm2 else "Kab/Kota")
1367
  cols_upto = [
1368
  "group_key",
1369
  label_name,
@@ -1376,6 +1576,7 @@ def run_calc(prov_value, kab_value, kew_value, df_all, df_raw, pop_kab, pop_prov
1376
  cols_upto = [c for c in cols_upto if c in agg_jenis_full.columns]
1377
  agg_jenis_view = agg_jenis_full[cols_upto].copy()
1378
 
 
1379
  raw = df_raw.copy()
1380
  if prov_value and prov_value != "(Semua)":
1381
  raw = raw[raw["PROV_DISP"] == prov_value]
@@ -1384,7 +1585,7 @@ def run_calc(prov_value, kab_value, kew_value, df_all, df_raw, pop_kab, pop_prov
1384
  if kew_value and kew_value != "(Semua)":
1385
  raw = raw[raw["KEW_NORM"] == kew_value]
1386
 
1387
- # bell curve per jenis
1388
  if detail_view is None or detail_view.empty:
1389
  fig_umum = _make_bell_curve_entitas(pd.DataFrame(), "Bell Curve β€” Jenis: Umum")
1390
  fig_sekolah = _make_bell_curve_entitas(pd.DataFrame(), "Bell Curve β€” Jenis: Sekolah")
@@ -1407,9 +1608,10 @@ def run_calc(prov_value, kab_value, kew_value, df_all, df_raw, pop_kab, pop_prov
1407
  fig_umum = _fig("umum")
1408
  fig_khusus = _fig("khusus")
1409
 
 
1410
  kpi_md = build_kpi_markdown(summary_jenis)
1411
 
1412
- # export
1413
  tmpdir = tempfile.mkdtemp()
1414
  prov_slug = (_canon(prov_value or "SEMUA").upper() or "SEMUA")
1415
  kab_slug = (_canon(kab_value or "SEMUA").upper() or "SEMUA")
@@ -1428,15 +1630,13 @@ def run_calc(prov_value, kab_value, kew_value, df_all, df_raw, pop_kab, pop_prov
1428
  verif_total.to_excel(p_verif, index=False)
1429
 
1430
  wilayah_txt = kab_value if (kab_value and kab_value != "(Semua)") else (prov_value if (prov_value and prov_value != "(Semua)") else "Nasional/All")
1431
-
1432
- # βœ… LLM merujuk TABEL RINGKASAN (summary_jenis)
1433
- analysis_text = generate_llm_analysis(summary_jenis, wilayah_txt, kew_value or "(Semua)")
1434
  word_path = generate_word_report(wilayah_txt, summary_jenis, analysis_text)
1435
 
1436
  msg = (
1437
- f"βœ… Selesai (TARGET {TARGET_RATIO*100:.2f}%): raw={len(raw)} | entitas={len(detail_view)} | "
1438
  f"wilayah(keseluruhan)={len(agg_total)} | jenis(agregat)={len(agg_jenis_full)}"
1439
- + ("" if DOCX_AVAILABLE else "<br>⚠️ python-docx tidak tersedia β†’ laporan Word dimatikan.")
1440
  )
1441
 
1442
  return (
@@ -1448,7 +1648,7 @@ def run_calc(prov_value, kab_value, kew_value, df_all, df_raw, pop_kab, pop_prov
1448
  )
1449
 
1450
  except Exception as e:
1451
- return _empty_outputs(f"⚠️ Runtime error: {repr(e)}")
1452
 
1453
 
1454
  # ============================================================
@@ -1493,34 +1693,29 @@ def on_prov_change(prov_value):
1493
 
1494
 
1495
  with gr.Blocks() as demo:
1496
- gr.Markdown(f"""
1497
- # IPLM 2025 β€” Final (Target Sampel **33.88%** per Jenis) β€” TANPA Kinerja Relatif / Percentile
1498
-
1499
- **Mode NO UPLOAD (cache aktif).** File dibaca dari repo/server:
1500
- target_pct = TARGET_RATIO * 100
1501
 
1502
- with gr.Blocks() as demo:
1503
  gr.Markdown(f"""
1504
  # IPLM 2025 β€” Final (Target Sampel **{target_pct:.2f}%** per Jenis) β€” TANPA Kinerja Relatif / Percentile
1505
 
1506
- **Mode NO UPLOAD (cache aktif).** File dibaca dari repo/server:
1507
- - `DATA_FILE` = **{DATA_FILE}**
1508
- - `POP_KAB` = **{POP_KAB}**
1509
- - `POP_PROV` = **{POP_PROV}**
1510
- - `POP_KHUSUS` = **{POP_KHUSUS}**
1511
 
1512
- **TARGET RATIO (per jenis): {target_pct:.2f}%**
1513
 
1514
- Dashboard KPI:
1515
  - Indeks IPLM FINAL (disesuaikan {target_pct:.2f}%)
1516
  - Indeks Dasar (tanpa penyesuaian)
1517
 
1518
  Ringkasan (Jenis + Keseluruhan) memuat:
1519
- - Rata2_sub_koleksi, Rata2_sub_sdm, Rata2_sub_pelayanan, Rata2_sub_pengelolaan
1520
- - Rata2_dim_kepatuhan, Rata2_dim_kinerja
1521
 
1522
- LLM untuk laporan Word wajib merujuk tabel ringkasan tersebut.
1523
- """)
1524
 
1525
  state_df = gr.State(None)
1526
  state_raw = gr.State(None)
@@ -1540,22 +1735,21 @@ LLM untuk laporan Word wajib merujuk tabel ringkasan tersebut.
1540
 
1541
  run_btn = gr.Button("Jalankan Perhitungan")
1542
  msg_out = gr.Markdown()
1543
-
1544
  kpi_out = gr.Markdown()
1545
 
1546
- gr.Markdown("## Ringkasan (Jenis + Keseluruhan) β€” Pop/Target33.88/Terkumpul/Coverage + Penyesuaian + Rata2 sub/dim")
1547
  out_summary = gr.DataFrame(interactive=False)
1548
 
1549
  gr.Markdown("## Agregat Wilayah (Keseluruhan) β€” FIX avg3")
1550
  out_agg_total = gr.DataFrame(interactive=False)
1551
 
1552
- gr.Markdown("## Agregat Wilayah Γ— Jenis β€” (ditampilkan sampai Indeks_Dasar_Agregat_0_100)")
1553
  out_agg_jenis = gr.DataFrame(interactive=False)
1554
 
1555
  gr.Markdown("## Detail Entitas (Final menempel dari wilayah)")
1556
  out_detail = gr.DataFrame(interactive=False)
1557
 
1558
- gr.Markdown("## Kecukupan Sampel 33.88%")
1559
  out_verif = gr.DataFrame(interactive=False)
1560
 
1561
  gr.Markdown("## Bell Curve β€” Indeks Dasar per Entitas (per Jenis) + Nama Perpustakaan")
@@ -1568,7 +1762,7 @@ LLM untuk laporan Word wajib merujuk tabel ringkasan tersebut.
1568
  gr.Markdown("### Perpustakaan Khusus")
1569
  bell_khusus = gr.Plot(scale=1)
1570
 
1571
- gr.Markdown("## Analisis Otomatis (LLM) β€” Rujukan Utama: Tabel Ringkasan")
1572
  analysis_out = gr.Markdown()
1573
 
1574
  with gr.Row():
@@ -1596,4 +1790,4 @@ LLM untuk laporan Word wajib merujuk tabel ringkasan tersebut.
1596
  outputs=[state_df, state_raw, state_pop_kab, state_pop_prov, state_pop_khusus, state_meta, info_box, dd_prov, dd_kab, dd_kew]
1597
  )
1598
 
1599
- demo.launch()
 
2
  """
3
  IPLM 2025 β€” Final (Target Sampel 33.88% per Jenis) β€” TANPA Kinerja Relatif / Percentile
4
 
5
+ KONSEP INTI
6
+ A) Level entitas:
7
+ Yeo-Johnson per indikator -> MinMax global (0–1) -> sub -> dim -> Indeks_Dasar_0_100
8
+ B) Penyesuaian kecukupan sampel (target 33.88% per jenis):
9
+ faktor_penyesuaian_jenis = min(n_jenis / target_total_jenis, 1.0)
10
+ Indeks_Final_Agregat_0_100 = Indeks_Dasar_Agregat_0_100 * faktor_penyesuaian_jenis
11
+ C) Keseluruhan (FIX avg3):
12
+ indeks keseluruhan = (sekolah + umum + khusus)/3, missing=0 tetap /3
13
+ D) Dashboard KPI:
14
+ hanya 2 kartu: Indeks Final & Indeks Dasar (tanpa coverage KPI)
15
+ E) Analisis LLM:
16
+ wajib merujuk TABEL Ringkasan (Jenis + Keseluruhan),
17
+ netral-deskriptif, wajib kutip angka, tanpa label normatif,
18
+ dan memuat tindakan teknis untuk menaikkan indeks (berbasis pola angka).
19
  """
20
 
21
  import os
 
23
  import time
24
  import tempfile
25
  from pathlib import Path
26
+ from typing import List, Optional
27
 
28
  import gradio as gr
29
  import numpy as np
 
52
  # 1) KONFIGURASI
53
  # ============================================================
54
 
55
+ DATA_FILE = os.getenv("DATA_FILE", "DATA CLEAN GABUNGAN SANGGAH-TIDAK SANGGAH - ALLL.xlsx")
56
  POP_KAB = os.getenv("POP_KAB", "Data_populasi_Kab_kota_fixed.xlsx")
57
  POP_PROV = os.getenv("POP_PROV", "Data_populasi_propinsi.xlsx")
58
  POP_KHUSUS = os.getenv("POP_KHUSUS", "Data_populasi_perp_khusus.xlsx")
 
89
  t = str(x).strip().upper()
90
  return " ".join(t.split())
91
 
92
+ def pick_col(df, candidates: List[str]) -> Optional[str]:
93
  if df is None or df.empty:
94
  return None
95
  for c in candidates:
 
111
  t = t.replace("\u00a0", " ").replace("Rp", "").replace("%", "")
112
  t = re.sub(r"[^0-9,.\-]", "", t)
113
 
114
+ # smart decimal
115
  if t.count(".") > 1 and t.count(",") == 1:
116
  t = t.replace(".", "").replace(",", ".")
117
  elif t.count(",") > 1 and t.count(".") == 1:
 
190
  return float(num) / float(den)
191
 
192
  def faktor_penyesuaian_total(n_total: float, target_total: float) -> float:
193
+ """
194
+ faktor = min(n / target, 1.0)
195
+ - Jika target <= 0 -> default 1.0 (tidak menghukum)
196
+ """
197
  if target_total is None or pd.isna(target_total) or float(target_total) <= 0:
198
  return 1.0
199
  if n_total is None or pd.isna(n_total) or float(n_total) < 0:
200
  n_total = 0.0
201
  return float(min(float(n_total) / float(target_total), 1.0))
202
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
203
 
204
  # ============================================================
205
  # 3) INDIKATOR IPLM
 
273
  return float(np.mean(vals)) if vals else 0.0
274
 
275
  def prepare_global(df_src: pd.DataFrame) -> pd.DataFrame:
276
+ """
277
+ Transform + normalisasi indikator pada level entitas:
278
+ - rename kolom indikator (alias)
279
+ - coerce numeric
280
+ - Yeo-Johnson per indikator (standardize=False)
281
+ - MinMax global 0-1
282
+ - hitung sub_*, dim_*, Indeks_Dasar_0_100
283
+ """
284
  if df_src is None or df_src.empty:
285
  return df_src
286
 
287
  df = df_src.copy()
288
 
289
+ # rename indikator
290
  rename_map = {}
291
  for col in df.columns:
292
  c = _canon(col)
 
304
  for c in available:
305
  df[c] = df[c].apply(coerce_num)
306
 
307
+ # YJ per indikator + MinMax global
308
  for c in available:
309
  x = pd.to_numeric(df[c], errors="coerce").astype(float).values
310
  mask = ~np.isnan(x)
 
336
  # 5) CACHE LOADER (NO UPLOAD)
337
  # ============================================================
338
 
339
+ _CACHE = {
340
+ "key": None,
341
+ "df_all": None,
342
+ "df_raw": None,
343
+ "pop_kab": None,
344
+ "pop_prov": None,
345
+ "pop_khusus": None,
346
+ "meta": None,
347
+ "info": None
348
+ }
349
 
350
  def _parse_pop_khusus(path_xlsx: str) -> pd.DataFrame:
351
+ """
352
+ POP_KHUSUS format campuran:
353
+ - Baris 'PROVINSI X' -> level PROV
354
+ - Baris berikutnya -> KAB/KOTA dibawah prov tsb
355
+ Output:
356
+ LEVEL: PROV / KAB
357
+ prov_key / kab_key
358
+ Pop_Total_Jenis
359
+ """
360
  df = pd.read_excel(path_xlsx)
361
  if df is None or df.empty:
362
  return pd.DataFrame()
 
378
 
379
  rows = []
380
  current_prov = None
381
+
382
  for m, pval in zip(mix.tolist(), pop_series.tolist()):
383
  mm = _disp_text(m) or ""
384
  if mm == "":
 
387
  if mm.startswith("PROVINSI "):
388
  prov_name = mm.replace("PROVINSI", "").strip()
389
  current_prov = prov_name
390
+ rows.append({
391
+ "LEVEL": "PROV",
392
+ "Provinsi_Label": f"PROVINSI {prov_name}",
393
+ "Kab_Kota_Label": None,
394
+ "Pop_Total_Jenis": pval,
395
+ })
396
  continue
397
 
398
+ rows.append({
399
+ "LEVEL": "KAB",
400
+ "Provinsi_Label": f"PROVINSI {current_prov}" if current_prov else None,
401
+ "Kab_Kota_Label": mm,
402
+ "Pop_Total_Jenis": pval,
403
+ })
404
 
405
  pop = pd.DataFrame(rows)
406
  if pop.empty:
 
412
  return pop
413
 
414
  def load_default_files(force=False):
415
+ """
416
+ Load:
417
+ - DM (DATA_FILE) multi-sheet -> concat
418
+ - POP_KAB, POP_PROV, POP_KHUSUS
419
+ - Standarisasi kolom wilayah & jenis
420
+ - Dedup baris DM
421
+ - prepare_global() (YJ+MinMax+Indeks_Dasar)
422
+ """
423
+ key = (
424
+ DATA_FILE, POP_KAB, POP_PROV, POP_KHUSUS,
425
+ _mtime(DATA_FILE), _mtime(POP_KAB), _mtime(POP_PROV), _mtime(POP_KHUSUS)
426
+ )
427
+
428
  if (not force) and _CACHE["key"] == key and _CACHE["df_all"] is not None:
429
  return _CACHE["df_all"], _CACHE["df_raw"], _CACHE["pop_kab"], _CACHE["pop_prov"], _CACHE["pop_khusus"], _CACHE["meta"], _CACHE["info"]
430
 
431
  for p, label in [(DATA_FILE, "DM"), (POP_KAB, "POP_KAB"), (POP_PROV, "POP_PROV"), (POP_KHUSUS, "POP_KHUSUS")]:
432
  if not Path(p).exists():
433
+ info = f"File {label} tidak ditemukan: `{p}`"
434
  _CACHE.update({"key": key, "df_all": None, "df_raw": None, "pop_kab": None, "pop_prov": None, "pop_khusus": None, "meta": {}, "info": info})
435
  return None, None, None, None, None, {}, info
436
 
 
451
  if kew_col is None: missing.append("Kewenangan")
452
  if jenis_col is None: missing.append("Jenis Perpustakaan")
453
  if missing:
454
+ info = f"Kolom wajib tidak ditemukan di DM: {', '.join(missing)}"
455
  _CACHE.update({"key": key, "df_all": None, "df_raw": None, "pop_kab": None, "pop_prov": None, "pop_khusus": None, "meta": {}, "info": info})
456
  return None, None, None, None, None, {}, info
457
 
 
468
  df_raw["prov_key"] = df_raw["PROV_DISP"].apply(norm_prov_label)
469
  df_raw["kab_key"] = df_raw["KAB_DISP"].apply(norm_kab_label)
470
 
471
+ # Dedup aman berdasarkan (prov,kab,kew,jenis,nama_perpus)
472
  if nama_col and nama_col in df_raw.columns:
473
  kcols = [prov_col, kab_col, kew_col, jenis_col, nama_col]
474
  else:
 
480
  df_raw = df_raw.drop_duplicates(subset=["_row_key"], keep="first").copy()
481
  after = len(df_raw)
482
 
483
+ # POP KAB
484
  pk = pd.read_excel(POP_KAB)
485
  c_kab = pick_col(pk, ["KABUPATEN_KOTA","Kab/Kota","Kabupaten/Kota","KAB/KOTA","Kabupaten_Kota","kab_kota","kabupaten_kota"])
486
  c_prov = pick_col(pk, ["PROVINSI","Provinsi","provinsi"])
487
  if c_kab is None:
488
+ info = "POP_KAB: wajib ada kolom Kab/Kota."
489
  _CACHE.update({"key": key, "df_all": None, "df_raw": None, "pop_kab": None, "pop_prov": None, "pop_khusus": None, "meta": {}, "info": info})
490
  return None, None, None, None, None, {}, info
491
 
 
495
  pop_kab["kab_key"] = pop_kab["Kab_Kota_Label"].apply(norm_kab_label)
496
  pop_kab = pop_kab.groupby("kab_key", as_index=False).first()
497
 
498
+ # POP PROV
499
  pp = pd.read_excel(POP_PROV)
500
  c_pr = pick_col(pp, ["Provinsi","PROVINSI","provinsi","Propinsi","PROPINSI","propinsi"])
501
  if c_pr is None:
502
+ info = "POP_PROV: wajib ada kolom Provinsi."
503
  _CACHE.update({"key": key, "df_all": None, "df_raw": None, "pop_kab": None, "pop_prov": None, "pop_khusus": None, "meta": {}, "info": info})
504
  return None, None, None, None, None, {}, info
505
 
 
508
  pop_prov["prov_key"] = pop_prov["Provinsi_Label"].apply(norm_prov_label)
509
  pop_prov = pop_prov.groupby("prov_key", as_index=False).first()
510
 
511
+ # POP KHUSUS
512
  try:
513
  pop_khusus = _parse_pop_khusus(POP_KHUSUS)
514
  except Exception as e:
515
+ info = f"POP_KHUSUS gagal dibaca: {repr(e)}"
516
  _CACHE.update({"key": key, "df_all": None, "df_raw": None, "pop_kab": None, "pop_prov": None, "pop_khusus": None, "meta": {}, "info": info})
517
  return None, None, None, None, None, {}, info
518
 
 
520
  meta = dict(prov_col=prov_col, kab_col=kab_col, kew_col=kew_col, jenis_col=jenis_col, nama_col=nama_col)
521
 
522
  info = (
523
+ f"Mode NO UPLOAD (cache aktif)\n"
524
+ f"DM: {fp.name} | Baris: {before} -> dedup: {after}\n"
525
+ f"POP_KAB: {Path(POP_KAB).name} (n={len(pop_kab)})\n"
526
+ f"POP_PROV: {Path(POP_PROV).name} (n={len(pop_prov)})\n"
527
+ f"POP_KHUSUS: {Path(POP_KHUSUS).name} (n={len(pop_khusus)})\n"
528
+ f"TARGET sampel per jenis: {TARGET_RATIO*100:.2f}%\n"
529
+ f"mtime: DM={time.ctime(_mtime(DATA_FILE))} | Kab={time.ctime(_mtime(POP_KAB))} | Prov={time.ctime(_mtime(POP_PROV))} | Khusus={time.ctime(_mtime(POP_KHUSUS))}"
530
  )
531
 
532
+ _CACHE.update({
533
+ "key": key,
534
+ "df_all": df_all,
535
+ "df_raw": df_raw,
536
+ "pop_kab": pop_kab,
537
+ "pop_prov": pop_prov,
538
+ "pop_khusus": pop_khusus,
539
+ "meta": meta,
540
+ "info": info
541
+ })
542
  return df_all, df_raw, pop_kab, pop_prov, pop_khusus, meta, info
543
 
544
 
 
546
  # 6) FAKTOR WILAYAH β€” PER JENIS (TARGET 33.88%)
547
  # ============================================================
548
 
549
+ def build_faktor_wilayah_jenis(
550
+ df_filtered: pd.DataFrame,
551
+ pop_kab: pd.DataFrame,
552
+ pop_prov: pd.DataFrame,
553
+ pop_khusus: pd.DataFrame,
554
+ kew_value: str
555
+ ):
556
+ """
557
+ Output:
558
+ group_key + (Kab/Kota atau Provinsi) + Jenis
559
+ n_jenis, pop_total_jenis, target_total_33_88_jenis,
560
+ coverage_jenis_%, faktor_penyesuaian_jenis, gap_target33_88_jenis
561
+ """
562
  if df_filtered is None or df_filtered.empty:
563
  return pd.DataFrame()
564
 
 
570
 
571
  jenis_list = ["sekolah", "umum", "khusus"]
572
 
573
+ # tentukan level berdasarkan kewenangan
574
  if "PROV" in kew_norm:
575
  key_col, label_col, label_name, mode = "prov_key", "PROV_DISP", "Provinsi", "PROV"
576
  base_pop = pop_prov.copy() if (pop_prov is not None and not pop_prov.empty) else pd.DataFrame()
 
585
  base_pop = base_pop.set_index("kab_key") if (not base_pop.empty and "kab_key" in base_pop.columns) else pd.DataFrame().set_index(pd.Index([]))
586
 
587
  base_keys = df[[key_col, label_col]].drop_duplicates().rename(columns={key_col: "group_key", label_col: label_name})
588
+ full = base_keys.assign(_tmp=1).merge(
589
+ pd.DataFrame({"Jenis": jenis_list, "_tmp": 1}),
590
+ on="_tmp"
591
+ ).drop(columns="_tmp")
592
 
593
  cnt = (
594
  df.groupby([key_col, label_col, "_dataset"], dropna=False)
 
604
  base_n["target_total_33_88_jenis"] = 0.0
605
  base_n["pop_total_jenis"] = 0.0
606
 
607
+ # SEKOLAH + UMUM dari POP_KAB/POP_PROV
608
  if not base_pop.empty:
609
  if mode == "KAB":
610
  pop_sekolah = pd.to_numeric(base_pop.get("jumlah_populasi_sekolah", 0), errors="coerce").fillna(0.0)
611
  pop_umum = pd.to_numeric(base_pop.get("jumlah_populasi_umum", 0), errors="coerce").fillna(0.0)
612
+
613
  tgt_sekolah = pop_sekolah * float(TARGET_RATIO)
614
  tgt_umum = pop_umum * float(TARGET_RATIO)
615
  else:
 
618
  slb = pd.to_numeric(base_pop.get("slb", 0), errors="coerce").fillna(0.0)
619
  pop_sekolah = sma + smk + slb
620
  tgt_sekolah = pop_sekolah * float(TARGET_RATIO)
621
+
622
  pop_umum = pd.to_numeric(base_pop.get("perpus_umum_prop", 0), errors="coerce").fillna(0.0)
623
  tgt_umum = pop_umum * float(TARGET_RATIO)
624
 
 
630
  base_n.loc[m, "pop_total_jenis"] = base_n.loc[m, "group_key"].map(pop_umum).fillna(0.0).values
631
  base_n.loc[m, "target_total_33_88_jenis"] = base_n.loc[m, "group_key"].map(tgt_umum).fillna(0.0).values
632
 
633
+ # KHUSUS dari POP_KHUSUS
634
  if pop_khusus is not None and not pop_khusus.empty:
635
  pk = pop_khusus.copy()
636
  pk["Pop_Total_Jenis"] = pd.to_numeric(pk.get("Pop_Total_Jenis", 0), errors="coerce").fillna(0.0)
 
694
  # ============================================================
695
 
696
  def build_agg_wilayah_jenis(df_filtered: pd.DataFrame, faktor_wilayah_jenis: pd.DataFrame, kew_value: str):
697
+ """
698
+ Agregasi wilayah Γ— jenis:
699
+ - Jumlah entitas
700
+ - rata-rata sub/dim
701
+ - Indeks_Dasar_Agregat_0_100 = mean(Indeks_Dasar_0_100)
702
+ - Indeks_Final_Agregat_0_100 = Indeks_Dasar_Agregat_0_100 * faktor_penyesuaian_jenis
703
+ """
704
  if df_filtered is None or df_filtered.empty:
705
  return pd.DataFrame()
706
 
 
719
  jenis_list = ["sekolah", "umum", "khusus"]
720
 
721
  base_keys = df[[key_col, label_col]].drop_duplicates().rename(columns={key_col: "group_key", label_col: label_name})
722
+ full = base_keys.assign(_tmp=1).merge(
723
+ pd.DataFrame({"Jenis": jenis_list, "_tmp": 1}),
724
+ on="_tmp"
725
+ ).drop(columns="_tmp")
726
 
727
  agg_real = df.groupby([key_col, label_col, "_dataset"], dropna=False).agg(
728
  Jumlah=("Indeks_Dasar_0_100", "size"),
 
736
  ).reset_index().rename(columns={key_col: "group_key", label_col: label_name, "_dataset": "Jenis"})
737
 
738
  agg_real["Jenis"] = agg_real["Jenis"].astype(str).str.lower().str.strip()
 
739
 
740
+ agg = full.merge(agg_real, on=["group_key", label_name, "Jenis"], how="left")
741
+ for c in [
742
+ "Jumlah","Rata2_sub_koleksi","Rata2_sub_sdm","Rata2_sub_pelayanan","Rata2_sub_pengelolaan",
743
+ "Rata2_dim_kepatuhan","Rata2_dim_kinerja","Indeks_Dasar_Agregat_0_100"
744
+ ]:
745
  if c in agg.columns:
746
  agg[c] = pd.to_numeric(agg[c], errors="coerce").fillna(0.0)
747
 
 
757
  else:
758
  fw = faktor_wilayah_jenis.copy()
759
  fw["Jenis"] = fw["Jenis"].astype(str).str.lower().str.strip()
760
+
761
  keep = ["group_key", label_name, "Jenis",
762
  "faktor_penyesuaian_jenis", "target_total_33_88_jenis", "pop_total_jenis",
763
  "coverage_jenis_%", "gap_target33_88_jenis", "n_jenis"]
 
798
  # ============================================================
799
 
800
  def build_agg_wilayah_total_from_jenis(agg_jenis: pd.DataFrame, faktor_wilayah_jenis: pd.DataFrame, kew_value: str):
801
+ """
802
+ Keseluruhan dari agg_jenis, FIX avg3:
803
+ Indeks_Dasar_Agregat_0_100 (keseluruhan) = mean(dasar_3jenis) [missing=0, tetap /3]
804
+ Indeks_Final_Wilayah_0_100 (keseluruhan) = mean(final_3jenis) [missing=0, tetap /3]
805
+ """
806
  if agg_jenis is None or agg_jenis.empty:
807
  return pd.DataFrame()
808
 
 
814
  a["Jenis"] = a["Jenis"].astype(str).str.lower().str.strip()
815
 
816
  base_keys = a[["group_key", label_name]].drop_duplicates()
817
+ full = base_keys.assign(_tmp=1).merge(
818
+ pd.DataFrame({"Jenis": jenis_list, "_tmp": 1}),
819
+ on="_tmp"
820
+ ).drop(columns="_tmp")
821
 
822
+ cols_need = [
823
  "Jumlah",
824
  "Rata2_sub_koleksi","Rata2_sub_sdm","Rata2_sub_pelayanan","Rata2_sub_pengelolaan",
825
  "Rata2_dim_kepatuhan","Rata2_dim_kinerja",
826
  "Indeks_Dasar_Agregat_0_100",
827
  "Indeks_Final_Agregat_0_100",
828
+ ]
829
+ cols_present = [c for c in cols_need if c in a.columns]
830
 
831
  full = full.merge(
832
  a[["group_key", label_name, "Jenis"] + cols_present],
 
849
  Indeks_Final_Wilayah_0_100=("Indeks_Final_Agregat_0_100", "mean"),
850
  )
851
 
852
+ # Tempel info Pop/Target/N per jenis + total (untuk verifikasi/ekspor)
853
  if faktor_wilayah_jenis is not None and not faktor_wilayah_jenis.empty:
854
  fw = faktor_wilayah_jenis.copy()
855
  fw["Jenis"] = fw["Jenis"].astype(str).str.lower().str.strip()
856
+
857
  piv = fw.pivot_table(
858
  index=["group_key", label_name],
859
  columns="Jenis",
 
893
 
894
  out["coverage_target33_88_all_%"] = np.where(
895
  pd.to_numeric(out["target_total_33_88_all"], errors="coerce").fillna(0).values > 0,
896
+ (pd.to_numeric(out["terkumpul_all"], errors="coerce").fillna(0).values /
897
+ pd.to_numeric(out["target_total_33_88_all"], errors="coerce").fillna(0).values) * 100.0,
898
  0.0
899
  )
900
  out["coverage_target33_88_all_%"] = pd.to_numeric(out["coverage_target33_88_all_%"], errors="coerce").fillna(0.0).round(2)
 
915
 
916
 
917
  # ============================================================
918
+ # 9) SUMMARY (PER JENIS) + KESELURUHAN + sub/dim
919
  # ============================================================
920
 
921
  def build_summary_per_jenis(agg_jenis: pd.DataFrame, agg_total: pd.DataFrame):
922
+ """
923
+ Ringkasan nasional (per jenis + keseluruhan) sebagai RUJUKAN UTAMA.
924
+ Memuat:
925
+ - Pop/Target33.88/Terkumpul/Coverage
926
+ - Indeks_Dasar_0_100, Indeks_Final_Disesuaikan_0_100, Penyesuaian_Poin
927
+ - Rata2 sub/dim: koleksi, sdm, pelayanan, pengelolaan, dim_kepatuhan, dim_kinerja
928
+
929
+ Catatan:
930
+ - Untuk 'keseluruhan': indeks dasar/final mengikuti aturan avg3 (missing=0 tetap /3),
931
+ sub/dim keseluruhan dihitung avg3 dari 3 jenis (missing=0 tetap /3).
932
+ """
933
  jenis_list = ["sekolah", "umum", "khusus"]
 
 
 
 
 
 
 
 
934
 
935
  def _row_default(jenis):
936
+ return {
937
  "Jenis": jenis,
938
  "Jumlah_Wilayah": 0,
939
  "Total_Perpus": 0,
 
944
  "Indeks_Dasar_0_100": 0.0,
945
  "Indeks_Final_Disesuaikan_0_100": 0.0,
946
  "Penyesuaian_Poin": 0.0,
947
+ "Rata2_sub_koleksi": 0.0,
948
+ "Rata2_sub_sdm": 0.0,
949
+ "Rata2_sub_pelayanan": 0.0,
950
+ "Rata2_sub_pengelolaan": 0.0,
951
+ "Rata2_dim_kepatuhan": 0.0,
952
+ "Rata2_dim_kinerja": 0.0,
953
  }
 
 
 
954
 
955
  rows_by_jenis = {j: _row_default(j) for j in jenis_list}
956
 
 
964
  "Indeks_Final_Agregat_0_100",
965
  "pop_total_jenis",
966
  "target_total_33_88_jenis",
967
+ "Rata2_sub_koleksi",
968
+ "Rata2_sub_sdm",
969
+ "Rata2_sub_pelayanan",
970
+ "Rata2_sub_pengelolaan",
971
+ "Rata2_dim_kepatuhan",
972
+ "Rata2_dim_kinerja",
973
+ ]
974
  for c in num_cols:
975
  if c in a.columns:
976
  a[c] = pd.to_numeric(a[c], errors="coerce").fillna(0.0)
 
981
  continue
982
 
983
  jumlah_wilayah = int(sub.shape[0])
984
+ terkumpul = int(sub.get("Jumlah", 0).sum())
985
+ pop_total = int(sub.get("pop_total_jenis", 0).sum())
986
+ target3388 = int(sub.get("target_total_33_88_jenis", 0).sum())
 
987
  coverage = (terkumpul / target3388 * 100.0) if target3388 > 0 else 0.0
 
 
988
 
989
+ dasar = float(sub.get("Indeks_Dasar_Agregat_0_100", 0).mean())
990
+ final = float(sub.get("Indeks_Final_Agregat_0_100", 0).mean())
991
+
992
+ r_kol = float(sub.get("Rata2_sub_koleksi", 0).mean())
993
+ r_sdm = float(sub.get("Rata2_sub_sdm", 0).mean())
994
+ r_pel = float(sub.get("Rata2_sub_pelayanan", 0).mean())
995
+ r_png = float(sub.get("Rata2_sub_pengelolaan", 0).mean())
996
+ r_kep = float(sub.get("Rata2_dim_kepatuhan", 0).mean())
997
+ r_kin = float(sub.get("Rata2_dim_kinerja", 0).mean())
998
+
999
+ rows_by_jenis[jenis] = {
1000
  "Jenis": jenis,
1001
  "Jumlah_Wilayah": jumlah_wilayah,
1002
  "Total_Perpus": terkumpul,
 
1007
  "Indeks_Dasar_0_100": float(dasar),
1008
  "Indeks_Final_Disesuaikan_0_100": float(final),
1009
  "Penyesuaian_Poin": float(final - dasar),
1010
+ "Rata2_sub_koleksi": r_kol,
1011
+ "Rata2_sub_sdm": r_sdm,
1012
+ "Rata2_sub_pelayanan": r_pel,
1013
+ "Rata2_sub_pengelolaan": r_png,
1014
+ "Rata2_dim_kepatuhan": r_kep,
1015
+ "Rata2_dim_kinerja": r_kin,
1016
  }
 
 
 
 
1017
 
1018
  rows = [rows_by_jenis[j] for j in jenis_list]
1019
 
1020
+ dasar_all = (rows_by_jenis["sekolah"]["Indeks_Dasar_0_100"]
1021
+ + rows_by_jenis["umum"]["Indeks_Dasar_0_100"]
1022
+ + rows_by_jenis["khusus"]["Indeks_Dasar_0_100"]) / 3.0
1023
+
1024
+ final_all = (rows_by_jenis["sekolah"]["Indeks_Final_Disesuaikan_0_100"]
1025
+ + rows_by_jenis["umum"]["Indeks_Final_Disesuaikan_0_100"]
1026
+ + rows_by_jenis["khusus"]["Indeks_Final_Disesuaikan_0_100"]) / 3.0
1027
+
1028
+ r_kol_all = (rows_by_jenis["sekolah"]["Rata2_sub_koleksi"]
1029
+ + rows_by_jenis["umum"]["Rata2_sub_koleksi"]
1030
+ + rows_by_jenis["khusus"]["Rata2_sub_koleksi"]) / 3.0
1031
+ r_sdm_all = (rows_by_jenis["sekolah"]["Rata2_sub_sdm"]
1032
+ + rows_by_jenis["umum"]["Rata2_sub_sdm"]
1033
+ + rows_by_jenis["khusus"]["Rata2_sub_sdm"]) / 3.0
1034
+ r_pel_all = (rows_by_jenis["sekolah"]["Rata2_sub_pelayanan"]
1035
+ + rows_by_jenis["umum"]["Rata2_sub_pelayanan"]
1036
+ + rows_by_jenis["khusus"]["Rata2_sub_pelayanan"]) / 3.0
1037
+ r_png_all = (rows_by_jenis["sekolah"]["Rata2_sub_pengelolaan"]
1038
+ + rows_by_jenis["umum"]["Rata2_sub_pengelolaan"]
1039
+ + rows_by_jenis["khusus"]["Rata2_sub_pengelolaan"]) / 3.0
1040
+ r_kep_all = (rows_by_jenis["sekolah"]["Rata2_dim_kepatuhan"]
1041
+ + rows_by_jenis["umum"]["Rata2_dim_kepatuhan"]
1042
+ + rows_by_jenis["khusus"]["Rata2_dim_kepatuhan"]) / 3.0
1043
+ r_kin_all = (rows_by_jenis["sekolah"]["Rata2_dim_kinerja"]
1044
+ + rows_by_jenis["umum"]["Rata2_dim_kinerja"]
1045
+ + rows_by_jenis["khusus"]["Rata2_dim_kinerja"]) / 3.0
1046
 
1047
  pop_all = int(rows_by_jenis["sekolah"]["Pop_Total_Jenis"]
1048
  + rows_by_jenis["umum"]["Pop_Total_Jenis"]
 
1064
  rows_by_jenis["khusus"]["Jumlah_Wilayah"])
1065
  )
1066
 
1067
+ rows.append({
1068
  "Jenis": "keseluruhan",
1069
  "Jumlah_Wilayah": jumlah_wilayah_all,
1070
  "Total_Perpus": terkumpul_all,
 
1075
  "Indeks_Dasar_0_100": float(dasar_all),
1076
  "Indeks_Final_Disesuaikan_0_100": float(final_all),
1077
  "Penyesuaian_Poin": float(final_all - dasar_all),
1078
+ "Rata2_sub_koleksi": float(r_kol_all),
1079
+ "Rata2_sub_sdm": float(r_sdm_all),
1080
+ "Rata2_sub_pelayanan": float(r_pel_all),
1081
+ "Rata2_sub_pengelolaan": float(r_png_all),
1082
+ "Rata2_dim_kepatuhan": float(r_kep_all),
1083
+ "Rata2_dim_kinerja": float(r_kin_all),
1084
+ })
1085
 
 
1086
  out = pd.DataFrame(rows)
1087
 
1088
  for c in ["Jumlah_Wilayah","Total_Perpus","Pop_Total_Jenis","Target33_88_Total_Jenis","Terkumpul_Jenis"]:
1089
+ out[c] = pd.to_numeric(out[c], errors="coerce").fillna(0).round(0).astype(int)
 
1090
 
1091
+ for c in ["Coverage_Target33_88_Jenis_%","Indeks_Dasar_0_100","Indeks_Final_Disesuaikan_0_100","Penyesuaian_Poin"]:
1092
+ out[c] = pd.to_numeric(out[c], errors="coerce").fillna(0.0).round(2)
 
1093
 
1094
+ for c in [
1095
+ "Rata2_sub_koleksi","Rata2_sub_sdm","Rata2_sub_pelayanan","Rata2_sub_pengelolaan",
1096
+ "Rata2_dim_kepatuhan","Rata2_dim_kinerja"
1097
+ ]:
1098
+ out[c] = pd.to_numeric(out[c], errors="coerce").fillna(0.0).round(3)
1099
 
1100
  return out
1101
 
 
1186
 
1187
 
1188
  # ============================================================
1189
+ # 12) BELL CURVE β€” Indeks Dasar per Entitas (per Jenis) + Hover Nama Perpus
1190
  # ============================================================
1191
 
1192
+ def _make_bell_curve_entitas(
1193
+ dfp: pd.DataFrame,
1194
+ title: str,
1195
+ xcol: str = "Indeks_Dasar_0_100",
1196
+ label_col: str = "nm_perpustakaan",
1197
+ hover_cols: Optional[List[str]] = None,
1198
+ min_points: int = 2
1199
+ ):
1200
  fig = go.Figure()
1201
  fig.update_layout(
1202
  title=title,
 
1252
 
1253
  if len(x) < min_points:
1254
  x_single = float(x[0])
1255
+ fig.add_trace(go.Scatter(
1256
+ x=[x_single], y=[0],
1257
+ mode="markers", showlegend=False,
1258
+ hovertext=[hover_text[0]] if hover_text else None,
1259
+ hoverinfo="text"
1260
+ ))
1261
  fig.add_vline(x=x_single, line_width=1, line_dash="dash",
1262
  annotation_text=f"Nilai: {x_single:.1f}", annotation_position="top")
1263
  fig.update_xaxes(range=[0, 100])
 
1274
  pdf = (1.0 / (sigma * np.sqrt(2 * np.pi))) * np.exp(-0.5 * ((xs - mu) / sigma) ** 2)
1275
 
1276
  fig.add_trace(go.Scatter(x=xs, y=pdf, mode="lines", name="Kurva Normal (fit)"))
1277
+ fig.add_trace(go.Scatter(
1278
+ x=x, y=np.zeros_like(x),
1279
+ mode="markers", showlegend=False,
1280
+ hovertext=hover_text if hover_text else None,
1281
+ hoverinfo="text"
1282
+ ))
1283
 
1284
  q1, q2, q3 = np.percentile(x, [25, 50, 75])
1285
  for xv, lab in [(q1, "Q1"), (q2, "Q2 (Median)"), (q3, "Q3"), (mu, "Mean")]:
 
1306
  return float(pd.to_numeric(sub[col], errors="coerce").fillna(default).iloc[0])
1307
 
1308
  def compute_dashboard_kpis(summary_jenis: pd.DataFrame):
1309
+ final_all = _safe_first(summary_jenis, "Indeks_Final_Disesuaikan_0_100", 0.0,
1310
+ where=summary_jenis["Jenis"].astype(str).str.lower().eq("keseluruhan"))
1311
+ dasar_all = _safe_first(summary_jenis, "Indeks_Dasar_0_100", 0.0,
1312
+ where=summary_jenis["Jenis"].astype(str).str.lower().eq("keseluruhan"))
1313
  return {"final_all": final_all, "dasar_all": dasar_all}
1314
 
1315
  def build_kpi_markdown(summary_jenis: pd.DataFrame) -> str:
1316
  if summary_jenis is None or summary_jenis.empty:
1317
  return ""
1318
+
1319
  k = compute_dashboard_kpis(summary_jenis)
1320
 
1321
  def fmt(x, nd=2):
1322
  return "NA" if pd.isna(x) else f"{x:.{nd}f}"
1323
 
1324
+ target_pct = TARGET_RATIO * 100.0
1325
+
1326
  return f"""
1327
  <div style="display:flex; gap:12px; flex-wrap:wrap;">
1328
  <div style="border:1px solid #333; border-radius:10px; padding:10px 12px; min-width:260px;">
1329
+ <div style="opacity:0.8;">Indeks IPLM FINAL (Disesuaikan {target_pct:.2f}%)</div>
1330
  <div style="font-size:26px; font-weight:700;">{fmt(k["final_all"],2)}</div>
1331
+ <div style="opacity:0.7;">final = dasar Γ— faktor kecukupan sampel</div>
1332
  </div>
1333
 
1334
  <div style="border:1px solid #333; border-radius:10px; padding:10px 12px; min-width:260px;">
1335
  <div style="opacity:0.8;">Indeks Dasar (Tanpa Penyesuaian)</div>
1336
  <div style="font-size:26px; font-weight:700;">{fmt(k["dasar_all"],2)}</div>
1337
+ <div style="opacity:0.7;">skor absolut sebelum faktor kecukupan data</div>
1338
  </div>
1339
  </div>
1340
  """.strip()
1341
 
1342
 
1343
  # ============================================================
1344
+ # 14) LLM + WORD (OPSIONAL)
1345
  # ============================================================
1346
 
1347
  _HF_CLIENT = None
 
1360
  _HF_CLIENT = None
1361
  return None
1362
 
1363
+ def _summary_md_for_llm(summary_jenis: pd.DataFrame) -> str:
1364
+ """
1365
+ Buat tabel markdown yang stabil untuk LLM (hanya 4 baris wajib).
1366
+ """
1367
+ if summary_jenis is None or summary_jenis.empty:
1368
+ return "(Tabel ringkasan kosong)"
1369
+
1370
+ s = summary_jenis.copy()
1371
+ s["Jenis"] = s["Jenis"].astype(str).str.lower().str.strip()
1372
+
1373
+ order = ["sekolah", "umum", "khusus", "keseluruhan"]
1374
+ s = s[s["Jenis"].isin(order)].copy()
1375
+ if s.empty:
1376
+ return "(Tabel ringkasan tidak memuat baris sekolah/umum/khusus/keseluruhan)"
1377
+
1378
+ s["_ord"] = s["Jenis"].map({k: i for i, k in enumerate(order)})
1379
+ s = s.sort_values("_ord").drop(columns=["_ord"])
1380
+
1381
+ cols = [
1382
+ "Jenis",
1383
+ "Jumlah_Wilayah",
1384
+ "Total_Perpus",
1385
+ "Pop_Total_Jenis",
1386
+ "Target33_88_Total_Jenis",
1387
+ "Terkumpul_Jenis",
1388
+ "Coverage_Target33_88_Jenis_%",
1389
+ "Indeks_Dasar_0_100",
1390
+ "Indeks_Final_Disesuaikan_0_100",
1391
+ "Penyesuaian_Poin",
1392
+ "Rata2_sub_koleksi",
1393
+ "Rata2_sub_sdm",
1394
+ "Rata2_sub_pelayanan",
1395
+ "Rata2_sub_pengelolaan",
1396
+ "Rata2_dim_kepatuhan",
1397
+ "Rata2_dim_kinerja",
1398
+ ]
1399
+ cols = [c for c in cols if c in s.columns]
1400
+ s = s[cols].copy()
1401
+
1402
+ def _fmt(v, col):
1403
+ if pd.isna(v):
1404
+ return ""
1405
+ if col in ["Jumlah_Wilayah", "Total_Perpus", "Pop_Total_Jenis", "Target33_88_Total_Jenis", "Terkumpul_Jenis"]:
1406
+ try:
1407
+ return str(int(v))
1408
+ except Exception:
1409
+ return str(v)
1410
+ if col in ["Coverage_Target33_88_Jenis_%", "Indeks_Dasar_0_100", "Indeks_Final_Disesuaikan_0_100", "Penyesuaian_Poin"]:
1411
+ return f"{float(v):.2f}"
1412
+ if col.startswith("Rata2_"):
1413
+ return f"{float(v):.3f}"
1414
+ return str(v)
1415
+
1416
+ s_disp = s.copy()
1417
+ for c in s_disp.columns:
1418
+ s_disp[c] = [_fmt(v, c) for v in s_disp[c].tolist()]
1419
+
1420
+ try:
1421
+ return s_disp.to_markdown(index=False)
1422
+ except Exception:
1423
+ return s_disp.to_string(index=False)
1424
+
1425
+ def generate_llm_analysis(summary_jenis, agg_total, verif_total, wilayah, kew):
1426
  client = get_llm_client()
1427
  if client is None or (not USE_LLM):
1428
  return "Analisis otomatis (LLM) tidak digunakan / tidak tersedia."
1429
 
1430
+ ctx = f"Wilayah={wilayah} | Kewenangan={kew} | Target_per_jenis={TARGET_RATIO*100:.2f}%"
1431
+ summary_md = _summary_md_for_llm(summary_jenis)
1432
 
1433
  prompt = f"""
 
1434
  Anda adalah analis kebijakan perpustakaan di Indonesia.
1435
+ Tulis analisis berbasis DATA, tanpa percentile/benchmarking.
1436
+
1437
+ GAYA & ATURAN (WAJIB):
1438
+ - Netral dan deskriptif: dilarang memakai label normatif seperti β€œbaik/buruk”, β€œtinggi/sedang/rendah”, β€œmemuaskan/kurang”, β€œoptimal/tidak optimal”.
1439
+ - Interpretasi hanya boleh memakai relasi angka: β€œlebih besar/kecil”, β€œselisih”, β€œgap”, β€œkonsisten/tidak konsisten”, β€œdominan”, β€œterkonsentrasi”, β€œproporsi”, β€œkontribusi”, β€œperubahan absolut”.
1440
+ - DILARANG mengarang angka. Semua angka yang disebut harus muncul di TABEL.
1441
+
1442
+ WAJIB (RUJUKAN UTAMA):
1443
+ - Jadikan TABEL 'Ringkasan (Jenis + Keseluruhan)' sebagai rujukan utama.
1444
+ - Kutip angka penting minimal untuk baris: sekolah, umum, khusus, keseluruhan.
1445
+ - Bahas metrik sub/dimensi:
1446
+ Rata2_sub_koleksi, Rata2_sub_sdm, Rata2_sub_pelayanan, Rata2_sub_pengelolaan,
1447
+ Rata2_dim_kepatuhan, Rata2_dim_kinerja.
1448
+ - Jelaskan makna penyesuaian {TARGET_RATIO*100:.2f}%:
1449
+ tekankan bahwa Indeks_Final = Indeks_Dasar Γ— faktor kecukupan sampel (target per jenis),
1450
+ sehingga selisih final vs dasar merepresentasikan konsekuensi kecukupan data (cakupan),
1451
+ bukan perubahan capaian layanan itu sendiri.
1452
+
1453
+ FORMAT OUTPUT (WAJIB): tepat 3 paragraf, tanpa bullet list.
1454
+ (1) Gambaran Indeks Dasar + profil sub/dimensi (pakai angka; sebut relasi β€œlebih besar/kecil” dan gap antar-sub/dimensi).
1455
+ (2) Dampak penyesuaian {TARGET_RATIO*100:.2f}%: bandingkan Indeks_Final vs Indeks_Dasar per jenis dan keseluruhan
1456
+ (gunakan angka Dasar, Final, dan/atau Penyesuaian_Poin dari tabel).
1457
+ (3) Tindakan teknis untuk menaikkan indeks (tanpa menghakimi): 3–4 kalimat tindakan berbasis pola angka,
1458
+ misalnya sub yang lebih kecil -> prioritas penguatan; gap kepatuhan vs kinerja -> arah intervensi;
1459
+ konsistensi antar-jenis -> standardisasi; serta langkah perbaikan kecukupan data agar selisih final vs dasar mengecil.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1460
 
1461
  Konteks:
1462
  {ctx}
1463
 
1464
+ TABEL RINGKASAN (RUJUKAN UTAMA):
1465
  {summary_md}
 
 
1466
  """.strip()
1467
 
1468
  try:
1469
  resp = client.chat_completion(
1470
  model=LLM_MODEL_NAME,
1471
  messages=[
1472
+ {"role":"system","content":"Ikuti instruksi pengguna secara ketat. Jangan menilai kualitas; hanya relasi angka."},
1473
  {"role":"user","content":prompt}
1474
  ],
1475
+ max_tokens=650,
1476
+ temperature=0.15,
1477
  top_p=0.9,
1478
  )
1479
  text = resp.choices[0].message.content.strip()
1480
  return text if text else "LLM mengembalikan respon kosong."
1481
  except Exception as e:
1482
+ return f"Error LLM: {repr(e)}"
1483
 
1484
  def generate_word_report(wilayah, summary_jenis, analysis_text):
1485
  if (not DOCX_AVAILABLE) or (Document is None):
 
1501
  if pd.isna(v):
1502
  cells[i].text = ""
1503
  elif isinstance(v, (float, np.floating)):
1504
+ # angka ringkasan sudah dibulatkan, tapi tulis rapi
1505
+ cells[i].text = f"{float(v)}"
1506
  elif isinstance(v, (int, np.integer)):
1507
  cells[i].text = str(int(v))
1508
  else:
1509
  cells[i].text = str(v)
1510
 
1511
+ doc.add_heading("Analisis (LLM, opsional)", level=2)
1512
  for p in (analysis_text or "").split("\n"):
1513
  if p.strip():
1514
  doc.add_paragraph(p.strip())
 
1522
  # 15) CORE RUN
1523
  # ============================================================
1524
 
1525
+ def _empty_outputs(msg="Data belum siap."):
1526
  empty = pd.DataFrame()
1527
  empty_fig = go.Figure()
1528
  return (
 
1536
  def run_calc(prov_value, kab_value, kew_value, df_all, df_raw, pop_kab, pop_prov, pop_khusus, meta):
1537
  try:
1538
  if df_all is None or df_all.empty or df_raw is None or df_raw.empty:
1539
+ return _empty_outputs("Data belum ter-load. Pastikan file tersedia di repo/server.")
1540
 
1541
  df = df_all.copy()
1542
  if prov_value and prov_value != "(Semua)":
 
1554
  agg_jenis_full = build_agg_wilayah_jenis(df, faktor_wilayah_jenis, kew_norm)
1555
  agg_total = build_agg_wilayah_total_from_jenis(agg_jenis_full, faktor_wilayah_jenis, kew_norm)
1556
 
 
1557
  summary_jenis = build_summary_per_jenis(agg_jenis_full, agg_total)
1558
  verif_total = build_verif_jenis(faktor_wilayah_jenis, kew_norm)
1559
  detail_view = attach_final_to_detail(df, agg_total, meta, kew_norm)
1560
 
1561
+ # agg_jenis view: tampilkan sampai indeks dasar agregat
1562
  if agg_jenis_full is None or agg_jenis_full.empty:
1563
  agg_jenis_view = agg_jenis_full
1564
  else:
1565
  kew_norm2 = str(kew_norm).upper()
1566
+ label_name = "Provinsi" if "PROV" in kew_norm2 else "Kab/Kota"
1567
  cols_upto = [
1568
  "group_key",
1569
  label_name,
 
1576
  cols_upto = [c for c in cols_upto if c in agg_jenis_full.columns]
1577
  agg_jenis_view = agg_jenis_full[cols_upto].copy()
1578
 
1579
+ # RAW untuk download (hasil filter dari df_raw)
1580
  raw = df_raw.copy()
1581
  if prov_value and prov_value != "(Semua)":
1582
  raw = raw[raw["PROV_DISP"] == prov_value]
 
1585
  if kew_value and kew_value != "(Semua)":
1586
  raw = raw[raw["KEW_NORM"] == kew_value]
1587
 
1588
+ # Bell curve per jenis (pakai detail_view)
1589
  if detail_view is None or detail_view.empty:
1590
  fig_umum = _make_bell_curve_entitas(pd.DataFrame(), "Bell Curve β€” Jenis: Umum")
1591
  fig_sekolah = _make_bell_curve_entitas(pd.DataFrame(), "Bell Curve β€” Jenis: Sekolah")
 
1608
  fig_umum = _fig("umum")
1609
  fig_khusus = _fig("khusus")
1610
 
1611
+ # KPI 2 kartu
1612
  kpi_md = build_kpi_markdown(summary_jenis)
1613
 
1614
+ # Export
1615
  tmpdir = tempfile.mkdtemp()
1616
  prov_slug = (_canon(prov_value or "SEMUA").upper() or "SEMUA")
1617
  kab_slug = (_canon(kab_value or "SEMUA").upper() or "SEMUA")
 
1630
  verif_total.to_excel(p_verif, index=False)
1631
 
1632
  wilayah_txt = kab_value if (kab_value and kab_value != "(Semua)") else (prov_value if (prov_value and prov_value != "(Semua)") else "Nasional/All")
1633
+ analysis_text = generate_llm_analysis(summary_jenis, agg_total, verif_total, wilayah_txt, kew_value or "(Semua)")
 
 
1634
  word_path = generate_word_report(wilayah_txt, summary_jenis, analysis_text)
1635
 
1636
  msg = (
1637
+ f"Selesai (TARGET {TARGET_RATIO*100:.2f}%): raw={len(raw)} | entitas={len(detail_view)} | "
1638
  f"wilayah(keseluruhan)={len(agg_total)} | jenis(agregat)={len(agg_jenis_full)}"
1639
+ + ("" if DOCX_AVAILABLE else "\npython-docx tidak tersedia -> laporan Word dimatikan.")
1640
  )
1641
 
1642
  return (
 
1648
  )
1649
 
1650
  except Exception as e:
1651
+ return _empty_outputs(f"Runtime error: {repr(e)}")
1652
 
1653
 
1654
  # ============================================================
 
1693
 
1694
 
1695
  with gr.Blocks() as demo:
1696
+ target_pct = TARGET_RATIO * 100.0
 
 
 
 
1697
 
 
1698
  gr.Markdown(f"""
1699
  # IPLM 2025 β€” Final (Target Sampel **{target_pct:.2f}%** per Jenis) β€” TANPA Kinerja Relatif / Percentile
1700
 
1701
+ Mode NO UPLOAD (cache aktif). File dibaca dari repo/server:
1702
+ - DATA_FILE = {DATA_FILE}
1703
+ - POP_KAB = {POP_KAB}
1704
+ - POP_PROV = {POP_PROV}
1705
+ - POP_KHUSUS = {POP_KHUSUS}
1706
 
1707
+ TARGET RATIO (per jenis): {target_pct:.2f}%
1708
 
1709
+ Dashboard KPI menampilkan:
1710
  - Indeks IPLM FINAL (disesuaikan {target_pct:.2f}%)
1711
  - Indeks Dasar (tanpa penyesuaian)
1712
 
1713
  Ringkasan (Jenis + Keseluruhan) memuat:
1714
+ - Pop/Target/Terkumpul/Coverage + Penyesuaian
1715
+ - Rata2 sub/dim: koleksi, sdm, pelayanan, pengelolaan, dim_kepatuhan, dim_kinerja
1716
 
1717
+ Analisis otomatis (opsional) wajib merujuk tabel ringkasan tersebut.
1718
+ """.strip())
1719
 
1720
  state_df = gr.State(None)
1721
  state_raw = gr.State(None)
 
1735
 
1736
  run_btn = gr.Button("Jalankan Perhitungan")
1737
  msg_out = gr.Markdown()
 
1738
  kpi_out = gr.Markdown()
1739
 
1740
+ gr.Markdown("## Ringkasan (Jenis + Keseluruhan) β€” Pop/Target/Terkumpul/Coverage + Penyesuaian + Rata2 sub/dim")
1741
  out_summary = gr.DataFrame(interactive=False)
1742
 
1743
  gr.Markdown("## Agregat Wilayah (Keseluruhan) β€” FIX avg3")
1744
  out_agg_total = gr.DataFrame(interactive=False)
1745
 
1746
+ gr.Markdown("## Agregat Wilayah Γ— Jenis β€” ditampilkan sampai Indeks_Dasar_Agregat_0_100")
1747
  out_agg_jenis = gr.DataFrame(interactive=False)
1748
 
1749
  gr.Markdown("## Detail Entitas (Final menempel dari wilayah)")
1750
  out_detail = gr.DataFrame(interactive=False)
1751
 
1752
+ gr.Markdown("## Kecukupan Sampel (Target per jenis)")
1753
  out_verif = gr.DataFrame(interactive=False)
1754
 
1755
  gr.Markdown("## Bell Curve β€” Indeks Dasar per Entitas (per Jenis) + Nama Perpustakaan")
 
1762
  gr.Markdown("### Perpustakaan Khusus")
1763
  bell_khusus = gr.Plot(scale=1)
1764
 
1765
+ gr.Markdown("## Analisis Otomatis (opsional)")
1766
  analysis_out = gr.Markdown()
1767
 
1768
  with gr.Row():
 
1790
  outputs=[state_df, state_raw, state_pop_kab, state_pop_prov, state_pop_khusus, state_meta, info_box, dd_prov, dd_kab, dd_kew]
1791
  )
1792
 
1793
+ demo.launch()