Dyraa18 commited on
Commit
23e9bac
·
verified ·
1 Parent(s): aebbb79
Files changed (1) hide show
  1. app.py +592 -646
app.py CHANGED
@@ -1,9 +1,3 @@
1
- # app.py
2
- # Flask RAG app (HF Spaces / CPU) — fixed finalization protocol for R1-style models
3
- # - Forces model to write answer inside <final>...</final> and stops at </final>
4
- # - Safer cleaning of <think> blocks
5
- # - Same routes, admin pages, and Postgres auth as before
6
-
7
  import os, json, re, time, logging
8
  from functools import lru_cache, wraps
9
  from typing import Dict, List, Tuple
@@ -13,7 +7,7 @@ from zoneinfo import ZoneInfo
13
  from pathlib import Path
14
 
15
  from flask import (
16
- Flask, render_template, request, redirect, url_for, session, jsonify, flash
17
  )
18
 
19
  import numpy as np
@@ -25,309 +19,320 @@ from dotenv import load_dotenv
25
  load_dotenv()
26
 
27
  # ========= ENV & LOGGING =========
 
28
  os.environ.setdefault("KMP_DUPLICATE_LIB_OK", "TRUE")
29
  os.environ.setdefault("OMP_NUM_THREADS", "1")
30
- # keep CPU footprint low in HF Spaces
31
  try:
32
- torch.set_num_threads(int(os.environ.get("NUM_THREADS", "4")))
33
- torch.set_num_interop_threads(1)
34
  except Exception:
35
- pass
36
 
37
- logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s")
38
  log = logging.getLogger("rag-app")
39
 
40
- # ========= IMPORT EKSTERNAL =========
41
- # Expect file Guardrail.py with validate_input(text:str)->bool
42
- # Expect file Model.py with load_model(gguf_path, n_ctx, n_gpu_layers, n_threads) and
43
- # generate(llm, prompt, max_tokens, temperature, top_p, stop:list[str]) -> str
44
- from Guardrail import validate_input # -> bool (lazy in file)
45
  from Model import load_model, generate # -> llama.cpp wrapper
46
 
47
- # ========= PATH ROOT PROYEK =========
48
- BASE_DIR = Path(__file__).resolve().parent
 
49
 
50
- # ========= KONFIGURASI RAG =========
51
- MODEL_PATH = str(BASE_DIR / "models" / os.getenv("GGUF_FILENAME", "DeepSeek-R1-Distill-Qwen-7B-Q4_K_M.gguf"))
52
- CTX_WINDOW = int(os.environ.get("CTX_WINDOW", 2048)) # 2048 cukup untuk RAG singkat
53
- N_GPU_LAYERS = int(os.environ.get("N_GPU_LAYERS", 0)) # HF Spaces CPU only
54
- N_THREADS = int(os.environ.get("NUM_THREADS", 4))
 
 
55
 
56
- # ganti ke encoder lain jika perlu (m-e5-large cukup bagus untuk multilingual)
57
  ENCODER_NAME = os.environ.get("ENCODER_NAME", "intfloat/multilingual-e5-large")
58
  ENCODER_DEVICE = torch.device("cpu")
59
 
60
- # Dataset sudah ada di Space → path RELATIF
 
61
  SUBJECTS: Dict[str, Dict[str, str]] = {
62
- "ipas": {
63
- "index": str(BASE_DIR / "Rag-Pipeline" / "Vektor Database" / "Ipas" / "IPA_index.index"),
64
- "chunks": str(BASE_DIR / "Dataset" / "Ipas" / "Chunk" / "ipas_chunks.json"),
65
- "embeddings": str(BASE_DIR / "Dataset" / "Ipas" / "Embedd"/ "ipas_embeddings.npy"),
66
- "label": "IPAS",
67
- "desc": "Ilmu Pengetahuan Alam dan Sosial"
68
- },
69
- "penjas": {
70
- "index": str(BASE_DIR / "Rag-Pipeline" / "Vektor Database" / "Penjas" / "PENJAS_index.index"),
71
- "chunks": str(BASE_DIR / "Dataset" / "Penjas" / "Chunk" / "penjas_chunks.json"),
72
- "embeddings": str(BASE_DIR / "Dataset" / "Penjas" / "Embedd" / "penjas_embeddings.npy"),
73
- "label": "PJOK",
74
- "desc": "Pendidikan Jasmani, Olahraga, dan Kesehatan"
75
- },
76
- "pancasila": {
77
- "index": str(BASE_DIR / "Rag-Pipeline" / "Vektor Database" / "Pancasila" / "PANCASILA_index.index"),
78
- "chunks": str(BASE_DIR / "Dataset" / "Pancasila" / "Chunk" / "pancasila_chunks.json"),
79
- "embeddings": str(BASE_DIR / "Dataset" / "Pancasila" / "Embedd" / "pancasila_embeddings.npy"),
80
- "label": "PANCASILA",
81
- "desc": "Pendidikan Pancasila dan Kewarganegaraan"
82
- }
83
  }
84
 
85
- # Threshold dan fallback
 
86
  TOP_K_FAISS = int(os.environ.get("TOP_K_FAISS", 15))
87
- TOP_K_FINAL = int(os.environ.get("TOP_K_FINAL", 5))
88
- MIN_COSINE = float(os.environ.get("MIN_COSINE", 0.83))
89
- MIN_HYBRID = float(os.environ.get("MIN_HYBRID", 0.10))
90
  FALLBACK_TEXT = os.environ.get("FALLBACK_TEXT", "maap pengetahuan tidak ada dalam database")
91
  GUARDRAIL_BLOCK_TEXT = os.environ.get("GUARDRAIL_BLOCK_TEXT", "maap, pertanyaan ditolak oleh guardrail")
92
  ENABLE_PROFILING = os.environ.get("ENABLE_PROFILING", "false").lower() == "true"
93
 
94
  # ========= APP =========
95
- app = Flask(__name__)
 
96
  app.secret_key = os.environ.get("FLASK_SECRET_KEY", "dev-secret-please-change")
97
 
98
  from werkzeug.middleware.proxy_fix import ProxyFix
99
  app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
100
- # supaya session tersimpan di browser saat lewat proxy/HTTPS (HF Spaces)
101
  app.config.update(
102
- SESSION_COOKIE_NAME="session",
103
- SESSION_COOKIE_SAMESITE="None",
104
- SESSION_COOKIE_SECURE=True,
105
- SESSION_COOKIE_HTTPONLY=True,
106
- SESSION_COOKIE_PATH="/",
107
- PREFERRED_URL_SCHEME="https",
108
  )
109
 
110
- # ========= GLOBAL MODEL =========
 
111
  ENCODER_TOKENIZER = None
112
  ENCODER_MODEL = None
113
  LLM = None
114
 
115
  @dataclass(frozen=True)
116
  class SubjectAssets:
117
- index: faiss.Index
118
- texts: List[str]
119
- embs: np.ndarray
 
 
120
 
121
- # ========= TEKS UTILITAS =========
122
  STOPWORDS_ID = {
123
- "yang","dan","atau","pada","di","ke","dari","itu","ini","adalah","dengan",
124
- "untuk","serta","sebagai","oleh","dalam","akan","kamu","apa","karena",
125
- "agar","sehingga","terhadap","dapat","juga","para","diri",
126
  }
127
  TOKEN_RE = re.compile(r"[A-Za-zÀ-ÖØ-öø-ÿ]+", re.UNICODE)
128
 
 
 
 
 
129
  def tok_id(text: str) -> List[str]:
130
- return [t.lower() for t in TOKEN_RE.findall(text or "") if t.lower() not in STOPWORDS_ID]
131
 
132
  def lexical_overlap(query: str, sent: str) -> float:
133
- q = set(tok_id(query)); s = set(tok_id(sent))
134
- if not q or not s:
135
- return 0.0
136
- return len(q & s) / max(1, len(q | s))
137
 
138
  QUESTION_LIKE_RE = re.compile(r"(^\s*(apa|mengapa|bagaimana|sebutkan|jelaskan)\b|[?]$)", re.IGNORECASE)
139
  INSTRUCTION_RE = re.compile(r"\b(jelaskan|sebutkan|uraikan|kerjakan|diskusikan|tugas|latihan|menurut\s+pendapatmu)\b", re.IGNORECASE)
140
  META_PREFIX_PATTERNS = [
141
- r"berdasarkan\s+(?:kalimat|sumber|teks|konten|informasi)(?:\s+(?:di\s+atas|tersebut))?",
142
- r"menurut\s+(?:sumber|teks|konten)",
143
- r"merujuk\s+pada",
144
- r"mengacu\s+pada",
145
- r"bersumber\s+dari",
146
- r"dari\s+(?:kalimat|sumber|teks|konten)"
147
  ]
148
  META_PREFIX_RE = re.compile(r"^\s*(?:" + r"|".join(META_PREFIX_PATTERNS) + r")\s*[:\-–—,]?\s*", re.IGNORECASE)
149
 
150
  def clean_prefix(t: str) -> str:
151
- t = (t or "").strip()
152
- for _ in range(5):
153
- t2 = META_PREFIX_RE.sub("", t).lstrip()
154
- if t2 == t:
155
- break
156
- t = t2
157
- return t
158
 
159
  def strip_meta_sentence(s: str) -> str:
160
- s = clean_prefix(s or "")
161
- if re.match(r"^\s*(berdasarkan|menurut|merujuk|mengacu|bersumber|dari)\b", s, re.IGNORECASE):
162
- s = re.sub(r"^\s*[^,.;!?]*[,.;!?]\s*", "", s) or s
163
- s = clean_prefix(s)
164
- return s.strip()
165
 
166
  SENT_SPLIT_RE = re.compile(r"(?<=[.!?])\s+")
167
 
168
- def split_sentences(text: str) -> List[str]:
169
- outs = []
170
- for p in SENT_SPLIT_RE.split(text or ""):
171
- s = clean_prefix((p or "").strip())
172
- if not s:
173
- continue
174
- if s[-1] not in ".!?":
175
- s += "."
176
- if QUESTION_LIKE_RE.search(s):
177
- continue
178
- if INSTRUCTION_RE.search(s):
179
- continue
180
- if len(s.strip()) < 10:
181
- continue
182
- outs.append(s)
183
- return outs
184
-
185
- # ========= MODEL WARMUP (LAZY) =========
 
186
 
187
  def warmup_models():
188
- global ENCODER_TOKENIZER, ENCODER_MODEL, LLM
189
- if ENCODER_TOKENIZER is None or ENCODER_MODEL is None:
190
- log.info(f"[INIT] Load encoder: {ENCODER_NAME} (CPU)")
191
- ENCODER_TOKENIZER = AutoTokenizer.from_pretrained(ENCODER_NAME)
192
- ENCODER_MODEL = AutoModel.from_pretrained(ENCODER_NAME).to(ENCODER_DEVICE).eval()
193
- if LLM is None:
194
- log.info(f"[INIT] Load LLM: {MODEL_PATH}")
195
- LLM = load_model(MODEL_PATH, n_ctx=CTX_WINDOW, n_gpu_layers=N_GPU_LAYERS, n_threads=N_THREADS)
196
 
197
- # ========= LOAD ASSETS PER-MAPEL =========
198
 
199
  @lru_cache(maxsize=8)
200
- def load_subject_assets(subject_key: str) -> SubjectAssets:
201
- if subject_key not in SUBJECTS:
202
- raise ValueError(f"Unknown subject: {subject_key}")
203
- cfg = SUBJECTS[subject_key]
204
- log.info(f"[ASSETS] Loading subject={subject_key} | index={cfg['index']}")
205
- if not os.path.exists(cfg["index"]):
206
- raise FileNotFoundError(cfg["index"])
207
- if not os.path.exists(cfg["chunks"]):
208
- raise FileNotFoundError(cfg["chunks"])
209
- if not os.path.exists(cfg["embeddings"]):
210
- raise FileNotFoundError(cfg["embeddings"])
211
- index = faiss.read_index(cfg["index"])
212
- with open(cfg["chunks"], "r", encoding="utf-8") as f:
213
- texts = [it["text"] for it in json.load(f)]
214
- embs = np.load(cfg["embeddings"]) # shape: (N, dim)
215
- if index.ntotal != len(embs):
216
- raise RuntimeError(f"Mismatch ntotal({index.ntotal}) vs emb({len(embs)})")
217
- return SubjectAssets(index=index, texts=texts, embs=embs)
218
-
219
- # ========= ENCODER & RETRIEVAL =========
220
 
221
  @torch.inference_mode()
 
222
  def encode_query_exact(text: str) -> np.ndarray:
223
- toks = ENCODER_TOKENIZER(text, padding=True, truncation=True, return_tensors="pt").to(ENCODER_DEVICE)
224
- out = ENCODER_MODEL(**toks)
225
- # simple mean pooling (CLS-less encoders)
226
- vec = out.last_hidden_state.mean(dim=1)
227
- return vec.cpu().numpy()
228
 
229
  def cosine_sim(a: np.ndarray, b: np.ndarray) -> float:
230
- a = np.asarray(a).reshape(-1); b = np.asarray(b).reshape(-1)
231
- return float(np.dot(a, b) / ((np.linalg.norm(a) * np.linalg.norm(b)) + 1e-12))
 
 
 
232
 
233
  def best_cosine_from_faiss(query: str, subject_key: str) -> float:
234
- assets = load_subject_assets(subject_key)
235
- q = encode_query_exact(query)
236
- _, I = assets.index.search(q, TOP_K_FAISS)
237
- qv = q.reshape(-1)
238
- best = -1.0
239
- for i in I[0]:
240
- if 0 <= i < len(assets.texts):
241
- best = max(best, cosine_sim(qv, assets.embs[i]))
242
- return best
243
-
244
- def retrieve_rerank_cosine(query: str, subject_key: str) -> List[str]:
245
- assets = load_subject_assets(subject_key)
246
- q = encode_query_exact(query)
247
- D, idx = assets.index.search(q, TOP_K_FAISS)
248
- idxs = [i for i in idx[0] if 0 <= i < len(assets.texts)]
249
- if not idxs:
250
- return []
251
- qv = q.reshape(-1)
252
- scores = [cosine_sim(qv, assets.embs[i]) for i in idxs]
253
- pairs = sorted(zip(scores, idxs), reverse=True)
254
- top_texts = [assets.texts[i] for _, i in pairs[:TOP_K_FINAL]]
255
- log.info(f"[RETRIEVE] subject={subject_key} | top={len(top_texts)}")
256
- return top_texts
257
-
258
- def pick_best_sentences(query: str, chunks: List[str], top_k: int = 5) -> List[str]:
259
- if not chunks:
260
- return []
261
- qv = encode_query_exact(query).reshape(-1)
262
- cands: List[Tuple[float, str]] = []
263
- for ch in chunks:
264
- for s in split_sentences(ch):
265
- sv = encode_query_exact(s).reshape(-1)
266
- cos = cosine_sim(qv, sv)
267
- ovl = lexical_overlap(query, s)
268
- penalty = 0.1 if len(s) < 50 else 0.0
269
- score = 0.7 * cos + 0.3 * ovl - penalty
270
- if score >= MIN_HYBRID:
271
- cands.append((score, s))
272
- cands.sort(key=lambda x: x[0], reverse=True)
273
- return [s for _, s in cands[:top_k]]
274
 
275
  def build_prompt(user_query: str, sentences: List[str]) -> str:
276
- block = "\n".join(f"- {clean_prefix(s)}" for s in sentences)
277
- system = (
278
- "Kamu asisten RAG.\n"
279
- "- Jawab HANYA berdasarkan daftar kalimat fakta di bawah.\n"
280
- f"- Jika tidak ada kalimat yang relevan, tulis persis: {FALLBACK_TEXT}\n"
281
- "- Jawab TEPAT 1 kalimat, ringkas, Bahasa Indonesia baku.\n"
282
- "- DILARANG menulis frasa meta seperti 'berdasarkan', 'menurut', 'merujuk', 'mengacu', atau 'bersumber'.\n"
283
- "- Tulis jawaban final di dalam tag <final>... seperti: <final>Jawaban satu kalimat.</final>\n"
284
- "- Jangan menulis apa pun setelah </final>."
285
- )
286
- return (
287
- f"{system}\n\n"
288
- f"KALIMAT SUMBER:\n{block}\n\n"
289
- f"PERTANYAAN: {user_query}\n"
290
- f"TULIS JAWABAN DI DALAM <final>...</final> SAJA:"
291
- )
292
-
293
- @lru_cache(maxsize=512)
 
 
 
 
294
  def validate_input_cached(q: str) -> bool:
295
- try:
296
- return validate_input(q)
297
- except Exception as e:
298
- log.exception(f"[GUARDRAIL] error: {e}")
299
- return False
300
 
301
  # ========= AUTH (POSTGRES) =========
 
302
  from werkzeug.security import generate_password_hash, check_password_hash
303
  from sqlalchemy import create_engine, Column, Integer, String, Text, Boolean, func, or_
304
  from sqlalchemy.orm import sessionmaker, scoped_session, declarative_base, Session
305
 
306
  POSTGRES_URL = os.environ.get("POSTGRES_URL")
307
  if not POSTGRES_URL:
308
- raise RuntimeError("POSTGRES_URL tidak ditemukan. Set di Settings → Variables.")
309
 
310
  engine = create_engine(POSTGRES_URL, pool_pre_ping=True, future=True, echo=False)
311
  SessionLocal = scoped_session(sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True))
312
  Base = declarative_base()
313
 
314
  class User(Base):
315
- __tablename__ = "users"
316
- id = Column(Integer, primary_key=True)
317
- username = Column(String(50), unique=True, nullable=False, index=True)
318
- email = Column(String(120), unique=True, nullable=False, index=True)
319
- password = Column(Text, nullable=False)
320
- is_active = Column(Boolean, default=True, nullable=False)
321
- is_admin = Column(Boolean, default=False, nullable=False)
322
 
323
  class ChatHistory(Base):
324
- __tablename__ = "chat_history"
325
- id = Column(Integer, primary_key=True)
326
- user_id = Column(Integer, nullable=False, index=True)
327
- subject_key = Column(String(50), nullable=False, index=True)
328
- role = Column(String(10), nullable=False)
329
- message = Column(Text, nullable=False)
330
- timestamp = Column(Integer, server_default=func.extract("epoch", func.now()))
331
 
332
  Base.metadata.create_all(bind=engine)
333
 
@@ -335,500 +340,441 @@ JKT_TZ = ZoneInfo("Asia/Jakarta")
335
 
336
  @app.template_filter("fmt_ts")
337
  def fmt_ts(epoch_int: int):
338
- try:
339
- dt = datetime.fromtimestamp(int(epoch_int), tz=JKT_TZ)
340
- return dt.strftime("%d %b %Y %H:%M")
341
- except Exception:
342
- return "-"
343
 
344
  def db():
345
- return SessionLocal()
346
 
347
  def login_required(view_func):
348
- @wraps(view_func)
349
- def wrapper(*args, **kwargs):
350
- if not session.get("logged_in"):
351
- return redirect(url_for("auth_login"))
352
- return view_func(*args, **kwargs)
353
- return wrapper
354
 
355
  def admin_required(view_func):
356
- @wraps(view_func)
357
- def wrapper(*args, **kwargs):
358
- if not session.get("logged_in"):
359
- return redirect(url_for("auth_login"))
360
- if not session.get("is_admin"):
361
- flash("Hanya admin yang boleh mengakses halaman itu.", "error")
362
- return redirect(url_for("subjects"))
363
- return view_func(*args, **kwargs)
364
- return wrapper
365
 
366
  # ========= ROUTES =========
 
367
  @app.route("/")
368
  def root():
369
- return redirect(url_for("auth_login"))
370
 
371
  @app.route("/auth/login", methods=["GET", "POST"])
372
  def auth_login():
373
- if request.method == "POST":
374
- identity = (
375
- request.form.get("identity") or request.form.get("email") or request.form.get("username") or ""
376
- ).strip().lower()
377
- pw_input = (request.form.get("password") or "").strip()
378
-
379
- if not identity or not pw_input:
380
- flash("Mohon isi email/username dan password.", "error")
381
- return render_template("login.html"), 400
382
-
383
- s = db()
384
- try:
385
- user = (
386
- s.query(User)
387
- .filter(or_(func.lower(User.username) == identity, func.lower(User.email) == identity))
388
- .first()
389
- )
390
- log.info(f"[LOGIN] identity='{identity}' found={bool(user)} active={getattr(user,'is_active',None)}")
391
- ok = bool(user and user.is_active and check_password_hash(user.password, pw_input))
392
- finally:
393
- s.close()
394
-
395
- if not ok:
396
- flash("Identitas atau password salah.", "error")
397
- return render_template("login.html"), 401
398
-
399
- session["logged_in"] = True
400
- session["user_id"] = user.id
401
- session["username"] = user.username
402
- session["is_admin"] = bool(user.is_admin)
403
- log.info(f"[LOGIN] OK user_id={user.id}; session set.")
404
- return redirect(url_for("subjects"))
405
-
406
- return render_template("login.html")
407
 
408
  @app.route("/whoami")
409
  def whoami():
410
- return {
411
- "logged_in": bool(session.get("logged_in")),
412
- "user_id": session.get("user_id"),
413
- "username": session.get("username"),
414
- "is_admin": session.get("is_admin"),
415
- }
416
 
417
  @app.route("/auth/register", methods=["GET", "POST"])
418
  def auth_register():
419
- if request.method == "POST":
420
- username = (request.form.get("username") or "").strip().lower()
421
- email = (request.form.get("email") or "").strip().lower()
422
- pw = (request.form.get("password") or "").strip()
423
- confirm = (request.form.get("confirm") or "").strip()
424
-
425
- if not username or not email or not pw:
426
- flash("Semua field wajib diisi.", "error")
427
- return render_template("register.html"), 400
428
- if len(pw) < 6:
429
- flash("Password minimal 6 karakter.", "error")
430
- return render_template("register.html"), 400
431
- if pw != confirm:
432
- flash("Konfirmasi password tidak cocok.", "error")
433
- return render_template("register.html"), 400
434
-
435
- s = db()
436
- try:
437
- existed = (
438
- s.query(User)
439
- .filter(or_(func.lower(User.username) == username, func.lower(User.email) == email))
440
- .first()
441
- )
442
- if existed:
443
- flash("Username/Email sudah terpakai.", "error")
444
- return render_template("register.html"), 409
445
- u = User(username=username, email=email, password=generate_password_hash(pw), is_active=True)
446
- s.add(u); s.commit()
447
- finally:
448
- s.close()
449
-
450
- flash("Registrasi berhasil. Silakan login.", "success")
451
- return redirect(url_for("auth_login"))
452
-
453
- return render_template("register.html")
454
 
455
  @app.route("/auth/logout")
456
  def auth_logout():
457
- session.clear()
458
- return redirect(url_for("auth_login"))
459
 
460
  @app.route("/about")
461
  def about():
462
- return render_template("about.html")
463
 
464
  @app.route("/subjects")
465
  @login_required
466
  def subjects():
467
- log.info(f"[SESSION DEBUG] logged_in={session.get('logged_in')} user_id={session.get('user_id')}")
468
- return render_template("home.html", subjects=SUBJECTS)
469
 
470
  @app.route("/chat/<subject_key>")
471
  @login_required
472
  def chat_subject(subject_key: str):
473
- if subject_key not in SUBJECTS:
474
- return redirect(url_for("subjects"))
475
- session["subject_selected"] = subject_key
476
- label = SUBJECTS[subject_key]["label"]
477
-
478
- s = db()
479
- try:
480
- uid = session.get("user_id")
481
- rows = (
482
- s.query(ChatHistory)
483
- .filter_by(user_id=uid, subject_key=subject_key)
484
- .order_by(ChatHistory.id.asc())
485
- .all()
486
- )
487
- history = [{"role": r.role, "message": r.message} for r in rows]
488
- finally:
489
- s.close()
490
-
491
- return render_template("chat.html", subject=subject_key, subject_label=label, history=history)
492
 
493
  @app.route("/health")
494
  def health():
495
- return jsonify({
496
- "ok": True,
497
- "encoder_loaded": ENCODER_MODEL is not None,
498
- "llm_loaded": LLM is not None,
499
- "model_path": MODEL_PATH,
500
- "ctx_window": CTX_WINDOW,
501
- })
 
502
 
503
  @app.route("/ask/<subject_key>", methods=["POST"])
504
  @login_required
505
  def ask(subject_key: str):
506
- if subject_key not in SUBJECTS:
507
- return jsonify({"ok": False, "error": "invalid subject"}), 400
508
-
509
- # pastikan model siap saat request (lazy)
510
- warmup_models()
511
- t0 = time.perf_counter()
 
 
 
 
 
 
512
 
513
- data = request.get_json(silent=True) or {}
514
- query = (data.get("message") or "").strip()
515
- if not query:
516
- return jsonify({"ok": False, "error": "empty query"}), 400
517
-
518
- if not validate_input_cached(query):
519
- return jsonify({"ok": True, "answer": GUARDRAIL_BLOCK_TEXT})
520
 
521
- try:
522
- _ = load_subject_assets(subject_key)
523
- except Exception as e:
524
- log.exception(f"[ASSETS] error: {e}")
525
- return jsonify({"ok": False, "error": f"subject assets error: {e}"}), 500
526
 
527
- best = best_cosine_from_faiss(query, subject_key)
528
- log.info(f"[RAG] Subject={subject_key.upper()} | Best cosine={best:.3f}")
529
- if best < MIN_COSINE:
530
- return jsonify({"ok": True, "answer": FALLBACK_TEXT})
531
 
532
- chunks = retrieve_rerank_cosine(query, subject_key)
533
- if not chunks:
534
- return jsonify({"ok": True, "answer": FALLBACK_TEXT})
535
 
536
- sentences = pick_best_sentences(query, chunks, top_k=5)
537
- if not sentences:
538
- return jsonify({"ok": True, "answer": FALLBACK_TEXT})
539
 
540
- prompt = build_prompt(query, sentences)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
541
 
542
- try:
543
- # === 1st pass (deterministik) ===
544
- raw_answer = generate(
 
 
 
 
 
 
 
545
  LLM,
546
- prompt,
547
  max_tokens=int(os.environ.get("MAX_TOKENS", 72)),
548
- temperature=float(os.environ.get("TEMP", 0.0)),
549
  top_p=1.0,
550
- stop=["</final>"]
551
  ) or ""
552
- raw_answer = raw_answer.strip()
553
- log.info(f"[LLM] Raw answer repr (pass1): {repr(raw_answer)}")
554
-
555
- # Bersihkan blok <think> dan ambil isi <final>
556
- text = re.sub(r"<think\b[^>]*>.*?</think>", "", raw_answer, flags=re.DOTALL | re.IGNORECASE).strip()
557
- text = re.sub(r"</?think\b[^>]*>", "", text, flags=re.IGNORECASE).strip()
558
- m_final = re.search(r"<final>\s*(.+)$", text, flags=re.IGNORECASE | re.DOTALL)
559
- cleaned = (m_final.group(1).strip() if m_final else re.sub(r"<[^>]+>", "", text).strip())
560
-
561
- def _alpha_tokens(s: str) -> List[str]:
562
- return re.findall(r"[A-Za-zÀ-ÖØ-öø-ÿ]+", s or "")
563
-
564
- def _is_bad(s: str) -> bool:
565
- s2 = (s or "").strip()
566
- if not s2:
567
- return True
568
- # nolak placeholder/ellipsis saja
569
- if s2 in {"...", ".", "..", "…"}:
570
- return True
571
- toks = _alpha_tokens(s2)
572
- # cukup 4 token alfabetik untuk lolos (lebih toleran utk jawaban singkat)
573
- if len(toks) >= 4:
574
- return False
575
- # pengecualian: fakta pendek dengan unit/istilah umum tetap lolos
576
- if any(t.lower() in {"newton","n","kg","m","s"} for t in toks) and len(toks) >= 3:
577
- return False
578
- return True
579
 
580
- # Retry hanya jika PASS-1 benar-benar buruk
581
- if _is_bad(cleaned):
582
- prompt_retry = (
583
- prompt
584
- + "\n\nULANGI DENGAN TAAT FORMAT: "
585
- "Tulis satu kalimat faktual tanpa placeholder/ellipsis, "
586
- "mulai huruf kapital dan akhiri titik. "
587
- "Tulis hanya di dalam <final>...</final>."
588
- )
589
- raw_answer2 = generate(
590
- LLM,
591
- prompt_retry,
592
- max_tokens=int(os.environ.get("MAX_TOKENS", 72)),
593
- temperature=0.2,
594
- top_p=1.0,
595
- stop=["</final>"]
596
- ) or ""
597
- raw_answer2 = raw_answer2.strip()
598
- log.info(f"[LLM] Raw answer repr (pass2): {repr(raw_answer2)}")
599
-
600
- text2 = re.sub(r"<think\b[^>]*>.*?</think>", "", raw_answer2, flags=re.DOTALL | re.IGNORECASE).strip()
601
- text2 = re.sub(r"</?think\b[^>]*>", "", text2, flags=re.IGNORECASE).strip()
602
- m_final2 = re.search(r"<final>\s*(.+)$", text2, flags=re.IGNORECASE | re.DOTALL)
603
- cleaned2 = (m_final2.group(1).strip() if m_final2 else re.sub(r"<[^>]+>", "", text2).strip())
604
- cleaned = cleaned2 or cleaned
605
-
606
- answer = cleaned
607
-
608
- except Exception as e:
609
- log.exception(f"[LLM] generate error: {e}")
610
- return jsonify({"ok": True, "answer": FALLBACK_TEXT})
611
-
612
- # Ambil 1 kalimat pertama (jika model mengeluarkan beberapa kalimat)
613
- m = re.search(r"(.+?[.!?])(\s|$)", answer)
614
- answer = (m.group(1) if m else answer).strip()
615
- answer = strip_meta_sentence(answer)
616
-
617
- # === Simpan ke history ===
618
  try:
619
- s = db()
620
- uid = session.get("user_id")
621
- s.add_all([
622
- ChatHistory(user_id=uid, subject_key=subject_key, role="user", message=query),
623
- ChatHistory(user_id=uid, subject_key=subject_key, role="bot", message=answer),
624
- ])
625
- s.commit()
626
- except Exception as e:
627
- log.exception(f"[DB] gagal simpan chat history: {e}")
628
- finally:
629
- try:
630
- s.close()
631
- except Exception:
632
- pass
633
-
634
- if not answer or len(answer) < 2:
635
- answer = FALLBACK_TEXT
636
-
637
- if ENABLE_PROFILING:
638
- log.info({
639
- "latency_total": time.perf_counter() - t0,
640
- "subject": subject_key,
641
- "faiss_best": best,
642
- })
643
-
644
- return jsonify({"ok": True, "answer": answer})
645
-
646
- # ===== Admin views & delete actions (tetap) =====
647
 
648
  @app.route("/admin")
649
  @admin_required
650
  def admin_dashboard():
651
- s = db()
652
- try:
653
- total_users = s.query(func.count(User.id)).scalar() or 0
654
- total_active = s.query(func.count(User.id)).filter(User.is_active.is_(True)).scalar() or 0
655
- total_admins = s.query(func.count(User.id)).filter(User.is_admin.is_(True)).scalar() or 0
656
- total_msgs = s.query(func.count(ChatHistory.id)).scalar() or 0
657
- finally:
658
- s.close()
659
- return render_template(
660
- "admin_dashboard.html",
661
- total_users=total_users,
662
- total_active=total_active,
663
- total_admins=total_admins,
664
- total_msgs=total_msgs,
665
- )
666
 
667
  @app.route("/admin/users")
668
  @admin_required
669
  def admin_users():
670
- q = (request.args.get("q") or "").strip().lower()
671
- page = max(int(request.args.get("page", 1)), 1)
672
- per_page = min(max(int(request.args.get("per_page", 20)), 5), 100)
673
-
674
- s = db()
675
- try:
676
- base = s.query(User)
677
- if q:
678
- base = base.filter(
679
- or_(
680
- func.lower(User.username).like(f"%{q}%"),
681
- func.lower(User.email).like(f"%{q}%"),
682
- )
683
- )
684
- total = base.count()
685
- users = (
686
- base.order_by(User.id.asc())
687
- .offset((page - 1) * per_page)
688
- .limit(per_page)
689
- .all()
690
- )
691
- user_ids = [u.id for u in users] or [-1]
692
- counts = dict(
693
- s.query(ChatHistory.user_id, func.count(ChatHistory.id))
694
- .filter(ChatHistory.user_id.in_(user_ids))
695
- .group_by(ChatHistory.user_id)
696
- .all()
697
- )
698
- finally:
699
- s.close()
700
-
701
- return render_template("admin_users.html", users=users, counts=counts, q=q, page=page, per_page=per_page, total=total)
702
 
703
  @app.route("/admin/history")
704
  @admin_required
705
  def admin_history():
706
- q = (request.args.get("q") or "").strip().lower()
707
- username = (request.args.get("username") or "").strip().lower()
708
- subject = (request.args.get("subject") or "").strip().lower()
709
- role = (request.args.get("role") or "").strip().lower()
710
-
711
- page = max(int(request.args.get("page", 1)), 1)
712
- per_page = min(max(int(request.args.get("per_page", 30)), 5), 200)
713
-
714
- s = db()
715
- try:
716
- base = (s.query(ChatHistory, User).join(User, User.id == ChatHistory.user_id))
717
- if q:
718
- base = base.filter(func.lower(ChatHistory.message).like(f"%{q}%"))
719
- if username:
720
- base = base.filter(
721
- or_(
722
- func.lower(User.username) == username,
723
- func.lower(User.email) == username,
724
- )
725
- )
726
- if subject:
727
- base = base.filter(func.lower(ChatHistory.subject_key) == subject)
728
- if role in ("user", "bot"):
729
- base = base.filter(ChatHistory.role == role)
730
-
731
- total = base.count()
732
- rows = (
733
- base.order_by(ChatHistory.id.desc())
734
- .offset((page - 1) * per_page)
735
- .limit(per_page)
736
- .all()
737
- )
738
- finally:
739
- s.close()
740
-
741
- items = [{
742
- "id": r.ChatHistory.id,
743
- "username": r.User.username,
744
- "email": r.User.email,
745
- "subject": r.ChatHistory.subject_key,
746
- "role": r.ChatHistory.role,
747
- "message": r.ChatHistory.message,
748
- "timestamp": r.ChatHistory.timestamp,
749
- } for r in rows]
750
-
751
- return render_template(
752
- "admin_history.html",
753
- items=items,
754
- subjects=SUBJECTS,
755
- q=q,
756
- username=username,
757
- subject=subject,
758
- role=role,
759
- page=page,
760
- per_page=per_page,
761
- total=total,
762
- )
763
-
764
-
765
- def _is_last_admin(s: Session) -> bool:
766
- return (s.query(func.count(User.id)).filter(User.is_admin.is_(True)).scalar() or 0) <= 1
767
-
768
- @app.route("/admin/users/<int:user_id>/delete", methods=["POST"])
769
  @admin_required
770
  def admin_delete_user(user_id: int):
771
- s = db()
772
- try:
773
- me_id = session.get("user_id")
774
- user = s.query(User).filter_by(id=user_id).first()
775
- if not user:
776
- flash("User tidak ditemukan.", "error")
777
- return redirect(request.referrer or url_for("admin_users"))
778
- if user.id == me_id:
779
- flash("Tidak bisa menghapus akun yang sedang login.", "error")
780
- return redirect(request.referrer or url_for("admin_users"))
781
- if user.is_admin and _is_last_admin(s):
782
- flash("Tidak bisa menghapus admin terakhir.", "error")
783
- return redirect(request.referrer or url_for("admin_users"))
784
- s.query(ChatHistory).filter(ChatHistory.user_id == user.id).delete(synchronize_session=False)
785
- s.delete(user); s.commit()
786
- flash(f"User #{user_id} beserta seluruh riwayatnya telah dihapus.", "success")
787
- except Exception as e:
788
- s.rollback(); log.exception(f"[ADMIN] delete user error: {e}")
789
- flash("Gagal menghapus user.", "error")
790
- finally:
791
- s.close()
792
- return redirect(request.referrer or url_for("admin_users"))
793
-
794
- @app.route("/admin/users/<int:user_id>/history/clear", methods=["POST"])
795
  @admin_required
796
  def admin_clear_user_history(user_id: int):
797
- s = db()
798
- try:
799
- exists = s.query(User.id).filter_by(id=user_id).first()
800
- if not exists:
801
- flash("User tidak ditemukan.", "error")
802
- return redirect(request.referrer or url_for("admin_history"))
803
- deleted = s.query(ChatHistory).filter(ChatHistory.user_id == user_id).delete(synchronize_session=False)
804
- s.commit()
805
- flash(f"Riwayat chat user #{user_id} dihapus ({deleted} baris).", "success")
806
- except Exception as e:
807
- s.rollback(); log.exception(f"[ADMIN] clear history error: {e}")
808
- flash("Gagal menghapus riwayat.", "error")
809
- finally:
810
- s.close()
811
- return redirect(request.referrer or url_for("admin_history"))
812
-
813
- @app.route("/admin/history/<int:chat_id>/delete", methods=["POST"])
814
  @admin_required
815
  def admin_delete_chat(chat_id: int):
816
- s = db()
817
- try:
818
- row = s.query(ChatHistory).filter_by(id=chat_id).first()
819
- if not row:
820
- flash("Baris riwayat tidak ditemukan.", "error")
821
- return redirect(request.referrer or url_for("admin_history"))
822
- s.delete(row); s.commit()
823
- flash(f"Riwayat chat #{chat_id} dihapus.", "success")
824
- except Exception as e:
825
- s.rollback(); log.exception(f"[ADMIN] delete chat error: {e}")
826
- flash("Gagal menghapus riwayat.", "error")
827
- finally:
828
- s.close()
829
- return redirect(request.referrer or url_for("admin_history"))
830
 
831
  # ========= ENTRY =========
832
- if __name__ == "__main__":
833
- port = int(os.environ.get("PORT", 7860))
834
- app.run(host="0.0.0.0", port=port, debug=False)
 
 
 
 
 
 
 
 
1
  import os, json, re, time, logging
2
  from functools import lru_cache, wraps
3
  from typing import Dict, List, Tuple
 
7
  from pathlib import Path
8
 
9
  from flask import (
10
+ Flask, render_template, request, redirect, url_for, session, jsonify, flash
11
  )
12
 
13
  import numpy as np
 
19
  load_dotenv()
20
 
21
  # ========= ENV & LOGGING =========
22
+
23
  os.environ.setdefault("KMP_DUPLICATE_LIB_OK", "TRUE")
24
  os.environ.setdefault("OMP_NUM_THREADS", "1")
 
25
  try:
26
+ torch.set_num_threads(int(os.environ.get("NUM_THREADS", "3"))) # 3 thread cukup di CPU Spaces
27
+ torch.set_num_interop_threads(1)
28
  except Exception:
29
+ pass
30
 
31
+ logging.basicConfig([level=logging.INFO](http://level=logging.info/), format="%(asctime)s | %(levelname)s | %(message)s")
32
  log = logging.getLogger("rag-app")
33
 
34
+ # ========= IMPORT EKSTERNAL (wrapper & guardrail) =========
35
+
36
+ from Guardrail import validate_input # -> bool
 
 
37
  from Model import load_model, generate # -> llama.cpp wrapper
38
 
39
+ # ========= PATH ROOT =========
40
+
41
+ BASE_DIR = Path(**file**).resolve().parent
42
 
43
+ # ========= KONFIG MODEL & RAG (di-tune untuk CPU) =========
44
+
45
+ GGUF_DEFAULT = "DeepSeek-R1-Distill-Qwen-7B-Q4_K_M.gguf" # kecil & cepat; upload ke /models
46
+ MODEL_PATH = str(BASE_DIR / "models" / os.getenv("GGUF_FILENAME", GGUF_DEFAULT))
47
+ CTX_WINDOW = int(os.environ.get("CTX_WINDOW", 1024))
48
+ N_GPU_LAYERS = int(os.environ.get("N_GPU_LAYERS", 0))
49
+ N_THREADS = int(os.environ.get("NUM_THREADS", 3))
50
 
 
51
  ENCODER_NAME = os.environ.get("ENCODER_NAME", "intfloat/multilingual-e5-large")
52
  ENCODER_DEVICE = torch.device("cpu")
53
 
54
+ # Dataset sudah ada di Space → path RELATIF (samakan dengan struktur kamu)
55
+
56
  SUBJECTS: Dict[str, Dict[str, str]] = {
57
+ "ipas": {
58
+ "index": str(BASE_DIR / "Rag-Pipeline" / "Vektor Database" / "Ipas" / "IPA_index.index"),
59
+ "chunks": str(BASE_DIR / "Dataset" / "Ipas" / "Chunk" / "ipas_chunks.json"),
60
+ "embeddings": str(BASE_DIR / "Dataset" / "Ipas" / "Embedd"/ "ipas_embeddings.npy"),
61
+ "label": "IPAS",
62
+ "desc": "Ilmu Pengetahuan Alam dan Sosial"
63
+ },
64
+ "penjas": {
65
+ "index": str(BASE_DIR / "Rag-Pipeline" / "Vektor Database" / "Penjas" / "PENJAS_index.index"),
66
+ "chunks": str(BASE_DIR / "Dataset" / "Penjas" / "Chunk" / "penjas_chunks.json"),
67
+ "embeddings": str(BASE_DIR / "Dataset" / "Penjas" / "Embedd" / "penjas_embeddings.npy"),
68
+ "label": "PJOK",
69
+ "desc": "Pendidikan Jasmani, Olahraga, dan Kesehatan"
70
+ },
71
+ "pancasila": {
72
+ "index": str(BASE_DIR / "Rag-Pipeline" / "Vektor Database" / "Pancasila" / "PANCASILA_index.index"),
73
+ "chunks": str(BASE_DIR / "Dataset" / "Pancasila" / "Chunk" / "pancasila_chunks.json"),
74
+ "embeddings": str(BASE_DIR / "Dataset" / "Pancasila" / "Embedd" / "pancasila_embeddings.npy"),
75
+ "label": "PANCASILA",
76
+ "desc": "Pendidikan Pancasila dan Kewarganegaraan"
77
+ }
78
  }
79
 
80
+ # Threshold & parameter cepat
81
+
82
  TOP_K_FAISS = int(os.environ.get("TOP_K_FAISS", 15))
83
+ TOP_K_FINAL = int(os.environ.get("TOP_K_FINAL", 10))
84
+ MIN_COSINE = float(os.environ.get("MIN_COSINE", 0.83)) # lebih longgar biar jarang fallback
85
+ MIN_LEXICAL = float(os.environ.get("MIN_LEXICAL", 0.10))
86
  FALLBACK_TEXT = os.environ.get("FALLBACK_TEXT", "maap pengetahuan tidak ada dalam database")
87
  GUARDRAIL_BLOCK_TEXT = os.environ.get("GUARDRAIL_BLOCK_TEXT", "maap, pertanyaan ditolak oleh guardrail")
88
  ENABLE_PROFILING = os.environ.get("ENABLE_PROFILING", "false").lower() == "true"
89
 
90
  # ========= APP =========
91
+
92
+ app = Flask(**name**)
93
  app.secret_key = os.environ.get("FLASK_SECRET_KEY", "dev-secret-please-change")
94
 
95
  from werkzeug.middleware.proxy_fix import ProxyFix
96
  app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
 
97
  app.config.update(
98
+ SESSION_COOKIE_NAME="session",
99
+ SESSION_COOKIE_SAMESITE="None",
100
+ SESSION_COOKIE_SECURE=True,
101
+ SESSION_COOKIE_HTTPONLY=True,
102
+ SESSION_COOKIE_PATH="/",
103
+ PREFERRED_URL_SCHEME="https",
104
  )
105
 
106
+ # ========= GLOBALS =========
107
+
108
  ENCODER_TOKENIZER = None
109
  ENCODER_MODEL = None
110
  LLM = None
111
 
112
  @dataclass(frozen=True)
113
  class SubjectAssets:
114
+ index: faiss.Index
115
+ texts: List[str]
116
+ embs: np.ndarray
117
+
118
+ # ========= TEKS UTIL =========
119
 
 
120
  STOPWORDS_ID = {
121
+ "yang","dan","atau","pada","di","ke","dari","itu","ini","adalah","dengan",
122
+ "untuk","serta","sebagai","oleh","dalam","akan","kamu","apa","karena",
123
+ "agar","sehingga","terhadap","dapat","juga","para","diri",
124
  }
125
  TOKEN_RE = re.compile(r"[A-Za-zÀ-ÖØ-öø-ÿ]+", re.UNICODE)
126
 
127
+ @lru_cache(maxsize=4096)
128
+ def _tok_cached(word: str) -> str:
129
+ return word.lower()
130
+
131
  def tok_id(text: str) -> List[str]:
132
+ return [tw for w in TOKEN_RE.findall(text or "") if (tw:=_tok_cached(w)) not in STOPWORDS_ID]
133
 
134
  def lexical_overlap(query: str, sent: str) -> float:
135
+ q = set(tok_id(query)); s = set(tok_id(sent))
136
+ if not q or not s:
137
+ return 0.0
138
+ return len(q & s) / max(1, len(q | s))
139
 
140
  QUESTION_LIKE_RE = re.compile(r"(^\s*(apa|mengapa|bagaimana|sebutkan|jelaskan)\b|[?]$)", re.IGNORECASE)
141
  INSTRUCTION_RE = re.compile(r"\b(jelaskan|sebutkan|uraikan|kerjakan|diskusikan|tugas|latihan|menurut\s+pendapatmu)\b", re.IGNORECASE)
142
  META_PREFIX_PATTERNS = [
143
+ r"berdasarkan\s+(?:kalimat|sumber|teks|konten|informasi)(?:\s+(?:di\s+atas|tersebut))?",
144
+ r"menurut\s+(?:sumber|teks|konten)",
145
+ r"merujuk\s+pada",
146
+ r"mengacu\s+pada",
147
+ r"bersumber\s+dari",
148
+ r"dari\s+(?:kalimat|sumber|teks|konten)"
149
  ]
150
  META_PREFIX_RE = re.compile(r"^\s*(?:" + r"|".join(META_PREFIX_PATTERNS) + r")\s*[:\-–—,]?\s*", re.IGNORECASE)
151
 
152
  def clean_prefix(t: str) -> str:
153
+ t = (t or "").strip()
154
+ for _ in range(3):
155
+ t2 = META_PREFIX_RE.sub("", t).lstrip()
156
+ if t2 == t:
157
+ break
158
+ t = t2
159
+ return t
160
 
161
  def strip_meta_sentence(s: str) -> str:
162
+ s = clean_prefix(s or "")
163
+ if re.match(r"^\s*(berdasarkan|menurut|merujuk|mengacu|bersumber|dari)\b", s, re.IGNORECASE):
164
+ s = re.sub(r"^\s*[^,.;!?]*[,.;!?]\s*", "", s) or s
165
+ s = clean_prefix(s)
166
+ return s.strip()
167
 
168
  SENT_SPLIT_RE = re.compile(r"(?<=[.!?])\s+")
169
 
170
+ def split_sentences_fast(text: str) -> List[str]:
171
+ # tanpa encoding per-kalimat
172
+ outs = []
173
+ for p in SENT_SPLIT_RE.split(text or ""):
174
+ s = clean_prefix((p or "").strip())
175
+ if not s:
176
+ continue
177
+ if s[-1] not in ".!?":
178
+ s += "."
179
+ if QUESTION_LIKE_RE.search(s):
180
+ continue
181
+ if INSTRUCTION_RE.search(s):
182
+ continue
183
+ if len(s) < 12:
184
+ continue
185
+ outs.append(s)
186
+ return outs
187
+
188
+ # ========= MODEL WARMUP =========
189
 
190
  def warmup_models():
191
+ global ENCODER_TOKENIZER, ENCODER_MODEL, LLM
192
+ if ENCODER_TOKENIZER is None or ENCODER_MODEL is None:
193
+ [log.info](http://log.info/)(f"[INIT] Load encoder: {ENCODER_NAME} (CPU)")
194
+ ENCODER_TOKENIZER = AutoTokenizer.from_pretrained(ENCODER_NAME)
195
+ ENCODER_MODEL = AutoModel.from_pretrained(ENCODER_NAME).to(ENCODER_DEVICE).eval()
196
+ if LLM is None:
197
+ [log.info](http://log.info/)(f"[INIT] Load LLM: {MODEL_PATH} | ctx={CTX_WINDOW} | threads={N_THREADS}")
198
+ LLM = load_model(MODEL_PATH, n_ctx=CTX_WINDOW, n_gpu_layers=N_GPU_LAYERS, n_threads=N_THREADS)
199
 
200
+ # ========= ASSETS =========
201
 
202
  @lru_cache(maxsize=8)
203
+ def load_subject_assets(subject_key: str) -> "SubjectAssets":
204
+ if subject_key not in SUBJECTS:
205
+ raise ValueError(f"Unknown subject: {subject_key}")
206
+ cfg = SUBJECTS[subject_key]
207
+ [log.info](http://log.info/)(f"[ASSETS] Loading subject={subject_key} | index={cfg['index']}")
208
+ if not os.path.exists(cfg["index"]):
209
+ raise FileNotFoundError(cfg["index"])
210
+ if not os.path.exists(cfg["chunks"]):
211
+ raise FileNotFoundError(cfg["chunks"])
212
+ if not os.path.exists(cfg["embeddings"]):
213
+ raise FileNotFoundError(cfg["embeddings"])
214
+ index = faiss.read_index(cfg["index"])
215
+ with open(cfg["chunks"], "r", encoding="utf-8") as f:
216
+ texts = [it.get("text", "") for it in json.load(f)]
217
+ embs = np.load(cfg["embeddings"]) # (N, dim)
218
+ if index.ntotal != len(embs):
219
+ raise RuntimeError(f"Mismatch ntotal({index.ntotal}) vs emb({len(embs)})")
220
+ return SubjectAssets(index=index, texts=texts, embs=embs)
221
+
222
+ # ========= ENCODER =========
223
 
224
  @torch.inference_mode()
225
+ @lru_cache(maxsize=1024)
226
  def encode_query_exact(text: str) -> np.ndarray:
227
+ toks = ENCODER_TOKENIZER(text, padding=True, truncation=True, return_tensors="pt").to(ENCODER_DEVICE)
228
+ out = ENCODER_MODEL(**toks)
229
+ vec = out.last_hidden_state.mean(dim=1)
230
+ return vec.cpu().numpy()
 
231
 
232
  def cosine_sim(a: np.ndarray, b: np.ndarray) -> float:
233
+ a = np.asarray(a).reshape(-1); b = np.asarray(b).reshape(-1)
234
+ denom = (np.linalg.norm(a) * np.linalg.norm(b)) + 1e-12
235
+ return float(np.dot(a, b) / denom)
236
+
237
+ # ========= RETRIEVAL CEPAT =========
238
 
239
  def best_cosine_from_faiss(query: str, subject_key: str) -> float:
240
+ assets = load_subject_assets(subject_key)
241
+ q = encode_query_exact(query)
242
+ _, I = assets.index.search(q, TOP_K_FAISS)
243
+ qv = q.reshape(-1)
244
+ best = -1.0
245
+ for i in I[0]:
246
+ if 0 <= i < len(assets.texts):
247
+ best = max(best, cosine_sim(qv, assets.embs[i]))
248
+ return best
249
+
250
+ def retrieve_top_chunks(query: str, subject_key: str) -> List[str]:
251
+ assets = load_subject_assets(subject_key)
252
+ q = encode_query_exact(query)
253
+ _, idx = assets.index.search(q, TOP_K_FAISS)
254
+ idxs = [i for i in idx[0] if 0 <= i < len(assets.texts)]
255
+ return [assets.texts[i] for i in idxs[:TOP_K_FINAL]]
256
+
257
+ def pick_best_sentences_fast(query: str, chunks: List[str], top_k: int = 4) -> List[str]:
258
+ # Tanpa encode per kalimat hanya lexical overlap + panjang wajar
259
+ cands: List[Tuple[float, str]] = []
260
+ for ch in chunks:
261
+ for s in split_sentences_fast(ch):
262
+ ovl = lexical_overlap(query, s)
263
+ if ovl < MIN_LEXICAL:
264
+ continue
265
+ # bonus sedikit kalau kalimat panjang wajar (50–220 char)
266
+ L = len(s)
267
+ len_bonus = 0.05 if 50 <= L <= 220 else 0.0
268
+ score = ovl + len_bonus
269
+ cands.append((score, s))
270
+ cands.sort(key=lambda x: x[0], reverse=True)
271
+ return [s for _, s in cands[:top_k]]
272
+
273
+ # ========= PROMPT =========
 
 
 
 
 
 
274
 
275
  def build_prompt(user_query: str, sentences: List[str]) -> str:
276
+ block = "\n".join(f"- {clean_prefix(s)}" for s in sentences)
277
+ system = (
278
+ "Kamu asisten RAG.\n"
279
+ f"- Jika tidak ada kalimat yang relevan, tulis persis: {FALLBACK_TEXT}\n"
280
+ "- Jawab TEPAT 1 kalimat, ringkas, Bahasa Indonesia baku (≥ 6 kata).\n"
281
+ "- Tanpa frasa meta (berdasarkan/menurut/merujuk/mengacu/bersumber).\n"
282
+ "- Tulis jawaban final di dalam tag <final>Jawaban.</final> dan jangan menulis apa pun setelah </final>."
283
+ )
284
+ fewshot = (
285
+ "Contoh format: \n"
286
+ "KALIMAT SUMBER:\n- Air memuai saat dipanaskan.\n"
287
+ "PERTANYAAN: Apa yang terjadi pada air saat dipanaskan?\n"
288
+ "<final>Air akan memuai ketika dipanaskan.</final>\n"
289
+ )
290
+ return (
291
+ f"{system}\n\n{fewshot}\n"
292
+ f"KALIMAT SUMBER:\n{block}\n\n"
293
+ f"PERTANYAAN: {user_query}\n"
294
+ f"TULIS JAWABAN DI DALAM <final>...</final> SAJA:"
295
+ )
296
+
297
+ @lru_cache(maxsize=1024)
298
  def validate_input_cached(q: str) -> bool:
299
+ try:
300
+ return validate_input(q)
301
+ except Exception as e:
302
+ log.exception(f"[GUARDRAIL] error: {e}")
303
+ return False
304
 
305
  # ========= AUTH (POSTGRES) =========
306
+
307
  from werkzeug.security import generate_password_hash, check_password_hash
308
  from sqlalchemy import create_engine, Column, Integer, String, Text, Boolean, func, or_
309
  from sqlalchemy.orm import sessionmaker, scoped_session, declarative_base, Session
310
 
311
  POSTGRES_URL = os.environ.get("POSTGRES_URL")
312
  if not POSTGRES_URL:
313
+ raise RuntimeError("POSTGRES_URL tidak ditemukan. Set di Settings → Variables.")
314
 
315
  engine = create_engine(POSTGRES_URL, pool_pre_ping=True, future=True, echo=False)
316
  SessionLocal = scoped_session(sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True))
317
  Base = declarative_base()
318
 
319
  class User(Base):
320
+ **tablename** = "users"
321
+ id = Column(Integer, primary_key=True)
322
+ username = Column(String(50), unique=True, nullable=False, index=True)
323
+ email = Column(String(120), unique=True, nullable=False, index=True)
324
+ password = Column(Text, nullable=False)
325
+ is_active = Column(Boolean, default=True, nullable=False)
326
+ is_admin = Column(Boolean, default=False, nullable=False)
327
 
328
  class ChatHistory(Base):
329
+ **tablename** = "chat_history"
330
+ id = Column(Integer, primary_key=True)
331
+ user_id = Column(Integer, nullable=False, index=True)
332
+ subject_key = Column(String(50), nullable=False, index=True)
333
+ role = Column(String(10), nullable=False)
334
+ message = Column(Text, nullable=False)
335
+ timestamp = Column(Integer, server_default=func.extract("epoch", func.now()))
336
 
337
  Base.metadata.create_all(bind=engine)
338
 
 
340
 
341
  @app.template_filter("fmt_ts")
342
  def fmt_ts(epoch_int: int):
343
+ try:
344
+ dt = datetime.fromtimestamp(int(epoch_int), tz=JKT_TZ)
345
+ return dt.strftime("%d %b %Y %H:%M")
346
+ except Exception:
347
+ return "-"
348
 
349
  def db():
350
+ return SessionLocal()
351
 
352
  def login_required(view_func):
353
+ @wraps(view_func)
354
+ def wrapper(*args, **kwargs):
355
+ if not session.get("logged_in"):
356
+ return redirect(url_for("auth_login"))
357
+ return view_func(*args, **kwargs)
358
+ return wrapper
359
 
360
  def admin_required(view_func):
361
+ @wraps(view_func)
362
+ def wrapper(*args, **kwargs):
363
+ if not session.get("logged_in"):
364
+ return redirect(url_for("auth_login"))
365
+ if not session.get("is_admin"):
366
+ flash("Hanya admin yang boleh mengakses halaman itu.", "error")
367
+ return redirect(url_for("subjects"))
368
+ return view_func(*args, **kwargs)
369
+ return wrapper
370
 
371
  # ========= ROUTES =========
372
+
373
  @app.route("/")
374
  def root():
375
+ return redirect(url_for("auth_login"))
376
 
377
  @app.route("/auth/login", methods=["GET", "POST"])
378
  def auth_login():
379
+ if request.method == "POST":
380
+ identity = (
381
+ request.form.get("identity") or request.form.get("email") or request.form.get("username") or ""
382
+ ).strip().lower()
383
+ pw_input = (request.form.get("password") or "").strip()
384
+ if not identity or not pw_input:
385
+ flash("Mohon isi email/username dan password.", "error")
386
+ return render_template("login.html"), 400
387
+ s = db()
388
+ try:
389
+ user = (
390
+ s.query(User)
391
+ .filter(or_(func.lower(User.username) == identity, func.lower(User.email) == identity))
392
+ .first()
393
+ )
394
+ [log.info](http://log.info/)(f"[LOGIN] identity='{identity}' found={bool(user)} active={getattr(user,'is_active',None)}")
395
+ ok = bool(user and user.is_active and check_password_hash(user.password, pw_input))
396
+ finally:
397
+ s.close()
398
+ if not ok:
399
+ flash("Identitas atau password salah.", "error")
400
+ return render_template("login.html"), 401
401
+ session["logged_in"] = True
402
+ session["user_id"] = [user.id](http://user.id/)
403
+ session["username"] = user.username
404
+ session["is_admin"] = bool(user.is_admin)
405
+ [log.info](http://log.info/)(f"[LOGIN] OK user_id={[user.id](http://user.id/)}; session set.")
406
+ return redirect(url_for("subjects"))
407
+ return render_template("login.html")
 
 
 
 
 
408
 
409
  @app.route("/whoami")
410
  def whoami():
411
+ return {
412
+ "logged_in": bool(session.get("logged_in")),
413
+ "user_id": session.get("user_id"),
414
+ "username": session.get("username"),
415
+ "is_admin": session.get("is_admin"),
416
+ }
417
 
418
  @app.route("/auth/register", methods=["GET", "POST"])
419
  def auth_register():
420
+ if request.method == "POST":
421
+ username = (request.form.get("username") or "").strip().lower()
422
+ email = (request.form.get("email") or "").strip().lower()
423
+ pw = (request.form.get("password") or "").strip()
424
+ confirm = (request.form.get("confirm") or "").strip()
425
+ if not username or not email or not pw:
426
+ flash("Semua field wajib diisi.", "error")
427
+ return render_template("register.html"), 400
428
+ if len(pw) < 6:
429
+ flash("Password minimal 6 karakter.", "error")
430
+ return render_template("register.html"), 400
431
+ if pw != confirm:
432
+ flash("Konfirmasi password tidak cocok.", "error")
433
+ return render_template("register.html"), 400
434
+ s = db()
435
+ try:
436
+ existed = (
437
+ s.query(User)
438
+ .filter(or_(func.lower(User.username) == username, func.lower(User.email) == email))
439
+ .first()
440
+ )
441
+ if existed:
442
+ flash("Username/Email sudah terpakai.", "error")
443
+ return render_template("register.html"), 409
444
+ u = User(username=username, email=email, password=generate_password_hash(pw), is_active=True)
445
+ s.add(u); s.commit()
446
+ finally:
447
+ s.close()
448
+ flash("Registrasi berhasil. Silakan login.", "success")
449
+ return redirect(url_for("auth_login"))
450
+ return render_template("register.html")
 
 
 
 
451
 
452
  @app.route("/auth/logout")
453
  def auth_logout():
454
+ session.clear()
455
+ return redirect(url_for("auth_login"))
456
 
457
  @app.route("/about")
458
  def about():
459
+ return render_template("about.html")
460
 
461
  @app.route("/subjects")
462
  @login_required
463
  def subjects():
464
+ [log.info](http://log.info/)(f"[SESSION DEBUG] logged_in={session.get('logged_in')} user_id={session.get('user_id')}")
465
+ return render_template("home.html", subjects=SUBJECTS)
466
 
467
  @app.route("/chat/<subject_key>")
468
  @login_required
469
  def chat_subject(subject_key: str):
470
+ if subject_key not in SUBJECTS:
471
+ return redirect(url_for("subjects"))
472
+ session["subject_selected"] = subject_key
473
+ label = SUBJECTS[subject_key]["label"]
474
+ s = db()
475
+ try:
476
+ uid = session.get("user_id")
477
+ rows = (
478
+ s.query(ChatHistory)
479
+ .filter_by(user_id=uid, subject_key=subject_key)
480
+ .order_by(ChatHistory.id.asc())
481
+ .all()
482
+ )
483
+ history = [{"role": r.role, "message": r.message} for r in rows]
484
+ finally:
485
+ s.close()
486
+ return render_template("chat.html", subject=subject_key, subject_label=label, history=history)
 
 
487
 
488
  @app.route("/health")
489
  def health():
490
+ return jsonify({
491
+ "ok": True,
492
+ "encoder_loaded": ENCODER_MODEL is not None,
493
+ "llm_loaded": LLM is not None,
494
+ "model_path": MODEL_PATH,
495
+ "ctx_window": CTX_WINDOW,
496
+ "threads": N_THREADS,
497
+ })
498
 
499
  @app.route("/ask/<subject_key>", methods=["POST"])
500
  @login_required
501
  def ask(subject_key: str):
502
+ if subject_key not in SUBJECTS:
503
+ return jsonify({"ok": False, "error": "invalid subject"}), 400
504
+ warmup_models()
505
+ t0 = time.perf_counter()
506
+
507
+ ```
508
+ data = request.get_json(silent=True) or {}
509
+ query = (data.get("message") or "").strip()
510
+ if not query:
511
+ return jsonify({"ok": False, "error": "empty query"}), 400
512
+ if not validate_input_cached(query):
513
+ return jsonify({"ok": True, "answer": GUARDRAIL_BLOCK_TEXT})
514
 
515
+ try:
516
+ _ = load_subject_assets(subject_key)
517
+ except Exception as e:
518
+ log.exception(f"[ASSETS] error: {e}")
519
+ return jsonify({"ok": False, "error": f"subject assets error: {e}"}), 500
 
 
520
 
521
+ best = best_cosine_from_faiss(query, subject_key)
522
+ log.info(f"[RAG] Subject={subject_key.upper()} | Best cosine={best:.3f}")
523
+ if best < MIN_COSINE:
524
+ return jsonify({"ok": True, "answer": FALLBACK_TEXT})
 
525
 
526
+ chunks = retrieve_top_chunks(query, subject_key)
527
+ if not chunks:
528
+ return jsonify({"ok": True, "answer": FALLBACK_TEXT})
 
529
 
530
+ sentences = pick_best_sentences_fast(query, chunks, top_k=4)
531
+ if not sentences:
532
+ return jsonify({"ok": True, "answer": FALLBACK_TEXT})
533
 
534
+ prompt = build_prompt(query, sentences)
 
 
535
 
536
+ try:
537
+ # PASS-1: deterministik & singkat
538
+ raw_answer = generate(
539
+ LLM,
540
+ prompt,
541
+ max_tokens=int(os.environ.get("MAX_TOKENS", 72)),
542
+ temperature=float(os.environ.get("TEMP", 0.0)),
543
+ top_p=1.0,
544
+ stop=["</final>"]
545
+ ) or ""
546
+ raw_answer = raw_answer.strip()
547
+ log.info(f"[LLM] Raw answer repr (pass1): {repr(raw_answer)}")
548
+
549
+ text = re.sub(r"<think\\b[^>]*>.*?</think>", "", raw_answer, flags=re.DOTALL | re.IGNORECASE).strip()
550
+ text = re.sub(r"</?think\\b[^>]*>", "", text, flags=re.IGNORECASE).strip()
551
+ m_final = re.search(r"<final>\\s*(.+)$", text, flags=re.IGNORECASE | re.DOTALL)
552
+ cleaned = (m_final.group(1).strip() if m_final else re.sub(r"<[^>]+>", "", text).strip())
553
+
554
+ def _alpha_tokens(s: str) -> List[str]:
555
+ return re.findall(r"[A-Za-zÀ-ÖØ-öø-ÿ]+", s or "")
556
+
557
+ def _is_good(s: str) -> bool:
558
+ s2 = (s or "").strip()
559
+ if not s2:
560
+ return False
561
+ if s2 in {"...", ".", "..", "…"}:
562
+ return False
563
+ toks = _alpha_tokens(s2)
564
+ # ≥4 token alfabetik dianggap cukup untuk jawaban ringkas
565
+ if len(toks) >= 4:
566
+ return True
567
+ # pengecualian: fakta pendek dengan unit/istilah umum → cukup ≥3 token
568
+ if any(t.lower() in {"newton", "n", "kg", "m", "s"} for t in toks) and len(toks) >= 3:
569
+ return True
570
+ return False
571
 
572
+ # ======= JALANKAN PASS-2 HANYA JIKA PASS-1 BURUK =======
573
+ if not _is_good(cleaned):
574
+ prompt_retry = (
575
+ prompt
576
+ + "\n\nULANGI DENGAN TAAT FORMAT: "
577
+ "Tulis satu kalimat faktual tanpa placeholder/ellipsis, "
578
+ "mulai huruf kapital dan akhiri titik. "
579
+ "Tulis hanya di dalam <final>...</final>."
580
+ )
581
+ raw_answer2 = generate(
582
  LLM,
583
+ prompt_retry,
584
  max_tokens=int(os.environ.get("MAX_TOKENS", 72)),
585
+ temperature=0.2,
586
  top_p=1.0,
587
+ stop=["</final>"],
588
  ) or ""
589
+ raw_answer2 = raw_answer2.strip()
590
+ log.info(f"[LLM] Raw answer repr (pass2): {repr(raw_answer2)}")
591
+
592
+ text2 = re.sub(r"<think\b[^>]*>.*?</think>", "", raw_answer2, flags=re.DOTALL | re.IGNORECASE).strip()
593
+ text2 = re.sub(r"</?think\b[^>]*>", "", text2, flags=re.IGNORECASE).strip()
594
+ m_final2 = re.search(r"<final>\s*(.+)$", text2, flags=re.IGNORECASE | re.DOTALL)
595
+ cleaned2 = (m_final2.group(1).strip() if m_final2 else re.sub(r"<[^>]+>", "", text2).strip())
596
+ if _is_good(cleaned2):
597
+ cleaned = cleaned2 # hanya pakai PASS-2 jika memang lebih baik
598
+
599
+ answer = cleaned
600
+
601
+ except Exception as e:
602
+ log.exception(f"[LLM] generate error: {e}")
603
+ return jsonify({"ok": True, "answer": FALLBACK_TEXT})
 
 
 
 
 
 
 
 
 
 
 
 
604
 
605
+ # Ambil 1 kalimat pertama saja
606
+ m = re.search(r"(.+?[.!?])(\\s|$)", answer)
607
+ answer = (m.group(1) if m else answer).strip()
608
+ answer = strip_meta_sentence(answer)
609
+
610
+ # Simpan history
611
+ try:
612
+ s = db()
613
+ uid = session.get("user_id")
614
+ s.add_all([
615
+ ChatHistory(user_id=uid, subject_key=subject_key, role="user", message=query),
616
+ ChatHistory(user_id=uid, subject_key=subject_key, role="bot", message=answer),
617
+ ])
618
+ s.commit()
619
+ except Exception as e:
620
+ log.exception(f"[DB] gagal simpan chat history: {e}")
621
+ finally:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
622
  try:
623
+ s.close()
624
+ except Exception:
625
+ pass
626
+
627
+ if not answer or len(answer) < 2:
628
+ answer = FALLBACK_TEXT
629
+
630
+ if ENABLE_PROFILING:
631
+ log.info({
632
+ "latency_total": time.perf_counter() - t0,
633
+ "subject": subject_key,
634
+ "faiss_best": best,
635
+ })
636
+
637
+ return jsonify({"ok": True, "answer": answer})
638
+
639
+ ```
640
+
641
+ # ===== Admin =====
 
 
 
 
 
 
 
 
 
642
 
643
  @app.route("/admin")
644
  @admin_required
645
  def admin_dashboard():
646
+ s = db()
647
+ try:
648
+ total_users = s.query(func.count([User.id](http://user.id/))).scalar() or 0
649
+ total_active = s.query(func.count([User.id](http://user.id/))).filter(User.is_active.is_(True)).scalar() or 0
650
+ total_admins = s.query(func.count([User.id](http://user.id/))).filter(User.is_admin.is_(True)).scalar() or 0
651
+ total_msgs = s.query(func.count([ChatHistory.id](http://chathistory.id/))).scalar() or 0
652
+ finally:
653
+ s.close()
654
+ return render_template("admin_dashboard.html", total_users=total_users, total_active=total_active, total_admins=total_admins, total_msgs=total_msgs)
 
 
 
 
 
 
655
 
656
  @app.route("/admin/users")
657
  @admin_required
658
  def admin_users():
659
+ q = (request.args.get("q") or "").strip().lower()
660
+ page = max(int(request.args.get("page", 1)), 1)
661
+ per_page = min(max(int(request.args.get("per_page", 20)), 5), 100)
662
+ s = db()
663
+ try:
664
+ base = s.query(User)
665
+ if q:
666
+ base = base.filter(or_(func.lower(User.username).like(f"%{q}%"), func.lower(User.email).like(f"%{q}%")))
667
+ total = base.count()
668
+ users = base.order_by(User.id.asc()).offset((page - 1) * per_page).limit(per_page).all()
669
+ user_ids = [[u.id](http://u.id/) for u in users] or [-1]
670
+ counts = dict(s.query(ChatHistory.user_id, func.count([ChatHistory.id](http://chathistory.id/))).filter(ChatHistory.user_id.in_(user_ids)).group_by(ChatHistory.user_id).all())
671
+ finally:
672
+ s.close()
673
+ return render_template("admin_users.html", users=users, counts=counts, q=q, page=page, per_page=per_page, total=total)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
674
 
675
  @app.route("/admin/history")
676
  @admin_required
677
  def admin_history():
678
+ q = (request.args.get("q") or "").strip().lower()
679
+ username = (request.args.get("username") or "").strip().lower()
680
+ subject = (request.args.get("subject") or "").strip().lower()
681
+ role = (request.args.get("role") or "").strip().lower()
682
+ page = max(int(request.args.get("page", 1)), 1)
683
+ per_page = min(max(int(request.args.get("per_page", 30)), 5), 200)
684
+ s = db()
685
+ try:
686
+ base = (s.query(ChatHistory, User).join(User, [User.id](http://user.id/) == ChatHistory.user_id))
687
+ if q:
688
+ base = base.filter(func.lower(ChatHistory.message).like(f"%{q}%"))
689
+ if username:
690
+ base = base.filter(or_(func.lower(User.username) == username, func.lower(User.email) == username))
691
+ if subject:
692
+ base = base.filter(func.lower(ChatHistory.subject_key) == subject)
693
+ if role in ("user", "bot"):
694
+ base = base.filter(ChatHistory.role == role)
695
+ total = base.count()
696
+ rows = base.order_by(ChatHistory.id.desc()).offset((page - 1) * per_page).limit(per_page).all()
697
+ finally:
698
+ s.close()
699
+ items = [{
700
+ "id": [r.ChatHistory.id](http://r.chathistory.id/),
701
+ "username": r.User.username,
702
+ "email": r.User.email,
703
+ "subject": r.ChatHistory.subject_key,
704
+ "role": r.ChatHistory.role,
705
+ "message": r.ChatHistory.message,
706
+ "timestamp": r.ChatHistory.timestamp,
707
+ } for r in rows]
708
+ return render_template("admin_history.html", items=items, subjects=SUBJECTS, q=q, username=username, subject=subject, role=role, page=page, per_page=per_page, total=total)
709
+
710
+ def *is_last_admin(s: Session) -> bool:
711
+ return (s.query(func.count([User.id](http://user.id/))).filter(User.is_admin.is*(True)).scalar() or 0) <= 1
712
+
713
+ @app.route("/admin/users/int:user_id/delete", methods=["POST"])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
714
  @admin_required
715
  def admin_delete_user(user_id: int):
716
+ s = db()
717
+ try:
718
+ me_id = session.get("user_id")
719
+ user = s.query(User).filter_by(id=user_id).first()
720
+ if not user:
721
+ flash("User tidak ditemukan.", "error")
722
+ return redirect(request.referrer or url_for("admin_users"))
723
+ if [user.id](http://user.id/) == me_id:
724
+ flash("Tidak bisa menghapus akun yang sedang login.", "error")
725
+ return redirect(request.referrer or url_for("admin_users"))
726
+ if user.is_admin and _is_last_admin(s):
727
+ flash("Tidak bisa menghapus admin terakhir.", "error")
728
+ return redirect(request.referrer or url_for("admin_users"))
729
+ s.query(ChatHistory).filter(ChatHistory.user_id == [user.id](http://user.id/)).delete(synchronize_session=False)
730
+ s.delete(user); s.commit()
731
+ flash(f"User #{user_id} beserta seluruh riwayatnya telah dihapus.", "success")
732
+ except Exception as e:
733
+ s.rollback(); log.exception(f"[ADMIN] delete user error: {e}")
734
+ flash("Gagal menghapus user.", "error")
735
+ finally:
736
+ s.close()
737
+ return redirect(request.referrer or url_for("admin_users"))
738
+
739
+ @app.route("/admin/users/int:user_id/history/clear", methods=["POST"])
740
  @admin_required
741
  def admin_clear_user_history(user_id: int):
742
+ s = db()
743
+ try:
744
+ exists = s.query([User.id](http://user.id/)).filter_by(id=user_id).first()
745
+ if not exists:
746
+ flash("User tidak ditemukan.", "error")
747
+ return redirect(request.referrer or url_for("admin_history"))
748
+ deleted = s.query(ChatHistory).filter(ChatHistory.user_id == user_id).delete(synchronize_session=False)
749
+ s.commit()
750
+ flash(f"Riwayat chat user #{user_id} dihapus ({deleted} baris).", "success")
751
+ except Exception as e:
752
+ s.rollback(); log.exception(f"[ADMIN] clear history error: {e}")
753
+ flash("Gagal menghapus riwayat.", "error")
754
+ finally:
755
+ s.close()
756
+ return redirect(request.referrer or url_for("admin_history"))
757
+
758
+ @app.route("/admin/history/int:chat_id/delete", methods=["POST"])
759
  @admin_required
760
  def admin_delete_chat(chat_id: int):
761
+ s = db()
762
+ try:
763
+ row = s.query(ChatHistory).filter_by(id=chat_id).first()
764
+ if not row:
765
+ flash("Baris riwayat tidak ditemukan.", "error")
766
+ return redirect(request.referrer or url_for("admin_history"))
767
+ s.delete(row); s.commit()
768
+ flash(f"Riwayat chat #{chat_id} dihapus.", "success")
769
+ except Exception as e:
770
+ s.rollback(); log.exception(f"[ADMIN] delete chat error: {e}")
771
+ flash("Gagal menghapus riwayat.", "error")
772
+ finally:
773
+ s.close()
774
+ return redirect(request.referrer or url_for("admin_history"))
775
 
776
  # ========= ENTRY =========
777
+
778
+ if **name** == "**main**":
779
+ port = int(os.environ.get("PORT", 7860))
780
+ app.run(host="0.0.0.0", port=port, debug=False)