feat: add natural language reminder with LLM parsing and confirmation flow

This commit is contained in:
Power BI Dev
2026-06-04 10:52:36 +07:00
parent 2dfa6f95fb
commit 7acdd5347c
6 changed files with 304 additions and 6 deletions
+1 -1
View File
@@ -1,4 +1,4 @@
python-telegram-bot==21.3 python-telegram-bot[job-queue]==21.3
openai>=1.52.0,<2.0 openai>=1.52.0,<2.0
python-dotenv==1.0.1 python-dotenv==1.0.1
chromadb==0.5.3 chromadb==0.5.3
+1
View File
@@ -12,3 +12,4 @@ DB_PATH = os.getenv("DB_PATH", "/app/data/second_brain.db")
CHROMA_PATH = os.getenv("CHROMA_PATH", "/app/data/chromadb") CHROMA_PATH = os.getenv("CHROMA_PATH", "/app/data/chromadb")
UPLOAD_PATH = os.getenv("UPLOAD_PATH", "/app/data/uploads") UPLOAD_PATH = os.getenv("UPLOAD_PATH", "/app/data/uploads")
EMBEDDING_MODEL = os.getenv("EMBEDDING_MODEL", "sentence-transformers/all-MiniLM-L6-v2") EMBEDDING_MODEL = os.getenv("EMBEDDING_MODEL", "sentence-transformers/all-MiniLM-L6-v2")
BOT_TIMEZONE = os.getenv("BOT_TIMEZONE", "Asia/Jakarta")
+56
View File
@@ -53,6 +53,19 @@ def init_db():
FOREIGN KEY (user_id) REFERENCES users(id) FOREIGN KEY (user_id) REFERENCES users(id)
); );
CREATE TABLE IF NOT EXISTS reminders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
telegram_id INTEGER NOT NULL,
user_db_id INTEGER NOT NULL,
message TEXT NOT NULL,
remind_at TEXT NOT NULL,
is_recurring INTEGER DEFAULT 0,
recur_type TEXT,
created_at TEXT DEFAULT (datetime('now')),
is_active INTEGER DEFAULT 1,
FOREIGN KEY (user_db_id) REFERENCES users(id)
);
CREATE TABLE IF NOT EXISTS conversations ( CREATE TABLE IF NOT EXISTS conversations (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL, user_id INTEGER NOT NULL,
@@ -190,3 +203,46 @@ def get_conversation_history(user_id: int, limit: int = 10) -> list:
def clear_conversation(user_id: int): def clear_conversation(user_id: int):
with get_conn() as conn: with get_conn() as conn:
conn.execute("DELETE FROM conversations WHERE user_id = ?", (user_id,)) conn.execute("DELETE FROM conversations WHERE user_id = ?", (user_id,))
# --- Reminder ---
def create_reminder(telegram_id: int, user_db_id: int, message: str, remind_at: str,
is_recurring: bool = False, recur_type: str = None) -> int:
with get_conn() as conn:
cur = conn.execute(
"INSERT INTO reminders (telegram_id, user_db_id, message, remind_at, is_recurring, recur_type) VALUES (?, ?, ?, ?, ?, ?)",
(telegram_id, user_db_id, message, remind_at, int(is_recurring), recur_type)
)
return cur.lastrowid
def get_active_reminders() -> list:
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM reminders WHERE is_active = 1 ORDER BY remind_at"
).fetchall()
return [dict(r) for r in rows]
def get_user_reminders(user_db_id: int) -> list:
with get_conn() as conn:
rows = conn.execute(
"SELECT * FROM reminders WHERE user_db_id = ? AND is_active = 1 ORDER BY remind_at",
(user_db_id,)
).fetchall()
return [dict(r) for r in rows]
def deactivate_reminder(reminder_id: int):
with get_conn() as conn:
conn.execute("UPDATE reminders SET is_active = 0 WHERE id = ?", (reminder_id,))
def cancel_reminder(reminder_id: int, user_db_id: int) -> bool:
with get_conn() as conn:
cur = conn.execute(
"UPDATE reminders SET is_active = 0 WHERE id = ? AND user_db_id = ?",
(reminder_id, user_db_id)
)
return cur.rowcount > 0
+99 -3
View File
@@ -1,14 +1,20 @@
import html import html
from datetime import datetime
from pathlib import Path from pathlib import Path
from telegram import Update from telegram import Update
from telegram.ext import ContextTypes from telegram.ext import ContextTypes
from openai import AsyncOpenAI from openai import AsyncOpenAI
from config import DEEPSEEK_API_KEY, DEEPSEEK_BASE_URL, DEEPSEEK_MODEL, ADMIN_TELEGRAM_ID from config import DEEPSEEK_API_KEY, DEEPSEEK_BASE_URL, DEEPSEEK_MODEL, ADMIN_TELEGRAM_ID, BOT_TIMEZONE
from zoneinfo import ZoneInfo
import database as db import database as db
import rag import rag
import reminder as reminder_module
import document as doc_processor import document as doc_processor
from guard import is_academic_topic, REJECT_MESSAGE from guard import is_academic_topic, REJECT_MESSAGE
_pending_confirmations: dict = {}
REMINDER_KEYWORDS = ["ingatkan", "pengingat", "remind", "jadwalkan", "tolong ingat"]
_client = AsyncOpenAI(api_key=DEEPSEEK_API_KEY, base_url=DEEPSEEK_BASE_URL) _client = AsyncOpenAI(api_key=DEEPSEEK_API_KEY, base_url=DEEPSEEK_BASE_URL)
ACADEMIC_SYSTEM_PROMPT = ( ACADEMIC_SYSTEM_PROMPT = (
@@ -219,16 +225,106 @@ async def upload_doc(update: Update, context: ContextTypes.DEFAULT_TYPE):
) )
async def list_reminders(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
reminders = db.get_user_reminders(user_data["id"])
if not reminders:
await update.message.reply_text("Tidak ada pengingat aktif.")
return
tz = ZoneInfo(BOT_TIMEZONE)
lines = ["⏰ <b>Pengingat Aktif:</b>\n"]
for r in reminders:
if r["is_recurring"]:
schedule_str = f"Setiap hari jam {r['remind_at'][:5]} WIB"
else:
try:
dt = datetime.fromisoformat(r["remind_at"]).replace(tzinfo=tz)
schedule_str = dt.strftime("%d %b %Y %H:%M WIB")
except Exception:
schedule_str = r["remind_at"]
lines.append(f"#{r['id']} — <i>{html.escape(r['message'])}</i>\n 📅 {schedule_str}")
lines.append("\nGunakan /cancel_remind ID untuk membatalkan.")
await update.message.reply_text("\n".join(lines), parse_mode="HTML")
async def cancel_remind(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: /cancel_remind ID")
return
try:
reminder_id = int(context.args[0])
except ValueError:
await update.message.reply_text("ID harus berupa angka.")
return
success = db.cancel_reminder(reminder_id, user_data["id"])
if not success:
await update.message.reply_text("❌ Pengingat tidak ditemukan.")
return
for job in context.application.job_queue.get_jobs_by_name(f"reminder_{reminder_id}"):
job.schedule_removal()
await update.message.reply_text("✅ Pengingat berhasil dibatalkan.")
async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE): async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE):
user_data = db.get_user(update.effective_user.id) user_data = db.get_user(update.effective_user.id)
if not user_data: if not user_data:
await update.message.reply_text("⛔ Belum terdaftar. Gunakan /start TOKEN untuk registrasi.") await update.message.reply_text("⛔ Belum terdaftar. Gunakan /start TOKEN untuk registrasi.")
return return
question = update.message.text.strip() text = update.message.text.strip()
if not question: if not text:
return return
user_id = update.effective_user.id
# Cek pending konfirmasi reminder
if user_id in _pending_confirmations:
answer = text.lower()
if answer in ("ya", "iya", "yes", "y"):
pending = _pending_confirmations.pop(user_id)
parsed = pending["parsed"]
reminder_id = db.create_reminder(
telegram_id=user_id,
user_db_id=user_data["id"],
message=parsed["message"],
remind_at=parsed["remind_at"],
is_recurring=parsed.get("is_recurring", False),
recur_type=parsed.get("recur_type"),
)
reminder_module.schedule_new_reminder(
context.application.job_queue, reminder_id, user_id, parsed["message"], parsed
)
await update.message.reply_text("✅ Pengingat berhasil disimpan! Gunakan /reminders untuk melihat daftar.")
return
elif answer in ("tidak", "batal", "cancel", "no", "n"):
_pending_confirmations.pop(user_id)
await update.message.reply_text("❌ Pengingat dibatalkan.")
return
else:
_pending_confirmations.pop(user_id)
# Deteksi intent reminder dari plain text
text_lower = text.lower()
if any(kw in text_lower for kw in REMINDER_KEYWORDS):
await update.message.chat.send_action("typing")
parsed = await reminder_module.parse_reminder_intent(text, _client, DEEPSEEK_MODEL)
if parsed:
_pending_confirmations[user_id] = {"parsed": parsed}
await update.message.reply_text(reminder_module.format_confirmation(parsed), parse_mode="HTML")
return
question = text
await update.message.chat.send_action("typing") await update.message.chat.send_action("typing")
if not await is_academic_topic(question, _client, DEEPSEEK_MODEL): if not await is_academic_topic(question, _client, DEEPSEEK_MODEL):
+17 -2
View File
@@ -3,6 +3,7 @@ from telegram import Update
from telegram.ext import ApplicationBuilder, CommandHandler, MessageHandler, filters from telegram.ext import ApplicationBuilder, CommandHandler, MessageHandler, filters
from config import TELEGRAM_BOT_TOKEN from config import TELEGRAM_BOT_TOKEN
import database as db import database as db
import reminder as reminder_module
from handlers import admin as admin_handler from handlers import admin as admin_handler
from handlers import user as user_handler from handlers import user as user_handler
@@ -16,10 +17,22 @@ async def error_handler(update: object, context) -> None:
await update.message.reply_text(f"❌ Terjadi kesalahan: {str(context.error)[:200]}") await update.message.reply_text(f"❌ Terjadi kesalahan: {str(context.error)[:200]}")
async def on_startup(app):
reminders = db.get_active_reminders()
for r in reminders:
reminder_module.schedule_reminder(app.job_queue, r)
logger.info(f"Loaded {len(reminders)} reminder(s) from database.")
def main(): def main():
db.init_db() db.init_db()
app = ApplicationBuilder().token(TELEGRAM_BOT_TOKEN).build() app = (
ApplicationBuilder()
.token(TELEGRAM_BOT_TOKEN)
.post_init(on_startup)
.build()
)
app.add_error_handler(error_handler) app.add_error_handler(error_handler)
@@ -30,6 +43,8 @@ def main():
app.add_handler(CommandHandler("ask", user_handler.ask)) app.add_handler(CommandHandler("ask", user_handler.ask))
app.add_handler(CommandHandler("clear", user_handler.clear_chat)) app.add_handler(CommandHandler("clear", user_handler.clear_chat))
app.add_handler(CommandHandler("help", user_handler.help_cmd)) app.add_handler(CommandHandler("help", user_handler.help_cmd))
app.add_handler(CommandHandler("reminders", user_handler.list_reminders))
app.add_handler(CommandHandler("cancel_remind", user_handler.cancel_remind))
# --- Admin commands --- # --- Admin commands ---
app.add_handler(CommandHandler("generate_token", admin_handler.generate_token)) app.add_handler(CommandHandler("generate_token", admin_handler.generate_token))
@@ -50,7 +65,7 @@ def main():
)) ))
app.add_handler(MessageHandler(filters.Document.ALL, user_handler.upload_doc)) app.add_handler(MessageHandler(filters.Document.ALL, user_handler.upload_doc))
# Plain text → langsung tanya AI tanpa perlu /ask # Plain text → AI chat + reminder detection
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, user_handler.handle_message)) app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, user_handler.handle_message))
print("🤖 Second Brain Bot is running...") print("🤖 Second Brain Bot is running...")
+130
View File
@@ -0,0 +1,130 @@
import json
from datetime import datetime, time
from zoneinfo import ZoneInfo
from config import BOT_TIMEZONE
import database as db
TIMEZONE = ZoneInfo(BOT_TIMEZONE)
PARSE_PROMPT = """Anda adalah parser pengingat untuk bot dosen. Analisis pesan berikut.
Waktu sekarang: {now} (WIB)
Hari Indonesia: Senin=Mon, Selasa=Tue, Rabu=Wed, Kamis=Thu, Jumat=Fri, Sabtu=Sat, Minggu=Sun
Pesan: "{text}"
Jika ini permintaan pengingat, kembalikan JSON:
Sekali: {{"is_reminder":true,"remind_at":"YYYY-MM-DDTHH:MM:SS","message":"isi ringkas","is_recurring":false,"recur_type":null}}
Harian: {{"is_reminder":true,"remind_at":"HH:MM:SS","message":"isi ringkas","is_recurring":true,"recur_type":"daily"}}
Jika waktu tidak disebutkan gunakan 08:00:00. Jika bukan pengingat: {{"is_reminder":false}}
Kembalikan HANYA JSON valid."""
async def parse_reminder_intent(text: str, client, model: str) -> dict | None:
now = datetime.now(TIMEZONE).strftime("%Y-%m-%d %H:%M (%A, %d %B %Y)")
try:
response = await client.chat.completions.create(
model=model,
messages=[{"role": "user", "content": PARSE_PROMPT.format(now=now, text=text)}],
max_tokens=150,
temperature=0,
)
content = response.choices[0].message.content.strip()
if "```" in content:
content = content.split("```")[1]
if content.startswith("json"):
content = content[4:]
data = json.loads(content.strip())
if data.get("is_reminder"):
return data
except Exception:
pass
return None
def format_confirmation(parsed: dict) -> str:
if parsed.get("is_recurring") and parsed.get("recur_type") == "daily":
time_str = parsed["remind_at"][:5]
return (
f"⏰ <b>Konfirmasi Pengingat Harian</b>\n\n"
f"📌 Pesan: <i>{parsed['message']}</i>\n"
f"🕐 Jadwal: setiap hari jam <b>{time_str} WIB</b>\n\n"
f"Balas <b>ya</b> untuk konfirmasi atau <b>batal</b> untuk membatalkan."
)
else:
try:
dt = datetime.fromisoformat(parsed["remind_at"])
dt_str = dt.strftime("%A, %d %B %Y jam %H:%M WIB")
except Exception:
dt_str = parsed["remind_at"]
return (
f"⏰ <b>Konfirmasi Pengingat</b>\n\n"
f"📌 Pesan: <i>{parsed['message']}</i>\n"
f"📅 Waktu: <b>{dt_str}</b>\n\n"
f"Balas <b>ya</b> untuk konfirmasi atau <b>batal</b> untuk membatalkan."
)
async def _send_reminder(context):
data = context.job.data
await context.bot.send_message(
chat_id=data["telegram_id"],
text=f"⏰ <b>Pengingat:</b>\n{data['message']}",
parse_mode="HTML"
)
if not data["is_recurring"]:
db.deactivate_reminder(data["reminder_id"])
def schedule_reminder(job_queue, reminder: dict):
data = {
"telegram_id": reminder["telegram_id"],
"message": reminder["message"],
"reminder_id": reminder["id"],
"is_recurring": bool(reminder["is_recurring"]),
}
job_name = f"reminder_{reminder['id']}"
if reminder["is_recurring"] and reminder["recur_type"] == "daily":
try:
parts = reminder["remind_at"].split(":")
h, m, s = int(parts[0]), int(parts[1]), int(parts[2]) if len(parts) > 2 else 0
t = time(h, m, s, tzinfo=TIMEZONE)
job_queue.run_daily(_send_reminder, time=t, data=data, name=job_name)
except Exception:
pass
else:
try:
dt = datetime.fromisoformat(reminder["remind_at"])
if dt.tzinfo is None:
dt = dt.replace(tzinfo=TIMEZONE)
now = datetime.now(TIMEZONE)
if dt > now:
job_queue.run_once(_send_reminder, when=dt, data=data, name=job_name)
else:
db.deactivate_reminder(reminder["id"])
except Exception:
pass
def schedule_new_reminder(job_queue, reminder_id: int, telegram_id: int, message: str, parsed: dict):
data = {
"telegram_id": telegram_id,
"message": message,
"reminder_id": reminder_id,
"is_recurring": parsed.get("is_recurring", False),
}
job_name = f"reminder_{reminder_id}"
if parsed.get("is_recurring") and parsed.get("recur_type") == "daily":
raw = parsed["remind_at"] + ":00" if parsed["remind_at"].count(":") == 1 else parsed["remind_at"]
parts = raw.split(":")
h, m, s = int(parts[0]), int(parts[1]), int(parts[2])
t = time(h, m, s, tzinfo=TIMEZONE)
job_queue.run_daily(_send_reminder, time=t, data=data, name=job_name)
else:
dt = datetime.fromisoformat(parsed["remind_at"])
if dt.tzinfo is None:
dt = dt.replace(tzinfo=TIMEZONE)
job_queue.run_once(_send_reminder, when=dt, data=data, name=job_name)