Catalna commited on
Commit
3f49a5f
·
1 Parent(s): 7d3a67d

fix: make creating and viewing activities work like a conversation

Browse files

Reported: 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 CHANGED
@@ -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
- if intent.action == ACTION_ADD_ACTIVITY or _looks_like_slot_answer(text):
151
- merged = _merge_draft(draft, intent)
152
- if _draft_complete(merged):
153
- context.user_data.pop(KEY_DRAFT, None)
154
- await _stage_confirmation(
155
- update, context, user_id,
156
- Intent(action=ACTION_ADD_ACTIVITY, slots=merged["slots"], missing=[]),
157
- text,
158
- )
159
- else:
160
- context.user_data[KEY_DRAFT] = merged
161
- await _ask_for_missing(update, merged)
162
  return
163
 
164
- # Different topic: drop the stale draft and continue with the new intent.
165
- context.user_data.pop(KEY_DRAFT, None)
 
 
 
 
 
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
- # Everything below changes data and must be confirmed first.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- marker = "✅" if act.get("done") else "📌"
271
- lines.append(
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
- marker = "✅" if act.get("done") else "📌"
280
- lines.append(
281
- f"{marker} *{_md(act.get('title'))}*\n 🕒 "
282
- f"{format_activity_datetime(act.get('activity_dt'))}"
283
- )
284
 
285
- await update.message.reply_text("\n".join(lines), parse_mode="Markdown")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- stripped = text.strip()
341
- if not stripped:
 
 
 
 
 
342
  return False
343
- if stripped.startswith("/"):
344
- return False
345
- words = stripped.split()
346
- if len(words) <= 6:
 
 
 
 
 
 
347
  return True
348
- # A longer sentence can still be "besok jam 3 sore ya meetingnya di kafe".
349
- return any(token.isdigit() for token in stripped) if len(words) <= 14 else False
 
 
 
 
 
 
 
 
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
- lines = ["boleh, aku catatin~"]
385
  if slots.get("title"):
386
- lines.append(f"\n📌 *{_md(slots['title'])}*")
 
 
 
 
387
 
388
  question = SLOT_QUESTIONS.get(missing[0], missing[0])
389
- lines.append(f"\n{question}")
 
 
 
 
390
 
391
  await update.message.reply_text("\n".join(lines), parse_mode="Markdown")
392
- await _remember(update.effective_user.id, update.message.text or "", "\n".join(lines))
 
 
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 _apply_confirmed_action(user_id: int, intent: Intent):
619
- """Run the staged action. Returns (message, keyboard_or_None)."""
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- keyboard = InlineKeyboardMarkup([
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
+
main.py CHANGED
@@ -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))
services/intent_service.py CHANGED
@@ -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
- Isi: {{"title": "...", "date": "YYYY-MM-DD", "time": "HH:MM", "description": "...", "remind_mins": 30}}
 
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
- Contoh: "ingetin aku besok jam 10 meeting", "tambahin jadwal kontrol dokter lusa".
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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).
tests/test_slot_extraction.py ADDED
@@ -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()