first init
This commit is contained in:
@@ -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
|
||||
+15
@@ -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"]
|
||||
@@ -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
|
||||
@@ -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")
|
||||
+192
@@ -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,))
|
||||
@@ -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
|
||||
@@ -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."
|
||||
)
|
||||
@@ -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")
|
||||
@@ -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")
|
||||
+49
@@ -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()
|
||||
+76
@@ -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]
|
||||
Reference in New Issue
Block a user