Spaces:
Running on Zero
fix: make creating and viewing activities work like a conversation
Browse filesReported: scheduling only happened when the user typed "masukkan ke kegiatan",
the double-confirmation felt awkward, the agenda could not be checked, and old
activity titles leaked into brand new ones.
Routing
- "ingetin aku", "jangan lupa aku", "pengingat" and stated plans ("aku ada X
besok jam 10", "aku mau X jam 2 sore") now always produce ADD_ACTIVITY, even
with no details yet, instead of falling through to CHAT. The model previously
replied "siap, nanti aku ingetin" and saved nothing, which is worse than not
replying at all.
- Word order no longer matters ("jam 2 sore ... aku mau ngechat pacar" and
"aku mau ngechat pacar ... jam 2 sore" both work).
- Agenda/view phrasings broadened.
Draft filling
- Dates and times are now parsed locally as well as by the model. The model
answered CHAT for a plain "Hari Minggu, 11 Oktober 2026" while a draft was
open, which stranded the user mid-flow; a bare instruction followed by a date
and a time now completes correctly.
- Asking for a missing slot now echoes what is already captured instead of
repeating "boleh, aku catatin~" on every turn.
- Extractor handles "pukul 5 sore", "11/10", weekday names, and relative days,
and never resolves a named weekday to a past date.
Titles can no longer be inherited
- The router prompt forbids copying a title from the stored activity list,
which is what made a new event appear as "Tugas Internship".
Flow
- Adding an activity now saves immediately with done/delete buttons, the same
as logging food: asking "are you sure?" before saving something the user just
said, then asking again on the result, is what made it feel like double work.
Deletions and corrections still confirm, because those are destructive.
- reply_fn was accepted but never invoked, so an activity could be saved with
no reply sent at all.
- The agenda listing now carries per-activity selesai/hapus buttons, with a
"batalkan selesai" toggle once one is marked.
54 tests pass (up from 40); 14 fresh router cases and 14 extractor cases.
- bot/handlers/message_handler.py +344 -54
- main.py +2 -0
- services/intent_service.py +26 -4
- tests/test_slot_extraction.py +154 -0
|
@@ -14,9 +14,12 @@ TTL, so a stale inline button can never re-trigger an old action and a container
|
|
| 14 |
restart simply invalidates whatever was pending.
|
| 15 |
"""
|
| 16 |
|
|
|
|
| 17 |
import logging
|
|
|
|
| 18 |
import secrets
|
| 19 |
import time
|
|
|
|
| 20 |
|
| 21 |
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
|
| 22 |
from telegram.ext import ContextTypes, MessageHandler, filters
|
|
@@ -30,6 +33,7 @@ from services.activity_service import (
|
|
| 30 |
get_today_activities,
|
| 31 |
get_upcoming_activities,
|
| 32 |
mark_activity_done,
|
|
|
|
| 33 |
update_activity,
|
| 34 |
)
|
| 35 |
from services.intent_service import (
|
|
@@ -147,22 +151,26 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
|
|
| 147 |
|
| 148 |
# A pending activity draft absorbs follow-up slot-filling messages.
|
| 149 |
if draft:
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
|
| 153 |
-
|
| 154 |
-
|
| 155 |
-
|
| 156 |
-
|
| 157 |
-
|
| 158 |
-
|
| 159 |
-
|
| 160 |
-
|
| 161 |
-
await _ask_for_missing(update, merged)
|
| 162 |
return
|
| 163 |
|
| 164 |
-
#
|
| 165 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
|
| 167 |
if intent.action == ACTION_LOG_FOOD:
|
| 168 |
await _execute_log_food(update, context, user_id, text, intent, ai_service)
|
|
@@ -177,7 +185,24 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
|
|
| 177 |
await _handle_chat(update, context, user_id, text, ai_service)
|
| 178 |
return
|
| 179 |
|
| 180 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 181 |
if not intent.is_complete:
|
| 182 |
context.user_data[KEY_DRAFT] = {
|
| 183 |
"action": intent.action,
|
|
@@ -264,25 +289,51 @@ async def _show_agenda(update, user_id: int) -> None:
|
|
| 264 |
return
|
| 265 |
|
| 266 |
lines = ["📅 *agenda kamu*\n"]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
if today:
|
| 268 |
lines.append("✨ *hari ini:*")
|
| 269 |
for act in today:
|
| 270 |
-
|
| 271 |
-
|
| 272 |
-
f"{marker} *{_md(act.get('title'))}*\n 🕒 "
|
| 273 |
-
f"{format_activity_datetime(act.get('activity_dt'))}"
|
| 274 |
-
)
|
| 275 |
|
| 276 |
if future:
|
| 277 |
lines.append("\n🗓️ *7 hari ke depan:*")
|
| 278 |
for act in future:
|
| 279 |
-
|
| 280 |
-
|
| 281 |
-
f"{marker} *{_md(act.get('title'))}*\n 🕒 "
|
| 282 |
-
f"{format_activity_datetime(act.get('activity_dt'))}"
|
| 283 |
-
)
|
| 284 |
|
| 285 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 286 |
|
| 287 |
|
| 288 |
async def _build_chat_context(user_id: int) -> dict:
|
|
@@ -336,17 +387,36 @@ def _draft_note(draft: dict) -> str:
|
|
| 336 |
|
| 337 |
|
| 338 |
def _looks_like_slot_answer(text: str) -> bool:
|
| 339 |
-
"""Whether a message plausibly supplies a missing slot value.
|
| 340 |
-
|
| 341 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 342 |
return False
|
| 343 |
-
|
| 344 |
-
|
| 345 |
-
|
| 346 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 347 |
return True
|
| 348 |
-
|
| 349 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 350 |
|
| 351 |
|
| 352 |
def _merge_draft(draft: dict, intent: Intent) -> dict:
|
|
@@ -377,19 +447,182 @@ def _draft_complete(draft: dict) -> bool:
|
|
| 377 |
return not (draft.get("missing") or [])
|
| 378 |
|
| 379 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 380 |
async def _ask_for_missing(update: Update, draft: dict) -> None:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 381 |
slots = draft.get("slots") or {}
|
| 382 |
missing = draft.get("missing") or list(_REQUIRED_ACTIVITY_SLOTS)
|
| 383 |
|
| 384 |
-
|
| 385 |
if slots.get("title"):
|
| 386 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 387 |
|
| 388 |
question = SLOT_QUESTIONS.get(missing[0], missing[0])
|
| 389 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 390 |
|
| 391 |
await update.message.reply_text("\n".join(lines), parse_mode="Markdown")
|
| 392 |
-
await _remember(
|
|
|
|
|
|
|
| 393 |
|
| 394 |
|
| 395 |
# ── Confirmation ──────────────────────────────────────────────────────────────
|
|
@@ -580,7 +813,10 @@ async def handle_confirm_callback(update: Update, context: ContextTypes.DEFAULT_
|
|
| 580 |
|
| 581 |
try:
|
| 582 |
message, keyboard = await _apply_confirmed_action(
|
| 583 |
-
update.effective_user.id, intent
|
|
|
|
|
|
|
|
|
|
| 584 |
)
|
| 585 |
except Exception as exc:
|
| 586 |
logger.error("Confirmed action failed: %s", exc, exc_info=True)
|
|
@@ -608,15 +844,67 @@ async def handle_done_callback(update: Update, context: ContextTypes.DEFAULT_TYP
|
|
| 608 |
await query.edit_message_text("hmm, kegiatannya nggak ketemu")
|
| 609 |
return
|
| 610 |
|
|
|
|
| 611 |
await query.edit_message_text(
|
| 612 |
-
f"✅ *{_md(updated['title'])}* selesai ya. mantap! 🎉"
|
|
|
|
| 613 |
parse_mode="Markdown",
|
|
|
|
| 614 |
)
|
| 615 |
await _remember(user_id, "[tombol tandai selesai]", f"{updated['title']} selesai")
|
| 616 |
|
| 617 |
|
| 618 |
-
async def
|
| 619 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 620 |
slots = intent.slots or {}
|
| 621 |
action = intent.action
|
| 622 |
|
|
@@ -700,7 +988,10 @@ async def _apply_confirmed_action(user_id: int, intent: Intent):
|
|
| 700 |
if not when:
|
| 701 |
return "tanggal atau jamnya masih kurang lengkap nih", None
|
| 702 |
|
| 703 |
-
title = str(slots.get("title"))
|
|
|
|
|
|
|
|
|
|
| 704 |
remind = int(slots.get("remind_mins", 30) or 30)
|
| 705 |
activity_id = await add_activity(
|
| 706 |
user_id=user_id,
|
|
@@ -710,19 +1001,16 @@ async def _apply_confirmed_action(user_id: int, intent: Intent):
|
|
| 710 |
remind_mins=remind,
|
| 711 |
)
|
| 712 |
|
| 713 |
-
|
| 714 |
-
|
| 715 |
-
InlineKeyboardButton("✅ tandai selesai", callback_data=f"done:{activity_id}"),
|
| 716 |
-
InlineKeyboardButton("🗑️ hapus", callback_data=f"del_activity:{activity_id}"),
|
| 717 |
-
]
|
| 718 |
-
])
|
| 719 |
-
return (
|
| 720 |
-
f"✅ oke, udah aku simpan!\n\n"
|
| 721 |
f"📌 *{_md(title)}*\n"
|
| 722 |
f"🕒 {format_activity_datetime(when)} WIB\n"
|
| 723 |
-
f"⏰ aku ingatkan {remind} menit sebelumnya 🔔"
|
| 724 |
-
keyboard,
|
| 725 |
)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 726 |
|
| 727 |
if action == ACTION_UPDATE_ACTIVITY:
|
| 728 |
existing = await find_activity_by_reference(user_id, str(slots.get("target") or ""))
|
|
@@ -770,3 +1058,5 @@ def build_message_handlers():
|
|
| 770 |
return [
|
| 771 |
MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message),
|
| 772 |
]
|
|
|
|
|
|
|
|
|
| 14 |
restart simply invalidates whatever was pending.
|
| 15 |
"""
|
| 16 |
|
| 17 |
+
import inspect
|
| 18 |
import logging
|
| 19 |
+
import re
|
| 20 |
import secrets
|
| 21 |
import time
|
| 22 |
+
from datetime import date, timedelta
|
| 23 |
|
| 24 |
from telegram import InlineKeyboardButton, InlineKeyboardMarkup, Update
|
| 25 |
from telegram.ext import ContextTypes, MessageHandler, filters
|
|
|
|
| 33 |
get_today_activities,
|
| 34 |
get_upcoming_activities,
|
| 35 |
mark_activity_done,
|
| 36 |
+
unmark_activity_done,
|
| 37 |
update_activity,
|
| 38 |
)
|
| 39 |
from services.intent_service import (
|
|
|
|
| 151 |
|
| 152 |
# A pending activity draft absorbs follow-up slot-filling messages.
|
| 153 |
if draft:
|
| 154 |
+
merged = _merge_draft(draft, intent)
|
| 155 |
+
|
| 156 |
+
# Deterministic safety net: the model sometimes answers CHAT for a plain
|
| 157 |
+
# "Hari Minggu, 11 Oktober 2026" while a draft is open, which used to
|
| 158 |
+
# strand the user mid-flow. Extract dates/times locally so slot filling
|
| 159 |
+
# never depends on the model remembering the draft.
|
| 160 |
+
merged = _fill_slots_locally(merged, text)
|
| 161 |
+
|
| 162 |
+
if _draft_complete(merged):
|
| 163 |
+
context.user_data.pop(KEY_DRAFT, None)
|
| 164 |
+
await _finish_activity_draft(update, context, user_id, merged, text)
|
|
|
|
| 165 |
return
|
| 166 |
|
| 167 |
+
# The user switched to an unrelated topic: drop the draft.
|
| 168 |
+
if intent.action not in (ACTION_ADD_ACTIVITY,) and not _looks_like_slot_answer(text):
|
| 169 |
+
context.user_data.pop(KEY_DRAFT, None)
|
| 170 |
+
else:
|
| 171 |
+
context.user_data[KEY_DRAFT] = merged
|
| 172 |
+
await _ask_for_missing(update, merged)
|
| 173 |
+
return
|
| 174 |
|
| 175 |
if intent.action == ACTION_LOG_FOOD:
|
| 176 |
await _execute_log_food(update, context, user_id, text, intent, ai_service)
|
|
|
|
| 185 |
await _handle_chat(update, context, user_id, text, ai_service)
|
| 186 |
return
|
| 187 |
|
| 188 |
+
# ── Actions that write data ──────────────────────────────────────────────
|
| 189 |
+
#
|
| 190 |
+
# Adding an activity behaves like logging food: it happens straight away.
|
| 191 |
+
# Asking "are you sure?" before saving something the user just said out
|
| 192 |
+
# loud, then asking again on the result, is what made this feel like double
|
| 193 |
+
# work. Deletion and corrections stay behind a confirmation because those
|
| 194 |
+
# are destructive and easy to trigger by accident.
|
| 195 |
+
if intent.action == ACTION_ADD_ACTIVITY and intent.is_complete:
|
| 196 |
+
await _apply_confirmed_action(
|
| 197 |
+
user_id,
|
| 198 |
+
intent,
|
| 199 |
+
reply_fn=lambda message, keyboard: update.message.reply_text(
|
| 200 |
+
message, parse_mode="Markdown", reply_markup=keyboard
|
| 201 |
+
),
|
| 202 |
+
)
|
| 203 |
+
await _remember(user_id, text, "kegiatan disimpan")
|
| 204 |
+
return
|
| 205 |
+
|
| 206 |
if not intent.is_complete:
|
| 207 |
context.user_data[KEY_DRAFT] = {
|
| 208 |
"action": intent.action,
|
|
|
|
| 289 |
return
|
| 290 |
|
| 291 |
lines = ["📅 *agenda kamu*\n"]
|
| 292 |
+
buttons: list[list[InlineKeyboardButton]] = []
|
| 293 |
+
|
| 294 |
+
def _entry(act: dict) -> str:
|
| 295 |
+
marker = "✅" if act.get("done") else "📌"
|
| 296 |
+
return (
|
| 297 |
+
f"{marker} *{_md(act.get('title'))}*\n 🕒 "
|
| 298 |
+
f"{format_activity_datetime(act.get('activity_dt'))}"
|
| 299 |
+
)
|
| 300 |
+
|
| 301 |
if today:
|
| 302 |
lines.append("✨ *hari ini:*")
|
| 303 |
for act in today:
|
| 304 |
+
lines.append(_entry(act))
|
| 305 |
+
buttons.append(_activity_buttons(act))
|
|
|
|
|
|
|
|
|
|
| 306 |
|
| 307 |
if future:
|
| 308 |
lines.append("\n🗓️ *7 hari ke depan:*")
|
| 309 |
for act in future:
|
| 310 |
+
lines.append(_entry(act))
|
| 311 |
+
buttons.append(_activity_buttons(act))
|
|
|
|
|
|
|
|
|
|
| 312 |
|
| 313 |
+
lines.append(
|
| 314 |
+
"\n💡 _ketuk tombol di bawah buat tandai selesai atau hapus_"
|
| 315 |
+
)
|
| 316 |
+
|
| 317 |
+
await update.message.reply_text(
|
| 318 |
+
"\n".join(lines),
|
| 319 |
+
parse_mode="Markdown",
|
| 320 |
+
reply_markup=InlineKeyboardMarkup(buttons) if buttons else None,
|
| 321 |
+
)
|
| 322 |
+
|
| 323 |
+
|
| 324 |
+
def _activity_buttons(act: dict) -> list[InlineKeyboardButton]:
|
| 325 |
+
"""Per-activity actions shown under the agenda listing."""
|
| 326 |
+
activity_id = act["id"]
|
| 327 |
+
if act.get("done"):
|
| 328 |
+
return [
|
| 329 |
+
InlineKeyboardButton(
|
| 330 |
+
"↩️ batalkan selesai", callback_data=f"undone:{activity_id}"
|
| 331 |
+
)
|
| 332 |
+
]
|
| 333 |
+
return [
|
| 334 |
+
InlineKeyboardButton("✅ selesai", callback_data=f"done:{activity_id}"),
|
| 335 |
+
InlineKeyboardButton("🗑️ hapus", callback_data=f"del_activity:{activity_id}"),
|
| 336 |
+
]
|
| 337 |
|
| 338 |
|
| 339 |
async def _build_chat_context(user_id: int) -> dict:
|
|
|
|
| 387 |
|
| 388 |
|
| 389 |
def _looks_like_slot_answer(text: str) -> bool:
|
| 390 |
+
"""Whether a message plausibly supplies a missing slot value.
|
| 391 |
+
|
| 392 |
+
Used to decide whether to keep an open draft alive. Deliberately narrow:
|
| 393 |
+
while a draft is open, a bare "besok" or "jam 3" is part of it, but a
|
| 394 |
+
sentence about something else should be allowed to start a new topic.
|
| 395 |
+
"""
|
| 396 |
+
stripped = (text or "").strip()
|
| 397 |
+
if not stripped or stripped.startswith("/"):
|
| 398 |
return False
|
| 399 |
+
|
| 400 |
+
lowered = stripped.lower()
|
| 401 |
+
|
| 402 |
+
# Signals that the user is changing the subject entirely.
|
| 403 |
+
for cue in ("hadeh", "lupakan", "udahan", "stop", "batal", "nggajadi", "nggak jadi"):
|
| 404 |
+
if cue in lowered:
|
| 405 |
+
return False
|
| 406 |
+
|
| 407 |
+
# Anything short is treated as an answer to the pending question.
|
| 408 |
+
if len(stripped.split()) <= 8:
|
| 409 |
return True
|
| 410 |
+
|
| 411 |
+
# Longer messages still count when they carry a concrete date/time.
|
| 412 |
+
return bool(
|
| 413 |
+
_CLOCK_HOUR.search(lowered)
|
| 414 |
+
or _BARE_TIME.search(lowered)
|
| 415 |
+
or _DATE_DAY_MONTH.search(lowered)
|
| 416 |
+
or _DATE_NUMERIC.search(lowered)
|
| 417 |
+
or any(word in lowered for word in ("besok", "lusa", "hari ini", "minggu", "senin",
|
| 418 |
+
"selasa", "rabu", "kamis", "jumat", "sabtu"))
|
| 419 |
+
)
|
| 420 |
|
| 421 |
|
| 422 |
def _merge_draft(draft: dict, intent: Intent) -> dict:
|
|
|
|
| 447 |
return not (draft.get("missing") or [])
|
| 448 |
|
| 449 |
|
| 450 |
+
# ── Deterministic slot extraction ─────────────────────────────────────────────
|
| 451 |
+
#
|
| 452 |
+
# Filling the date/time of a half-finished activity used to depend entirely on
|
| 453 |
+
# the model, which answered CHAT for plain "Hari Minggu, 11 Oktober 2026" and
|
| 454 |
+
# left the user stranded mid-flow. The clock is interchangeable, the schedule is
|
| 455 |
+
# not, so parse it locally.
|
| 456 |
+
|
| 457 |
+
_TIME_WORDS = {
|
| 458 |
+
"pagi": 9, "subuh": 5, "dini": 5, "siang": 12, "sore": 15,
|
| 459 |
+
"petang": 17, "malam": 20, "begadang": 23,
|
| 460 |
+
}
|
| 461 |
+
|
| 462 |
+
_DAY_WORDS = {
|
| 463 |
+
"senin": 0, "selasa": 1, "rabu": 2, "kamis": 3,
|
| 464 |
+
"jumat": 4, "jum'at": 4, "sabtu": 5, "minggu": 6,
|
| 465 |
+
}
|
| 466 |
+
|
| 467 |
+
_MONTH_WORDS = {
|
| 468 |
+
"januari": 1, "februari": 2, "maret": 3, "april": 4, "mei": 5, "juni": 6,
|
| 469 |
+
"juli": 7, "agustus": 8, "september": 9, "oktober": 10, "november": 11, "desember": 12,
|
| 470 |
+
}
|
| 471 |
+
|
| 472 |
+
_BARE_TIME = re.compile(r"\b([01]?\d|2[0-3])[.:]([0-5]\d)\b")
|
| 473 |
+
_CLOCK_HOUR = re.compile(r"\b(?:jam|pukul)\s+([01]?\d|2[0-3])(?:[.:]([0-5]\d))?\b")
|
| 474 |
+
_DATE_NUMERIC = re.compile(r"\b(\d{1,2})[-/](\d{1,2})(?:[-/](\d{2,4}))?\b")
|
| 475 |
+
_DATE_DAY_MONTH = re.compile(
|
| 476 |
+
r"\b(\d{1,2})\s+([a-z]+)\s*(?:(\d{4}))?\b", re.IGNORECASE
|
| 477 |
+
)
|
| 478 |
+
|
| 479 |
+
|
| 480 |
+
def _extract_time(text: str) -> str | None:
|
| 481 |
+
"""Pull an HH:MM out of free text, including 'jam 3 sore'."""
|
| 482 |
+
lowered = text.lower()
|
| 483 |
+
|
| 484 |
+
match = _CLOCK_HOUR.search(lowered)
|
| 485 |
+
if match:
|
| 486 |
+
hour = int(match.group(1))
|
| 487 |
+
minute = int(match.group(2) or 0)
|
| 488 |
+
# "jam 3 sore" means 15:00, not 03:00.
|
| 489 |
+
for word, shift in sorted(_TIME_WORDS.items(), key=lambda kv: -kv[1]):
|
| 490 |
+
if word in lowered and hour < 12 and shift >= 12:
|
| 491 |
+
hour += 12
|
| 492 |
+
break
|
| 493 |
+
return f"{hour:02d}:{minute:02d}"
|
| 494 |
+
|
| 495 |
+
match = _BARE_TIME.search(lowered)
|
| 496 |
+
if match:
|
| 497 |
+
return f"{int(match.group(1)):02d}:{int(match.group(2)):02d}"
|
| 498 |
+
|
| 499 |
+
for word, hour in sorted(_TIME_WORDS.items(), key=lambda kv: -kv[1]):
|
| 500 |
+
if word in lowered and "jam" not in lowered and "pukul" not in lowered:
|
| 501 |
+
return f"{hour:02d}:00"
|
| 502 |
+
return None
|
| 503 |
+
|
| 504 |
+
|
| 505 |
+
def _extract_date(text: str, today: date) -> str | None:
|
| 506 |
+
"""Pull a YYYY-MM-DD out of free text."""
|
| 507 |
+
lowered = text.lower()
|
| 508 |
+
|
| 509 |
+
# "Hari Minggu, 11 Oktober 2026" / "11 Oktober"
|
| 510 |
+
match = _DATE_DAY_MONTH.search(lowered)
|
| 511 |
+
if match:
|
| 512 |
+
day = int(match.group(1))
|
| 513 |
+
month = _MONTH_WORDS.get(match.group(2))
|
| 514 |
+
if month:
|
| 515 |
+
year = int(match.group(3)) if match.group(3) else today.year
|
| 516 |
+
try:
|
| 517 |
+
return date(year, month, day).isoformat()
|
| 518 |
+
except ValueError:
|
| 519 |
+
pass
|
| 520 |
+
|
| 521 |
+
# "11/10", "11-10", or "11-10-2026". Day/month order (Indonesian habit).
|
| 522 |
+
match = _DATE_NUMERIC.search(lowered)
|
| 523 |
+
if match:
|
| 524 |
+
day, month = int(match.group(1)), int(match.group(2))
|
| 525 |
+
raw_year = match.group(3)
|
| 526 |
+
if raw_year:
|
| 527 |
+
year = int(raw_year)
|
| 528 |
+
if year < 100:
|
| 529 |
+
year += 2000
|
| 530 |
+
else:
|
| 531 |
+
year = today.year
|
| 532 |
+
try:
|
| 533 |
+
return date(year, month, day).isoformat()
|
| 534 |
+
except ValueError:
|
| 535 |
+
pass
|
| 536 |
+
|
| 537 |
+
# Weekday names resolve to the NEXT occurrence, so a reminder is never
|
| 538 |
+
# silently scheduled in the past.
|
| 539 |
+
for word, weekday in _DAY_WORDS.items():
|
| 540 |
+
if word in lowered:
|
| 541 |
+
delta = (weekday - today.weekday()) % 7
|
| 542 |
+
delta = delta or 7
|
| 543 |
+
return (today + timedelta(days=delta)).isoformat()
|
| 544 |
+
|
| 545 |
+
if "hari ini" in lowered:
|
| 546 |
+
return today.isoformat()
|
| 547 |
+
if "besok" in lowered:
|
| 548 |
+
return (today + timedelta(days=1)).isoformat()
|
| 549 |
+
if "lusa" in lowered:
|
| 550 |
+
return (today + timedelta(days=2)).isoformat()
|
| 551 |
+
|
| 552 |
+
return None
|
| 553 |
+
|
| 554 |
+
|
| 555 |
+
def _fill_slots_locally(draft: dict, text: str) -> dict:
|
| 556 |
+
"""Fill empty date/time slots from the raw message without calling the model."""
|
| 557 |
+
slots = dict(draft.get("slots") or {})
|
| 558 |
+
today = _today_for_draft()
|
| 559 |
+
|
| 560 |
+
if not slots.get("time"):
|
| 561 |
+
found = _extract_time(text)
|
| 562 |
+
if found:
|
| 563 |
+
slots["time"] = found
|
| 564 |
+
|
| 565 |
+
if not slots.get("date"):
|
| 566 |
+
found = _extract_date(text, today)
|
| 567 |
+
if found:
|
| 568 |
+
slots["date"] = found
|
| 569 |
+
|
| 570 |
+
draft = dict(draft)
|
| 571 |
+
draft["slots"] = slots
|
| 572 |
+
draft["missing"] = [
|
| 573 |
+
slot for slot in _REQUIRED_ACTIVITY_SLOTS if not slots.get(slot)
|
| 574 |
+
]
|
| 575 |
+
return draft
|
| 576 |
+
|
| 577 |
+
|
| 578 |
+
def _today_for_draft() -> date:
|
| 579 |
+
return date.today()
|
| 580 |
+
|
| 581 |
+
|
| 582 |
+
async def _finish_activity_draft(update, context, user_id, draft, text) -> None:
|
| 583 |
+
"""Save a fully-specified activity and reply with its undo/delete buttons."""
|
| 584 |
+
await _apply_confirmed_action(
|
| 585 |
+
user_id,
|
| 586 |
+
Intent(
|
| 587 |
+
action=ACTION_ADD_ACTIVITY,
|
| 588 |
+
slots=draft["slots"],
|
| 589 |
+
missing=[],
|
| 590 |
+
),
|
| 591 |
+
reply_fn=lambda message, keyboard: update.message.reply_text(
|
| 592 |
+
message, parse_mode="Markdown", reply_markup=keyboard
|
| 593 |
+
),
|
| 594 |
+
)
|
| 595 |
+
await _remember(user_id, text, "kegiatan disimpan")
|
| 596 |
+
|
| 597 |
+
|
| 598 |
async def _ask_for_missing(update: Update, draft: dict) -> None:
|
| 599 |
+
"""Ask for whatever an activity is still missing, in Ginchiee's voice.
|
| 600 |
+
|
| 601 |
+
Shows what is already known so the user can see the draft taking shape,
|
| 602 |
+
instead of the same "boleh, aku catatin~" line on every turn.
|
| 603 |
+
"""
|
| 604 |
slots = draft.get("slots") or {}
|
| 605 |
missing = draft.get("missing") or list(_REQUIRED_ACTIVITY_SLOTS)
|
| 606 |
|
| 607 |
+
known: list[str] = []
|
| 608 |
if slots.get("title"):
|
| 609 |
+
known.append(f"📌 *{_md(slots['title'])}*")
|
| 610 |
+
if slots.get("date"):
|
| 611 |
+
known.append(f"🗓️ {slots['date']}")
|
| 612 |
+
elif slots.get("time"):
|
| 613 |
+
known.append(f"🕒 {slots['time']}")
|
| 614 |
|
| 615 |
question = SLOT_QUESTIONS.get(missing[0], missing[0])
|
| 616 |
+
|
| 617 |
+
if known:
|
| 618 |
+
lines = ["oke, ini yang aku tangkep sejauh ini:"] + known + ["", question]
|
| 619 |
+
else:
|
| 620 |
+
lines = [question]
|
| 621 |
|
| 622 |
await update.message.reply_text("\n".join(lines), parse_mode="Markdown")
|
| 623 |
+
await _remember(
|
| 624 |
+
update.effective_user.id, update.message.text or "", "\n".join(lines)
|
| 625 |
+
)
|
| 626 |
|
| 627 |
|
| 628 |
# ── Confirmation ──────────────────────────────────────────────────────────────
|
|
|
|
| 813 |
|
| 814 |
try:
|
| 815 |
message, keyboard = await _apply_confirmed_action(
|
| 816 |
+
update.effective_user.id, intent,
|
| 817 |
+
reply_fn=lambda m, k: query.edit_message_text(
|
| 818 |
+
m, parse_mode="Markdown", reply_markup=k
|
| 819 |
+
),
|
| 820 |
)
|
| 821 |
except Exception as exc:
|
| 822 |
logger.error("Confirmed action failed: %s", exc, exc_info=True)
|
|
|
|
| 844 |
await query.edit_message_text("hmm, kegiatannya nggak ketemu")
|
| 845 |
return
|
| 846 |
|
| 847 |
+
# Re-render the entry in place so the row now reads as completed.
|
| 848 |
await query.edit_message_text(
|
| 849 |
+
f"✅ *{_md(updated['title'])}* selesai ya. mantap! 🎉\n"
|
| 850 |
+
f"🕒 {format_activity_datetime(updated['activity_dt'])}",
|
| 851 |
parse_mode="Markdown",
|
| 852 |
+
reply_markup=InlineKeyboardMarkup([_activity_buttons(updated)]),
|
| 853 |
)
|
| 854 |
await _remember(user_id, "[tombol tandai selesai]", f"{updated['title']} selesai")
|
| 855 |
|
| 856 |
|
| 857 |
+
async def handle_undone_callback(update: Update, context: ContextTypes.DEFAULT_TYPE) -> None:
|
| 858 |
+
query = update.callback_query
|
| 859 |
+
await query.answer()
|
| 860 |
+
user_id = update.effective_user.id
|
| 861 |
+
|
| 862 |
+
try:
|
| 863 |
+
activity_id = int((query.data or "").split(":", 1)[1])
|
| 864 |
+
except (ValueError, IndexError):
|
| 865 |
+
await query.edit_message_text("id-nya aneh, coba lagi ya")
|
| 866 |
+
return
|
| 867 |
+
|
| 868 |
+
updated = await unmark_activity_done(activity_id, user_id)
|
| 869 |
+
if not updated:
|
| 870 |
+
await query.edit_message_text("hmm, kegiatannya nggak ketemu")
|
| 871 |
+
return
|
| 872 |
+
|
| 873 |
+
await query.edit_message_text(
|
| 874 |
+
f"📌 *{_md(updated['title'])}*\n"
|
| 875 |
+
f"🕒 {format_activity_datetime(updated['activity_dt'])}\n\n"
|
| 876 |
+
f"_oke, aku aktifin lagi ya_",
|
| 877 |
+
parse_mode="Markdown",
|
| 878 |
+
reply_markup=InlineKeyboardMarkup([_activity_buttons(updated)]),
|
| 879 |
+
)
|
| 880 |
+
await _remember(user_id, "[tombol batalkan selesai]", f"{updated['title']} aktif lagi")
|
| 881 |
+
|
| 882 |
+
|
| 883 |
+
async def _apply_confirmed_action(user_id: int, intent: Intent, reply_fn=None):
|
| 884 |
+
"""Run an action and return (message, keyboard_or_None).
|
| 885 |
+
|
| 886 |
+
When `reply_fn` is given the result is also delivered through it, which
|
| 887 |
+
lets the same body serve both a fresh chat reply (an activity added
|
| 888 |
+
straight away) and the confirmation callback (which edits the button
|
| 889 |
+
message itself and passes None).
|
| 890 |
+
"""
|
| 891 |
+
message, keyboard = await _run_action(user_id, intent)
|
| 892 |
+
|
| 893 |
+
if reply_fn is None:
|
| 894 |
+
return message, keyboard
|
| 895 |
+
|
| 896 |
+
try:
|
| 897 |
+
result = reply_fn(message, keyboard)
|
| 898 |
+
if inspect.isawaitable(result):
|
| 899 |
+
await result
|
| 900 |
+
except Exception as exc: # noqa: BLE001
|
| 901 |
+
logger.error("Failed to deliver action reply: %s", exc, exc_info=True)
|
| 902 |
+
|
| 903 |
+
return message, keyboard
|
| 904 |
+
|
| 905 |
+
|
| 906 |
+
async def _run_action(user_id: int, intent: Intent):
|
| 907 |
+
"""Execute an action. Returns (message, keyboard_or_None)."""
|
| 908 |
slots = intent.slots or {}
|
| 909 |
action = intent.action
|
| 910 |
|
|
|
|
| 988 |
if not when:
|
| 989 |
return "tanggal atau jamnya masih kurang lengkap nih", None
|
| 990 |
|
| 991 |
+
title = str(slots.get("title") or "").strip()
|
| 992 |
+
if not title:
|
| 993 |
+
return "judul kegiatannya apa nih? aku belum tangkep", None
|
| 994 |
+
|
| 995 |
remind = int(slots.get("remind_mins", 30) or 30)
|
| 996 |
activity_id = await add_activity(
|
| 997 |
user_id=user_id,
|
|
|
|
| 1001 |
remind_mins=remind,
|
| 1002 |
)
|
| 1003 |
|
| 1004 |
+
message = (
|
| 1005 |
+
f"✅ oke, udah aku catat!\n\n"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1006 |
f"📌 *{_md(title)}*\n"
|
| 1007 |
f"🕒 {format_activity_datetime(when)} WIB\n"
|
| 1008 |
+
f"⏰ aku ingatkan {remind} menit sebelumnya 🔔"
|
|
|
|
| 1009 |
)
|
| 1010 |
+
return message, InlineKeyboardMarkup([[
|
| 1011 |
+
InlineKeyboardButton("✅ selesai", callback_data=f"done:{activity_id}"),
|
| 1012 |
+
InlineKeyboardButton("🗑️ hapus", callback_data=f"del_activity:{activity_id}"),
|
| 1013 |
+
]])
|
| 1014 |
|
| 1015 |
if action == ACTION_UPDATE_ACTIVITY:
|
| 1016 |
existing = await find_activity_by_reference(user_id, str(slots.get("target") or ""))
|
|
|
|
| 1058 |
return [
|
| 1059 |
MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message),
|
| 1060 |
]
|
| 1061 |
+
|
| 1062 |
+
|
|
@@ -82,6 +82,7 @@ from bot.handlers.message_handler import (
|
|
| 82 |
handle_confirm_callback,
|
| 83 |
handle_done_callback,
|
| 84 |
handle_message,
|
|
|
|
| 85 |
)
|
| 86 |
from bot.handlers.photo_handler import build_photo_handlers
|
| 87 |
from bot.handlers.profile_handler import build_profile_handlers
|
|
@@ -278,6 +279,7 @@ def build_app() -> Application:
|
|
| 278 |
# per-activity done/delete buttons.
|
| 279 |
app.add_handler(CallbackQueryHandler(handle_confirm_callback, pattern=r"^confirm:"))
|
| 280 |
app.add_handler(CallbackQueryHandler(handle_done_callback, pattern=r"^done:"))
|
|
|
|
| 281 |
|
| 282 |
# Free-form text is handled by the intent router.
|
| 283 |
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message))
|
|
|
|
| 82 |
handle_confirm_callback,
|
| 83 |
handle_done_callback,
|
| 84 |
handle_message,
|
| 85 |
+
handle_undone_callback,
|
| 86 |
)
|
| 87 |
from bot.handlers.photo_handler import build_photo_handlers
|
| 88 |
from bot.handlers.profile_handler import build_profile_handlers
|
|
|
|
| 279 |
# per-activity done/delete buttons.
|
| 280 |
app.add_handler(CallbackQueryHandler(handle_confirm_callback, pattern=r"^confirm:"))
|
| 281 |
app.add_handler(CallbackQueryHandler(handle_done_callback, pattern=r"^done:"))
|
| 282 |
+
app.add_handler(CallbackQueryHandler(handle_undone_callback, pattern=r"^undone:"))
|
| 283 |
|
| 284 |
# Free-form text is handled by the intent router.
|
| 285 |
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_message))
|
|
@@ -214,12 +214,32 @@ Pilih TEPAT SATU action:
|
|
| 214 |
Isi: {{"target": "semua" atau "terakhir" atau nama makanan}}
|
| 215 |
Contoh: "hapus nasi goreng dari catatan", "gajadi deh nasi tadi hapus aja", "hapus semua makananku hari ini".
|
| 216 |
|
| 217 |
-
4. ADD_ACTIVITY - user ingin MENYIMPAN kegiatan/jadwal baru
|
| 218 |
-
|
|
|
|
| 219 |
Slot wajib: title, date, time. Kalau user belum menyebut salah satunya,
|
| 220 |
kosongkan field tsb (biarkan "") dan laporkan di "missing".
|
| 221 |
Untuk "besok jam 3 sore" pakai date/tanggal besok dan time 15:00 (sore = 15:00-18:00).
|
| 222 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 223 |
|
| 224 |
5. UPDATE_ACTIVITY - user ingin MENGUBAH kegiatan yang sudah ada.
|
| 225 |
Isi: {{"target": "nama/judul kegiatan", "title": "...", "date": "...", "time": "...", "description": "...", "remind_mins": 30}}
|
|
@@ -237,8 +257,10 @@ Pilih TEPAT SATU action:
|
|
| 237 |
Isi: {{"name": "nama baru"}}
|
| 238 |
Contoh: "panggil aku Bella aja", "ganti nama aku ya", "namaku sekarang Gyna".
|
| 239 |
|
| 240 |
-
9. VIEW_AGENDA - user minta MELIHAT jadwal/kegiatan.
|
| 241 |
Isi: {{}}
|
|
|
|
|
|
|
| 242 |
|
| 243 |
10. CHAT - selain semua di atas: ngobrol santai, tanya jawab, curhat, cerita,
|
| 244 |
pujian, atau menanyakan progress makanan (bukan mencatat).
|
|
|
|
| 214 |
Isi: {{"target": "semua" atau "terakhir" atau nama makanan}}
|
| 215 |
Contoh: "hapus nasi goreng dari catatan", "gajadi deh nasi tadi hapus aja", "hapus semua makananku hari ini".
|
| 216 |
|
| 217 |
+
4. ADD_ACTIVITY - user ingin MENYIMPAN kegiatan/jadwal/acara baru, atau minta
|
| 218 |
+
diingatkan sesuatu. Isi:
|
| 219 |
+
{{"title": "...", "date": "YYYY-MM-DD", "time": "HH:MM", "description": "...", "remind_mins": 30}}
|
| 220 |
Slot wajib: title, date, time. Kalau user belum menyebut salah satunya,
|
| 221 |
kosongkan field tsb (biarkan "") dan laporkan di "missing".
|
| 222 |
Untuk "besok jam 3 sore" pakai date/tanggal besok dan time 15:00 (sore = 15:00-18:00).
|
| 223 |
+
|
| 224 |
+
WAJIB pilih ADD_ACTIVITY kalau pesan mengandung salah satu pemicu ini,
|
| 225 |
+
TERLEPAS dari berapa informasi yang ada:
|
| 226 |
+
- "ingetin aku...", "ingetin ya...", "tolong ingetin...", "bisa ingetin aku?"
|
| 227 |
+
- "jangan lupa aku...", "pengingat..."
|
| 228 |
+
- "masukkan ke jadwal/kegiatan/agenda", "catat jadwal", "simpen jadwal"
|
| 229 |
+
- pernyataan rencana yang punya hari/tanggal/jam: "aku ada X besok jam 10",
|
| 230 |
+
"aku mau X jam 2 sore", "besok ada X", "lusa ada X jam 3"
|
| 231 |
+
Kalau hanya pemicu tanpa detail (mis. "ingetin aku ya"), tetap ADD_ACTIVITY
|
| 232 |
+
dengan missing berisi semua slot yang belum diketahui. JANGAN jatuh ke CHAT.
|
| 233 |
+
|
| 234 |
+
URUTAN KATA TIDAK PENTING. Semua ini sama saja:
|
| 235 |
+
- "aku mau ngechat pacar jam 2 sore"
|
| 236 |
+
- "jam 2 sore aku mau ngechat pacar"
|
| 237 |
+
- "jam 2 sore yaa, aku mau ngechat pacar"
|
| 238 |
+
|
| 239 |
+
JUDUL HARUS BARU. Judul kegiatan hanya boleh berasal dari pesan user.
|
| 240 |
+
DILARANG KERAS menyalin judul dari "Kegiatan milik user yang tersimpan" di
|
| 241 |
+
atas — daftar itu hanya untuk melacak kegiatan yang mau diubah/dihapus.
|
| 242 |
+
Kalau user belum menyebut judulnya, title dibiarkan "" dan masuk ke missing.
|
| 243 |
|
| 244 |
5. UPDATE_ACTIVITY - user ingin MENGUBAH kegiatan yang sudah ada.
|
| 245 |
Isi: {{"target": "nama/judul kegiatan", "title": "...", "date": "...", "time": "...", "description": "...", "remind_mins": 30}}
|
|
|
|
| 257 |
Isi: {{"name": "nama baru"}}
|
| 258 |
Contoh: "panggil aku Bella aja", "ganti nama aku ya", "namaku sekarang Gyna".
|
| 259 |
|
| 260 |
+
9. VIEW_AGENDA - user minta MELIHAT jadwal/kegiatan/agenda.
|
| 261 |
Isi: {{}}
|
| 262 |
+
Pemicu: "agenda aku apa aja", "ada jadwal apa", "list kegiatan aku",
|
| 263 |
+
"apa aja yang harus aku lakukan", "kegiatan minggu ini apa", "jadwalku dong".
|
| 264 |
|
| 265 |
10. CHAT - selain semua di atas: ngobrol santai, tanya jawab, curhat, cerita,
|
| 266 |
pujian, atau menanyakan progress makanan (bukan mencatat).
|
|
@@ -0,0 +1,154 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
tests/test_slot_extraction.py
|
| 3 |
+
|
| 4 |
+
Unit tests for the deterministic date/time extractor that backs activity
|
| 5 |
+
drafting.
|
| 6 |
+
|
| 7 |
+
Why this exists: filling the date and time of a half-finished activity used to
|
| 8 |
+
depend entirely on the model, which answered CHAT for a plain "Hari Minggu,
|
| 9 |
+
11 Oktober 2026" and stranded the user mid-flow. The clock is interchangeable,
|
| 10 |
+
the schedule is not, so these are parsed locally and never need a model.
|
| 11 |
+
|
| 12 |
+
Run:
|
| 13 |
+
python -m unittest tests.test_slot_extraction -v
|
| 14 |
+
"""
|
| 15 |
+
|
| 16 |
+
import unittest
|
| 17 |
+
from datetime import date
|
| 18 |
+
from pathlib import Path
|
| 19 |
+
import sys
|
| 20 |
+
|
| 21 |
+
PROJECT_ROOT = Path(__file__).resolve().parents[1]
|
| 22 |
+
sys.path.insert(0, str(PROJECT_ROOT))
|
| 23 |
+
|
| 24 |
+
from bot.handlers.message_handler import ( # noqa: E402
|
| 25 |
+
_extract_date,
|
| 26 |
+
_extract_time,
|
| 27 |
+
_fill_slots_locally,
|
| 28 |
+
)
|
| 29 |
+
|
| 30 |
+
# A Sunday, chosen so weekday resolution is unambiguous.
|
| 31 |
+
TODAY = date(2026, 10, 11)
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
class ExtractTimeTests(unittest.TestCase):
|
| 35 |
+
def test_clock_phrases(self) -> None:
|
| 36 |
+
for text, expected in [
|
| 37 |
+
("Jam 2 sore ginchiee", "14:00"),
|
| 38 |
+
("jam 3 sore", "15:00"),
|
| 39 |
+
("jam 10 pagi", "10:00"),
|
| 40 |
+
("jam 7 malam", "19:00"),
|
| 41 |
+
("pukul 5 sore", "17:00"),
|
| 42 |
+
("jam 5 subuh", "05:00"),
|
| 43 |
+
]:
|
| 44 |
+
with self.subTest(text=text):
|
| 45 |
+
self.assertEqual(_extract_time(text), expected)
|
| 46 |
+
|
| 47 |
+
def test_numeric_times(self) -> None:
|
| 48 |
+
for text, expected in [
|
| 49 |
+
("jam 09:30", "09:30"),
|
| 50 |
+
("14:00", "14:00"),
|
| 51 |
+
("jam 23.15", "23:15"),
|
| 52 |
+
]:
|
| 53 |
+
with self.subTest(text=text):
|
| 54 |
+
self.assertEqual(_extract_time(text), expected)
|
| 55 |
+
|
| 56 |
+
def test_no_time_returns_none(self) -> None:
|
| 57 |
+
self.assertIsNone(_extract_time("aku ada rapat"))
|
| 58 |
+
self.assertIsNone(_extract_time("besok ya"))
|
| 59 |
+
|
| 60 |
+
def test_pm_words_shift_late_hours(self) -> None:
|
| 61 |
+
# "sore" must push a morning-shaped hour into the afternoon.
|
| 62 |
+
self.assertEqual(_extract_time("jam 11 sore"), "23:00")
|
| 63 |
+
self.assertEqual(_extract_time("jam 11 pagi"), "11:00")
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
class ExtractDateTests(unittest.TestCase):
|
| 67 |
+
def test_long_date(self) -> None:
|
| 68 |
+
self.assertEqual(
|
| 69 |
+
_extract_date("Hari Minggu, 11 Oktober 2026", TODAY), "2026-10-11"
|
| 70 |
+
)
|
| 71 |
+
|
| 72 |
+
def test_day_month_without_year(self) -> None:
|
| 73 |
+
self.assertEqual(_extract_date("11 Oktober", TODAY), "2026-10-11")
|
| 74 |
+
self.assertEqual(_extract_date("3 Desember", TODAY), "2026-12-03")
|
| 75 |
+
|
| 76 |
+
def test_numeric_dates(self) -> None:
|
| 77 |
+
self.assertEqual(_extract_date("11/10", TODAY), "2026-10-11")
|
| 78 |
+
self.assertEqual(_extract_date("11-10", TODAY), "2026-10-11")
|
| 79 |
+
self.assertEqual(_extract_date("11-10-2026", TODAY), "2026-10-11")
|
| 80 |
+
|
| 81 |
+
def test_relative_days(self) -> None:
|
| 82 |
+
self.assertEqual(_extract_date("hari ini", TODAY), "2026-10-11")
|
| 83 |
+
self.assertEqual(_extract_date("besok", TODAY), "2026-10-12")
|
| 84 |
+
self.assertEqual(_extract_date("lusa", TODAY), "2026-10-13")
|
| 85 |
+
|
| 86 |
+
def test_weekday_names_resolve_to_next_occurrence(self) -> None:
|
| 87 |
+
# TODAY is Sunday 11 Oct 2026.
|
| 88 |
+
self.assertEqual(_extract_date("hari jumat", TODAY), "2026-10-16")
|
| 89 |
+
self.assertEqual(_extract_date("hari senin", TODAY), "2026-10-12")
|
| 90 |
+
|
| 91 |
+
def test_weekday_never_returns_today_or_past(self) -> None:
|
| 92 |
+
"""A named day always means a future one, so a reminder is never lost."""
|
| 93 |
+
result = _extract_date("minggu", TODAY)
|
| 94 |
+
self.assertEqual(result, "2026-10-18")
|
| 95 |
+
self.assertGreater(result, TODAY.isoformat())
|
| 96 |
+
|
| 97 |
+
def test_no_date_returns_none(self) -> None:
|
| 98 |
+
self.assertIsNone(_extract_date("jam 2 sore", TODAY))
|
| 99 |
+
self.assertIsNone(_extract_date("mau ngechat pacar", TODAY))
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
class FillSlotsTests(unittest.TestCase):
|
| 103 |
+
"""The exact production sequence that used to break."""
|
| 104 |
+
|
| 105 |
+
def test_bare_instruction_then_date_then_time(self) -> None:
|
| 106 |
+
draft = {
|
| 107 |
+
"slots": {"title": "", "date": "", "time": ""},
|
| 108 |
+
"missing": ["title", "date", "time"],
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
# 1. "Masukkin ke jadwal kegiatan ginchiee" -> nothing parseable yet.
|
| 112 |
+
draft = _fill_slots_locally(draft, "Masukkin ke jadwal kegiatan ginchiee")
|
| 113 |
+
self.assertEqual(draft["slots"]["date"], "")
|
| 114 |
+
self.assertEqual(draft["slots"]["time"], "")
|
| 115 |
+
self.assertEqual(sorted(draft["missing"]), ["date", "time", "title"])
|
| 116 |
+
|
| 117 |
+
# 2. Date-only reply, which the model used to answer CHAT for.
|
| 118 |
+
draft = _fill_slots_locally(draft, "Hari Minggu, 11 Oktober 2026")
|
| 119 |
+
self.assertEqual(draft["slots"]["date"], "2026-10-11")
|
| 120 |
+
self.assertEqual(draft["slots"]["time"], "")
|
| 121 |
+
self.assertNotIn("date", draft["missing"])
|
| 122 |
+
|
| 123 |
+
# 3. Time-only reply.
|
| 124 |
+
draft = _fill_slots_locally(draft, "Jam 2 sore ginchiee")
|
| 125 |
+
self.assertEqual(draft["slots"]["time"], "14:00")
|
| 126 |
+
|
| 127 |
+
# Only the title is still outstanding, so the bot asks for just that.
|
| 128 |
+
self.assertEqual(draft["missing"], ["title"])
|
| 129 |
+
|
| 130 |
+
def test_does_not_overwrite_existing_slots(self) -> None:
|
| 131 |
+
draft = {
|
| 132 |
+
"slots": {"title": "rapat", "date": "2026-10-12", "time": ""},
|
| 133 |
+
"missing": ["time"],
|
| 134 |
+
}
|
| 135 |
+
draft = _fill_slots_locally(draft, "besok jam 9 pagi")
|
| 136 |
+
# Date already set, so "besok" must not replace 2026-10-12.
|
| 137 |
+
self.assertEqual(draft["slots"]["date"], "2026-10-12")
|
| 138 |
+
self.assertEqual(draft["slots"]["time"], "09:00")
|
| 139 |
+
self.assertEqual(draft["missing"], [])
|
| 140 |
+
|
| 141 |
+
def test_never_uses_an_old_activity_title(self) -> None:
|
| 142 |
+
"""The extractor cannot invent a title, so old titles cannot leak."""
|
| 143 |
+
draft = {
|
| 144 |
+
"slots": {"title": "", "date": "", "time": ""},
|
| 145 |
+
"missing": ["title", "date", "time"],
|
| 146 |
+
}
|
| 147 |
+
draft = _fill_slots_locally(draft, "Hari Minggu, 11 Oktober 2026")
|
| 148 |
+
draft = _fill_slots_locally(draft, "Jam 2 sore ginchiee")
|
| 149 |
+
self.assertEqual(draft["slots"]["title"], "")
|
| 150 |
+
self.assertIn("title", draft["missing"])
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
if __name__ == "__main__":
|
| 154 |
+
unittest.main()
|