Spaces:
Running on Zero
fix: stop an open activity draft from swallowing unrelated messages
Browse filesReported 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 +74 -49
- services/intent_service.py +4 -1
- tests/test_slot_extraction.py +56 -0
|
@@ -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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 159 |
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
|
|
|
| 165 |
|
| 166 |
-
|
| 167 |
-
|
| 168 |
-
|
| 169 |
-
|
| 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: {
|
| 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 |
+
|
|
@@ -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") -
|
|
@@ -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()
|