Dyraa18 commited on
Commit
a211075
·
verified ·
1 Parent(s): 23e9bac
Files changed (1) hide show
  1. app.py +551 -558
app.py CHANGED
@@ -1,3 +1,11 @@
 
 
 
 
 
 
 
 
1
  import os, json, re, time, logging
2
  from functools import lru_cache, wraps
3
  from typing import Dict, List, Tuple
@@ -7,7 +15,7 @@ from zoneinfo import ZoneInfo
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,29 +27,25 @@ from dotenv import load_dotenv
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))
@@ -52,287 +56,282 @@ 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,441 +339,435 @@ JKT_TZ = ZoneInfo("Asia/Jakarta")
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)
 
1
+ # app.py (HF Spaces CPU-Optimized)
2
+ # RAG sekolah super hemat CPU:
3
+ # - Default model: 3B instruct (GGUF) + ctx 1024
4
+ # - Retrieval cepat: FAISS top-12 → pilih kalimat pakai lexical overlap (tanpa encode per-kalimat)
5
+ # - Encoder dipakai HANYA untuk query & FAISS (1x per request)
6
+ # - Jawaban final lewat <final>...</final>, stop di </final>, retry kalau kosong/ellipsis
7
+ # - Admin + Auth Postgres tetap sama
8
+
9
  import os, json, re, time, logging
10
  from functools import lru_cache, wraps
11
  from typing import Dict, List, Tuple
 
15
  from pathlib import Path
16
 
17
  from flask import (
18
+ Flask, render_template, request, redirect, url_for, session, jsonify, flash
19
  )
20
 
21
  import numpy as np
 
27
  load_dotenv()
28
 
29
  # ========= ENV & LOGGING =========
 
30
  os.environ.setdefault("KMP_DUPLICATE_LIB_OK", "TRUE")
31
  os.environ.setdefault("OMP_NUM_THREADS", "1")
32
  try:
33
+ torch.set_num_threads(int(os.environ.get("NUM_THREADS", "3"))) # 3 thread cukup di CPU Spaces
34
+ torch.set_num_interop_threads(1)
35
  except Exception:
36
+ pass
37
 
38
+ logging.basicConfig(level=logging.INFO, format="%(asctime)s | %(levelname)s | %(message)s")
39
  log = logging.getLogger("rag-app")
40
 
41
  # ========= IMPORT EKSTERNAL (wrapper & guardrail) =========
 
42
  from Guardrail import validate_input # -> bool
43
  from Model import load_model, generate # -> llama.cpp wrapper
44
 
45
  # ========= PATH ROOT =========
46
+ BASE_DIR = Path(__file__).resolve().parent
 
47
 
48
  # ========= KONFIG MODEL & RAG (di-tune untuk CPU) =========
 
49
  GGUF_DEFAULT = "DeepSeek-R1-Distill-Qwen-7B-Q4_K_M.gguf" # kecil & cepat; upload ke /models
50
  MODEL_PATH = str(BASE_DIR / "models" / os.getenv("GGUF_FILENAME", GGUF_DEFAULT))
51
  CTX_WINDOW = int(os.environ.get("CTX_WINDOW", 1024))
 
56
  ENCODER_DEVICE = torch.device("cpu")
57
 
58
  # Dataset sudah ada di Space → path RELATIF (samakan dengan struktur kamu)
 
59
  SUBJECTS: Dict[str, Dict[str, str]] = {
60
+ "ipas": {
61
+ "index": str(BASE_DIR / "Rag-Pipeline" / "Vektor Database" / "Ipas" / "IPA_index.index"),
62
+ "chunks": str(BASE_DIR / "Dataset" / "Ipas" / "Chunk" / "ipas_chunks.json"),
63
+ "embeddings": str(BASE_DIR / "Dataset" / "Ipas" / "Embedd"/ "ipas_embeddings.npy"),
64
+ "label": "IPAS",
65
+ "desc": "Ilmu Pengetahuan Alam dan Sosial"
66
+ },
67
+ "penjas": {
68
+ "index": str(BASE_DIR / "Rag-Pipeline" / "Vektor Database" / "Penjas" / "PENJAS_index.index"),
69
+ "chunks": str(BASE_DIR / "Dataset" / "Penjas" / "Chunk" / "penjas_chunks.json"),
70
+ "embeddings": str(BASE_DIR / "Dataset" / "Penjas" / "Embedd" / "penjas_embeddings.npy"),
71
+ "label": "PJOK",
72
+ "desc": "Pendidikan Jasmani, Olahraga, dan Kesehatan"
73
+ },
74
+ "pancasila": {
75
+ "index": str(BASE_DIR / "Rag-Pipeline" / "Vektor Database" / "Pancasila" / "PANCASILA_index.index"),
76
+ "chunks": str(BASE_DIR / "Dataset" / "Pancasila" / "Chunk" / "pancasila_chunks.json"),
77
+ "embeddings": str(BASE_DIR / "Dataset" / "Pancasila" / "Embedd" / "pancasila_embeddings.npy"),
78
+ "label": "PANCASILA",
79
+ "desc": "Pendidikan Pancasila dan Kewarganegaraan"
80
+ }
81
  }
82
 
83
  # Threshold & parameter cepat
 
84
  TOP_K_FAISS = int(os.environ.get("TOP_K_FAISS", 15))
85
  TOP_K_FINAL = int(os.environ.get("TOP_K_FINAL", 10))
86
+ MIN_COSINE = float(os.environ.get("MIN_COSINE", 0.83)) # sedikit lebih longgar biar jarang fallback
87
+ MIN_LEXICAL = float(os.environ.get("MIN_LEXICAL", 0.8))
88
  FALLBACK_TEXT = os.environ.get("FALLBACK_TEXT", "maap pengetahuan tidak ada dalam database")
89
  GUARDRAIL_BLOCK_TEXT = os.environ.get("GUARDRAIL_BLOCK_TEXT", "maap, pertanyaan ditolak oleh guardrail")
90
  ENABLE_PROFILING = os.environ.get("ENABLE_PROFILING", "false").lower() == "true"
91
 
92
  # ========= APP =========
93
+ app = Flask(__name__)
 
94
  app.secret_key = os.environ.get("FLASK_SECRET_KEY", "dev-secret-please-change")
95
 
96
  from werkzeug.middleware.proxy_fix import ProxyFix
97
  app.wsgi_app = ProxyFix(app.wsgi_app, x_for=1, x_proto=1, x_host=1)
98
  app.config.update(
99
+ SESSION_COOKIE_NAME="session",
100
+ SESSION_COOKIE_SAMESITE="None",
101
+ SESSION_COOKIE_SECURE=True,
102
+ SESSION_COOKIE_HTTPONLY=True,
103
+ SESSION_COOKIE_PATH="/",
104
+ PREFERRED_URL_SCHEME="https",
105
  )
106
 
107
  # ========= GLOBALS =========
 
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
  STOPWORDS_ID = {
120
+ "yang","dan","atau","pada","di","ke","dari","itu","ini","adalah","dengan",
121
+ "untuk","serta","sebagai","oleh","dalam","akan","kamu","apa","karena",
122
+ "agar","sehingga","terhadap","dapat","juga","para","diri",
123
  }
124
  TOKEN_RE = re.compile(r"[A-Za-zÀ-ÖØ-öø-ÿ]+", re.UNICODE)
125
 
126
  @lru_cache(maxsize=4096)
127
  def _tok_cached(word: str) -> str:
128
+ # cache lowercase
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(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(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(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
  from werkzeug.security import generate_password_hash, check_password_hash
307
  from sqlalchemy import create_engine, Column, Integer, String, Text, Boolean, func, or_
308
  from sqlalchemy.orm import sessionmaker, scoped_session, declarative_base, Session
309
 
310
  POSTGRES_URL = os.environ.get("POSTGRES_URL")
311
  if not POSTGRES_URL:
312
+ raise RuntimeError("POSTGRES_URL tidak ditemukan. Set di Settings → Variables.")
313
 
314
  engine = create_engine(POSTGRES_URL, pool_pre_ping=True, future=True, echo=False)
315
  SessionLocal = scoped_session(sessionmaker(bind=engine, autoflush=False, autocommit=False, future=True))
316
  Base = declarative_base()
317
 
318
  class User(Base):
319
+ __tablename__ = "users"
320
+ id = Column(Integer, primary_key=True)
321
+ username = Column(String(50), unique=True, nullable=False, index=True)
322
+ email = Column(String(120), unique=True, nullable=False, index=True)
323
+ password = Column(Text, nullable=False)
324
+ is_active = Column(Boolean, default=True, nullable=False)
325
+ is_admin = Column(Boolean, default=False, nullable=False)
326
 
327
  class ChatHistory(Base):
328
+ __tablename__ = "chat_history"
329
+ id = Column(Integer, primary_key=True)
330
+ user_id = Column(Integer, nullable=False, index=True)
331
+ subject_key = Column(String(50), nullable=False, index=True)
332
+ role = Column(String(10), nullable=False)
333
+ message = Column(Text, nullable=False)
334
+ timestamp = Column(Integer, server_default=func.extract("epoch", func.now()))
335
 
336
  Base.metadata.create_all(bind=engine)
337
 
 
339
 
340
  @app.template_filter("fmt_ts")
341
  def fmt_ts(epoch_int: int):
342
+ try:
343
+ dt = datetime.fromtimestamp(int(epoch_int), tz=JKT_TZ)
344
+ return dt.strftime("%d %b %Y %H:%M")
345
+ except Exception:
346
+ return "-"
347
 
348
  def db():
349
+ return SessionLocal()
350
 
351
  def login_required(view_func):
352
+ @wraps(view_func)
353
+ def wrapper(*args, **kwargs):
354
+ if not session.get("logged_in"):
355
+ return redirect(url_for("auth_login"))
356
+ return view_func(*args, **kwargs)
357
+ return wrapper
358
 
359
  def admin_required(view_func):
360
+ @wraps(view_func)
361
+ def wrapper(*args, **kwargs):
362
+ if not session.get("logged_in"):
363
+ return redirect(url_for("auth_login"))
364
+ if not session.get("is_admin"):
365
+ flash("Hanya admin yang boleh mengakses halaman itu.", "error")
366
+ return redirect(url_for("subjects"))
367
+ return view_func(*args, **kwargs)
368
+ return wrapper
369
 
370
  # ========= ROUTES =========
 
371
  @app.route("/")
372
  def root():
373
+ return redirect(url_for("auth_login"))
374
 
375
  @app.route("/auth/login", methods=["GET", "POST"])
376
  def auth_login():
377
+ if request.method == "POST":
378
+ identity = (
379
+ request.form.get("identity") or request.form.get("email") or request.form.get("username") or ""
380
+ ).strip().lower()
381
+ pw_input = (request.form.get("password") or "").strip()
382
+ if not identity or not pw_input:
383
+ flash("Mohon isi email/username dan password.", "error")
384
+ return render_template("login.html"), 400
385
+ s = db()
386
+ try:
387
+ user = (
388
+ s.query(User)
389
+ .filter(or_(func.lower(User.username) == identity, func.lower(User.email) == identity))
390
+ .first()
391
+ )
392
+ log.info(f"[LOGIN] identity='{identity}' found={bool(user)} active={getattr(user,'is_active',None)}")
393
+ ok = bool(user and user.is_active and check_password_hash(user.password, pw_input))
394
+ finally:
395
+ s.close()
396
+ if not ok:
397
+ flash("Identitas atau password salah.", "error")
398
+ return render_template("login.html"), 401
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
+ return render_template("login.html")
406
 
407
  @app.route("/whoami")
408
  def whoami():
409
+ return {
410
+ "logged_in": bool(session.get("logged_in")),
411
+ "user_id": session.get("user_id"),
412
+ "username": session.get("username"),
413
+ "is_admin": session.get("is_admin"),
414
+ }
415
 
416
  @app.route("/auth/register", methods=["GET", "POST"])
417
  def auth_register():
418
+ if request.method == "POST":
419
+ username = (request.form.get("username") or "").strip().lower()
420
+ email = (request.form.get("email") or "").strip().lower()
421
+ pw = (request.form.get("password") or "").strip()
422
+ confirm = (request.form.get("confirm") or "").strip()
423
+ if not username or not email or not pw:
424
+ flash("Semua field wajib diisi.", "error")
425
+ return render_template("register.html"), 400
426
+ if len(pw) < 6:
427
+ flash("Password minimal 6 karakter.", "error")
428
+ return render_template("register.html"), 400
429
+ if pw != confirm:
430
+ flash("Konfirmasi password tidak cocok.", "error")
431
+ return render_template("register.html"), 400
432
+ s = db()
433
+ try:
434
+ existed = (
435
+ s.query(User)
436
+ .filter(or_(func.lower(User.username) == username, func.lower(User.email) == email))
437
+ .first()
438
+ )
439
+ if existed:
440
+ flash("Username/Email sudah terpakai.", "error")
441
+ return render_template("register.html"), 409
442
+ u = User(username=username, email=email, password=generate_password_hash(pw), is_active=True)
443
+ s.add(u); s.commit()
444
+ finally:
445
+ s.close()
446
+ flash("Registrasi berhasil. Silakan login.", "success")
447
+ return redirect(url_for("auth_login"))
448
+ return render_template("register.html")
449
 
450
  @app.route("/auth/logout")
451
  def auth_logout():
452
+ session.clear()
453
+ return redirect(url_for("auth_login"))
454
 
455
  @app.route("/about")
456
  def about():
457
+ return render_template("about.html")
458
 
459
  @app.route("/subjects")
460
  @login_required
461
  def subjects():
462
+ log.info(f"[SESSION DEBUG] logged_in={session.get('logged_in')} user_id={session.get('user_id')}")
463
+ return render_template("home.html", subjects=SUBJECTS)
464
 
465
  @app.route("/chat/<subject_key>")
466
  @login_required
467
  def chat_subject(subject_key: str):
468
+ if subject_key not in SUBJECTS:
469
+ return redirect(url_for("subjects"))
470
+ session["subject_selected"] = subject_key
471
+ label = SUBJECTS[subject_key]["label"]
472
+ s = db()
473
+ try:
474
+ uid = session.get("user_id")
475
+ rows = (
476
+ s.query(ChatHistory)
477
+ .filter_by(user_id=uid, subject_key=subject_key)
478
+ .order_by(ChatHistory.id.asc())
479
+ .all()
480
+ )
481
+ history = [{"role": r.role, "message": r.message} for r in rows]
482
+ finally:
483
+ s.close()
484
+ return render_template("chat.html", subject=subject_key, subject_label=label, history=history)
485
 
486
  @app.route("/health")
487
  def health():
488
+ return jsonify({
489
+ "ok": True,
490
+ "encoder_loaded": ENCODER_MODEL is not None,
491
+ "llm_loaded": LLM is not None,
492
+ "model_path": MODEL_PATH,
493
+ "ctx_window": CTX_WINDOW,
494
+ "threads": N_THREADS,
495
+ })
496
 
497
  @app.route("/ask/<subject_key>", methods=["POST"])
498
  @login_required
499
  def ask(subject_key: str):
500
+ if subject_key not in SUBJECTS:
501
+ return jsonify({"ok": False, "error": "invalid subject"}), 400
502
+ warmup_models()
503
+ t0 = time.perf_counter()
504
+
505
+ data = request.get_json(silent=True) or {}
506
+ query = (data.get("message") or "").strip()
507
+ if not query:
508
+ return jsonify({"ok": False, "error": "empty query"}), 400
509
+ if not validate_input_cached(query):
510
+ return jsonify({"ok": True, "answer": GUARDRAIL_BLOCK_TEXT})
 
511
 
512
+ try:
513
+ _ = load_subject_assets(subject_key)
514
+ except Exception as e:
515
+ log.exception(f"[ASSETS] error: {e}")
516
+ return jsonify({"ok": False, "error": f"subject assets error: {e}"}), 500
 
 
 
 
 
517
 
518
+ best = best_cosine_from_faiss(query, subject_key)
519
+ log.info(f"[RAG] Subject={subject_key.upper()} | Best cosine={best:.3f}")
520
+ if best < MIN_COSINE:
521
+ return jsonify({"ok": True, "answer": FALLBACK_TEXT})
522
 
523
+ chunks = retrieve_top_chunks(query, subject_key)
524
+ if not chunks:
525
+ return jsonify({"ok": True, "answer": FALLBACK_TEXT})
526
 
527
+ sentences = pick_best_sentences_fast(query, chunks, top_k=5)
528
+ if not sentences:
529
+ return jsonify({"ok": True, "answer": FALLBACK_TEXT})
530
 
531
+ prompt = build_prompt(query, sentences)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
532
 
533
+ try:
534
+ # PASS-1: deterministik & singkat
535
+ raw_answer = generate(
 
 
 
 
 
 
 
536
  LLM,
537
+ prompt,
538
+ max_tokens=int(os.environ.get("MAX_TOKENS", 64)),
539
+ temperature=float(os.environ.get("TEMP", 0.2)),
540
  top_p=1.0,
541
+ stop=["</final>"]
542
  ) or ""
543
+ raw_answer = raw_answer.strip()
544
+ log.info(f"[LLM] Raw answer repr (pass1): {repr(raw_answer)}")
545
+
546
+ text = re.sub(r"<think\\b[^>]*>.*?</think>", "", raw_answer, flags=re.DOTALL | re.IGNORECASE).strip()
547
+ text = re.sub(r"</?think\\b[^>]*>", "", text, flags=re.IGNORECASE).strip()
548
+ m_final = re.search(r"<final>\\s*(.+)$", text, flags=re.IGNORECASE | re.DOTALL)
549
+ cleaned = (m_final.group(1).strip() if m_final else re.sub(r"<[^>]+>", "", text).strip())
550
+
551
+ def _alpha_tokens(s: str) -> List[str]:
552
+ return re.findall(r"[A-Za-zÀ-ÖØ-öø-ÿ]+", s or "")
553
+
554
+ def _is_bad(s: str) -> bool:
555
+ s2 = (s or "").strip()
556
+ if not s2:
557
+ return True
558
+ # nolak placeholder/ellipsis saja
559
+ if s2 in {"...", ".", "..", "…"}:
560
+ return True
561
+ toks = _alpha_tokens(s2)
562
+ # cukup 4 token alfabetik untuk lolos (lebih toleran utk jawaban singkat)
563
+ if len(toks) >= 4:
564
+ return False
565
+ # pengecualian: fakta pendek dengan unit/istilah umum tetap lolos
566
+ if any(t.lower() in {"newton","n","kg","m","s"} for t in toks) and len(toks) >= 3:
567
+ return False
568
+ return True
569
 
570
+ if _is_bad(cleaned):
571
+ prompt_retry = (
572
+ prompt
573
+ + "\n\nULANGI DENGAN TAAT FORMAT: "
574
+ "Tulis satu kalimat faktual tanpa placeholder/ellipsis, "
575
+ "mulai huruf kapital dan akhiri titik. "
576
+ "Tulis hanya di dalam <final>...</final>."
577
+ )
578
+ raw_answer2 = generate(
579
+ LLM,
580
+ prompt_retry,
581
+ max_tokens=int(os.environ.get("MAX_TOKENS", 64)),
582
+ temperature=0.2,
583
+ top_p=1.0,
584
+ stop=["</final>"]
585
+ ) or ""
586
+ raw_answer2 = raw_answer2.strip()
587
+ log.info(f"[LLM] Raw answer repr (pass2): {repr(raw_answer2)}")
588
+
589
+ text2 = re.sub(r"<think\b[^>]*>.*?</think>", "", raw_answer2, flags=re.DOTALL | re.IGNORECASE).strip()
590
+ text2 = re.sub(r"</?think\b[^>]*>", "", text2, flags=re.IGNORECASE).strip()
591
+ m_final2 = re.search(r"<final>\s*(.+)$", text2, flags=re.IGNORECASE | re.DOTALL)
592
+ cleaned2 = (m_final2.group(1).strip() if m_final2 else re.sub(r"<[^>]+>", "", text2).strip())
593
+ cleaned = cleaned2 or cleaned
594
+
595
+ answer = cleaned
596
+
597
+ except Exception as e:
598
+ log.exception(f"[LLM] generate error: {e}")
599
+ return jsonify({"ok": True, "answer": FALLBACK_TEXT})
600
+
601
+ # Ambil 1 kalimat pertama saja
602
+ m = re.search(r"(.+?[.!?])(\\s|$)", answer)
603
+ answer = (m.group(1) if m else answer).strip()
604
+ answer = strip_meta_sentence(answer)
605
+
606
+ # Simpan history
607
  try:
608
+ s = db()
609
+ uid = session.get("user_id")
610
+ s.add_all([
611
+ ChatHistory(user_id=uid, subject_key=subject_key, role="user", message=query),
612
+ ChatHistory(user_id=uid, subject_key=subject_key, role="bot", message=answer),
613
+ ])
614
+ s.commit()
615
+ except Exception as e:
616
+ log.exception(f"[DB] gagal simpan chat history: {e}")
617
+ finally:
618
+ try:
619
+ s.close()
620
+ except Exception:
621
+ pass
622
+
623
+ if not answer or len(answer) < 2:
624
+ answer = FALLBACK_TEXT
625
+
626
+ if ENABLE_PROFILING:
627
+ log.info({
628
+ "latency_total": time.perf_counter() - t0,
629
+ "subject": subject_key,
630
+ "faiss_best": best,
631
+ })
632
+
633
+ return jsonify({"ok": True, "answer": answer})
634
 
635
  # ===== Admin =====
 
636
  @app.route("/admin")
637
  @admin_required
638
  def admin_dashboard():
639
+ s = db()
640
+ try:
641
+ total_users = s.query(func.count(User.id)).scalar() or 0
642
+ total_active = s.query(func.count(User.id)).filter(User.is_active.is_(True)).scalar() or 0
643
+ total_admins = s.query(func.count(User.id)).filter(User.is_admin.is_(True)).scalar() or 0
644
+ total_msgs = s.query(func.count(ChatHistory.id)).scalar() or 0
645
+ finally:
646
+ s.close()
647
+ return render_template("admin_dashboard.html", total_users=total_users, total_active=total_active, total_admins=total_admins, total_msgs=total_msgs)
648
 
649
  @app.route("/admin/users")
650
  @admin_required
651
  def admin_users():
652
+ q = (request.args.get("q") or "").strip().lower()
653
+ page = max(int(request.args.get("page", 1)), 1)
654
+ per_page = min(max(int(request.args.get("per_page", 20)), 5), 100)
655
+ s = db()
656
+ try:
657
+ base = s.query(User)
658
+ if q:
659
+ base = base.filter(or_(func.lower(User.username).like(f"%{q}%"), func.lower(User.email).like(f"%{q}%")))
660
+ total = base.count()
661
+ users = base.order_by(User.id.asc()).offset((page - 1) * per_page).limit(per_page).all()
662
+ user_ids = [u.id for u in users] or [-1]
663
+ counts = dict(s.query(ChatHistory.user_id, func.count(ChatHistory.id)).filter(ChatHistory.user_id.in_(user_ids)).group_by(ChatHistory.user_id).all())
664
+ finally:
665
+ s.close()
666
+ return render_template("admin_users.html", users=users, counts=counts, q=q, page=page, per_page=per_page, total=total)
667
 
668
  @app.route("/admin/history")
669
  @admin_required
670
  def admin_history():
671
+ q = (request.args.get("q") or "").strip().lower()
672
+ username = (request.args.get("username") or "").strip().lower()
673
+ subject = (request.args.get("subject") or "").strip().lower()
674
+ role = (request.args.get("role") or "").strip().lower()
675
+ page = max(int(request.args.get("page", 1)), 1)
676
+ per_page = min(max(int(request.args.get("per_page", 30)), 5), 200)
677
+ s = db()
678
+ try:
679
+ base = (s.query(ChatHistory, User).join(User, User.id == ChatHistory.user_id))
680
+ if q:
681
+ base = base.filter(func.lower(ChatHistory.message).like(f"%{q}%"))
682
+ if username:
683
+ base = base.filter(or_(func.lower(User.username) == username, func.lower(User.email) == username))
684
+ if subject:
685
+ base = base.filter(func.lower(ChatHistory.subject_key) == subject)
686
+ if role in ("user", "bot"):
687
+ base = base.filter(ChatHistory.role == role)
688
+ total = base.count()
689
+ rows = base.order_by(ChatHistory.id.desc()).offset((page - 1) * per_page).limit(per_page).all()
690
+ finally:
691
+ s.close()
692
+ items = [{
693
+ "id": r.ChatHistory.id,
694
+ "username": r.User.username,
695
+ "email": r.User.email,
696
+ "subject": r.ChatHistory.subject_key,
697
+ "role": r.ChatHistory.role,
698
+ "message": r.ChatHistory.message,
699
+ "timestamp": r.ChatHistory.timestamp,
700
+ } for r in rows]
701
+ 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)
702
+
703
+
704
+ def _is_last_admin(s: Session) -> bool:
705
+ return (s.query(func.count(User.id)).filter(User.is_admin.is_(True)).scalar() or 0) <= 1
706
+
707
+ @app.route("/admin/users/<int:user_id>/delete", methods=["POST"])
708
  @admin_required
709
  def admin_delete_user(user_id: int):
710
+ s = db()
711
+ try:
712
+ me_id = session.get("user_id")
713
+ user = s.query(User).filter_by(id=user_id).first()
714
+ if not user:
715
+ flash("User tidak ditemukan.", "error")
716
+ return redirect(request.referrer or url_for("admin_users"))
717
+ if user.id == me_id:
718
+ flash("Tidak bisa menghapus akun yang sedang login.", "error")
719
+ return redirect(request.referrer or url_for("admin_users"))
720
+ if user.is_admin and _is_last_admin(s):
721
+ flash("Tidak bisa menghapus admin terakhir.", "error")
722
+ return redirect(request.referrer or url_for("admin_users"))
723
+ s.query(ChatHistory).filter(ChatHistory.user_id == user.id).delete(synchronize_session=False)
724
+ s.delete(user); s.commit()
725
+ flash(f"User #{user_id} beserta seluruh riwayatnya telah dihapus.", "success")
726
+ except Exception as e:
727
+ s.rollback(); log.exception(f"[ADMIN] delete user error: {e}")
728
+ flash("Gagal menghapus user.", "error")
729
+ finally:
730
+ s.close()
731
+ return redirect(request.referrer or url_for("admin_users"))
732
+
733
+ @app.route("/admin/users/<int:user_id>/history/clear", methods=["POST"])
734
  @admin_required
735
  def admin_clear_user_history(user_id: int):
736
+ s = db()
737
+ try:
738
+ exists = s.query(User.id).filter_by(id=user_id).first()
739
+ if not exists:
740
+ flash("User tidak ditemukan.", "error")
741
+ return redirect(request.referrer or url_for("admin_history"))
742
+ deleted = s.query(ChatHistory).filter(ChatHistory.user_id == user_id).delete(synchronize_session=False)
743
+ s.commit()
744
+ flash(f"Riwayat chat user #{user_id} dihapus ({deleted} baris).", "success")
745
+ except Exception as e:
746
+ s.rollback(); log.exception(f"[ADMIN] clear history error: {e}")
747
+ flash("Gagal menghapus riwayat.", "error")
748
+ finally:
749
+ s.close()
750
+ return redirect(request.referrer or url_for("admin_history"))
751
+
752
+ @app.route("/admin/history/<int:chat_id>/delete", methods=["POST"])
753
  @admin_required
754
  def admin_delete_chat(chat_id: int):
755
+ s = db()
756
+ try:
757
+ row = s.query(ChatHistory).filter_by(id=chat_id).first()
758
+ if not row:
759
+ flash("Baris riwayat tidak ditemukan.", "error")
760
+ return redirect(request.referrer or url_for("admin_history"))
761
+ s.delete(row); s.commit()
762
+ flash(f"Riwayat chat #{chat_id} dihapus.", "success")
763
+ except Exception as e:
764
+ s.rollback(); log.exception(f"[ADMIN] delete chat error: {e}")
765
+ flash("Gagal menghapus riwayat.", "error")
766
+ finally:
767
+ s.close()
768
+ return redirect(request.referrer or url_for("admin_history"))
769
 
770
  # ========= ENTRY =========
771
+ if __name__ == "__main__":
772
+ port = int(os.environ.get("PORT", 7860))
773
+ app.run(host="0.0.0.0", port=port, debug=False)