From f62a711f5d5604d6a23430b0b37360862e8e0460 Mon Sep 17 00:00:00 2001 From: Power BI Dev Date: Thu, 4 Jun 2026 08:33:41 +0700 Subject: [PATCH] first init --- .env.example | 8 ++ Dockerfile | 15 +++ data/.gitkeep | 0 requirements.txt | 6 + src/config.py | 13 ++ src/database.py | 192 ++++++++++++++++++++++++++++++ src/document.py | 61 ++++++++++ src/guard.py | 57 +++++++++ src/handlers/__init__.py | 0 src/handlers/admin.py | 165 +++++++++++++++++++++++++ src/handlers/user.py | 251 +++++++++++++++++++++++++++++++++++++++ src/main.py | 49 ++++++++ src/rag.py | 76 ++++++++++++ 13 files changed, 893 insertions(+) create mode 100644 .env.example create mode 100644 Dockerfile create mode 100644 data/.gitkeep create mode 100644 requirements.txt create mode 100644 src/config.py create mode 100644 src/database.py create mode 100644 src/document.py create mode 100644 src/guard.py create mode 100644 src/handlers/__init__.py create mode 100644 src/handlers/admin.py create mode 100644 src/handlers/user.py create mode 100644 src/main.py create mode 100644 src/rag.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..683573e --- /dev/null +++ b/.env.example @@ -0,0 +1,8 @@ +TELEGRAM_BOT_TOKEN=isi_token_dari_botfather +OPENAI_API_KEY=isi_api_key_openai +OPENAI_MODEL=gpt-4o +OPENAI_EMBEDDING_MODEL=text-embedding-3-small +ADMIN_TELEGRAM_ID=isi_telegram_id_anda +DB_PATH=/app/data/second_brain.db +CHROMA_PATH=/app/data/chromadb +UPLOAD_PATH=/app/data/uploads diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..f8afa71 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,15 @@ +FROM python:3.11-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y --no-install-recommends \ + build-essential \ + gcc \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY src/ ./src/ + +CMD ["python", "src/main.py"] diff --git a/data/.gitkeep b/data/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..31a6872 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,6 @@ +python-telegram-bot==20.7 +openai==1.30.0 +python-dotenv==1.0.1 +chromadb==0.5.3 +pdfplumber==0.11.0 +python-docx==1.1.2 diff --git a/src/config.py b/src/config.py new file mode 100644 index 0000000..510d2a8 --- /dev/null +++ b/src/config.py @@ -0,0 +1,13 @@ +import os +from dotenv import load_dotenv + +load_dotenv() + +TELEGRAM_BOT_TOKEN = os.environ["TELEGRAM_BOT_TOKEN"] +OPENAI_API_KEY = os.environ["OPENAI_API_KEY"] +OPENAI_MODEL = os.getenv("OPENAI_MODEL", "gpt-4o") +OPENAI_EMBEDDING_MODEL = os.getenv("OPENAI_EMBEDDING_MODEL", "text-embedding-3-small") +ADMIN_TELEGRAM_ID = int(os.environ["ADMIN_TELEGRAM_ID"]) +DB_PATH = os.getenv("DB_PATH", "/app/data/second_brain.db") +CHROMA_PATH = os.getenv("CHROMA_PATH", "/app/data/chromadb") +UPLOAD_PATH = os.getenv("UPLOAD_PATH", "/app/data/uploads") diff --git a/src/database.py b/src/database.py new file mode 100644 index 0000000..5357605 --- /dev/null +++ b/src/database.py @@ -0,0 +1,192 @@ +import sqlite3 +import secrets +import string +from config import DB_PATH + + +def get_conn(): + conn = sqlite3.connect(DB_PATH) + conn.row_factory = sqlite3.Row + return conn + + +def init_db(): + with get_conn() as conn: + conn.executescript(""" + CREATE TABLE IF NOT EXISTS tokens ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + token TEXT UNIQUE NOT NULL, + label TEXT, + created_at TEXT DEFAULT (datetime('now')), + used_by_telegram_id INTEGER, + used_at TEXT, + is_active INTEGER DEFAULT 1 + ); + + CREATE TABLE IF NOT EXISTS users ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + telegram_id INTEGER UNIQUE NOT NULL, + full_name TEXT, + username TEXT, + registered_at TEXT DEFAULT (datetime('now')), + token_used TEXT, + is_active INTEGER DEFAULT 1 + ); + + CREATE TABLE IF NOT EXISTS memories ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + content TEXT NOT NULL, + scope TEXT DEFAULT 'private', + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (user_id) REFERENCES users(id) + ); + + CREATE TABLE IF NOT EXISTS documents ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER, + filename TEXT NOT NULL, + original_name TEXT, + scope TEXT DEFAULT 'private', + chunk_count INTEGER DEFAULT 0, + uploaded_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (user_id) REFERENCES users(id) + ); + + CREATE TABLE IF NOT EXISTS conversations ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL, + role TEXT NOT NULL, + content TEXT NOT NULL, + created_at TEXT DEFAULT (datetime('now')), + FOREIGN KEY (user_id) REFERENCES users(id) + ); + """) + + +# --- Token --- + +def generate_token(label: str) -> str: + alphabet = string.ascii_uppercase + string.digits + token = "DOSEN-" + "".join(secrets.choice(alphabet) for _ in range(8)) + with get_conn() as conn: + conn.execute("INSERT INTO tokens (token, label) VALUES (?, ?)", (token, label)) + return token + + +def use_token(token: str, telegram_id: int): + with get_conn() as conn: + row = conn.execute( + "SELECT * FROM tokens WHERE token = ? AND is_active = 1 AND used_by_telegram_id IS NULL", + (token,) + ).fetchone() + if not row: + return None + conn.execute( + "UPDATE tokens SET used_by_telegram_id = ?, used_at = datetime('now'), is_active = 0 WHERE token = ?", + (telegram_id, token) + ) + return dict(row) + + +# --- User --- + +def register_user(telegram_id: int, full_name: str, username: str, token: str) -> bool: + try: + with get_conn() as conn: + conn.execute( + "INSERT INTO users (telegram_id, full_name, username, token_used) VALUES (?, ?, ?, ?)", + (telegram_id, full_name, username, token) + ) + return True + except sqlite3.IntegrityError: + return False + + +def get_user(telegram_id: int): + with get_conn() as conn: + row = conn.execute( + "SELECT * FROM users WHERE telegram_id = ? AND is_active = 1", (telegram_id,) + ).fetchone() + return dict(row) if row else None + + +def get_all_users() -> list: + with get_conn() as conn: + rows = conn.execute("SELECT * FROM users ORDER BY registered_at DESC").fetchall() + return [dict(r) for r in rows] + + +def get_all_tokens() -> list: + with get_conn() as conn: + rows = conn.execute("SELECT * FROM tokens ORDER BY created_at DESC").fetchall() + return [dict(r) for r in rows] + + +def revoke_user(telegram_id: int) -> bool: + with get_conn() as conn: + cur = conn.execute("UPDATE users SET is_active = 0 WHERE telegram_id = ?", (telegram_id,)) + return cur.rowcount > 0 + + +# --- Memory --- + +def save_memory_meta(user_id: int, content: str, scope: str = "private") -> int: + with get_conn() as conn: + cur = conn.execute( + "INSERT INTO memories (user_id, content, scope) VALUES (?, ?, ?)", + (user_id, content, scope) + ) + return cur.lastrowid + + +def get_recent_memories(user_id: int, limit: int = 10) -> list: + with get_conn() as conn: + rows = conn.execute( + "SELECT * FROM memories WHERE user_id = ? ORDER BY created_at DESC LIMIT ?", + (user_id, limit) + ).fetchall() + return [dict(r) for r in rows] + + +# --- Document --- + +def save_document_meta(user_id: int, filename: str, original_name: str, scope: str, chunk_count: int) -> int: + with get_conn() as conn: + cur = conn.execute( + "INSERT INTO documents (user_id, filename, original_name, scope, chunk_count) VALUES (?, ?, ?, ?, ?)", + (user_id, filename, original_name, scope, chunk_count) + ) + return cur.lastrowid + + +def get_global_documents() -> list: + with get_conn() as conn: + rows = conn.execute( + "SELECT * FROM documents WHERE scope = 'global' ORDER BY uploaded_at DESC" + ).fetchall() + return [dict(r) for r in rows] + + +# --- Conversation --- + +def save_conversation(user_id: int, role: str, content: str): + with get_conn() as conn: + conn.execute( + "INSERT INTO conversations (user_id, role, content) VALUES (?, ?, ?)", + (user_id, role, content) + ) + + +def get_conversation_history(user_id: int, limit: int = 10) -> list: + with get_conn() as conn: + rows = conn.execute( + "SELECT role, content FROM conversations WHERE user_id = ? ORDER BY created_at DESC LIMIT ?", + (user_id, limit) + ).fetchall() + return [dict(r) for r in reversed(rows)] + + +def clear_conversation(user_id: int): + with get_conn() as conn: + conn.execute("DELETE FROM conversations WHERE user_id = ?", (user_id,)) diff --git a/src/document.py b/src/document.py new file mode 100644 index 0000000..32e0bac --- /dev/null +++ b/src/document.py @@ -0,0 +1,61 @@ +import os +import uuid +from pathlib import Path +from config import UPLOAD_PATH + + +def ensure_upload_dir(): + Path(UPLOAD_PATH).mkdir(parents=True, exist_ok=True) + + +def save_file(file_bytes: bytes, original_name: str) -> str: + ensure_upload_dir() + ext = Path(original_name).suffix.lower() + filename = f"{uuid.uuid4()}{ext}" + filepath = os.path.join(UPLOAD_PATH, filename) + with open(filepath, "wb") as f: + f.write(file_bytes) + return filename + + +def extract_text(filename: str) -> str: + filepath = os.path.join(UPLOAD_PATH, filename) + ext = Path(filename).suffix.lower() + + if ext == ".pdf": + return _extract_pdf(filepath) + elif ext in (".docx", ".doc"): + return _extract_docx(filepath) + elif ext == ".txt": + with open(filepath, "r", encoding="utf-8", errors="ignore") as f: + return f.read() + return "" + + +def _extract_pdf(filepath: str) -> str: + import pdfplumber + pages = [] + with pdfplumber.open(filepath) as pdf: + for page in pdf.pages: + text = page.extract_text() + if text: + pages.append(text) + return "\n".join(pages) + + +def _extract_docx(filepath: str) -> str: + from docx import Document + doc = Document(filepath) + return "\n".join(p.text for p in doc.paragraphs if p.text.strip()) + + +def chunk_text(text: str, chunk_size: int = 500, overlap: int = 50) -> list: + words = text.split() + chunks = [] + i = 0 + while i < len(words): + chunk = " ".join(words[i:i + chunk_size]) + if chunk.strip(): + chunks.append(chunk) + i += chunk_size - overlap + return chunks diff --git a/src/guard.py b/src/guard.py new file mode 100644 index 0000000..4f65107 --- /dev/null +++ b/src/guard.py @@ -0,0 +1,57 @@ +""" +Memeriksa apakah pertanyaan user relevan dengan konteks akademik/perkuliahan. +Bot ini hanya melayani topik: pengajaran, riset, kurikulum, administrasi dosen, +jurnal ilmiah, dan kegiatan kampus. +""" + +ACADEMIC_SYSTEM_CHECK = """Anda adalah penjaga topik untuk bot akademik dosen. +Tugasnya HANYA menentukan apakah pertanyaan berikut relevan dengan konteks akademik/perkuliahan. + +Topik yang DIIZINKAN: +- Pengajaran, metode pembelajaran, kurikulum, silabus, RPP +- Riset, jurnal ilmiah, publikasi, metodologi penelitian +- Administrasi kampus, akademik, birokrasi dosen +- Bimbingan mahasiswa, skripsi, tesis, disertasi +- Teknologi pendidikan, e-learning, LMS +- Statistik, analisis data untuk keperluan riset +- Bahasa ilmiah, penulisan akademik +- Konferensi, seminar, workshop akademik + +Topik yang DITOLAK (contoh, tidak terbatas pada ini): +- Saham, investasi, trading, kripto, keuangan pribadi +- Hiburan, gosip, olahraga (kecuali untuk riset) +- Resep masakan, gaya hidup +- Pertanyaan pribadi yang tidak berkaitan dengan profesi dosen +- Politik praktis, agama (kecuali dalam konteks kajian akademik) + +Jawab HANYA dengan satu kata: IZIN atau TOLAK""" + + +async def is_academic_topic(question: str, client, model: str) -> bool: + """Mengembalikan True jika pertanyaan relevan dengan topik akademik.""" + try: + response = await client.chat.completions.create( + model=model, + messages=[ + {"role": "system", "content": ACADEMIC_SYSTEM_CHECK}, + {"role": "user", "content": question} + ], + max_tokens=5, + temperature=0, + ) + verdict = response.choices[0].message.content.strip().upper() + return verdict == "IZIN" + except Exception: + # Jika guard gagal, izinkan saja agar bot tidak mati total + return True + + +REJECT_MESSAGE = ( + "โš ๏ธ Maaf, bot ini khusus untuk kegiatan akademik dan perkuliahan.\n\n" + "Saya dapat membantu dengan:\n" + "โ€ข ๐Ÿ“š Pengajaran & kurikulum\n" + "โ€ข ๐Ÿ”ฌ Riset & penulisan jurnal\n" + "โ€ข ๐ŸŽ“ Bimbingan mahasiswa\n" + "โ€ข ๐Ÿซ Administrasi kampus\n\n" + "Untuk pertanyaan di luar topik akademik, silakan gunakan layanan lain." +) diff --git a/src/handlers/__init__.py b/src/handlers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/handlers/admin.py b/src/handlers/admin.py new file mode 100644 index 0000000..a58a0da --- /dev/null +++ b/src/handlers/admin.py @@ -0,0 +1,165 @@ +from pathlib import Path +from telegram import Update +from telegram.ext import ContextTypes +from config import ADMIN_TELEGRAM_ID +import database as db +import rag +import document as doc_processor + + +def is_admin(telegram_id: int) -> bool: + return telegram_id == ADMIN_TELEGRAM_ID + + +async def generate_token(update: Update, context: ContextTypes.DEFAULT_TYPE): + if not is_admin(update.effective_user.id): + await update.message.reply_text("โ›” Akses ditolak.") + return + if not context.args: + await update.message.reply_text("Gunakan: /generate_token Nama Dosen") + return + + label = " ".join(context.args) + token = db.generate_token(label) + await update.message.reply_text( + f"โœ… Token berhasil dibuat untuk *{label}*\n\n" + f"๐Ÿ”‘ Token: `{token}`\n\n" + f"๐Ÿ“‹ Instruksi untuk dosen:\n" + f"1. Buka bot ini\n" + f"2. Kirim: `/start {token}`", + parse_mode="Markdown" + ) + + +async def list_users(update: Update, context: ContextTypes.DEFAULT_TYPE): + if not is_admin(update.effective_user.id): + await update.message.reply_text("โ›” Akses ditolak.") + return + + users = db.get_all_users() + if not users: + await update.message.reply_text("Belum ada dosen terdaftar.") + return + + lines = ["๐Ÿ‘ฅ *Daftar Dosen Terdaftar:*\n"] + for u in users: + status = "โœ…" if u["is_active"] else "โŒ" + lines.append(f"{status} *{u['full_name']}* โ€” ID: `{u['telegram_id']}`\n ๐Ÿ“… {u['registered_at'][:10]}") + await update.message.reply_text("\n".join(lines), parse_mode="Markdown") + + +async def list_tokens(update: Update, context: ContextTypes.DEFAULT_TYPE): + if not is_admin(update.effective_user.id): + await update.message.reply_text("โ›” Akses ditolak.") + return + + tokens = db.get_all_tokens() + if not tokens: + await update.message.reply_text("Belum ada token dibuat.") + return + + lines = ["๐Ÿ”‘ *Daftar Token:*\n"] + for t in tokens: + used = f"โœ… Terpakai (`{t['used_by_telegram_id']}`)" if t["used_by_telegram_id"] else "โณ Belum dipakai" + lines.append(f"โ€ข `{t['token']}` โ€” {t['label']}\n {used}") + await update.message.reply_text("\n".join(lines), parse_mode="Markdown") + + +async def revoke_user(update: Update, context: ContextTypes.DEFAULT_TYPE): + if not is_admin(update.effective_user.id): + await update.message.reply_text("โ›” Akses ditolak.") + return + if not context.args: + await update.message.reply_text("Gunakan: /revoke TELEGRAM_ID") + return + + try: + target_id = int(context.args[0]) + except ValueError: + await update.message.reply_text("ID harus berupa angka.") + return + + success = db.revoke_user(target_id) + msg = f"โœ… Akses `{target_id}` berhasil dicabut." if success else "โŒ User tidak ditemukan." + await update.message.reply_text(msg, parse_mode="Markdown") + + +async def upload_global(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Admin upload dokumen ke knowledge base global โ€” semua dosen bisa mengaksesnya.""" + if not is_admin(update.effective_user.id): + await update.message.reply_text("โ›” Akses ditolak.") + return + + doc = update.message.document + if not doc: + await update.message.reply_text( + "๐Ÿ“Ž Kirim file PDF, DOCX, atau TXT dengan caption `/upload_global`", + parse_mode="Markdown" + ) + return + + allowed_ext = {".pdf", ".docx", ".txt"} + if Path(doc.file_name).suffix.lower() not in allowed_ext: + await update.message.reply_text("โŒ Format tidak didukung. Gunakan PDF, DOCX, atau TXT.") + return + + await update.message.reply_text("โณ Memproses dokumen global...") + + file = await doc.get_file() + file_bytes = bytes(await file.download_as_bytearray()) + filename = doc_processor.save_file(file_bytes, doc.file_name) + text = doc_processor.extract_text(filename) + + if not text.strip(): + await update.message.reply_text("โŒ Gagal mengekstrak teks dari dokumen.") + return + + chunks = doc_processor.chunk_text(text) + doc_id = db.save_document_meta( + user_id=update.effective_user.id, + filename=filename, + original_name=doc.file_name, + scope="global", + chunk_count=len(chunks) + ) + await rag.add_document_chunks(user_id=0, doc_id=doc_id, chunks=chunks, scope="global") + + await update.message.reply_text( + f"โœ… {doc.file_name} berhasil ditambahkan ke knowledge base global!\n" + f"๐Ÿ“„ {len(chunks)} bagian tersimpan โ€” semua dosen dapat mengaksesnya via /ask." + ) + + +async def list_global_docs(update: Update, context: ContextTypes.DEFAULT_TYPE): + if not is_admin(update.effective_user.id): + await update.message.reply_text("โ›” Akses ditolak.") + return + + docs = db.get_global_documents() + if not docs: + await update.message.reply_text("Belum ada dokumen global.") + return + + lines = ["๐ŸŒ *Dokumen Knowledge Base Global:*\n"] + for d in docs: + lines.append(f"โ€ข *{d['original_name']}* ({d['chunk_count']} bagian)\n ๐Ÿ“… {d['uploaded_at'][:16]}") + await update.message.reply_text("\n".join(lines), parse_mode="Markdown") + + +async def help_admin(update: Update, context: ContextTypes.DEFAULT_TYPE): + if not is_admin(update.effective_user.id): + await update.message.reply_text("โ›” Akses ditolak.") + return + + text = ( + "๐Ÿ‘‘ *Perintah Admin:*\n\n" + "*Manajemen Akses:*\n" + "/generate_token `Nama` โ€” Buat token untuk dosen baru\n" + "/list_tokens โ€” Lihat semua token\n" + "/list_users โ€” Lihat semua dosen terdaftar\n" + "/revoke `ID` โ€” Cabut akses dosen\n\n" + "*Knowledge Base Global:*\n" + "/list_global_docs โ€” Lihat dokumen global\n" + "๐Ÿ“Ž Kirim file + caption `/upload_global` โ€” Upload dokumen global" + ) + await update.message.reply_text(text, parse_mode="Markdown") diff --git a/src/handlers/user.py b/src/handlers/user.py new file mode 100644 index 0000000..378fc44 --- /dev/null +++ b/src/handlers/user.py @@ -0,0 +1,251 @@ +from pathlib import Path +from telegram import Update +from telegram.ext import ContextTypes +from openai import AsyncOpenAI +from config import OPENAI_API_KEY, OPENAI_MODEL, ADMIN_TELEGRAM_ID +import database as db +import rag +import document as doc_processor +from guard import is_academic_topic, REJECT_MESSAGE + +_client = AsyncOpenAI(api_key=OPENAI_API_KEY) + +ACADEMIC_SYSTEM_PROMPT = ( + "Anda adalah asisten akademik personal untuk dosen di perguruan tinggi Indonesia. " + "Fokus HANYA pada topik akademik: pengajaran, riset, kurikulum, bimbingan mahasiswa, " + "penulisan jurnal, administrasi kampus, dan teknologi pendidikan. " + "Tolak dengan sopan jika ditanya di luar topik tersebut. " + "Jawab dalam bahasa yang sama dengan pertanyaan pengguna." +) + + +async def start(update: Update, context: ContextTypes.DEFAULT_TYPE): + user = update.effective_user + existing = db.get_user(user.id) + + if existing: + await update.message.reply_text( + f"๐Ÿ‘‹ Selamat datang kembali, *{existing['full_name']}*!\n" + f"๐Ÿง  Second Brain Anda aktif. Gunakan /help untuk panduan.", + parse_mode="Markdown" + ) + return + + if user.id == ADMIN_TELEGRAM_ID: + await update.message.reply_text( + "๐Ÿ‘‘ *Mode Admin Aktif*\n\nGunakan /help_admin untuk melihat semua perintah admin.", + parse_mode="Markdown" + ) + return + + if not context.args: + await update.message.reply_text( + "๐Ÿ‘‹ Halo! Bot ini khusus untuk dosen dengan token registrasi.\n\n" + "Hubungi admin untuk mendapatkan token, lalu kirim:\n" + "`/start TOKEN_ANDA`", + parse_mode="Markdown" + ) + return + + token = context.args[0] + token_data = db.use_token(token, user.id) + if not token_data: + await update.message.reply_text("โŒ Token tidak valid atau sudah digunakan.") + return + + full_name = user.full_name or user.username or "Dosen" + db.register_user(user.id, full_name, user.username or "", token) + await update.message.reply_text( + f"โœ… *Registrasi berhasil!*\n\n" + f"๐Ÿ‘ค {full_name}\n" + f"๐Ÿง  Second Brain Anda telah aktif.\n\n" + f"Gunakan /help untuk panduan lengkap.", + parse_mode="Markdown" + ) + + +async def remember(update: Update, context: ContextTypes.DEFAULT_TYPE): + user_data = db.get_user(update.effective_user.id) + if not user_data: + await update.message.reply_text("โ›” Belum terdaftar. Gunakan /start TOKEN") + return + if not context.args: + await update.message.reply_text("Gunakan: /remember Catatan yang ingin disimpan") + return + + content = " ".join(context.args) + mem_id = db.save_memory_meta(user_data["id"], content, scope="private") + await rag.add_memory(user_data["id"], mem_id, content, scope="private") + await update.message.reply_text(f"โœ… Tersimpan di Second Brain:\n_{content}_", parse_mode="Markdown") + + +async def recall(update: Update, context: ContextTypes.DEFAULT_TYPE): + user_data = db.get_user(update.effective_user.id) + if not user_data: + await update.message.reply_text("โ›” Belum terdaftar.") + return + + if not context.args: + # Tampilkan 10 catatan terbaru + memories = db.get_recent_memories(user_data["id"]) + if not memories: + await update.message.reply_text("Second Brain Anda masih kosong. Gunakan /remember untuk menyimpan catatan.") + return + lines = ["๐Ÿง  *Catatan Terbaru:*\n"] + for m in memories: + lines.append(f"โ€ข {m['content'][:150]}\n _{m['created_at'][:16]}_") + await update.message.reply_text("\n".join(lines), parse_mode="Markdown") + return + + # Pencarian semantik + query = " ".join(context.args) + await update.message.chat.send_action("typing") + results = await rag.search(user_data["id"], query) + + if not results: + await update.message.reply_text("Tidak ada hasil yang relevan ditemukan.") + return + + lines = [f"๐Ÿ” Hasil pencarian '{query}':\n"] + for i, r in enumerate(results, 1): + scope_label = "๐ŸŒ Global" if r["scope"] == "global" else "๐Ÿ”’ Pribadi" + # cosine distance: 0 = identik, 2 = berlawanan โ†’ konversi ke persentase + relevance = round(max(0, (1 - r["distance"])) * 100, 1) + lines.append(f"{i}. [{scope_label}] {r['content'][:200]}\n {relevance}% relevan\n") + await update.message.reply_text("\n".join(lines), parse_mode="Markdown") + + +async def ask(update: Update, context: ContextTypes.DEFAULT_TYPE): + user_data = db.get_user(update.effective_user.id) + if not user_data: + await update.message.reply_text("โ›” Belum terdaftar.") + return + if not context.args: + await update.message.reply_text("Gunakan: /ask Pertanyaan Anda") + return + + question = " ".join(context.args) + await update.message.chat.send_action("typing") + + # Guard: hanya izinkan topik akademik + if not await is_academic_topic(question, _client, OPENAI_MODEL): + await update.message.reply_text(REJECT_MESSAGE) + return + + # Cari konteks relevan dari Second Brain + relevant = await rag.search(user_data["id"], question, n_results=5) + context_text = "" + if relevant: + context_text = "\n\nKonteks dari Second Brain pengguna:\n" + "\n".join( + f"[{'Global' if r['scope'] == 'global' else 'Pribadi'}] {r['content']}" + for r in relevant + ) + + history = db.get_conversation_history(user_data["id"]) + system_prompt = ( + f"{ACADEMIC_SYSTEM_PROMPT}\n\n" + f"Nama dosen: {user_data['full_name']}" + f"{context_text}" + ) + + messages = [{"role": "system", "content": system_prompt}] + messages.extend(history) + messages.append({"role": "user", "content": question}) + + try: + response = await _client.chat.completions.create( + model=OPENAI_MODEL, + messages=messages, + max_tokens=1500, + ) + answer = response.choices[0].message.content + except Exception as e: + await update.message.reply_text(f"โŒ Gagal menghubungi AI: {str(e)[:100]}") + return + + db.save_conversation(user_data["id"], "user", question) + db.save_conversation(user_data["id"], "assistant", answer) + await update.message.reply_text(answer) + + +async def upload_doc(update: Update, context: ContextTypes.DEFAULT_TYPE): + """Upload dokumen ke Second Brain pribadi.""" + user_data = db.get_user(update.effective_user.id) + if not user_data: + await update.message.reply_text("โ›” Belum terdaftar.") + return + + doc = update.message.document + if not doc: + await update.message.reply_text( + "๐Ÿ“Ž Kirim file PDF, DOCX, atau TXT dengan caption `/upload`", + parse_mode="Markdown" + ) + return + + allowed_ext = {".pdf", ".docx", ".txt"} + if Path(doc.file_name).suffix.lower() not in allowed_ext: + await update.message.reply_text("โŒ Format tidak didukung. Gunakan PDF, DOCX, atau TXT.") + return + + await update.message.reply_text("โณ Memproses dokumen Anda...") + + file = await doc.get_file() + file_bytes = bytes(await file.download_as_bytearray()) + filename = doc_processor.save_file(file_bytes, doc.file_name) + text = doc_processor.extract_text(filename) + + if not text.strip(): + await update.message.reply_text("โŒ Gagal mengekstrak teks dari dokumen.") + return + + chunks = doc_processor.chunk_text(text) + doc_id = db.save_document_meta( + user_id=user_data["id"], + filename=filename, + original_name=doc.file_name, + scope="private", + chunk_count=len(chunks) + ) + await rag.add_document_chunks(user_id=user_data["id"], doc_id=doc_id, chunks=chunks, scope="private") + + await update.message.reply_text( + f"โœ… {doc.file_name} berhasil diproses!\n" + f"๐Ÿ“„ {len(chunks)} bagian tersimpan di Second Brain Anda.\n" + f"Gunakan /ask untuk bertanya tentang isi dokumen ini." + ) + + +async def clear_chat(update: Update, context: ContextTypes.DEFAULT_TYPE): + user_data = db.get_user(update.effective_user.id) + if not user_data: + await update.message.reply_text("โ›” Belum terdaftar.") + return + db.clear_conversation(user_data["id"]) + await update.message.reply_text("๐Ÿงน Riwayat percakapan berhasil dihapus.") + + +async def help_cmd(update: Update, context: ContextTypes.DEFAULT_TYPE): + if update.effective_user.id == ADMIN_TELEGRAM_ID: + await update.message.reply_text("Gunakan /help_admin untuk perintah admin.") + return + + user_data = db.get_user(update.effective_user.id) + if not user_data: + await update.message.reply_text("Gunakan /start TOKEN untuk registrasi.") + return + + text = ( + "๐Ÿง  *Second Brain Bot โ€” Panduan*\n\n" + "*Catatan:*\n" + "/remember `teks` โ€” Simpan catatan baru\n" + "/recall โ€” Lihat 10 catatan terbaru\n" + "/recall `kata kunci` โ€” Cari dengan AI (semantik)\n\n" + "*Dokumen:*\n" + "๐Ÿ“Ž Kirim file + caption `/upload` โ€” Upload PDF/DOCX/TXT\n\n" + "*Tanya AI:*\n" + "/ask `pertanyaan` โ€” Tanya AI dengan konteks Second Brain\n" + "/clear โ€” Hapus riwayat percakapan\n\n" + "_Bot ini khusus untuk topik akademik dan perkuliahan._" + ) + await update.message.reply_text(text, parse_mode="Markdown") diff --git a/src/main.py b/src/main.py new file mode 100644 index 0000000..3d70951 --- /dev/null +++ b/src/main.py @@ -0,0 +1,49 @@ +from telegram.ext import ApplicationBuilder, CommandHandler, MessageHandler, filters +from config import TELEGRAM_BOT_TOKEN +import database as db +from handlers import admin as admin_handler +from handlers import user as user_handler + + +def main(): + db.init_db() + + app = ApplicationBuilder().token(TELEGRAM_BOT_TOKEN).build() + + # --- User commands --- + app.add_handler(CommandHandler("start", user_handler.start)) + app.add_handler(CommandHandler("remember", user_handler.remember)) + app.add_handler(CommandHandler("recall", user_handler.recall)) + app.add_handler(CommandHandler("ask", user_handler.ask)) + app.add_handler(CommandHandler("clear", user_handler.clear_chat)) + app.add_handler(CommandHandler("help", user_handler.help_cmd)) + + # --- Admin commands --- + app.add_handler(CommandHandler("generate_token", admin_handler.generate_token)) + app.add_handler(CommandHandler("list_users", admin_handler.list_users)) + app.add_handler(CommandHandler("list_tokens", admin_handler.list_tokens)) + app.add_handler(CommandHandler("revoke", admin_handler.revoke_user)) + app.add_handler(CommandHandler("list_global_docs", admin_handler.list_global_docs)) + app.add_handler(CommandHandler("help_admin", admin_handler.help_admin)) + + # --- Document upload via caption --- + # User: kirim file dengan caption "/upload" + app.add_handler(MessageHandler( + filters.Document.ALL & filters.CaptionRegex(r"^/upload$"), + user_handler.upload_doc + )) + # Admin: kirim file dengan caption "/upload_global" + app.add_handler(MessageHandler( + filters.Document.ALL & filters.CaptionRegex(r"^/upload_global$"), + admin_handler.upload_global + )) + + # Fallback: dokumen tanpa caption yang valid + app.add_handler(MessageHandler(filters.Document.ALL, user_handler.upload_doc)) + + print("๐Ÿค– Second Brain Bot is running...") + app.run_polling() + + +if __name__ == "__main__": + main() diff --git a/src/rag.py b/src/rag.py new file mode 100644 index 0000000..e929954 --- /dev/null +++ b/src/rag.py @@ -0,0 +1,76 @@ +import asyncio +import chromadb +from openai import AsyncOpenAI +from config import CHROMA_PATH, OPENAI_API_KEY, OPENAI_EMBEDDING_MODEL + +_client = AsyncOpenAI(api_key=OPENAI_API_KEY) +_chroma = chromadb.PersistentClient(path=CHROMA_PATH) + + +def _get_collection(name: str): + return _chroma.get_or_create_collection(name=name, metadata={"hnsw:space": "cosine"}) + + +async def _embed(text: str) -> list: + resp = await _client.embeddings.create(model=OPENAI_EMBEDDING_MODEL, input=text) + return resp.data[0].embedding + + +async def add_memory(user_id: int, memory_id: int, content: str, scope: str = "private"): + embedding = await _embed(content) + collection_name = "global" if scope == "global" else f"user_{user_id}" + collection = _get_collection(collection_name) + await asyncio.to_thread( + collection.add, + ids=[f"mem_{memory_id}"], + embeddings=[embedding], + documents=[content], + metadatas=[{"type": "memory", "user_id": str(user_id), "scope": scope}] + ) + + +async def add_document_chunks(user_id: int, doc_id: int, chunks: list, scope: str = "private"): + collection_name = "global" if scope == "global" else f"user_{user_id}" + collection = _get_collection(collection_name) + + ids = [f"doc_{doc_id}_chunk_{i}" for i in range(len(chunks))] + embeddings = [] + for chunk in chunks: + emb = await _embed(chunk) + embeddings.append(emb) + + await asyncio.to_thread( + collection.add, + ids=ids, + embeddings=embeddings, + documents=chunks, + metadatas=[{"type": "document", "doc_id": str(doc_id), "user_id": str(user_id), "scope": scope} for _ in chunks] + ) + + +async def search(user_id: int, query: str, n_results: int = 5) -> list: + query_embedding = await _embed(query) + results = [] + + for collection_name, scope_label in [(f"user_{user_id}", "private"), ("global", "global")]: + try: + col = _get_collection(collection_name) + count = await asyncio.to_thread(col.count) + if count == 0: + continue + res = await asyncio.to_thread( + col.query, + query_embeddings=[query_embedding], + n_results=min(n_results, count) + ) + for i, doc in enumerate(res["documents"][0]): + results.append({ + "content": doc, + "scope": scope_label, + "distance": res["distances"][0][i], + }) + except Exception: + continue + + results.sort(key=lambda x: x["distance"]) + return results[:n_results]