Catalna commited on
Commit
908edbb
·
1 Parent(s): 54aab32

fix: stop an open activity draft from swallowing unrelated messages

Browse files

Reported from a screenshot: the user had an activity draft open, then typed
"hitungin kalor sate 10 tusuk dan lontong", "ginchie" and "aku makan sate 10
tusuk dan lontong" -- and the bot answered all three with "masih kurang:
judulnya apa nih?, hari apa tanggal berapa?, jam berapa?".

Root cause: the draft was only dropped when the router returned a different
action AND `_looks_like_slot_answer()` was false. That helper counts any
message of eight words or fewer as a slot answer, so a seven-word food-log
request kept the draft alive forever and the food was never logged.

- Draft continuation now requires the router to still see the same action, or
the text to be a pure schedule answer (`_is_pure_schedule_answer`, which
needs an actual date/time token). "aku makan sate 10 tusuk dan lontong" and
"ginchie" no longer keep a draft alive.
- `_merge_draft` is only used for ADD_ACTIVITY drafts; other actions go
through `_merge_generic_draft`, which previously would have been rewritten
as an activity.
- Drafts expire after 15 minutes instead of lingering for the whole session.
- `_looks_like_slot_answer` removed (dead, and the source of the bug).
- The missing-slot question is phrased once; it used to appear both as the
question and inside the "masih kurang" list.
- "hitungin kalor X" now routes to LOG_FOOD: in a food-logging bot, asking for
calories means recording the food, not just quoting a number.

63 tests pass (up from 59), ruff clean, agenda flow verified end to end.

bot/handlers/message_handler.py CHANGED
@@ -95,6 +95,15 @@ _ACTIVITY_SLOT_ORDER = ("title", "date", "time")
95
  #: Kept for backwards compatibility with older call sites.
96
  _REQUIRED_ACTIVITY_SLOTS = _ACTIVITY_SLOT_ORDER
97
 
 
 
 
 
 
 
 
 
 
98
 
99
  # ── Formatting helpers ────────────────────────────────────────────────────────
100
 
@@ -142,6 +151,13 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
142
  logger.debug("Failed to send typing action: %s", exc)
143
 
144
  draft = context.user_data.get(KEY_DRAFT)
 
 
 
 
 
 
 
145
  pending_note = _draft_note(draft) if draft else ""
146
 
147
  intent = await route_message(
@@ -155,23 +171,40 @@ async def handle_message(update: Update, context: ContextTypes.DEFAULT_TYPE) ->
155
 
156
  # A pending activity draft absorbs follow-up slot-filling messages.
157
  if draft:
158
- merged = _merge_draft(draft, intent, text)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
159
 
160
- # Deterministic safety net: the model sometimes answers CHAT for a plain
161
- # "Hari Minggu, 11 Oktober 2026" while a draft is open, which used to
162
- # strand the user mid-flow. Extract dates/times locally so slot filling
163
- # never depends on the model remembering the draft.
164
- merged = _fill_slots_locally(merged, text)
 
165
 
166
- if _draft_complete(merged):
167
- context.user_data.pop(KEY_DRAFT, None)
168
- await _finish_activity_draft(update, context, user_id, merged, text)
169
- return
170
 
171
- # The user switched to an unrelated topic: drop the draft.
172
- if intent.action not in (ACTION_ADD_ACTIVITY,) and not _looks_like_slot_answer(text):
173
- context.user_data.pop(KEY_DRAFT, None)
174
- else:
175
  context.user_data[KEY_DRAFT] = merged
176
  await _ask_for_missing(update, merged)
177
  return
@@ -392,39 +425,6 @@ def _draft_note(draft: dict) -> str:
392
  )
393
 
394
 
395
- def _looks_like_slot_answer(text: str) -> bool:
396
- """Whether a message plausibly supplies a missing slot value.
397
-
398
- Used to decide whether to keep an open draft alive. Deliberately narrow:
399
- while a draft is open, a bare "besok" or "jam 3" is part of it, but a
400
- sentence about something else should be allowed to start a new topic.
401
- """
402
- stripped = (text or "").strip()
403
- if not stripped or stripped.startswith("/"):
404
- return False
405
-
406
- lowered = stripped.lower()
407
-
408
- # Signals that the user is changing the subject entirely.
409
- for cue in ("hadeh", "lupakan", "udahan", "stop", "batal", "nggajadi", "nggak jadi"):
410
- if cue in lowered:
411
- return False
412
-
413
- # Anything short is treated as an answer to the pending question.
414
- if len(stripped.split()) <= 8:
415
- return True
416
-
417
- # Longer messages still count when they carry a concrete date/time.
418
- return bool(
419
- _CLOCK_HOUR.search(lowered)
420
- or _BARE_TIME.search(lowered)
421
- or _DATE_DAY_MONTH.search(lowered)
422
- or _DATE_NUMERIC.search(lowered)
423
- or any(word in lowered for word in ("besok", "lusa", "hari ini", "minggu", "senin",
424
- "selasa", "rabu", "kamis", "jumat", "sabtu"))
425
- )
426
-
427
-
428
  #: Words that carry no meaning of their own when answering "jam berapa?".
429
  _SCHEDULE_FILLER = {
430
  "jam", "pukul", "hari", "tanggal", "tgl", "nih", "ya", "dong", "aja", "saja",
@@ -490,6 +490,27 @@ def _is_pure_schedule_answer(text: str) -> bool:
490
  return not _content_beyond_schedule(text)
491
 
492
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
493
  def _merge_draft(draft: dict, intent: Intent, text: str = "") -> dict:
494
  """Fold newly-detected slot values into a pending draft."""
495
  merged = {
@@ -693,16 +714,19 @@ async def _ask_for_missing(update: Update, draft: dict) -> None:
693
  known.append(f"🕒 {slots['time']}")
694
 
695
  # Ask for the highest-priority missing slot, and list the rest so the user
696
- # knows exactly what is still outstanding.
 
 
697
  question = SLOT_QUESTIONS.get(missing[0], missing[0])
698
  if len(missing) > 1:
 
699
  lines = [
700
  "oke, ini yang aku tangkep sejauh ini:",
701
  *known,
702
  "",
703
  f"{question}",
704
  "",
705
- f"_masih kurang: {', '.join(SLOT_QUESTIONS.get(m, m) for m in missing)}_",
706
  ]
707
  else:
708
  lines = [
@@ -1153,3 +1177,4 @@ def build_message_handlers():
1153
  ]
1154
 
1155
 
 
 
95
  #: Kept for backwards compatibility with older call sites.
96
  _REQUIRED_ACTIVITY_SLOTS = _ACTIVITY_SLOT_ORDER
97
 
98
+ #: Required slots per action, used when completing a draft.
99
+ _REQUIRED_SLOTS_FOR = {
100
+ ACTION_ADD_ACTIVITY: _ACTIVITY_SLOT_ORDER,
101
+ ACTION_CHANGE_NAME: ("name",),
102
+ }
103
+
104
+ #: A draft older than this is abandoned rather than kept alive forever.
105
+ DRAFT_TTL_SECONDS = 15 * 60
106
+
107
 
108
  # ── Formatting helpers ────────────────────────────────────────────────────────
109
 
 
151
  logger.debug("Failed to send typing action: %s", exc)
152
 
153
  draft = context.user_data.get(KEY_DRAFT)
154
+
155
+ # An abandoned draft must not outlive the conversation that created it.
156
+ if draft and (time.time() - draft.get("started_at", 0)) > DRAFT_TTL_SECONDS:
157
+ logger.info("Dropping expired %s draft", draft.get("action"))
158
+ context.user_data.pop(KEY_DRAFT, None)
159
+ draft = None
160
+
161
  pending_note = _draft_note(draft) if draft else ""
162
 
163
  intent = await route_message(
 
171
 
172
  # A pending activity draft absorbs follow-up slot-filling messages.
173
  if draft:
174
+ # Only keep the draft when the new message is genuinely part of it:
175
+ # either the router still sees the same action, or the text is a pure
176
+ # schedule answer ("Hari Minggu, 11 Oktober 2026", "Jam 9 pagi").
177
+ #
178
+ # `_looks_like_slot_answer` is NOT used here: it counts any message of
179
+ # eight words or fewer as an answer, so "aku makan sate 10 tusuk dan
180
+ # lontong" kept an activity draft alive and the bot asked for a title
181
+ # instead of logging the food.
182
+ same_action = intent.action == draft.get("action")
183
+ if not same_action and not _is_pure_schedule_answer(text):
184
+ logger.info(
185
+ "Dropping stale %s draft; user moved on to %s",
186
+ draft.get("action"), intent.action,
187
+ )
188
+ context.user_data.pop(KEY_DRAFT, None)
189
+ else:
190
+ merged = (
191
+ _merge_draft(draft, intent, text)
192
+ if draft.get("action") == ACTION_ADD_ACTIVITY
193
+ else _merge_generic_draft(draft, intent)
194
+ )
195
 
196
+ # Deterministic safety net: the model sometimes answers CHAT for a
197
+ # plain "Hari Minggu, 11 Oktober 2026" while a draft is open, which
198
+ # used to strand the user mid-flow. Extract dates/times locally so
199
+ # slot filling never depends on the model remembering the draft.
200
+ if draft.get("action") == ACTION_ADD_ACTIVITY:
201
+ merged = _fill_slots_locally(merged, text)
202
 
203
+ if _draft_complete(merged):
204
+ context.user_data.pop(KEY_DRAFT, None)
205
+ await _finish_activity_draft(update, context, user_id, merged, text)
206
+ return
207
 
 
 
 
 
208
  context.user_data[KEY_DRAFT] = merged
209
  await _ask_for_missing(update, merged)
210
  return
 
425
  )
426
 
427
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
428
  #: Words that carry no meaning of their own when answering "jam berapa?".
429
  _SCHEDULE_FILLER = {
430
  "jam", "pukul", "hari", "tanggal", "tgl", "nih", "ya", "dong", "aja", "saja",
 
490
  return not _content_beyond_schedule(text)
491
 
492
 
493
+ def _merge_generic_draft(draft: dict, intent: Intent) -> dict:
494
+ """Fold a single-slot intent (e.g. CHANGE_NAME) into its draft."""
495
+ merged = {
496
+ "action": draft.get("action"),
497
+ "slots": dict(draft.get("slots") or {}),
498
+ "missing": list(draft.get("missing") or []),
499
+ "started_at": draft.get("started_at", time.time()),
500
+ }
501
+
502
+ incoming = intent.slots or {}
503
+ for key, value in incoming.items():
504
+ if value in (None, "", 0):
505
+ continue
506
+ if not merged["slots"].get(key):
507
+ merged["slots"][key] = value
508
+
509
+ required = _REQUIRED_SLOTS_FOR.get(merged["action"], ())
510
+ merged["missing"] = [slot for slot in required if not merged["slots"].get(slot)]
511
+ return merged
512
+
513
+
514
  def _merge_draft(draft: dict, intent: Intent, text: str = "") -> dict:
515
  """Fold newly-detected slot values into a pending draft."""
516
  merged = {
 
714
  known.append(f"🕒 {slots['time']}")
715
 
716
  # Ask for the highest-priority missing slot, and list the rest so the user
717
+ # knows exactly what is still outstanding. The question is phrased once;
718
+ # repeating it inside the "masih kurang" list made the reply read as if the
719
+ # bot were asking the same thing twice.
720
  question = SLOT_QUESTIONS.get(missing[0], missing[0])
721
  if len(missing) > 1:
722
+ others = ", ".join(SLOT_QUESTIONS.get(m, m) for m in missing[1:])
723
  lines = [
724
  "oke, ini yang aku tangkep sejauh ini:",
725
  *known,
726
  "",
727
  f"{question}",
728
  "",
729
+ f"_masih kurang: {others}_",
730
  ]
731
  else:
732
  lines = [
 
1177
  ]
1178
 
1179
 
1180
+
services/intent_service.py CHANGED
@@ -196,7 +196,10 @@ Pilih TEPAT SATU action:
196
  Isi: {{"foods": [{{"name": "nasi goreng", "amount_g": 200, "calories": 320, "protein_g": 8, "carbs_g": 45, "fat_g": 10}}]}}
197
  Wajib isi calories. Jika user tidak sebut gram, perkirakan porsi wajar.
198
  Contoh: "tadi aku makan nasi goreng", "aku minum susu 1 gelas", "habis makan ayam geprek",
199
- "itu ga termasuk, kurang 1 makanan lagi", "oh iya tadi aku juga ngemil biskuit".
 
 
 
200
  CATATAN: ini adalah catatan BARU. Jangan berubah jadi CHAT hanya karena namanya
201
  sama dengan makanan yang sudah ada di arsip.
202
  JANGAN pilih ini untuk niat/keinginan (mis. "pengen nasi goreng", "mau makan sushi nanti") -
 
196
  Isi: {{"foods": [{{"name": "nasi goreng", "amount_g": 200, "calories": 320, "protein_g": 8, "carbs_g": 45, "fat_g": 10}}]}}
197
  Wajib isi calories. Jika user tidak sebut gram, perkirakan porsi wajar.
198
  Contoh: "tadi aku makan nasi goreng", "aku minum susu 1 gelas", "habis makan ayam geprek",
199
+ "itu ga termasuk, kurang 1 makanan lagi", "oh iya tadi aku juga ngemil biskuit",
200
+ "hitungin kalor sate 10 tusuk dan lontong", "aku makan sate 10 tusuk".
201
+ "hitungin kalor X" dianggap mencatat makanan: di bot ini menghitung kalori
202
+ berarti mencatat dan menghitung, bukan sekadar tanya angka.
203
  CATATAN: ini adalah catatan BARU. Jangan berubah jadi CHAT hanya karena namanya
204
  sama dengan makanan yang sudah ada di arsip.
205
  JANGAN pilih ini untuk niat/keinginan (mis. "pengen nasi goreng", "mau makan sushi nanti") -
tests/test_slot_extraction.py CHANGED
@@ -27,6 +27,7 @@ from bot.handlers.message_handler import ( # noqa: E402
27
  _fill_slots_locally,
28
  _is_pure_schedule_answer,
29
  _merge_draft,
 
30
  )
31
  from services.intent_service import ( # noqa: E402
32
  ACTION_ADD_ACTIVITY,
@@ -234,5 +235,60 @@ class TitleLeakGuardTests(unittest.TestCase):
234
  self.assertEqual(merged["missing"], [])
235
 
236
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
237
  if __name__ == "__main__":
238
  unittest.main()
 
27
  _fill_slots_locally,
28
  _is_pure_schedule_answer,
29
  _merge_draft,
30
+ _merge_generic_draft,
31
  )
32
  from services.intent_service import ( # noqa: E402
33
  ACTION_ADD_ACTIVITY,
 
235
  self.assertEqual(merged["missing"], [])
236
 
237
 
238
+ class DraftDoesNotTrapOtherTopicsTests(unittest.TestCase):
239
+ """A food-log request must never be swallowed by an open activity draft.
240
+
241
+ Regression for a reported bug: the user had an activity draft open, then
242
+ typed "aku makan sate 10 tusuk dan lontong" and "ginchie", and the bot kept
243
+ asking for the activity's title/date/time instead of logging the food.
244
+ """
245
+
246
+ def test_food_log_is_not_a_schedule_answer(self) -> None:
247
+ for text in [
248
+ "aku makan sate 10 tusuk dan lontong",
249
+ "hitungin kalor sate 10 tusuk dan lontong",
250
+ "ginchie",
251
+ "makasih ya ginchiee",
252
+ "agenda aku apa aja",
253
+ ]:
254
+ with self.subTest(text=text):
255
+ self.assertFalse(_is_pure_schedule_answer(text), text)
256
+
257
+ def test_schedule_answers_still_count(self) -> None:
258
+ for text in [
259
+ "Hari Minggu, 11 Oktober 2026",
260
+ "Jam 9 pagi ginchiee",
261
+ "besok",
262
+ "11/10",
263
+ ]:
264
+ with self.subTest(text=text):
265
+ self.assertTrue(_is_pure_schedule_answer(text), text)
266
+
267
+ def test_merge_generic_draft_fills_a_single_slot(self) -> None:
268
+ from services.intent_service import ACTION_CHANGE_NAME
269
+
270
+ draft = {
271
+ "action": ACTION_CHANGE_NAME,
272
+ "slots": {"name": ""},
273
+ "missing": ["name"],
274
+ }
275
+ intent = Intent(action=ACTION_CHANGE_NAME, slots={"name": "Bella"}, missing=[])
276
+ merged = _merge_generic_draft(draft, intent)
277
+ self.assertEqual(merged["slots"]["name"], "Bella")
278
+ self.assertEqual(merged["missing"], [])
279
+
280
+ def test_merge_generic_draft_does_not_overwrite(self) -> None:
281
+ from services.intent_service import ACTION_CHANGE_NAME
282
+
283
+ draft = {
284
+ "action": ACTION_CHANGE_NAME,
285
+ "slots": {"name": "Ayyi"},
286
+ "missing": [],
287
+ }
288
+ intent = Intent(action=ACTION_CHANGE_NAME, slots={"name": "Bella"}, missing=[])
289
+ merged = _merge_generic_draft(draft, intent)
290
+ self.assertEqual(merged["slots"]["name"], "Ayyi")
291
+
292
+
293
  if __name__ == "__main__":
294
  unittest.main()