Spaces:
Running
Running
| import os | |
| import base64 | |
| import json | |
| import logging | |
| import time | |
| import hmac | |
| import secrets | |
| from pathlib import Path | |
| from collections import defaultdict, deque, OrderedDict | |
| from datetime import datetime | |
| from flask import Flask, request, jsonify, render_template, Response, session, redirect | |
| try: | |
| from flask_limiter import Limiter | |
| from flask_limiter.util import get_remote_address | |
| _LIMITER_AVAILABLE = True | |
| except Exception as _limiter_exc: | |
| _LIMITER_AVAILABLE = False | |
| _LIMITER_ERROR = str(_limiter_exc) | |
| logging.getLogger("bord").warning("flask-limiter tidak aktif: %s", _LIMITER_ERROR) | |
| from groq import Groq | |
| from supabase import create_client | |
| PROJECT_ROOT = Path(__file__).resolve().parent | |
| SYSTEM_PROMPT_PATH = PROJECT_ROOT / "system_prompt.txt" | |
| def load_system_prompt() -> str: | |
| try: | |
| return SYSTEM_PROMPT_PATH.read_text(encoding="utf-8").strip() | |
| except Exception as exc: | |
| logging.getLogger("bord").warning("Gagal membaca system prompt: %s", exc) | |
| return ( | |
| "Kamu adalah Bord AI, asisten belajar berbahasa Indonesia untuk mahasiswa. " | |
| "Jawab dengan jelas, jujur, dan ramah." | |
| ) | |
| def identity_bootstrap(user_text: str): | |
| value = (user_text or "").strip().lower() | |
| if not value: | |
| return None | |
| identity_questions = [ | |
| "kamu siapa", | |
| "namamu", | |
| "siapa kamu", | |
| "siapa dirimu", | |
| "pembuatmu", | |
| "siapa yang membuatmu", | |
| "penciptamu", | |
| "siapa developer kamu", | |
| "pembuat kamu", | |
| ] | |
| if any(q in value for q in identity_questions): | |
| return ( | |
| "Aku Bord AI, asisten belajar yang dibuat oleh NaDev. " | |
| "Aku ada untuk bantu kamu belajar, bukan ngobrol santai terus. " | |
| "Ada yang mau ditanyain?" | |
| ) | |
| return None | |
| import re | |
| def redact_pii(text: str) -> str: | |
| value = text or "" | |
| value = re.sub(r'\b\d{4}[-\s]\d{4}[-\s]\d{4}[-\s]\d{4}\b', '[KARTU_TERSENSITIF]', value) | |
| return value | |
| # FIX: "pelan" dihapus dari blocklist. Karena matching-nya substring, | |
| # "jelasin pelan-pelan dong" ikut keblok. Hati-hati nambah kata pendek di sini. | |
| _MODERATION_BLOCKED = [ | |
| "cara membuat bom", | |
| "cara membuat narkoba", | |
| "cara membuat senjata", | |
| "cara menyuap", | |
| "cara korupsi", | |
| "cara curi", | |
| "cara mencuri", | |
| "cara hack", | |
| "cara meretas", | |
| "cara bobol", | |
| "pornografi", | |
| "child porn", | |
| "eksploitasi anak", | |
| ] | |
| def moderate_input(text: str) -> tuple[bool, str]: | |
| value = (text or "").strip() | |
| if not value: | |
| return True, "" | |
| lowered = value.lower() | |
| for phrase in _MODERATION_BLOCKED: | |
| if phrase in lowered: | |
| return False, "Maaf, permintaan ini nggak bisa aku bantu karena bertentangan dengan aturan keamanan." | |
| return True, "" | |
| log = logging.getLogger("bord") | |
| logging.basicConfig(level=logging.INFO) | |
| app = Flask(__name__) | |
| # Buat session cookie login admin. Set FLASK_SECRET_KEY di env HF Spaces biar | |
| # session nggak ke-reset tiap restart. Kalau kosong, fallback random (login ulang tiap restart). | |
| app.secret_key = os.environ.get("FLASK_SECRET_KEY") or secrets.token_hex(32) | |
| app.config["SESSION_COOKIE_HTTPONLY"] = True | |
| app.config["SESSION_COOKIE_SAMESITE"] = "Lax" | |
| app.config["SESSION_COOKIE_SECURE"] = True # HF Spaces pakai https | |
| app.config["RATELIMIT_HEADERS_ENABLED"] = True | |
| app.config["RATELIMIT_DEFAULT_ERROR_MESSAGE"] = "Bord lagi sibuk nih π Tunggu sebentar ya." | |
| if _LIMITER_AVAILABLE: | |
| limiter = Limiter( | |
| key_func=lambda: ( | |
| (request.get_json(silent=True) or {}).get("session_id") | |
| or request.remote_addr | |
| or "anon" | |
| ), | |
| app=app, | |
| default_limits=["60 per minute"], | |
| storage_uri="memory://", | |
| ) | |
| else: | |
| limiter = None | |
| STATE = {"model": "llama-3.1-8b-instant"} | |
| DEV_STATS = {"chats": 0, "tokens": 0, "foto": 0} | |
| SESSION_HISTORY = defaultdict(list) | |
| DEV_KEY = os.environ.get("DEV_KEY", "") | |
| ADMIN_PASSWORD = os.environ.get("BORD_ADMIN_PASSWORD", "") | |
| SYSTEM_PROMPT = load_system_prompt() | |
| # FIX: eviction sesi biar SESSION_HISTORY nggak numpuk selamanya. | |
| # Penting kalau nanti pindah ke server sendiri (HF Spaces ketolong restart, server sendiri enggak). | |
| SESSION_MAX = 500 # maksimal sesi disimpan (LRU) | |
| SESSION_TTL = 3600 # detik; sesi nganggur > 1 jam dibuang | |
| SESSION_SEEN = OrderedDict() # session_id -> epoch terakhir aktif | |
| def touch_session(session_id: str) -> None: | |
| """Tandai sesi aktif + buang yang kedaluwarsa / kelebihan kapasitas.""" | |
| now = time.time() | |
| SESSION_SEEN[session_id] = now | |
| SESSION_SEEN.move_to_end(session_id) | |
| # TTL: buang sesi yang udah lama nganggur | |
| expired = [sid for sid, ts in SESSION_SEEN.items() if now - ts > SESSION_TTL] | |
| for sid in expired: | |
| SESSION_SEEN.pop(sid, None) | |
| SESSION_HISTORY.pop(sid, None) | |
| # Cap: kalau kebanyakan, buang yang paling lama nggak kepake (LRU) | |
| while len(SESSION_SEEN) > SESSION_MAX: | |
| old_sid, _ = SESSION_SEEN.popitem(last=False) | |
| SESSION_HISTORY.pop(old_sid, None) | |
| EVENT_BUFFER = deque(maxlen=300) | |
| EVENT_BUFFER.append({ | |
| "ts": datetime.now().isoformat(), | |
| "type": "system", | |
| "session_id": "-", | |
| "summary": "Bot started", | |
| }) | |
| client = Groq(api_key=os.environ.get("GROQ_API_KEY")) | |
| # GEMINI | |
| GEMINI_API_KEY = os.environ.get("GEMINI_API_KEY", "") | |
| gemini_client = None | |
| GEMINI_MODEL = os.environ.get("GEMINI_MODEL", "gemini-2.5-flash") | |
| if GEMINI_API_KEY: | |
| try: | |
| from google import genai | |
| gemini_client = genai.Client(api_key=GEMINI_API_KEY) | |
| except Exception as exc: | |
| log.warning("Gemini gagal connect: %s", exc) | |
| OCR_PROMPT = ( | |
| "Kamu adalah pembaca teks dari gambar untuk membantu mahasiswa.\n" | |
| "Baca dan tuliskan ULANG semua teks, soal, atau materi yang ada di gambar ini dengan rapi dan selengkap mungkin, persis apa adanya.\n" | |
| "JANGAN menjawab soal atau memberi penjelasan β cukup tuliskan ulang isinya.\n" | |
| "Kalau ada rumus, simbol, atau kode, tuliskan sebisamu.\n" | |
| "Kalau gambar buram, gelap, atau teks tidak terbaca, jawab PERSIS: [TIDAK TERBACA]" | |
| ) | |
| def baca_foto(image_bytes, mime="image/jpeg"): | |
| if not gemini_client: | |
| return None, "Gemini belum dikonfigurasi (GEMINI_API_KEY kosong)." | |
| try: | |
| if not image_bytes: | |
| return None, "Gambar kosong." | |
| from google.genai import types as gtypes | |
| resp = gemini_client.models.generate_content( | |
| model=GEMINI_MODEL, | |
| contents=[OCR_PROMPT, gtypes.Part.from_bytes(data=image_bytes, mime_type=mime)], | |
| ) | |
| text = (resp.text or "").strip() | |
| if not text or "[TIDAK TERBACA]" in text.upper(): | |
| return "[TIDAK TERBACA]", None | |
| return text, None | |
| except Exception as exc: | |
| log.warning("OCR error: %s", exc) | |
| return None, str(exc) | |
| # SUPABASE | |
| SUPABASE_URL = os.environ.get("SUPABASE_URL", "") | |
| SUPABASE_KEY = os.environ.get("SUPABASE_KEY", "") | |
| supabase = None | |
| if SUPABASE_URL and SUPABASE_KEY: | |
| try: | |
| supabase = create_client(SUPABASE_URL, SUPABASE_KEY) | |
| except Exception as exc: | |
| log.warning("Supabase gagal connect: %s", exc) | |
| def log_chat(question, answer, model, dev=False): | |
| if not supabase: | |
| return | |
| try: | |
| supabase.table("percakapan").insert({ | |
| "pertanyaan": redact_pii(question or ""), | |
| "jawaban": redact_pii(answer or ""), | |
| "model": redact_pii(model or ""), | |
| "dev": bool(dev), | |
| }).execute() | |
| except Exception as exc: | |
| log.warning("Gagal simpan percakapan: %s", exc) | |
| def index(): | |
| return render_template("index.html") | |
| def healthz(): | |
| EVENT_BUFFER.append({ | |
| "ts": datetime.now().isoformat(), | |
| "type": "access", | |
| "session_id": "-", | |
| "summary": "healthz hit", | |
| }) | |
| return jsonify({"status": "ok", "model": STATE["model"]}) | |
| def metrics(): | |
| if not (DEV_KEY and request.args.get("dev_key") == DEV_KEY): | |
| return jsonify({"error": "unauthorized"}), 401 | |
| EVENT_BUFFER.append({ | |
| "ts": datetime.now().isoformat(), | |
| "type": "access", | |
| "session_id": "-", | |
| "summary": "metrics hit", | |
| }) | |
| return jsonify({"stats": DEV_STATS, "model": STATE["model"]}), 200 | |
| def require_admin() -> bool: | |
| """True kalau udah login lewat session cookie, ATAU header Authorization cocok | |
| (header tetep dipertahanin biar curl/script masih jalan). | |
| Pakai compare_digest biar nggak bisa ditebak lewat timing attack.""" | |
| if session.get("is_admin"): | |
| return True | |
| if not ADMIN_PASSWORD: | |
| return False | |
| auth = request.headers.get("Authorization", "") or "" | |
| if not auth: | |
| return False | |
| candidates = (ADMIN_PASSWORD, f"Bearer {ADMIN_PASSWORD}") | |
| return any(hmac.compare_digest(auth, c) for c in candidates) | |
| # Halaman login admin sederhana (self-contained, nggak butuh file template terpisah). | |
| _ADMIN_LOGIN_PAGE = """<!doctype html> | |
| <html lang="id"><head><meta charset="utf-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| <title>Bord Admin β Login</title> | |
| <style> | |
| *{box-sizing:border-box} body{margin:0;font-family:system-ui,-apple-system,sans-serif; | |
| background:#0d1117;color:#e6edf3;display:flex;min-height:100vh;align-items:center;justify-content:center} | |
| .card{background:#161b22;border:1px solid #30363d;border-radius:14px;padding:28px;width:min(360px,90vw)} | |
| h1{font-size:18px;margin:0 0 4px} p{color:#8b949e;font-size:13px;margin:0 0 18px} | |
| input{width:100%;padding:11px 13px;border-radius:9px;border:1px solid #30363d; | |
| background:#0d1117;color:#e6edf3;font-size:15px;margin-bottom:12px} | |
| input:focus{outline:none;border-color:#58a6ff} | |
| button{width:100%;padding:11px;border:0;border-radius:9px;background:#238636;color:#fff; | |
| font-size:15px;font-weight:600;cursor:pointer} button:hover{background:#2ea043} | |
| .err{color:#f85149;font-size:13px;margin-bottom:12px;__ERR_DISPLAY__} | |
| </style></head><body> | |
| <form class="card" method="POST" action="/admin/login"> | |
| <h1>π€ Bord Admin</h1> | |
| <p>Masukin password buat buka dashboard.</p> | |
| <div class="err">Password salah, coba lagi.</div> | |
| <input type="password" name="password" placeholder="Password admin" autofocus required> | |
| <button type="submit">Masuk</button> | |
| </form> | |
| </body></html>""" | |
| def _login_page(show_error: bool = False) -> str: | |
| return _ADMIN_LOGIN_PAGE.replace( | |
| "__ERR_DISPLAY__", "" if show_error else "display:none" | |
| ) | |
| def admin_login(): | |
| if session.get("is_admin"): | |
| return redirect("/admin") | |
| if request.method == "POST": | |
| pw = request.form.get("password", "") or "" | |
| if ADMIN_PASSWORD and hmac.compare_digest(pw, ADMIN_PASSWORD): | |
| session["is_admin"] = True | |
| return redirect("/admin") | |
| return _login_page(show_error=True), 401 | |
| return _login_page() | |
| def admin_logout(): | |
| session.pop("is_admin", None) | |
| return redirect("/admin/login") | |
| def admin_dashboard(): | |
| # Belum login -> lempar ke halaman login (bukan curl lagi). | |
| if not require_admin(): | |
| return redirect("/admin/login") | |
| return render_template("admin.html") | |
| def admin_events_json(): | |
| # FIX: dulu siapa pun bisa narik event log + session_id. Sekarang dijaga. | |
| if not require_admin(): | |
| return jsonify({"error": "unauthorized"}), 401 | |
| ordered = list(EVENT_BUFFER) | |
| ordered.reverse() | |
| now = datetime.now() | |
| enriched = [] | |
| for item in ordered: | |
| try: | |
| ts = datetime.fromisoformat(item["ts"]) | |
| except Exception: | |
| ts = now | |
| enriched.append({ | |
| "ts": item.get("ts"), | |
| "type": item.get("type"), | |
| "session_id": item.get("session_id"), | |
| "summary": item.get("summary"), | |
| "elapsed": _ago(ts, now), | |
| }) | |
| return jsonify({ | |
| "stats": DEV_STATS, | |
| "model": STATE["model"], | |
| "events": enriched, | |
| "active_sessions": len(SESSION_HISTORY), | |
| }) | |
| def _ago(start, end): | |
| diff = int((end - start).total_seconds()) | |
| if diff < 60: | |
| return f"{diff}s" | |
| if diff < 3600: | |
| m = diff // 60 | |
| s = diff % 60 | |
| return f"{m}m {s}s" | |
| h = diff // 3600 | |
| m = (diff % 3600) // 60 | |
| return f"{h}h {m}m" | |
| def admin_events_stream(): | |
| # FIX: stream SSE juga dijaga, dulu kebuka. | |
| if not require_admin(): | |
| return jsonify({"error": "unauthorized"}), 401 | |
| def event_stream(): | |
| snapshots = list(EVENT_BUFFER) | |
| for ev in snapshots: | |
| yield _format_sse(ev) | |
| last_ts = datetime.now().isoformat() | |
| while True: | |
| time.sleep(1) | |
| any_new = False | |
| for ev in EVENT_BUFFER: | |
| ts = ev.get("ts", "") | |
| if ts > last_ts: | |
| yield _format_sse(ev) | |
| last_ts = ts | |
| any_new = True | |
| if not any_new: | |
| yield ": ping\n\n" | |
| return Response(event_stream(), mimetype="text/event-stream") | |
| def _format_sse(ev: dict) -> str: | |
| data = json.dumps(ev, ensure_ascii=False) | |
| return f"data: {data}\n\n" | |
| def chat(): | |
| data = request.get_json(silent=True) or {} | |
| user_input = (data.get("message", "") or "").strip() | |
| image_b64 = data.get("image", "") or "" | |
| is_dev = bool(DEV_KEY) and ((data.get("dev_key", "") or "") == DEV_KEY) | |
| if not user_input and not image_b64: | |
| return jsonify({"reply": ""}), 200 | |
| if is_dev and not image_b64: | |
| cmd = (user_input or "").split() | |
| name = (cmd[0] or "").lower() if cmd else "" | |
| if name == "/stats": | |
| reply = ( | |
| f"π STATS\n" | |
| f"Chat: {DEV_STATS['chats']}\n" | |
| f"Token: {DEV_STATS['tokens']:,}\n" | |
| f"Foto (Gemini): {DEV_STATS['foto']}\n" | |
| f"Model: {STATE['model']}\n" | |
| f"Gemini: {'ON' if gemini_client else 'OFF'}\n" | |
| f"Supabase: {'ON' if supabase else 'OFF'}" | |
| ) | |
| return jsonify({"reply": reply, "debug": {"cmd": "stats"}}), 200 | |
| if name == "/model": | |
| if len(cmd) > 1: | |
| STATE["model"] = cmd[1] | |
| reply = f"β Model diganti ke: {cmd[1]}" | |
| else: | |
| reply = f"Model sekarang: {STATE['model']}\nContoh: /model llama-3.3-70b-versatile" | |
| return jsonify({"reply": reply, "debug": {"cmd": "model"}}), 200 | |
| if name == "/whoami": | |
| return jsonify({"reply": "π οΈ DEV MODE aktif.", "debug": {"cmd": "whoami"}}), 200 | |
| if name == "/clear": | |
| DEV_STATS.update({"chats": 0, "tokens": 0, "foto": 0}) | |
| SESSION_HISTORY.clear() | |
| SESSION_SEEN.clear() # FIX: ikut bersihin tracker eviction | |
| return jsonify({"reply": "π§Ή Stats + sesi direset.", "debug": {"cmd": "clear"}}), 200 | |
| if name == "/prompt": | |
| return jsonify({"reply": SYSTEM_PROMPT, "debug": {"cmd": "prompt"}}), 200 | |
| if name == "/help": | |
| return jsonify({"reply": ( | |
| "π οΈ DEV COMMANDS\n" | |
| "/stats\n/model <nama>\n/whoami\n/clear\n/prompt\n/help" | |
| ), "debug": {"cmd": "help"}}), 200 | |
| return jsonify({"reply": "Perintah dev tidak dikenali.", "debug": {"cmd": "unknown"}}), 200 | |
| identity_reply = identity_bootstrap(user_input) | |
| if identity_reply: | |
| return jsonify({"reply": identity_reply}), 200 | |
| if not image_b64: | |
| allowed, moderation_msg = moderate_input(user_input) | |
| if not allowed: | |
| return jsonify({"reply": moderation_msg}), 200 | |
| model_label = STATE["model"] | |
| session_id = data.get("session_id") or request.remote_addr or "anon" | |
| # FIX: history satu sumber kebenaran (server-side). Dulu di-merge sama | |
| # history kiriman client -> turn kedobel -> model baca u1,a1,u1,a1,... | |
| touch_session(session_id) | |
| combined = SESSION_HISTORY[session_id][-8:] | |
| if image_b64: | |
| base64_data = image_b64.split(",", 1)[1] if "," in image_b64 else image_b64 | |
| try: | |
| image_bytes = base64.b64decode(base64_data) | |
| except Exception: | |
| return jsonify({"reply": "Fotonya gagal dibaca, coba kirim ulang ya π"}), 200 | |
| teks_soal, err = baca_foto(image_bytes) | |
| if err or teks_soal == "": | |
| result = {"reply": "Aduh, kakak nggak bisa baca fotonya nih π Coba foto lebih terang & fokus, atau ketik soalnya langsung ya."} | |
| if is_dev: | |
| result["debug"] = {"error": err or "hasil kosong"} | |
| return jsonify(result), 200 | |
| if teks_soal == "[TIDAK TERBACA]": | |
| return jsonify({"reply": "Fotonya kurang jelas nih π Coba foto ulang yang lebih terang dan nggak buram ya, biar kakak bisa baca soalnya."}), 200 | |
| DEV_STATS["foto"] += 1 | |
| model_label = f"gemini+{STATE['model']}" | |
| user_input = f"[Mahasiswa mengirim foto soal. Hasil bacanya:]\n{teks_soal}" + (f"\n\n[Pesan tambahan]: {user_input}" if user_input else "") | |
| EVENT_BUFFER.append({ | |
| "ts": datetime.now().isoformat(), | |
| "type": "chat_start", | |
| "session_id": session_id, | |
| "summary": f"msg len={len(user_input)} image={bool(image_b64)}", | |
| }) | |
| messages = [{"role": "system", "content": SYSTEM_PROMPT}] | |
| messages.extend(combined) | |
| messages.append({"role": "user", "content": user_input}) | |
| try: | |
| response = client.chat.completions.create( | |
| model=STATE["model"], | |
| messages=messages, | |
| max_tokens=1024, | |
| timeout=15, | |
| ) | |
| except Exception as exc: | |
| EVENT_BUFFER.append({ | |
| "ts": datetime.now().isoformat(), | |
| "type": "chat_error", | |
| "session_id": session_id, | |
| "summary": str(exc), | |
| }) | |
| lower = str(exc).lower() | |
| if any(token in lower for token in ["rate", "429", "limit"]): | |
| msg = "Bord lagi sibuk nih π Tunggu sebentar ya." | |
| elif "invalid_api_key" in lower or "401" in lower: | |
| msg = "Konfigurasi API bermasalah. Hubungi admin untuk cek kunci API dulu ya." | |
| log.error("Groq API key error: %s", exc) | |
| else: | |
| msg = "Aduh, ada gangguan di sisi Bord nih π Coba lagi sebentar ya." | |
| result = {"reply": msg} | |
| if is_dev: | |
| result["debug"] = {"error": str(exc)} | |
| return jsonify(result), 200 | |
| reply = (response.choices[0].message.content or "").strip() | |
| usage = getattr(response, "usage", None) | |
| DEV_STATS["chats"] += 1 | |
| DEV_STATS["tokens"] += (getattr(usage, "total_tokens", 0) or 0) if usage else 0 | |
| # Simpan turn ini ke history server-side biar konteks ngalir antar request. | |
| turn = [ | |
| {"role": "user", "content": user_input}, | |
| {"role": "assistant", "content": reply}, | |
| ] | |
| SESSION_HISTORY[session_id] = (combined + turn)[-8:] | |
| log_chat(user_input, reply, model_label, dev=is_dev) | |
| EVENT_BUFFER.append({ | |
| "ts": datetime.now().isoformat(), | |
| "type": "chat_success", | |
| "session_id": session_id, | |
| "summary": f"reply len={len(reply)}", | |
| }) | |
| result = {"reply": reply} | |
| if is_dev and usage: | |
| result["debug"] = { | |
| "model": model_label, | |
| "prompt_tokens": getattr(usage, "prompt_tokens", 0) or 0, | |
| "completion_tokens": getattr(usage, "completion_tokens", 0) or 0, | |
| "total_tokens": getattr(usage, "total_tokens", 0) or 0, | |
| } | |
| return jsonify(result), 200 | |
| def handle_429(exc): | |
| return jsonify({"reply": "Bord lagi rame banget nih π Coba lagi dalam beberapa detik."}), 429 | |
| def handle_generic_exception(exc): | |
| log.exception("Unhandled error: %s", exc) | |
| return jsonify({"reply": "Aduh, ada gangguan nih π Coba kirim lagi sebentar ya."}), 200 | |
| if __name__ == "__main__": | |
| port = int(os.environ.get("PORT", "7860")) | |
| app.run(host="0.0.0.0", port=port) | |