irhamni commited on
Commit
b7236ce
·
verified ·
1 Parent(s): 4aa26b6

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +370 -291
app.py CHANGED
@@ -1,41 +1,23 @@
1
  # -*- coding: utf-8 -*-
2
  """
3
  IPLM 2025 — Final (Target Sampel 33.88% per Jenis) — TANPA Kinerja Relatif / Percentile
4
-
5
- KONSEP / DOKUMENTASI
6
-
7
- A. Skor ABSOLUT (untuk akuntabilitas)
8
- 1) Indeks_Dasar_0_100 (level entitas):
9
- Yeo-Johnson (per indikator) -> MinMax global (0–1) -> sub-indeks -> dimensi -> indeks
10
- dim_kepatuhan = mean(sub_koleksi, sub_sdm)
11
- dim_kinerja = mean(sub_pelayanan, sub_pengelolaan)
12
- Indeks_Dasar_0_100 = 100 * (W_KEPATUHAN*dim_kepatuhan + W_KINERJA*dim_kinerja)
13
-
14
- 2) Penyesuaian kecukupan sampel (TARGET 33.88% per jenis) pada level wilayah×jenis:
15
- target_total_33_88_jenis = pop_total_jenis * TARGET_RATIO
16
- faktor_penyesuaian_jenis = min(n_jenis / target_total_33_88_jenis, 1.0)
17
- Indeks_Final_Agregat_0_100 = Indeks_Dasar_Agregat_0_100 * faktor_penyesuaian_jenis
18
-
19
- 3) Agregat wilayah keseluruhan = rata-rata 3 jenis (FIX, missing dianggap 0 dan tetap dibagi 3):
20
- Indeks_Dasar_Agregat_0_100(keseluruhan) = (dasar_sekolah + dasar_umum + dasar_khusus)/3
21
- Indeks_Final_Wilayah_0_100(keseluruhan) = (final_sekolah + final_umum + final_khusus)/3
22
-
23
- B. UI
24
- - KPI Dashboard: hanya 2 kartu (Indeks Final & Indeks Dasar)
25
- - Tanpa kartu Coverage
26
- - Bell curve: menampilkan Indeks_Dasar_0_100 per entitas per jenis, hover menampilkan nama perpustakaan
27
-
28
- C. UPDATE PERMINTAAN ANDA (LLM -> WORD tabel seperti gambar)
29
- - LLM tidak lagi menulis narasi panjang.
30
- - LLM mengisi kolom "Interpretasi" dan "Rekomendasi" pada tabel Word:
31
- No | Dimensi | Nilai | Interpretasi | Rekomendasi
32
- - Nilai diisi dari hasil hitung (angka 0–100).
33
  """
34
 
35
  import os
36
  import re
37
  import time
38
  import json
 
39
  import tempfile
40
  from pathlib import Path
41
 
@@ -45,15 +27,18 @@ import pandas as pd
45
  import plotly.graph_objects as go
46
  from sklearn.preprocessing import PowerTransformer
47
 
48
- # python-docx opsional
49
  DOCX_AVAILABLE = True
50
  try:
51
  from docx import Document
 
 
 
52
  except Exception:
53
  DOCX_AVAILABLE = False
54
  Document = None
55
 
56
- # huggingface client opsional
57
  HF_AVAILABLE = True
58
  try:
59
  from huggingface_hub import InferenceClient
@@ -368,10 +353,20 @@ def _parse_pop_khusus(path_xlsx: str) -> pd.DataFrame:
368
  if mm.startswith("PROVINSI "):
369
  prov_name = mm.replace("PROVINSI", "").strip()
370
  current_prov = prov_name
371
- rows.append({"LEVEL": "PROV", "Provinsi_Label": f"PROVINSI {prov_name}", "Kab_Kota_Label": None, "Pop_Total_Jenis": pval})
 
 
 
 
 
372
  continue
373
 
374
- rows.append({"LEVEL": "KAB", "Provinsi_Label": f"PROVINSI {current_prov}" if current_prov else None, "Kab_Kota_Label": mm, "Pop_Total_Jenis": pval})
 
 
 
 
 
375
 
376
  pop = pd.DataFrame(rows)
377
  if pop.empty:
@@ -383,13 +378,17 @@ def _parse_pop_khusus(path_xlsx: str) -> pd.DataFrame:
383
  return pop
384
 
385
  def load_default_files(force=False):
386
- key = (DATA_FILE, POP_KAB, POP_PROV, POP_KHUSUS, _mtime(DATA_FILE), _mtime(POP_KAB), _mtime(POP_PROV), _mtime(POP_KHUSUS))
 
 
 
 
387
  if (not force) and _CACHE["key"] == key and _CACHE["df_all"] is not None:
388
  return _CACHE["df_all"], _CACHE["df_raw"], _CACHE["pop_kab"], _CACHE["pop_prov"], _CACHE["pop_khusus"], _CACHE["meta"], _CACHE["info"]
389
 
390
  for p, label in [(DATA_FILE, "DM"), (POP_KAB, "POP_KAB"), (POP_PROV, "POP_PROV"), (POP_KHUSUS, "POP_KHUSUS")]:
391
  if not Path(p).exists():
392
- info = f"File {label} tidak ditemukan: `{p}`"
393
  _CACHE.update({"key": key, "df_all": None, "df_raw": None, "pop_kab": None, "pop_prov": None, "pop_khusus": None, "meta": {}, "info": info})
394
  return None, None, None, None, None, {}, info
395
 
@@ -438,6 +437,7 @@ def load_default_files(force=False):
438
  df_raw = df_raw.drop_duplicates(subset=["_row_key"], keep="first").copy()
439
  after = len(df_raw)
440
 
 
441
  pk = pd.read_excel(POP_KAB)
442
  c_kab = pick_col(pk, ["KABUPATEN_KOTA","Kab/Kota","Kabupaten/Kota","KAB/KOTA","Kabupaten_Kota","kab_kota","kabupaten_kota"])
443
  c_prov = pick_col(pk, ["PROVINSI","Provinsi","provinsi"])
@@ -452,6 +452,7 @@ def load_default_files(force=False):
452
  pop_kab["kab_key"] = pop_kab["Kab_Kota_Label"].apply(norm_kab_label)
453
  pop_kab = pop_kab.groupby("kab_key", as_index=False).first()
454
 
 
455
  pp = pd.read_excel(POP_PROV)
456
  c_pr = pick_col(pp, ["Provinsi","PROVINSI","provinsi","Propinsi","PROPINSI","propinsi"])
457
  if c_pr is None:
@@ -464,6 +465,7 @@ def load_default_files(force=False):
464
  pop_prov["prov_key"] = pop_prov["Provinsi_Label"].apply(norm_prov_label)
465
  pop_prov = pop_prov.groupby("prov_key", as_index=False).first()
466
 
 
467
  try:
468
  pop_khusus = _parse_pop_khusus(POP_KHUSUS)
469
  except Exception as e:
@@ -479,12 +481,21 @@ def load_default_files(force=False):
479
  f"DM: {fp.name} | Baris: {before} -> dedup: {after}\n"
480
  f"POP_KAB: {Path(POP_KAB).name} (n={len(pop_kab)})\n"
481
  f"POP_PROV: {Path(POP_PROV).name} (n={len(pop_prov)})\n"
482
- f"POP_KHUSUS: {Path(POP_KHUSUS).name} (n={len(pop_khusus)}) (termasuk baris PROV)\n"
483
  f"TARGET sampel per jenis: {TARGET_RATIO*100:.2f}%\n"
484
  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))}"
485
- ).replace("\n", "<br>")
486
 
487
- _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})
 
 
 
 
 
 
 
 
 
488
  return df_all, df_raw, pop_kab, pop_prov, pop_khusus, meta, info
489
 
490
 
@@ -492,7 +503,7 @@ def load_default_files(force=False):
492
  # 6) FAKTOR WILAYAH — PER JENIS (TARGET 33.88%)
493
  # ============================================================
494
 
495
- def build_faktor_wilayah_jenis(df_filtered: pd.DataFrame, pop_kab: pd.DataFrame, pop_prov: pd.DataFrame, pop_khusus: pd.DataFrame, kew_value: str):
496
  if df_filtered is None or df_filtered.empty:
497
  return pd.DataFrame()
498
 
@@ -508,13 +519,13 @@ def build_faktor_wilayah_jenis(df_filtered: pd.DataFrame, pop_kab: pd.DataFrame,
508
  key_col, label_col, label_name, mode = "prov_key", "PROV_DISP", "Provinsi", "PROV"
509
  base_pop = pop_prov.copy() if (pop_prov is not None and not pop_prov.empty) else pd.DataFrame()
510
  if not base_pop.empty and "prov_key" not in base_pop.columns:
511
- base_pop["prov_key"] = base_pop["Provinsi_Label"].apply(norm_prov_label) if "Provinsi_Label" in base_pop.columns else base_pop.iloc[:, 0].apply(norm_prov_label)
512
  base_pop = base_pop.set_index("prov_key") if (not base_pop.empty and "prov_key" in base_pop.columns) else pd.DataFrame().set_index(pd.Index([]))
513
  else:
514
  key_col, label_col, label_name, mode = "kab_key", "KAB_DISP", "Kab/Kota", "KAB"
515
  base_pop = pop_kab.copy() if (pop_kab is not None and not pop_kab.empty) else pd.DataFrame()
516
  if not base_pop.empty and "kab_key" not in base_pop.columns:
517
- base_pop["kab_key"] = base_pop["Kab_Kota_Label"].apply(norm_kab_label) if "Kab_Kota_Label" in base_pop.columns else base_pop.iloc[:, 0].apply(norm_kab_label)
518
  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([]))
519
 
520
  base_keys = df[[key_col, label_col]].drop_duplicates().rename(columns={key_col: "group_key", label_col: label_name})
@@ -619,7 +630,7 @@ def build_faktor_wilayah_jenis(df_filtered: pd.DataFrame, pop_kab: pd.DataFrame,
619
  # 7) AGREGAT WILAYAH × JENIS
620
  # ============================================================
621
 
622
- def build_agg_wilayah_jenis(df_filtered: pd.DataFrame, faktor_wilayah_jenis: pd.DataFrame, kew_value: str):
623
  if df_filtered is None or df_filtered.empty:
624
  return pd.DataFrame()
625
 
@@ -652,8 +663,8 @@ def build_agg_wilayah_jenis(df_filtered: pd.DataFrame, faktor_wilayah_jenis: pd.
652
  ).reset_index().rename(columns={key_col: "group_key", label_col: label_name, "_dataset": "Jenis"})
653
 
654
  agg_real["Jenis"] = agg_real["Jenis"].astype(str).str.lower().str.strip()
655
- agg = full.merge(agg_real, on=["group_key", label_name, "Jenis"], how="left")
656
 
 
657
  for c in ["Jumlah","Rata2_sub_koleksi","Rata2_sub_sdm","Rata2_sub_pelayanan","Rata2_sub_pengelolaan",
658
  "Rata2_dim_kepatuhan","Rata2_dim_kinerja","Indeks_Dasar_Agregat_0_100"]:
659
  if c in agg.columns:
@@ -662,11 +673,6 @@ def build_agg_wilayah_jenis(df_filtered: pd.DataFrame, faktor_wilayah_jenis: pd.
662
 
663
  if faktor_wilayah_jenis is None or faktor_wilayah_jenis.empty:
664
  agg["faktor_penyesuaian_jenis"] = 1.0
665
- agg["target_total_33_88_jenis"] = 0
666
- agg["pop_total_jenis"] = 0
667
- agg["coverage_jenis_%"] = 0.0
668
- agg["gap_target33_88_jenis"] = 0
669
- agg["n_jenis"] = 0
670
  else:
671
  fw = faktor_wilayah_jenis.copy()
672
  fw["Jenis"] = fw["Jenis"].astype(str).str.lower().str.strip()
@@ -674,37 +680,33 @@ def build_agg_wilayah_jenis(df_filtered: pd.DataFrame, faktor_wilayah_jenis: pd.
674
  "faktor_penyesuaian_jenis", "target_total_33_88_jenis", "pop_total_jenis",
675
  "coverage_jenis_%", "gap_target33_88_jenis", "n_jenis"]
676
  fw = fw[[c for c in keep if c in fw.columns]].copy()
677
-
678
  agg = agg.merge(fw, on=["group_key", label_name, "Jenis"], how="left")
679
  agg["faktor_penyesuaian_jenis"] = pd.to_numeric(agg["faktor_penyesuaian_jenis"], errors="coerce").fillna(1.0)
680
 
681
- for c in ["target_total_33_88_jenis","pop_total_jenis","gap_target33_88_jenis","n_jenis"]:
682
- if c in agg.columns:
683
- agg[c] = pd.to_numeric(agg[c], errors="coerce").fillna(0).round(0).astype(int)
684
-
685
- if "coverage_jenis_%" in agg.columns:
686
- agg["coverage_jenis_%"] = pd.to_numeric(agg["coverage_jenis_%"], errors="coerce").fillna(0.0).round(2)
687
-
688
  agg["Indeks_Final_Agregat_0_100"] = (
689
  pd.to_numeric(agg["Indeks_Dasar_Agregat_0_100"], errors="coerce").fillna(0.0)
690
  * pd.to_numeric(agg["faktor_penyesuaian_jenis"], errors="coerce").fillna(1.0)
691
  )
692
 
693
- for c in ["Rata2_sub_koleksi","Rata2_sub_sdm","Rata2_sub_pelayanan","Rata2_sub_pengelolaan","Rata2_dim_kepatuhan","Rata2_dim_kinerja"]:
 
 
 
694
  if c in agg.columns:
695
  agg[c] = pd.to_numeric(agg[c], errors="coerce").fillna(0.0).round(3)
696
  for c in ["Indeks_Dasar_Agregat_0_100","Indeks_Final_Agregat_0_100"]:
697
  if c in agg.columns:
698
  agg[c] = pd.to_numeric(agg[c], errors="coerce").fillna(0.0).round(2)
 
699
  agg["faktor_penyesuaian_jenis"] = pd.to_numeric(agg["faktor_penyesuaian_jenis"], errors="coerce").fillna(1.0).round(3)
700
  return agg
701
 
702
 
703
  # ============================================================
704
- # 8) AGREGAT WILAYAH (KESELURUHAN) — avg3 FIX
705
  # ============================================================
706
 
707
- def build_agg_wilayah_total_from_jenis(agg_jenis: pd.DataFrame, faktor_wilayah_jenis: pd.DataFrame, kew_value: str):
708
  if agg_jenis is None or agg_jenis.empty:
709
  return pd.DataFrame()
710
 
@@ -718,12 +720,16 @@ def build_agg_wilayah_total_from_jenis(agg_jenis: pd.DataFrame, faktor_wilayah_j
718
  base_keys = a[["group_key", label_name]].drop_duplicates()
719
  full = base_keys.assign(_tmp=1).merge(pd.DataFrame({"Jenis": jenis_list, "_tmp": 1}), on="_tmp").drop(columns="_tmp")
720
 
721
- cols_need = ["Jumlah","Rata2_sub_koleksi","Rata2_sub_sdm","Rata2_sub_pelayanan","Rata2_sub_pengelolaan","Rata2_dim_kepatuhan","Rata2_dim_kinerja",
722
- "Indeks_Dasar_Agregat_0_100","Indeks_Final_Agregat_0_100"]
723
- cols_present = [c for c in cols_need if c in a.columns]
724
-
725
- full = full.merge(a[["group_key", label_name, "Jenis"] + cols_present], on=["group_key", label_name, "Jenis"], how="left")
 
 
726
 
 
 
727
  for c in cols_present:
728
  full[c] = pd.to_numeric(full[c], errors="coerce").fillna(0.0)
729
 
@@ -739,47 +745,12 @@ def build_agg_wilayah_total_from_jenis(agg_jenis: pd.DataFrame, faktor_wilayah_j
739
  Indeks_Final_Wilayah_0_100=("Indeks_Final_Agregat_0_100", "mean"),
740
  )
741
 
742
- if faktor_wilayah_jenis is not None and not faktor_wilayah_jenis.empty:
743
- fw = faktor_wilayah_jenis.copy()
744
- fw["Jenis"] = fw["Jenis"].astype(str).str.lower().str.strip()
745
-
746
- piv = fw.pivot_table(
747
- index=["group_key", label_name],
748
- columns="Jenis",
749
- values=["pop_total_jenis", "target_total_33_88_jenis", "n_jenis", "gap_target33_88_jenis", "faktor_penyesuaian_jenis"],
750
- aggfunc="first"
751
- )
752
- piv.columns = [f"{v}_{k}" for v, k in piv.columns]
753
- piv = piv.reset_index()
754
- out = out.merge(piv, on=["group_key", label_name], how="left")
755
-
756
- for j in ["sekolah", "umum", "khusus"]:
757
- for basecol in ["pop_total_jenis", "target_total_33_88_jenis", "n_jenis", "gap_target33_88_jenis"]:
758
- c = f"{basecol}_{j}"
759
- if c in out.columns:
760
- out[c] = pd.to_numeric(out[c], errors="coerce").fillna(0).round(0).astype(int)
761
- cfac = f"faktor_penyesuaian_jenis_{j}"
762
- if cfac in out.columns:
763
- out[cfac] = pd.to_numeric(out[cfac], errors="coerce").fillna(1.0).round(3)
764
-
765
- out["pop_total_all"] = (out.get("pop_total_jenis_sekolah", 0) + out.get("pop_total_jenis_umum", 0) + out.get("pop_total_jenis_khusus", 0)).astype(int)
766
- out["target_total_33_88_all"] = (out.get("target_total_33_88_jenis_sekolah", 0) + out.get("target_total_33_88_jenis_umum", 0) + out.get("target_total_33_88_jenis_khusus", 0)).astype(int)
767
- out["terkumpul_all"] = (out.get("n_jenis_sekolah", 0) + out.get("n_jenis_umum", 0) + out.get("n_jenis_khusus", 0)).astype(int)
768
- out["coverage_target33_88_all_%"] = np.where(
769
- pd.to_numeric(out["target_total_33_88_all"], errors="coerce").fillna(0).values > 0,
770
- (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,
771
- 0.0
772
- )
773
- out["coverage_target33_88_all_%"] = pd.to_numeric(out["coverage_target33_88_all_%"], errors="coerce").fillna(0.0).round(2)
774
-
775
  for c in ["Rata2_sub_koleksi","Rata2_sub_sdm","Rata2_sub_pelayanan","Rata2_sub_pengelolaan","Rata2_dim_kepatuhan","Rata2_dim_kinerja"]:
776
- if c in out.columns:
777
- out[c] = pd.to_numeric(out[c], errors="coerce").fillna(0.0).round(3)
778
  for c in ["Indeks_Dasar_Agregat_0_100","Indeks_Final_Wilayah_0_100"]:
779
- if c in out.columns:
780
- out[c] = pd.to_numeric(out[c], errors="coerce").fillna(0.0).round(2)
781
-
782
  out["n_total"] = pd.to_numeric(out["n_total"], errors="coerce").fillna(0).round(0).astype(int)
 
783
  return out
784
 
785
 
@@ -787,7 +758,7 @@ def build_agg_wilayah_total_from_jenis(agg_jenis: pd.DataFrame, faktor_wilayah_j
787
  # 9) SUMMARY (PER JENIS) + KESELURUHAN
788
  # ============================================================
789
 
790
- def build_summary_per_jenis(agg_jenis: pd.DataFrame, agg_total: pd.DataFrame):
791
  jenis_list = ["sekolah", "umum", "khusus"]
792
 
793
  def _row_default(jenis):
@@ -809,7 +780,6 @@ def build_summary_per_jenis(agg_jenis: pd.DataFrame, agg_total: pd.DataFrame):
809
  if agg_jenis is not None and not agg_jenis.empty:
810
  a = agg_jenis.copy()
811
  a["Jenis"] = a["Jenis"].astype(str).str.lower().str.strip()
812
-
813
  for c in ["Jumlah","Indeks_Dasar_Agregat_0_100","Indeks_Final_Agregat_0_100","pop_total_jenis","target_total_33_88_jenis"]:
814
  if c in a.columns:
815
  a[c] = pd.to_numeric(a[c], errors="coerce").fillna(0)
@@ -843,16 +813,32 @@ def build_summary_per_jenis(agg_jenis: pd.DataFrame, agg_total: pd.DataFrame):
843
 
844
  rows = [rows_by_jenis[j] for j in jenis_list]
845
 
846
- dasar_all = (rows_by_jenis["sekolah"]["Indeks_Dasar_0_100"] + rows_by_jenis["umum"]["Indeks_Dasar_0_100"] + rows_by_jenis["khusus"]["Indeks_Dasar_0_100"]) / 3.0
847
- final_all = (rows_by_jenis["sekolah"]["Indeks_Final_Disesuaikan_0_100"] + rows_by_jenis["umum"]["Indeks_Final_Disesuaikan_0_100"] + rows_by_jenis["khusus"]["Indeks_Final_Disesuaikan_0_100"]) / 3.0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
848
 
849
- pop_all = int(rows_by_jenis["sekolah"]["Pop_Total_Jenis"] + rows_by_jenis["umum"]["Pop_Total_Jenis"] + rows_by_jenis["khusus"]["Pop_Total_Jenis"])
850
- target_all = int(rows_by_jenis["sekolah"]["Target33_88_Total_Jenis"] + rows_by_jenis["umum"]["Target33_88_Total_Jenis"] + rows_by_jenis["khusus"]["Target33_88_Total_Jenis"])
851
- terkumpul_all = int(rows_by_jenis["sekolah"]["Terkumpul_Jenis"] + rows_by_jenis["umum"]["Terkumpul_Jenis"] + rows_by_jenis["khusus"]["Terkumpul_Jenis"])
852
  coverage_all = (terkumpul_all / target_all * 100.0) if target_all > 0 else 0.0
853
 
854
  jumlah_wilayah_all = int(agg_total.shape[0]) if (agg_total is not None and not agg_total.empty) else int(
855
- max(rows_by_jenis["sekolah"]["Jumlah_Wilayah"], rows_by_jenis["umum"]["Jumlah_Wilayah"], rows_by_jenis["khusus"]["Jumlah_Wilayah"])
 
 
856
  )
857
 
858
  rows.append({
@@ -869,22 +855,18 @@ def build_summary_per_jenis(agg_jenis: pd.DataFrame, agg_total: pd.DataFrame):
869
  })
870
 
871
  out = pd.DataFrame(rows)
872
-
873
  for c in ["Jumlah_Wilayah","Total_Perpus","Pop_Total_Jenis","Target33_88_Total_Jenis","Terkumpul_Jenis"]:
874
- if c in out.columns:
875
- out[c] = pd.to_numeric(out[c], errors="coerce").fillna(0).round(0).astype(int)
876
  for c in ["Coverage_Target33_88_Jenis_%","Indeks_Dasar_0_100","Indeks_Final_Disesuaikan_0_100","Penyesuaian_Poin"]:
877
- if c in out.columns:
878
- out[c] = pd.to_numeric(out[c], errors="coerce").fillna(0.0).round(2)
879
-
880
  return out
881
 
882
 
883
  # ============================================================
884
- # 10) DETAIL ENTITAS: Final menempel dari agg_total (wilayah)
885
  # ============================================================
886
 
887
- def attach_final_to_detail(df_filtered: pd.DataFrame, agg_total: pd.DataFrame, meta: dict, kew_value: str):
888
  if df_filtered is None or df_filtered.empty:
889
  return pd.DataFrame()
890
 
@@ -928,15 +910,14 @@ def attach_final_to_detail(df_filtered: pd.DataFrame, agg_total: pd.DataFrame, m
928
  for c in ["Indeks_Dasar_0_100","Indeks_Final_0_100"]:
929
  if c in out.columns:
930
  out[c] = pd.to_numeric(out[c], errors="coerce").fillna(0.0).round(2)
931
-
932
  return out
933
 
934
 
935
  # ============================================================
936
- # 11) VERIFIKASI PER JENIS (TARGET 33.88%)
937
  # ============================================================
938
 
939
- def build_verif_jenis(faktor_wilayah_jenis: pd.DataFrame, kew_value: str):
940
  if faktor_wilayah_jenis is None or faktor_wilayah_jenis.empty:
941
  return pd.DataFrame()
942
 
@@ -944,17 +925,19 @@ def build_verif_jenis(faktor_wilayah_jenis: pd.DataFrame, kew_value: str):
944
  label_col = "Provinsi" if "PROV" in kew_norm else "Kab/Kota"
945
 
946
  out = faktor_wilayah_jenis.copy()
947
- keep = [c for c in [label_col, "Jenis", "pop_total_jenis", "target_total_33_88_jenis", "n_jenis",
948
- "coverage_jenis_%", "faktor_penyesuaian_jenis", "gap_target33_88_jenis"] if c in out.columns]
 
 
 
 
949
  out = out[keep].copy()
950
 
951
  for c in ["pop_total_jenis", "target_total_33_88_jenis", "n_jenis", "gap_target33_88_jenis"]:
952
  if c in out.columns:
953
  out[c] = pd.to_numeric(out[c], errors="coerce").fillna(0).round(0).astype(int)
954
-
955
  if "coverage_jenis_%" in out.columns:
956
  out["coverage_jenis_%"] = pd.to_numeric(out["coverage_jenis_%"], errors="coerce").fillna(0.0).round(2)
957
-
958
  if "faktor_penyesuaian_jenis" in out.columns:
959
  out["faktor_penyesuaian_jenis"] = pd.to_numeric(out["faktor_penyesuaian_jenis"], errors="coerce").fillna(1.0).round(3)
960
 
@@ -962,11 +945,10 @@ def build_verif_jenis(faktor_wilayah_jenis: pd.DataFrame, kew_value: str):
962
 
963
 
964
  # ============================================================
965
- # 12) BELL CURVE — Indeks Dasar per Entitas (per Jenis) + Hover Nama Perpus
966
  # ============================================================
967
 
968
- def _make_bell_curve_entitas(dfp: pd.DataFrame, title: str, xcol: str = "Indeks_Dasar_0_100",
969
- label_col: str = "nm_perpustakaan", hover_cols=None, min_points: int = 2):
970
  fig = go.Figure()
971
  fig.update_layout(
972
  title=title,
@@ -1021,7 +1003,12 @@ def _make_bell_curve_entitas(dfp: pd.DataFrame, title: str, xcol: str = "Indeks_
1021
 
1022
  if len(x) < min_points:
1023
  x_single = float(x[0])
1024
- fig.add_trace(go.Scatter(x=[x_single], y=[0], mode="markers", showlegend=False, hovertext=[hover_text[0]] if hover_text else None, hoverinfo="text"))
 
 
 
 
 
1025
  fig.add_vline(x=x_single, line_width=1, line_dash="dash", annotation_text=f"Nilai: {x_single:.1f}", annotation_position="top")
1026
  fig.update_xaxes(range=[0, 100])
1027
  fig.update_yaxes(rangemode="tozero")
@@ -1037,7 +1024,12 @@ def _make_bell_curve_entitas(dfp: pd.DataFrame, title: str, xcol: str = "Indeks_
1037
  pdf = (1.0 / (sigma * np.sqrt(2 * np.pi))) * np.exp(-0.5 * ((xs - mu) / sigma) ** 2)
1038
 
1039
  fig.add_trace(go.Scatter(x=xs, y=pdf, mode="lines", name="Kurva Normal (fit)"))
1040
- fig.add_trace(go.Scatter(x=x, y=np.zeros_like(x), mode="markers", showlegend=False, hovertext=hover_text if hover_text else None, hoverinfo="text"))
 
 
 
 
 
1041
 
1042
  q1, q2, q3 = np.percentile(x, [25, 50, 75])
1043
  for xv, lab in [(q1, "Q1"), (q2, "Q2 (Median)"), (q3, "Q3"), (mu, "Mean")]:
@@ -1049,7 +1041,7 @@ def _make_bell_curve_entitas(dfp: pd.DataFrame, title: str, xcol: str = "Indeks_
1049
 
1050
 
1051
  # ============================================================
1052
- # 13) KPI DASHBOARD (HANYA 2 KARTU: FINAL + DASAR)
1053
  # ============================================================
1054
 
1055
  def _safe_first(df, col, default=0.0, where=None):
@@ -1062,16 +1054,11 @@ def _safe_first(df, col, default=0.0, where=None):
1062
  return default
1063
  return float(pd.to_numeric(sub[col], errors="coerce").fillna(default).iloc[0])
1064
 
1065
- def compute_dashboard_kpis(summary_jenis: pd.DataFrame):
1066
- final_all = _safe_first(summary_jenis, "Indeks_Final_Disesuaikan_0_100", 0.0, where=summary_jenis["Jenis"].astype(str).str.lower().eq("keseluruhan"))
1067
- dasar_all = _safe_first(summary_jenis, "Indeks_Dasar_0_100", 0.0, where=summary_jenis["Jenis"].astype(str).str.lower().eq("keseluruhan"))
1068
- return {"final_all": final_all, "dasar_all": dasar_all}
1069
-
1070
- def build_kpi_markdown(summary_jenis: pd.DataFrame) -> str:
1071
  if summary_jenis is None or summary_jenis.empty:
1072
  return ""
1073
-
1074
- k = compute_dashboard_kpis(summary_jenis)
1075
 
1076
  def fmt(x, nd=2):
1077
  return "NA" if pd.isna(x) else f"{x:.{nd}f}"
@@ -1080,13 +1067,13 @@ def build_kpi_markdown(summary_jenis: pd.DataFrame) -> str:
1080
  <div style="display:flex; gap:12px; flex-wrap:wrap;">
1081
  <div style="border:1px solid #333; border-radius:10px; padding:10px 12px; min-width:260px;">
1082
  <div style="opacity:0.8;">Indeks IPLM FINAL (Disesuaikan 33.88%)</div>
1083
- <div style="font-size:26px; font-weight:700;">{fmt(k["final_all"],2)}</div>
1084
- <div style="opacity:0.7;">Skor absolut</div>
1085
  </div>
1086
 
1087
  <div style="border:1px solid #333; border-radius:10px; padding:10px 12px; min-width:260px;">
1088
  <div style="opacity:0.8;">Indeks Dasar (Tanpa Penyesuaian)</div>
1089
- <div style="font-size:26px; font-weight:700;">{fmt(k["dasar_all"],2)}</div>
1090
  <div style="opacity:0.7;">Sebelum faktor kecukupan sampel</div>
1091
  </div>
1092
  </div>
@@ -1094,7 +1081,7 @@ def build_kpi_markdown(summary_jenis: pd.DataFrame) -> str:
1094
 
1095
 
1096
  # ============================================================
1097
- # 14) LLM -> WORD TABEL (SESUAI GAMBAR) + WORD REPORT
1098
  # ============================================================
1099
 
1100
  _HF_CLIENT = None
@@ -1113,154 +1100,229 @@ def get_llm_client():
1113
  _HF_CLIENT = None
1114
  return None
1115
 
1116
- def _get_overall_numbers_for_llm(summary_jenis: pd.DataFrame, agg_total: pd.DataFrame) -> dict:
1117
- def _mean_0_1_to_100(df, col):
1118
- if df is None or df.empty or col not in df.columns:
1119
  return 0.0
1120
- v = pd.to_numeric(df[col], errors="coerce").fillna(0.0).mean()
1121
- return float(v) * 100.0
1122
-
1123
- nilai_iplm = 0.0
1124
- if summary_jenis is not None and not summary_jenis.empty and "Jenis" in summary_jenis.columns:
1125
- mask = summary_jenis["Jenis"].astype(str).str.lower().eq("keseluruhan")
1126
- if mask.any() and "Indeks_Final_Disesuaikan_0_100" in summary_jenis.columns:
1127
- nilai_iplm = float(pd.to_numeric(summary_jenis.loc[mask, "Indeks_Final_Disesuaikan_0_100"], errors="coerce").fillna(0.0).iloc[0])
1128
-
1129
- return {
1130
- "kepatuhan": round(_mean_0_1_to_100(agg_total, "Rata2_dim_kepatuhan"), 2),
1131
- "koleksi": round(_mean_0_1_to_100(agg_total, "Rata2_sub_koleksi"), 2),
1132
- "tenaga": round(_mean_0_1_to_100(agg_total, "Rata2_sub_sdm"), 2),
1133
- "kinerja": round(_mean_0_1_to_100(agg_total, "Rata2_dim_kinerja"), 2),
1134
- "pelayanan": round(_mean_0_1_to_100(agg_total, "Rata2_sub_pelayanan"), 2),
1135
- "pengelolaan": round(_mean_0_1_to_100(agg_total, "Rata2_sub_pengelolaan"), 2),
1136
- "iplm": round(nilai_iplm, 2),
1137
- }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1138
 
1139
- def generate_llm_table_rows(summary_jenis, agg_total, wilayah, kew):
1140
- base_rows = [
1141
- {"No": "1", "Dimensi": "Kepatuhan", "Nilai": None, "Interpretasi": "", "Rekomendasi": ""},
1142
- {"No": "1.1", "Dimensi": "Variabel Koleksi", "Nilai": None, "Interpretasi": "", "Rekomendasi": ""},
1143
- {"No": "1.2", "Dimensi": "Variabel Tenaga Perpustakaan", "Nilai": None, "Interpretasi": "", "Rekomendasi": ""},
1144
- {"No": "2", "Dimensi": "Kinerja", "Nilai": None, "Interpretasi": "", "Rekomendasi": ""},
1145
- {"No": "2.1", "Dimensi": "Variabel Pelayanan", "Nilai": None, "Interpretasi": "", "Rekomendasi": ""},
1146
- {"No": "2.2", "Dimensi": "Variabel Penyelenggaraan/Pengelolaan", "Nilai": None, "Interpretasi": "", "Rekomendasi": ""},
1147
- {"No": "4", "Dimensi": "Nilai IPLM", "Nilai": None, "Interpretasi": "", "Rekomendasi": ""},
 
 
 
1148
  ]
1149
 
1150
- nums = _get_overall_numbers_for_llm(summary_jenis, agg_total)
1151
- mapping = {"1": "kepatuhan", "1.1": "koleksi", "1.2": "tenaga", "2": "kinerja", "2.1": "pelayanan", "2.2": "pengelolaan", "4": "iplm"}
1152
- for r in base_rows:
1153
- r["Nilai"] = nums.get(mapping.get(r["No"]), 0.0)
1154
-
 
 
 
 
 
 
 
1155
  client = get_llm_client()
1156
  if client is None or (not USE_LLM):
1157
- return base_rows
1158
-
1159
- ctx = f"Wilayah={wilayah} | Kewenangan={kew} | Target={TARGET_RATIO*100:.2f}%"
1160
- angka_ctx = (
1161
- f"- Kepatuhan: {nums['kepatuhan']}\n"
1162
- f"- Variabel Koleksi: {nums['koleksi']}\n"
1163
- f"- Variabel Tenaga Perpustakaan: {nums['tenaga']}\n"
1164
- f"- Kinerja: {nums['kinerja']}\n"
1165
- f"- Variabel Pelayanan: {nums['pelayanan']}\n"
1166
- f"- Variabel Penyelenggaraan/Pengelolaan: {nums['pengelolaan']}\n"
1167
- f"- Nilai IPLM: {nums['iplm']}\n"
 
 
 
 
 
 
 
 
 
 
1168
  )
1169
 
1170
- prompt_user = f"""
1171
- {ctx}
1172
-
1173
- Saya akan membuat tabel Word dengan kolom:
1174
- No | Dimensi | Nilai | Interpretasi | Rekomendasi
1175
-
1176
- Nilai (0–100) sudah ditetapkan sebagai berikut:
1177
- {angka_ctx}
1178
-
1179
- Tugas Anda:
1180
- 1) Isi "Interpretasi" dan "Rekomendasi" untuk tiap baris secara netral dan deskriptif.
1181
- 2) Jangan gunakan label normatif seperti: baik/buruk, tinggi/rendah, memuaskan/tidak, optimal/tidak.
1182
- 3) Interpretasi menjelaskan apa yang dicerminkan angka itu (tanpa menghakimi).
1183
- 4) Rekomendasi berisi langkah tindak lanjut yang operasional.
1184
- 5) Output harus berupa JSON array saja (tanpa teks lain), tiap elemen berisi:
1185
- - "No"
1186
- - "Interpretasi"
1187
- - "Rekomendasi"
1188
- Gunakan No persis: ["1","1.1","1.2","2","2.1","2.2","4"].
1189
- """.strip()
1190
 
1191
  try:
1192
  resp = client.chat_completion(
1193
  model=LLM_MODEL_NAME,
1194
  messages=[
1195
- {"role":"system","content":"Anda adalah analis kebijakan perpustakaan di Indonesia. Gaya netral-deskriptif, berbasis data, tanpa label normatif."},
1196
- {"role":"user","content":prompt_user}
1197
  ],
1198
- max_tokens=800,
1199
  temperature=0.2,
1200
  top_p=0.9,
1201
  )
1202
- raw = (resp.choices[0].message.content or "").strip()
1203
- if not raw:
1204
- return base_rows
1205
-
1206
- data = json.loads(raw)
1207
- by_no = {}
1208
- if isinstance(data, list):
1209
- for it in data:
1210
- no = str(it.get("No", "")).strip()
1211
- if no:
1212
- by_no[no] = {
1213
- "Interpretasi": str(it.get("Interpretasi", "") or "").strip(),
1214
- "Rekomendasi": str(it.get("Rekomendasi", "") or "").strip(),
1215
- }
1216
-
1217
- for r in base_rows:
1218
- if r["No"] in by_no:
1219
- r["Interpretasi"] = by_no[r["No"]]["Interpretasi"]
1220
- r["Rekomendasi"] = by_no[r["No"]]["Rekomendasi"]
1221
- return base_rows
1222
-
1223
- except Exception:
1224
- return base_rows
1225
-
1226
- def generate_llm_analysis(summary_jenis, agg_total, verif_total, wilayah, kew):
1227
- client = get_llm_client()
1228
- if client is None or (not USE_LLM):
1229
- return "Analisis otomatis (LLM) tidak digunakan / tidak tersedia."
1230
- return "Analisis otomatis disajikan pada Laporan Word dalam bentuk tabel (Interpretasi & Rekomendasi per dimensi)."
1231
-
1232
- def generate_word_report(wilayah, summary_jenis, analysis_text, agg_total=None, kew="(Semua)"):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1233
  if (not DOCX_AVAILABLE) or (Document is None):
1234
  return None
1235
 
1236
  doc = Document()
1237
- doc.add_heading(f"Interpretasi dan Rekomendasi IPLM — {wilayah}", level=1)
1238
- doc.add_paragraph(f"Target sampel per jenis: {TARGET_RATIO*100:.2f}%")
1239
-
1240
- rows = generate_llm_table_rows(summary_jenis, agg_total, wilayah, kew)
1241
-
1242
- table = doc.add_table(rows=1, cols=5)
1243
- table.style = "Table Grid"
1244
- hdr = table.rows[0].cells
1245
- hdr[0].text = "No"
1246
- hdr[1].text = "Dimensi"
1247
- hdr[2].text = "Nilai"
1248
- hdr[3].text = "Interpretasi"
1249
- hdr[4].text = "Rekomendasi"
1250
 
1251
- def _fmt_nilai(x):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1252
  try:
1253
- return f"{float(x):.2f}"
1254
  except Exception:
1255
- return ""
 
 
 
 
 
 
 
1256
 
1257
- for r in rows:
1258
- cells = table.add_row().cells
1259
- cells[0].text = str(r.get("No", "") or "")
1260
- cells[1].text = str(r.get("Dimensi", "") or "")
1261
- cells[2].text = _fmt_nilai(r.get("Nilai", ""))
1262
- cells[3].text = str(r.get("Interpretasi", "") or "")
1263
- cells[4].text = str(r.get("Rekomendasi", "") or "")
1264
 
1265
  outpath = tempfile.mktemp(suffix=".docx")
1266
  doc.save(outpath)
@@ -1274,13 +1336,22 @@ def generate_word_report(wilayah, summary_jenis, analysis_text, agg_total=None,
1274
  def _empty_outputs(msg="Data belum siap."):
1275
  empty = pd.DataFrame()
1276
  empty_fig = go.Figure()
1277
- return ("", empty, empty, empty, empty, empty, None, None, None, None, None, empty_fig, empty_fig, empty_fig, msg, "Analisis belum tersedia.")
 
 
 
 
 
 
 
 
1278
 
1279
  def run_calc(prov_value, kab_value, kew_value, df_all, df_raw, pop_kab, pop_prov, pop_khusus, meta):
1280
  try:
1281
  if df_all is None or df_all.empty or df_raw is None or df_raw.empty:
1282
- return _empty_outputs("Data belum ter-load. Pastikan file tersedia di repo/server.")
1283
 
 
1284
  df = df_all.copy()
1285
  if prov_value and prov_value != "(Semua)":
1286
  df = df[df["PROV_DISP"] == prov_value]
@@ -1288,7 +1359,6 @@ def run_calc(prov_value, kab_value, kew_value, df_all, df_raw, pop_kab, pop_prov
1288
  df = df[df["KAB_DISP"] == kab_value]
1289
  if kew_value and kew_value != "(Semua)":
1290
  df = df[df["KEW_NORM"] == kew_value]
1291
-
1292
  if df.empty:
1293
  return _empty_outputs("Tidak ada data untuk filter ini.")
1294
 
@@ -1301,13 +1371,17 @@ def run_calc(prov_value, kab_value, kew_value, df_all, df_raw, pop_kab, pop_prov
1301
  verif_total = build_verif_jenis(faktor_wilayah_jenis, kew_norm)
1302
  detail_view = attach_final_to_detail(df, agg_total, meta, kew_norm)
1303
 
 
1304
  if agg_jenis_full is None or agg_jenis_full.empty:
1305
  agg_jenis_view = agg_jenis_full
1306
  else:
1307
  kew_norm2 = str(kew_norm).upper()
1308
  label_name = "Kab/Kota" if ("KAB" in kew_norm2 or "KOTA" in kew_norm2) else ("Provinsi" if "PROV" in kew_norm2 else "Kab/Kota")
1309
  cols_upto = [
1310
- "group_key", label_name, "Jenis", "Jumlah",
 
 
 
1311
  "Rata2_sub_koleksi","Rata2_sub_sdm","Rata2_sub_pelayanan","Rata2_sub_pengelolaan",
1312
  "Rata2_dim_kepatuhan","Rata2_dim_kinerja",
1313
  "Indeks_Dasar_Agregat_0_100",
@@ -1315,6 +1389,7 @@ def run_calc(prov_value, kab_value, kew_value, df_all, df_raw, pop_kab, pop_prov
1315
  cols_upto = [c for c in cols_upto if c in agg_jenis_full.columns]
1316
  agg_jenis_view = agg_jenis_full[cols_upto].copy()
1317
 
 
1318
  raw = df_raw.copy()
1319
  if prov_value and prov_value != "(Semua)":
1320
  raw = raw[raw["PROV_DISP"] == prov_value]
@@ -1323,15 +1398,13 @@ def run_calc(prov_value, kab_value, kew_value, df_all, df_raw, pop_kab, pop_prov
1323
  if kew_value and kew_value != "(Semua)":
1324
  raw = raw[raw["KEW_NORM"] == kew_value]
1325
 
 
1326
  if detail_view is None or detail_view.empty:
1327
  fig_umum = _make_bell_curve_entitas(pd.DataFrame(), "Bell Curve — Jenis: Umum")
1328
  fig_sekolah = _make_bell_curve_entitas(pd.DataFrame(), "Bell Curve — Jenis: Sekolah")
1329
  fig_khusus = _make_bell_curve_entitas(pd.DataFrame(), "Bell Curve — Jenis: Khusus")
1330
  else:
1331
- hover_cols = []
1332
- for hc in ["Provinsi", "Kab/Kota", "Jenis"]:
1333
- if hc in detail_view.columns:
1334
- hover_cols.append(hc)
1335
 
1336
  def _fig(j):
1337
  d = detail_view[detail_view["Jenis"].astype(str).str.lower() == j].copy()
@@ -1350,6 +1423,7 @@ def run_calc(prov_value, kab_value, kew_value, df_all, df_raw, pop_kab, pop_prov
1350
 
1351
  kpi_md = build_kpi_markdown(summary_jenis)
1352
 
 
1353
  tmpdir = tempfile.mkdtemp()
1354
  prov_slug = (_canon(prov_value or "SEMUA").upper() or "SEMUA")
1355
  kab_slug = (_canon(kab_value or "SEMUA").upper() or "SEMUA")
@@ -1367,22 +1441,26 @@ def run_calc(prov_value, kab_value, kew_value, df_all, df_raw, pop_kab, pop_prov
1367
  detail_view.to_excel(p_detail, index=False)
1368
  verif_total.to_excel(p_verif, index=False)
1369
 
 
1370
  wilayah_txt = kab_value if (kab_value and kab_value != "(Semua)") else (prov_value if (prov_value and prov_value != "(Semua)") else "Nasional/All")
1371
- analysis_text = generate_llm_analysis(summary_jenis, agg_total, verif_total, wilayah_txt, kew_value or "(Semua)")
1372
- word_path = generate_word_report(wilayah_txt, summary_jenis, analysis_text, agg_total=agg_total, kew=(kew_value or "(Semua)"))
 
1373
 
1374
  msg = (
1375
  f"Selesai (TARGET {TARGET_RATIO*100:.2f}%): raw={len(raw)} | entitas={len(detail_view)} | "
1376
  f"wilayah(keseluruhan)={len(agg_total)} | jenis(agregat)={len(agg_jenis_full)}"
1377
- + ("" if DOCX_AVAILABLE else "<br>python-docx tidak tersedia -> laporan Word dimatikan.")
1378
  )
1379
 
1380
  return (
1381
  kpi_md,
1382
  summary_jenis, agg_total, agg_jenis_view, detail_view, verif_total,
1383
- p_summary, p_total, p_raw, p_detail, (word_path if word_path else None),
1384
  fig_umum, fig_sekolah, fig_khusus,
1385
- msg, analysis_text
 
 
1386
  )
1387
 
1388
  except Exception as e:
@@ -1445,11 +1523,9 @@ Dashboard KPI:
1445
  - Indeks IPLM FINAL (disesuaikan 33.88%)
1446
  - Indeks Dasar (tanpa penyesuaian)
1447
 
1448
- Bell Curve:
1449
- - Indeks_Dasar_0_100 per entitas (per jenis), hover menampilkan nama perpustakaan.
1450
-
1451
- Laporan Word (LLM):
1452
- - Tabel: No | Dimensi | Nilai | Interpretasi | Rekomendasi
1453
  """)
1454
 
1455
  state_df = gr.State(None)
@@ -1479,7 +1555,7 @@ Laporan Word (LLM):
1479
  gr.Markdown("## Agregat Wilayah (Keseluruhan) — FIX avg3")
1480
  out_agg_total = gr.DataFrame(interactive=False)
1481
 
1482
- gr.Markdown("## Agregat Wilayah × Jenis — (ditampilkan sampai Indeks_Dasar_Agregat_0_100)")
1483
  out_agg_jenis = gr.DataFrame(interactive=False)
1484
 
1485
  gr.Markdown("## Detail Entitas (Final menempel dari wilayah)")
@@ -1498,15 +1574,16 @@ Laporan Word (LLM):
1498
  gr.Markdown("### Perpustakaan Khusus")
1499
  bell_khusus = gr.Plot(scale=1)
1500
 
1501
- gr.Markdown("## Analisis Otomatis (opsional)")
1502
- analysis_out = gr.Markdown()
1503
 
1504
  with gr.Row():
1505
  dl_summary = gr.DownloadButton(label="Download Ringkasan (.xlsx)")
1506
  dl_total = gr.DownloadButton(label="Download Agregat Wilayah (.xlsx)")
1507
  dl_raw = gr.DownloadButton(label="Download Data Mentah (.xlsx)")
1508
  dl_detail = gr.DownloadButton(label="Download Detail Entitas (.xlsx)")
1509
- dl_word = gr.DownloadButton(label="Download Laporan Word (.docx)" if DOCX_AVAILABLE else "Download Laporan Word (OFF)")
 
1510
 
1511
  run_btn.click(
1512
  fn=run_calc,
@@ -1514,9 +1591,11 @@ Laporan Word (LLM):
1514
  outputs=[
1515
  kpi_out,
1516
  out_summary, out_agg_total, out_agg_jenis, out_detail, out_verif,
1517
- dl_summary, dl_total, dl_raw, dl_detail, dl_word,
1518
  bell_umum, bell_sekolah, bell_khusus,
1519
- msg_out, analysis_out
 
 
1520
  ]
1521
  )
1522
 
@@ -1526,4 +1605,4 @@ Laporan Word (LLM):
1526
  outputs=[state_df, state_raw, state_pop_kab, state_pop_prov, state_pop_khusus, state_meta, info_box, dd_prov, dd_kab, dd_kew]
1527
  )
1528
 
1529
- demo.launch()
 
1
  # -*- coding: utf-8 -*-
2
  """
3
  IPLM 2025 — Final (Target Sampel 33.88% per Jenis) — TANPA Kinerja Relatif / Percentile
4
+ UPDATE UTAMA (sesuai instruksi Anda):
5
+ - LLM TIDAK lagi menulis narasi 3 paragraf.
6
+ - LLM sekarang mengisi kolom "Interpretasi" dan "Rekomendasi" untuk tabel:
7
+ (Kepatuhan, Koleksi, Tenaga, Kinerja, Pelayanan, Penyelenggaraan/Pengelolaan, Nilai IPLM)
8
+ - Output tabel tersebut dibuat dalam format MS Word (.docx) dan bisa diunduh dari aplikasi.
9
+ - Nilai (kolom "Nilai") diambil APA ADANYA dari hasil perhitungan aplikasi (bukan dari LLM).
10
+
11
+ Catatan:
12
+ - Script ini tetap mempertahankan seluruh pipeline perhitungan Anda (Yeo-Johnson + MinMax + agregasi + penyesuaian 33.88%).
13
+ - Saya hanya "mengganti fungsi LLM + Word report" agar menghasilkan tabel interpretasi & rekomendasi seperti contoh.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  """
15
 
16
  import os
17
  import re
18
  import time
19
  import json
20
+ import math
21
  import tempfile
22
  from pathlib import Path
23
 
 
27
  import plotly.graph_objects as go
28
  from sklearn.preprocessing import PowerTransformer
29
 
30
+ # python-docx (wajib kalau mau Word)
31
  DOCX_AVAILABLE = True
32
  try:
33
  from docx import Document
34
+ from docx.shared import Pt, Inches
35
+ from docx.oxml import OxmlElement
36
+ from docx.oxml.ns import qn
37
  except Exception:
38
  DOCX_AVAILABLE = False
39
  Document = None
40
 
41
+ # huggingface client (opsional)
42
  HF_AVAILABLE = True
43
  try:
44
  from huggingface_hub import InferenceClient
 
353
  if mm.startswith("PROVINSI "):
354
  prov_name = mm.replace("PROVINSI", "").strip()
355
  current_prov = prov_name
356
+ rows.append({
357
+ "LEVEL": "PROV",
358
+ "Provinsi_Label": f"PROVINSI {prov_name}",
359
+ "Kab_Kota_Label": None,
360
+ "Pop_Total_Jenis": pval,
361
+ })
362
  continue
363
 
364
+ rows.append({
365
+ "LEVEL": "KAB",
366
+ "Provinsi_Label": f"PROVINSI {current_prov}" if current_prov else None,
367
+ "Kab_Kota_Label": mm,
368
+ "Pop_Total_Jenis": pval,
369
+ })
370
 
371
  pop = pd.DataFrame(rows)
372
  if pop.empty:
 
378
  return pop
379
 
380
  def load_default_files(force=False):
381
+ key = (
382
+ DATA_FILE, POP_KAB, POP_PROV, POP_KHUSUS,
383
+ _mtime(DATA_FILE), _mtime(POP_KAB), _mtime(POP_PROV), _mtime(POP_KHUSUS)
384
+ )
385
+
386
  if (not force) and _CACHE["key"] == key and _CACHE["df_all"] is not None:
387
  return _CACHE["df_all"], _CACHE["df_raw"], _CACHE["pop_kab"], _CACHE["pop_prov"], _CACHE["pop_khusus"], _CACHE["meta"], _CACHE["info"]
388
 
389
  for p, label in [(DATA_FILE, "DM"), (POP_KAB, "POP_KAB"), (POP_PROV, "POP_PROV"), (POP_KHUSUS, "POP_KHUSUS")]:
390
  if not Path(p).exists():
391
+ info = f"File tidak ditemukan ({label}): {p}"
392
  _CACHE.update({"key": key, "df_all": None, "df_raw": None, "pop_kab": None, "pop_prov": None, "pop_khusus": None, "meta": {}, "info": info})
393
  return None, None, None, None, None, {}, info
394
 
 
437
  df_raw = df_raw.drop_duplicates(subset=["_row_key"], keep="first").copy()
438
  after = len(df_raw)
439
 
440
+ # POP KAB
441
  pk = pd.read_excel(POP_KAB)
442
  c_kab = pick_col(pk, ["KABUPATEN_KOTA","Kab/Kota","Kabupaten/Kota","KAB/KOTA","Kabupaten_Kota","kab_kota","kabupaten_kota"])
443
  c_prov = pick_col(pk, ["PROVINSI","Provinsi","provinsi"])
 
452
  pop_kab["kab_key"] = pop_kab["Kab_Kota_Label"].apply(norm_kab_label)
453
  pop_kab = pop_kab.groupby("kab_key", as_index=False).first()
454
 
455
+ # POP PROV
456
  pp = pd.read_excel(POP_PROV)
457
  c_pr = pick_col(pp, ["Provinsi","PROVINSI","provinsi","Propinsi","PROPINSI","propinsi"])
458
  if c_pr is None:
 
465
  pop_prov["prov_key"] = pop_prov["Provinsi_Label"].apply(norm_prov_label)
466
  pop_prov = pop_prov.groupby("prov_key", as_index=False).first()
467
 
468
+ # POP KHUSUS
469
  try:
470
  pop_khusus = _parse_pop_khusus(POP_KHUSUS)
471
  except Exception as e:
 
481
  f"DM: {fp.name} | Baris: {before} -> dedup: {after}\n"
482
  f"POP_KAB: {Path(POP_KAB).name} (n={len(pop_kab)})\n"
483
  f"POP_PROV: {Path(POP_PROV).name} (n={len(pop_prov)})\n"
484
+ f"POP_KHUSUS: {Path(POP_KHUSUS).name} (n={len(pop_khusus)})\n"
485
  f"TARGET sampel per jenis: {TARGET_RATIO*100:.2f}%\n"
486
  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))}"
487
+ )
488
 
489
+ _CACHE.update({
490
+ "key": key,
491
+ "df_all": df_all,
492
+ "df_raw": df_raw,
493
+ "pop_kab": pop_kab,
494
+ "pop_prov": pop_prov,
495
+ "pop_khusus": pop_khusus,
496
+ "meta": meta,
497
+ "info": info
498
+ })
499
  return df_all, df_raw, pop_kab, pop_prov, pop_khusus, meta, info
500
 
501
 
 
503
  # 6) FAKTOR WILAYAH — PER JENIS (TARGET 33.88%)
504
  # ============================================================
505
 
506
+ def build_faktor_wilayah_jenis(df_filtered, pop_kab, pop_prov, pop_khusus, kew_value):
507
  if df_filtered is None or df_filtered.empty:
508
  return pd.DataFrame()
509
 
 
519
  key_col, label_col, label_name, mode = "prov_key", "PROV_DISP", "Provinsi", "PROV"
520
  base_pop = pop_prov.copy() if (pop_prov is not None and not pop_prov.empty) else pd.DataFrame()
521
  if not base_pop.empty and "prov_key" not in base_pop.columns:
522
+ base_pop["prov_key"] = base_pop["Provinsi_Label"].apply(norm_prov_label)
523
  base_pop = base_pop.set_index("prov_key") if (not base_pop.empty and "prov_key" in base_pop.columns) else pd.DataFrame().set_index(pd.Index([]))
524
  else:
525
  key_col, label_col, label_name, mode = "kab_key", "KAB_DISP", "Kab/Kota", "KAB"
526
  base_pop = pop_kab.copy() if (pop_kab is not None and not pop_kab.empty) else pd.DataFrame()
527
  if not base_pop.empty and "kab_key" not in base_pop.columns:
528
+ base_pop["kab_key"] = base_pop["Kab_Kota_Label"].apply(norm_kab_label)
529
  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([]))
530
 
531
  base_keys = df[[key_col, label_col]].drop_duplicates().rename(columns={key_col: "group_key", label_col: label_name})
 
630
  # 7) AGREGAT WILAYAH × JENIS
631
  # ============================================================
632
 
633
+ def build_agg_wilayah_jenis(df_filtered, faktor_wilayah_jenis, kew_value):
634
  if df_filtered is None or df_filtered.empty:
635
  return pd.DataFrame()
636
 
 
663
  ).reset_index().rename(columns={key_col: "group_key", label_col: label_name, "_dataset": "Jenis"})
664
 
665
  agg_real["Jenis"] = agg_real["Jenis"].astype(str).str.lower().str.strip()
 
666
 
667
+ agg = full.merge(agg_real, on=["group_key", label_name, "Jenis"], how="left")
668
  for c in ["Jumlah","Rata2_sub_koleksi","Rata2_sub_sdm","Rata2_sub_pelayanan","Rata2_sub_pengelolaan",
669
  "Rata2_dim_kepatuhan","Rata2_dim_kinerja","Indeks_Dasar_Agregat_0_100"]:
670
  if c in agg.columns:
 
673
 
674
  if faktor_wilayah_jenis is None or faktor_wilayah_jenis.empty:
675
  agg["faktor_penyesuaian_jenis"] = 1.0
 
 
 
 
 
676
  else:
677
  fw = faktor_wilayah_jenis.copy()
678
  fw["Jenis"] = fw["Jenis"].astype(str).str.lower().str.strip()
 
680
  "faktor_penyesuaian_jenis", "target_total_33_88_jenis", "pop_total_jenis",
681
  "coverage_jenis_%", "gap_target33_88_jenis", "n_jenis"]
682
  fw = fw[[c for c in keep if c in fw.columns]].copy()
 
683
  agg = agg.merge(fw, on=["group_key", label_name, "Jenis"], how="left")
684
  agg["faktor_penyesuaian_jenis"] = pd.to_numeric(agg["faktor_penyesuaian_jenis"], errors="coerce").fillna(1.0)
685
 
 
 
 
 
 
 
 
686
  agg["Indeks_Final_Agregat_0_100"] = (
687
  pd.to_numeric(agg["Indeks_Dasar_Agregat_0_100"], errors="coerce").fillna(0.0)
688
  * pd.to_numeric(agg["faktor_penyesuaian_jenis"], errors="coerce").fillna(1.0)
689
  )
690
 
691
+ for c in [
692
+ "Rata2_sub_koleksi","Rata2_sub_sdm","Rata2_sub_pelayanan","Rata2_sub_pengelolaan",
693
+ "Rata2_dim_kepatuhan","Rata2_dim_kinerja"
694
+ ]:
695
  if c in agg.columns:
696
  agg[c] = pd.to_numeric(agg[c], errors="coerce").fillna(0.0).round(3)
697
  for c in ["Indeks_Dasar_Agregat_0_100","Indeks_Final_Agregat_0_100"]:
698
  if c in agg.columns:
699
  agg[c] = pd.to_numeric(agg[c], errors="coerce").fillna(0.0).round(2)
700
+
701
  agg["faktor_penyesuaian_jenis"] = pd.to_numeric(agg["faktor_penyesuaian_jenis"], errors="coerce").fillna(1.0).round(3)
702
  return agg
703
 
704
 
705
  # ============================================================
706
+ # 8) AGREGAT WILAYAH (KESELURUHAN) — avg3 dari 3 jenis
707
  # ============================================================
708
 
709
+ def build_agg_wilayah_total_from_jenis(agg_jenis, faktor_wilayah_jenis, kew_value):
710
  if agg_jenis is None or agg_jenis.empty:
711
  return pd.DataFrame()
712
 
 
720
  base_keys = a[["group_key", label_name]].drop_duplicates()
721
  full = base_keys.assign(_tmp=1).merge(pd.DataFrame({"Jenis": jenis_list, "_tmp": 1}), on="_tmp").drop(columns="_tmp")
722
 
723
+ cols_present = [c for c in [
724
+ "Jumlah",
725
+ "Rata2_sub_koleksi","Rata2_sub_sdm","Rata2_sub_pelayanan","Rata2_sub_pengelolaan",
726
+ "Rata2_dim_kepatuhan","Rata2_dim_kinerja",
727
+ "Indeks_Dasar_Agregat_0_100",
728
+ "Indeks_Final_Agregat_0_100",
729
+ ] if c in a.columns]
730
 
731
+ full = full.merge(a[["group_key", label_name, "Jenis"] + cols_present],
732
+ on=["group_key", label_name, "Jenis"], how="left")
733
  for c in cols_present:
734
  full[c] = pd.to_numeric(full[c], errors="coerce").fillna(0.0)
735
 
 
745
  Indeks_Final_Wilayah_0_100=("Indeks_Final_Agregat_0_100", "mean"),
746
  )
747
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
748
  for c in ["Rata2_sub_koleksi","Rata2_sub_sdm","Rata2_sub_pelayanan","Rata2_sub_pengelolaan","Rata2_dim_kepatuhan","Rata2_dim_kinerja"]:
749
+ out[c] = pd.to_numeric(out[c], errors="coerce").fillna(0.0).round(3)
 
750
  for c in ["Indeks_Dasar_Agregat_0_100","Indeks_Final_Wilayah_0_100"]:
751
+ out[c] = pd.to_numeric(out[c], errors="coerce").fillna(0.0).round(2)
 
 
752
  out["n_total"] = pd.to_numeric(out["n_total"], errors="coerce").fillna(0).round(0).astype(int)
753
+
754
  return out
755
 
756
 
 
758
  # 9) SUMMARY (PER JENIS) + KESELURUHAN
759
  # ============================================================
760
 
761
+ def build_summary_per_jenis(agg_jenis, agg_total):
762
  jenis_list = ["sekolah", "umum", "khusus"]
763
 
764
  def _row_default(jenis):
 
780
  if agg_jenis is not None and not agg_jenis.empty:
781
  a = agg_jenis.copy()
782
  a["Jenis"] = a["Jenis"].astype(str).str.lower().str.strip()
 
783
  for c in ["Jumlah","Indeks_Dasar_Agregat_0_100","Indeks_Final_Agregat_0_100","pop_total_jenis","target_total_33_88_jenis"]:
784
  if c in a.columns:
785
  a[c] = pd.to_numeric(a[c], errors="coerce").fillna(0)
 
813
 
814
  rows = [rows_by_jenis[j] for j in jenis_list]
815
 
816
+ dasar_all = (rows_by_jenis["sekolah"]["Indeks_Dasar_0_100"]
817
+ + rows_by_jenis["umum"]["Indeks_Dasar_0_100"]
818
+ + rows_by_jenis["khusus"]["Indeks_Dasar_0_100"]) / 3.0
819
+
820
+ final_all = (rows_by_jenis["sekolah"]["Indeks_Final_Disesuaikan_0_100"]
821
+ + rows_by_jenis["umum"]["Indeks_Final_Disesuaikan_0_100"]
822
+ + rows_by_jenis["khusus"]["Indeks_Final_Disesuaikan_0_100"]) / 3.0
823
+
824
+ pop_all = int(rows_by_jenis["sekolah"]["Pop_Total_Jenis"]
825
+ + rows_by_jenis["umum"]["Pop_Total_Jenis"]
826
+ + rows_by_jenis["khusus"]["Pop_Total_Jenis"])
827
+
828
+ target_all = int(rows_by_jenis["sekolah"]["Target33_88_Total_Jenis"]
829
+ + rows_by_jenis["umum"]["Target33_88_Total_Jenis"]
830
+ + rows_by_jenis["khusus"]["Target33_88_Total_Jenis"])
831
+
832
+ terkumpul_all = int(rows_by_jenis["sekolah"]["Terkumpul_Jenis"]
833
+ + rows_by_jenis["umum"]["Terkumpul_Jenis"]
834
+ + rows_by_jenis["khusus"]["Terkumpul_Jenis"])
835
 
 
 
 
836
  coverage_all = (terkumpul_all / target_all * 100.0) if target_all > 0 else 0.0
837
 
838
  jumlah_wilayah_all = int(agg_total.shape[0]) if (agg_total is not None and not agg_total.empty) else int(
839
+ max(rows_by_jenis["sekolah"]["Jumlah_Wilayah"],
840
+ rows_by_jenis["umum"]["Jumlah_Wilayah"],
841
+ rows_by_jenis["khusus"]["Jumlah_Wilayah"])
842
  )
843
 
844
  rows.append({
 
855
  })
856
 
857
  out = pd.DataFrame(rows)
 
858
  for c in ["Jumlah_Wilayah","Total_Perpus","Pop_Total_Jenis","Target33_88_Total_Jenis","Terkumpul_Jenis"]:
859
+ out[c] = pd.to_numeric(out[c], errors="coerce").fillna(0).round(0).astype(int)
 
860
  for c in ["Coverage_Target33_88_Jenis_%","Indeks_Dasar_0_100","Indeks_Final_Disesuaikan_0_100","Penyesuaian_Poin"]:
861
+ out[c] = pd.to_numeric(out[c], errors="coerce").fillna(0.0).round(2)
 
 
862
  return out
863
 
864
 
865
  # ============================================================
866
+ # 10) DETAIL ENTITAS (Final menempel dari wilayah)
867
  # ============================================================
868
 
869
+ def attach_final_to_detail(df_filtered, agg_total, meta, kew_value):
870
  if df_filtered is None or df_filtered.empty:
871
  return pd.DataFrame()
872
 
 
910
  for c in ["Indeks_Dasar_0_100","Indeks_Final_0_100"]:
911
  if c in out.columns:
912
  out[c] = pd.to_numeric(out[c], errors="coerce").fillna(0.0).round(2)
 
913
  return out
914
 
915
 
916
  # ============================================================
917
+ # 11) VERIF (kecukupan sampel)
918
  # ============================================================
919
 
920
+ def build_verif_jenis(faktor_wilayah_jenis, kew_value):
921
  if faktor_wilayah_jenis is None or faktor_wilayah_jenis.empty:
922
  return pd.DataFrame()
923
 
 
925
  label_col = "Provinsi" if "PROV" in kew_norm else "Kab/Kota"
926
 
927
  out = faktor_wilayah_jenis.copy()
928
+ keep = [c for c in [
929
+ label_col, "Jenis",
930
+ "pop_total_jenis", "target_total_33_88_jenis", "n_jenis",
931
+ "coverage_jenis_%", "faktor_penyesuaian_jenis", "gap_target33_88_jenis"
932
+ ] if c in out.columns]
933
+
934
  out = out[keep].copy()
935
 
936
  for c in ["pop_total_jenis", "target_total_33_88_jenis", "n_jenis", "gap_target33_88_jenis"]:
937
  if c in out.columns:
938
  out[c] = pd.to_numeric(out[c], errors="coerce").fillna(0).round(0).astype(int)
 
939
  if "coverage_jenis_%" in out.columns:
940
  out["coverage_jenis_%"] = pd.to_numeric(out["coverage_jenis_%"], errors="coerce").fillna(0.0).round(2)
 
941
  if "faktor_penyesuaian_jenis" in out.columns:
942
  out["faktor_penyesuaian_jenis"] = pd.to_numeric(out["faktor_penyesuaian_jenis"], errors="coerce").fillna(1.0).round(3)
943
 
 
945
 
946
 
947
  # ============================================================
948
+ # 12) BELL CURVE — Indeks Dasar per Entitas (per Jenis) + Hover
949
  # ============================================================
950
 
951
+ def _make_bell_curve_entitas(dfp, title, xcol="Indeks_Dasar_0_100", label_col="nm_perpustakaan", hover_cols=None, min_points=2):
 
952
  fig = go.Figure()
953
  fig.update_layout(
954
  title=title,
 
1003
 
1004
  if len(x) < min_points:
1005
  x_single = float(x[0])
1006
+ fig.add_trace(go.Scatter(
1007
+ x=[x_single], y=[0],
1008
+ mode="markers", showlegend=False,
1009
+ hovertext=[hover_text[0]] if hover_text else None,
1010
+ hoverinfo="text"
1011
+ ))
1012
  fig.add_vline(x=x_single, line_width=1, line_dash="dash", annotation_text=f"Nilai: {x_single:.1f}", annotation_position="top")
1013
  fig.update_xaxes(range=[0, 100])
1014
  fig.update_yaxes(rangemode="tozero")
 
1024
  pdf = (1.0 / (sigma * np.sqrt(2 * np.pi))) * np.exp(-0.5 * ((xs - mu) / sigma) ** 2)
1025
 
1026
  fig.add_trace(go.Scatter(x=xs, y=pdf, mode="lines", name="Kurva Normal (fit)"))
1027
+ fig.add_trace(go.Scatter(
1028
+ x=x, y=np.zeros_like(x),
1029
+ mode="markers", showlegend=False,
1030
+ hovertext=hover_text if hover_text else None,
1031
+ hoverinfo="text"
1032
+ ))
1033
 
1034
  q1, q2, q3 = np.percentile(x, [25, 50, 75])
1035
  for xv, lab in [(q1, "Q1"), (q2, "Q2 (Median)"), (q3, "Q3"), (mu, "Mean")]:
 
1041
 
1042
 
1043
  # ============================================================
1044
+ # 13) KPI DASHBOARD (2 kartu: final + dasar)
1045
  # ============================================================
1046
 
1047
  def _safe_first(df, col, default=0.0, where=None):
 
1054
  return default
1055
  return float(pd.to_numeric(sub[col], errors="coerce").fillna(default).iloc[0])
1056
 
1057
+ def build_kpi_markdown(summary_jenis):
 
 
 
 
 
1058
  if summary_jenis is None or summary_jenis.empty:
1059
  return ""
1060
+ final_all = _safe_first(summary_jenis, "Indeks_Final_Disesuaikan_0_100", 0.0, where=summary_jenis["Jenis"].astype(str).str.lower().eq("keseluruhan"))
1061
+ dasar_all = _safe_first(summary_jenis, "Indeks_Dasar_0_100", 0.0, where=summary_jenis["Jenis"].astype(str).str.lower().eq("keseluruhan"))
1062
 
1063
  def fmt(x, nd=2):
1064
  return "NA" if pd.isna(x) else f"{x:.{nd}f}"
 
1067
  <div style="display:flex; gap:12px; flex-wrap:wrap;">
1068
  <div style="border:1px solid #333; border-radius:10px; padding:10px 12px; min-width:260px;">
1069
  <div style="opacity:0.8;">Indeks IPLM FINAL (Disesuaikan 33.88%)</div>
1070
+ <div style="font-size:26px; font-weight:700;">{fmt(final_all,2)}</div>
1071
+ <div style="opacity:0.7;">Skor absolut (untuk akuntabilitas)</div>
1072
  </div>
1073
 
1074
  <div style="border:1px solid #333; border-radius:10px; padding:10px 12px; min-width:260px;">
1075
  <div style="opacity:0.8;">Indeks Dasar (Tanpa Penyesuaian)</div>
1076
+ <div style="font-size:26px; font-weight:700;">{fmt(dasar_all,2)}</div>
1077
  <div style="opacity:0.7;">Sebelum faktor kecukupan sampel</div>
1078
  </div>
1079
  </div>
 
1081
 
1082
 
1083
  # ============================================================
1084
+ # 14) LLM: Isi Interpretasi & Rekomendasi (TABEL) + WORD
1085
  # ============================================================
1086
 
1087
  _HF_CLIENT = None
 
1100
  _HF_CLIENT = None
1101
  return None
1102
 
1103
+ def _to_2dec(x):
1104
+ try:
1105
+ if x is None or (isinstance(x, float) and math.isnan(x)):
1106
  return 0.0
1107
+ return float(x)
1108
+ except Exception:
1109
+ return 0.0
1110
+
1111
+ def build_interpretasi_table_values(agg_total, wilayah_label, target_ratio):
1112
+ """
1113
+ Mengambil NILAI apa adanya dari hasil aplikasi (agg_total):
1114
+ - Kepatuhan = 100 * Rata2_dim_kepatuhan
1115
+ - Koleksi = 100 * Rata2_sub_koleksi
1116
+ - Tenaga = 100 * Rata2_sub_sdm
1117
+ - Kinerja = 100 * Rata2_dim_kinerja
1118
+ - Pelayanan = 100 * Rata2_sub_pelayanan
1119
+ - Penyelenggaraan/Pengelolaan = 100 * Rata2_sub_pengelolaan
1120
+ - Nilai IPLM = Indeks_Final_Wilayah_0_100
1121
+
1122
+ Jika agg_total punya lebih dari 1 baris (mis. Nasional),
1123
+ diambil rata-rata kolom-kolom tersebut.
1124
+ """
1125
+ if agg_total is None or agg_total.empty:
1126
+ base = {
1127
+ "kepatuhan": 0.0, "koleksi": 0.0, "tenaga": 0.0,
1128
+ "kinerja": 0.0, "pelayanan": 0.0, "pengelolaan": 0.0,
1129
+ "iplm": 0.0
1130
+ }
1131
+ else:
1132
+ a = agg_total.copy()
1133
+ for c in ["Rata2_dim_kepatuhan","Rata2_sub_koleksi","Rata2_sub_sdm","Rata2_dim_kinerja","Rata2_sub_pelayanan","Rata2_sub_pengelolaan","Indeks_Final_Wilayah_0_100"]:
1134
+ if c in a.columns:
1135
+ a[c] = pd.to_numeric(a[c], errors="coerce").fillna(0.0)
1136
+ else:
1137
+ a[c] = 0.0
1138
+
1139
+ base = {
1140
+ "kepatuhan": 100.0 * float(a["Rata2_dim_kepatuhan"].mean()),
1141
+ "koleksi": 100.0 * float(a["Rata2_sub_koleksi"].mean()),
1142
+ "tenaga": 100.0 * float(a["Rata2_sub_sdm"].mean()),
1143
+ "kinerja": 100.0 * float(a["Rata2_dim_kinerja"].mean()),
1144
+ "pelayanan": 100.0 * float(a["Rata2_sub_pelayanan"].mean()),
1145
+ "pengelolaan": 100.0 * float(a["Rata2_sub_pengelolaan"].mean()),
1146
+ "iplm": float(a["Indeks_Final_Wilayah_0_100"].mean()),
1147
+ }
1148
 
1149
+ # pembulatan display (tetap "nilai aplikasi", hanya format tampilan)
1150
+ for k in base:
1151
+ base[k] = round(_to_2dec(base[k]), 2)
1152
+
1153
+ rows = [
1154
+ {"No":"1", "Dimensi":"Kepatuhan", "Nilai":base["kepatuhan"]},
1155
+ {"No":"1.1", "Dimensi":"Variabel Koleksi", "Nilai":base["koleksi"]},
1156
+ {"No":"1.2", "Dimensi":"Variabel Tenaga Perpustakaan", "Nilai":base["tenaga"]},
1157
+ {"No":"2", "Dimensi":"Kinerja", "Nilai":base["kinerja"]},
1158
+ {"No":"2.1", "Dimensi":"Variabel Pelayanan", "Nilai":base["pelayanan"]},
1159
+ {"No":"2.2", "Dimensi":"Variabel Penyelenggaraan/Pengelolaan", "Nilai":base["pengelolaan"]},
1160
+ {"No":"4", "Dimensi":"Nilai IPLM", "Nilai":base["iplm"]},
1161
  ]
1162
 
1163
+ header = {
1164
+ "judul": f"Interpretasi dan Rekomendasi IPLM {wilayah_label}",
1165
+ "target_sampel": f"{target_ratio*100:.2f}%"
1166
+ }
1167
+ return header, rows
1168
+
1169
+ def llm_fill_interpretasi_rekomendasi(header, rows, wilayah_label, kew_label):
1170
+ """
1171
+ LLM diminta mengisi kolom Interpretasi dan Rekomendasi
1172
+ dengan gaya netral-deskriptif (tanpa label tinggi/rendah/baik/buruk).
1173
+ Output wajib JSON agar mudah diparse.
1174
+ """
1175
  client = get_llm_client()
1176
  if client is None or (not USE_LLM):
1177
+ # fallback kosong
1178
+ out = []
1179
+ for r in rows:
1180
+ out.append({**r, "Interpretasi":"", "Rekomendasi":""})
1181
+ return out, "LLM tidak digunakan / tidak tersedia."
1182
+
1183
+ payload = {
1184
+ "wilayah": wilayah_label,
1185
+ "kewenangan": kew_label,
1186
+ "target_sampel_per_jenis": header["target_sampel"],
1187
+ "baris": rows
1188
+ }
1189
+
1190
+ system = (
1191
+ "Anda adalah analis kebijakan perpustakaan di Indonesia.\n"
1192
+ "Tugas: isi kolom Interpretasi dan Rekomendasi untuk tiap baris tabel.\n"
1193
+ "Gaya wajib: netral dan deskriptif; dilarang menggunakan label normatif seperti baik/buruk, tinggi/sedang/rendah, maju/tertinggal.\n"
1194
+ "Gunakan kalimat yang menjelaskan makna angka sebagai ringkasan kondisi berdasarkan data yang dilaporkan, tanpa menghakimi.\n"
1195
+ "Rekomendasi: operasional, spesifik, dan dapat ditindaklanjuti (2-3 butir ringkas) tanpa menyebut kategori penilaian.\n"
1196
+ "Dilarang mengubah NILAI. NILAI hanya dipakai sebagai konteks.\n"
1197
+ "Output harus JSON valid, tanpa teks tambahan."
1198
  )
1199
 
1200
+ user = (
1201
+ "Berikut input data tabel (JSON). Kembalikan JSON dengan struktur:\n"
1202
+ "{\n"
1203
+ ' "rows": [\n'
1204
+ ' {"No":"...","Dimensi":"...","Nilai":12.34,"Interpretasi":"...","Rekomendasi":"..."}\n'
1205
+ " ]\n"
1206
+ "}\n"
1207
+ "Pastikan jumlah baris sama dan urutan sama.\n\n"
1208
+ f"INPUT:\n{json.dumps(payload, ensure_ascii=False)}"
1209
+ )
 
 
 
 
 
 
 
 
 
 
1210
 
1211
  try:
1212
  resp = client.chat_completion(
1213
  model=LLM_MODEL_NAME,
1214
  messages=[
1215
+ {"role": "system", "content": system},
1216
+ {"role": "user", "content": user},
1217
  ],
1218
+ max_tokens=900,
1219
  temperature=0.2,
1220
  top_p=0.9,
1221
  )
1222
+ text = resp.choices[0].message.content.strip()
1223
+
1224
+ # parse JSON
1225
+ data = json.loads(text)
1226
+ rows_out = data.get("rows", [])
1227
+ # fallback jika tidak sesuai
1228
+ if not isinstance(rows_out, list) or len(rows_out) != len(rows):
1229
+ raise ValueError("Format JSON rows tidak sesuai.")
1230
+ return rows_out, "LLM mengisi Interpretasi & Rekomendasi."
1231
+ except Exception as e:
1232
+ out = []
1233
+ for r in rows:
1234
+ out.append({**r, "Interpretasi":"", "Rekomendasi":""})
1235
+ return out, f"LLM error: {repr(e)}"
1236
+
1237
+
1238
+ def _set_cell_shading(cell, fill_hex="1F1F1F"):
1239
+ """
1240
+ Set shading background untuk cell (python-docx).
1241
+ """
1242
+ tcPr = cell._tc.get_or_add_tcPr()
1243
+ shd = OxmlElement("w:shd")
1244
+ shd.set(qn("w:val"), "clear")
1245
+ shd.set(qn("w:color"), "auto")
1246
+ shd.set(qn("w:fill"), fill_hex)
1247
+ tcPr.append(shd)
1248
+
1249
+ def _set_cell_text_color(cell, rgb_hex="FFFFFF"):
1250
+ """
1251
+ Set font color untuk semua run dalam cell.
1252
+ """
1253
+ for p in cell.paragraphs:
1254
+ for run in p.runs:
1255
+ rPr = run._r.get_or_add_rPr()
1256
+ color = OxmlElement("w:color")
1257
+ color.set(qn("w:val"), rgb_hex)
1258
+ rPr.append(color)
1259
+
1260
+ def _set_table_borders(table):
1261
+ """
1262
+ Tambah border sederhana.
1263
+ """
1264
+ tbl = table._tbl
1265
+ tblPr = tbl.tblPr
1266
+ if tblPr is None:
1267
+ tblPr = OxmlElement('w:tblPr')
1268
+ tbl.append(tblPr)
1269
+ tblBorders = OxmlElement('w:tblBorders')
1270
+ for edge in ("top", "left", "bottom", "right", "insideH", "insideV"):
1271
+ elem = OxmlElement(f'w:{edge}')
1272
+ elem.set(qn('w:val'), 'single')
1273
+ elem.set(qn('w:sz'), '8')
1274
+ elem.set(qn('w:space'), '0')
1275
+ elem.set(qn('w:color'), 'FFFFFF')
1276
+ tblBorders.append(elem)
1277
+ tblPr.append(tblBorders)
1278
+
1279
+ def generate_word_table_interpretasi(header, rows_filled, wilayah_label):
1280
  if (not DOCX_AVAILABLE) or (Document is None):
1281
  return None
1282
 
1283
  doc = Document()
 
 
 
 
 
 
 
 
 
 
 
 
 
1284
 
1285
+ # Title
1286
+ title = doc.add_paragraph()
1287
+ run = title.add_run(header["judul"])
1288
+ run.bold = True
1289
+ run.font.size = Pt(18)
1290
+
1291
+ doc.add_paragraph(f"Target sampel per jenis: {header['target_sampel']}")
1292
+
1293
+ # Table
1294
+ cols = ["No", "Dimensi", "Nilai", "Interpretasi", "Rekomendasi"]
1295
+ table = doc.add_table(rows=1, cols=len(cols))
1296
+ table.autofit = True
1297
+ _set_table_borders(table)
1298
+
1299
+ hdr_cells = table.rows[0].cells
1300
+ for i, c in enumerate(cols):
1301
+ hdr_cells[i].text = c
1302
+ _set_cell_shading(hdr_cells[i], "1A1A1A")
1303
+ _set_cell_text_color(hdr_cells[i], "FFFFFF")
1304
+ for p in hdr_cells[i].paragraphs:
1305
+ for r in p.runs:
1306
+ r.bold = True
1307
+
1308
+ for r in rows_filled:
1309
+ row_cells = table.add_row().cells
1310
+ row_cells[0].text = str(r.get("No",""))
1311
+ row_cells[1].text = str(r.get("Dimensi",""))
1312
+ # nilai (apa adanya dari aplikasi, hanya format 2 desimal)
1313
  try:
1314
+ row_cells[2].text = f"{float(r.get('Nilai',0.0)):.2f}"
1315
  except Exception:
1316
+ row_cells[2].text = str(r.get("Nilai",""))
1317
+ row_cells[3].text = str(r.get("Interpretasi","") or "")
1318
+ row_cells[4].text = str(r.get("Rekomendasi","") or "")
1319
+
1320
+ # shading body (gelap) + teks putih agar mirip contoh
1321
+ for c in row_cells:
1322
+ _set_cell_shading(c, "262626")
1323
+ _set_cell_text_color(c, "FFFFFF")
1324
 
1325
+ doc.add_paragraph("") # spacer
 
 
 
 
 
 
1326
 
1327
  outpath = tempfile.mktemp(suffix=".docx")
1328
  doc.save(outpath)
 
1336
  def _empty_outputs(msg="Data belum siap."):
1337
  empty = pd.DataFrame()
1338
  empty_fig = go.Figure()
1339
+ return (
1340
+ "", # kpi_md
1341
+ empty, empty, empty, empty, empty,
1342
+ None, None, None, None, None,
1343
+ empty_fig, empty_fig, empty_fig,
1344
+ msg, # msg
1345
+ "LLM belum tersedia.", # status llm
1346
+ None # word path
1347
+ )
1348
 
1349
  def run_calc(prov_value, kab_value, kew_value, df_all, df_raw, pop_kab, pop_prov, pop_khusus, meta):
1350
  try:
1351
  if df_all is None or df_all.empty or df_raw is None or df_raw.empty:
1352
+ return _empty_outputs("Data belum ter-load. Pastikan file tersedia.")
1353
 
1354
+ # Filter
1355
  df = df_all.copy()
1356
  if prov_value and prov_value != "(Semua)":
1357
  df = df[df["PROV_DISP"] == prov_value]
 
1359
  df = df[df["KAB_DISP"] == kab_value]
1360
  if kew_value and kew_value != "(Semua)":
1361
  df = df[df["KEW_NORM"] == kew_value]
 
1362
  if df.empty:
1363
  return _empty_outputs("Tidak ada data untuk filter ini.")
1364
 
 
1371
  verif_total = build_verif_jenis(faktor_wilayah_jenis, kew_norm)
1372
  detail_view = attach_final_to_detail(df, agg_total, meta, kew_norm)
1373
 
1374
+ # agg_jenis view (UI hanya sampai indeks dasar)
1375
  if agg_jenis_full is None or agg_jenis_full.empty:
1376
  agg_jenis_view = agg_jenis_full
1377
  else:
1378
  kew_norm2 = str(kew_norm).upper()
1379
  label_name = "Kab/Kota" if ("KAB" in kew_norm2 or "KOTA" in kew_norm2) else ("Provinsi" if "PROV" in kew_norm2 else "Kab/Kota")
1380
  cols_upto = [
1381
+ "group_key",
1382
+ label_name,
1383
+ "Jenis",
1384
+ "Jumlah",
1385
  "Rata2_sub_koleksi","Rata2_sub_sdm","Rata2_sub_pelayanan","Rata2_sub_pengelolaan",
1386
  "Rata2_dim_kepatuhan","Rata2_dim_kinerja",
1387
  "Indeks_Dasar_Agregat_0_100",
 
1389
  cols_upto = [c for c in cols_upto if c in agg_jenis_full.columns]
1390
  agg_jenis_view = agg_jenis_full[cols_upto].copy()
1391
 
1392
+ # RAW download (hasil filter)
1393
  raw = df_raw.copy()
1394
  if prov_value and prov_value != "(Semua)":
1395
  raw = raw[raw["PROV_DISP"] == prov_value]
 
1398
  if kew_value and kew_value != "(Semua)":
1399
  raw = raw[raw["KEW_NORM"] == kew_value]
1400
 
1401
+ # Bell curve per jenis
1402
  if detail_view is None or detail_view.empty:
1403
  fig_umum = _make_bell_curve_entitas(pd.DataFrame(), "Bell Curve — Jenis: Umum")
1404
  fig_sekolah = _make_bell_curve_entitas(pd.DataFrame(), "Bell Curve — Jenis: Sekolah")
1405
  fig_khusus = _make_bell_curve_entitas(pd.DataFrame(), "Bell Curve — Jenis: Khusus")
1406
  else:
1407
+ hover_cols = [hc for hc in ["Provinsi", "Kab/Kota", "Jenis"] if hc in detail_view.columns]
 
 
 
1408
 
1409
  def _fig(j):
1410
  d = detail_view[detail_view["Jenis"].astype(str).str.lower() == j].copy()
 
1423
 
1424
  kpi_md = build_kpi_markdown(summary_jenis)
1425
 
1426
+ # Export xlsx
1427
  tmpdir = tempfile.mkdtemp()
1428
  prov_slug = (_canon(prov_value or "SEMUA").upper() or "SEMUA")
1429
  kab_slug = (_canon(kab_value or "SEMUA").upper() or "SEMUA")
 
1441
  detail_view.to_excel(p_detail, index=False)
1442
  verif_total.to_excel(p_verif, index=False)
1443
 
1444
+ # ====== NEW: Word tabel interpretasi & rekomendasi ======
1445
  wilayah_txt = kab_value if (kab_value and kab_value != "(Semua)") else (prov_value if (prov_value and prov_value != "(Semua)") else "Nasional/All")
1446
+ header, rows = build_interpretasi_table_values(agg_total, wilayah_txt, TARGET_RATIO)
1447
+ rows_filled, llm_status = llm_fill_interpretasi_rekomendasi(header, rows, wilayah_txt, kew_value or "(Semua)")
1448
+ word_path = generate_word_table_interpretasi(header, rows_filled, wilayah_txt)
1449
 
1450
  msg = (
1451
  f"Selesai (TARGET {TARGET_RATIO*100:.2f}%): raw={len(raw)} | entitas={len(detail_view)} | "
1452
  f"wilayah(keseluruhan)={len(agg_total)} | jenis(agregat)={len(agg_jenis_full)}"
1453
+ + ("" if DOCX_AVAILABLE else " | python-docx tidak tersedia (Word OFF)")
1454
  )
1455
 
1456
  return (
1457
  kpi_md,
1458
  summary_jenis, agg_total, agg_jenis_view, detail_view, verif_total,
1459
+ p_summary, p_total, p_raw, p_detail, p_verif,
1460
  fig_umum, fig_sekolah, fig_khusus,
1461
+ msg,
1462
+ llm_status,
1463
+ (word_path if word_path else None)
1464
  )
1465
 
1466
  except Exception as e:
 
1523
  - Indeks IPLM FINAL (disesuaikan 33.88%)
1524
  - Indeks Dasar (tanpa penyesuaian)
1525
 
1526
+ UPDATE LLM:
1527
+ - LLM mengisi tabel "Interpretasi & Rekomendasi IPLM" dalam Word (.docx) yang bisa diunduh.
1528
+ - Nilai tetap dari aplikasi.
 
 
1529
  """)
1530
 
1531
  state_df = gr.State(None)
 
1555
  gr.Markdown("## Agregat Wilayah (Keseluruhan) — FIX avg3")
1556
  out_agg_total = gr.DataFrame(interactive=False)
1557
 
1558
+ gr.Markdown("## Agregat Wilayah x Jenis — (ditampilkan sampai Indeks Dasar)")
1559
  out_agg_jenis = gr.DataFrame(interactive=False)
1560
 
1561
  gr.Markdown("## Detail Entitas (Final menempel dari wilayah)")
 
1574
  gr.Markdown("### Perpustakaan Khusus")
1575
  bell_khusus = gr.Plot(scale=1)
1576
 
1577
+ gr.Markdown("## Status LLM (Isi Interpretasi & Rekomendasi)")
1578
+ llm_status_out = gr.Markdown()
1579
 
1580
  with gr.Row():
1581
  dl_summary = gr.DownloadButton(label="Download Ringkasan (.xlsx)")
1582
  dl_total = gr.DownloadButton(label="Download Agregat Wilayah (.xlsx)")
1583
  dl_raw = gr.DownloadButton(label="Download Data Mentah (.xlsx)")
1584
  dl_detail = gr.DownloadButton(label="Download Detail Entitas (.xlsx)")
1585
+ dl_verif = gr.DownloadButton(label="Download Kecukupan Sampel (.xlsx)")
1586
+ dl_word = gr.DownloadButton(label="Download Word: Interpretasi & Rekomendasi (.docx)" if DOCX_AVAILABLE else "Download Word (OFF)")
1587
 
1588
  run_btn.click(
1589
  fn=run_calc,
 
1591
  outputs=[
1592
  kpi_out,
1593
  out_summary, out_agg_total, out_agg_jenis, out_detail, out_verif,
1594
+ dl_summary, dl_total, dl_raw, dl_detail, dl_verif,
1595
  bell_umum, bell_sekolah, bell_khusus,
1596
+ msg_out,
1597
+ llm_status_out,
1598
+ dl_word
1599
  ]
1600
  )
1601
 
 
1605
  outputs=[state_df, state_raw, state_pop_kab, state_pop_prov, state_pop_khusus, state_meta, info_box, dd_prov, dd_kab, dd_kew]
1606
  )
1607
 
1608
+ demo.launch()