Skip to content

Commit ed12257

Browse files
authored
Fix date parsing case (#2425)
* Fix current date parsing bug * Add type hints * Fix AM/PM case
1 parent 78bc3f0 commit ed12257

File tree

3 files changed

+227
-19
lines changed

3 files changed

+227
-19
lines changed

chatterbot/parsing.py

Lines changed: 25 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -503,7 +503,7 @@
503503
]
504504

505505

506-
def convert_string_to_number(value):
506+
def convert_string_to_number(value: str) -> int:
507507
"""
508508
Convert strings to numbers
509509
"""
@@ -517,7 +517,7 @@ def convert_string_to_number(value):
517517
return sum(num_list)
518518

519519

520-
def convert_time_to_hour_minute(hour, minute, convention):
520+
def convert_time_to_hour_minute(hour: str, minute: str, convention: str) -> dict:
521521
"""
522522
Convert time to hour, minute
523523
"""
@@ -532,12 +532,19 @@ def convert_time_to_hour_minute(hour, minute, convention):
532532
minute = int(minute)
533533

534534
if convention.lower() == 'pm':
535-
hour += 12
535+
# Handle 12 PM (noon) - it stays as 12
536+
# Handle 1-11 PM - add 12
537+
if hour != 12:
538+
hour += 12
539+
else:
540+
# Handle 12 AM (midnight) - convert to 0
541+
if hour == 12:
542+
hour = 0
536543

537544
return {'hours': hour, 'minutes': minute}
538545

539546

540-
def date_from_quarter(base_date, ordinal, year):
547+
def date_from_quarter(base_date: datetime, ordinal: int, year: int) -> list[datetime]:
541548
"""
542549
Extract date from quarter of a year
543550
"""
@@ -554,7 +561,7 @@ def date_from_quarter(base_date, ordinal, year):
554561
]
555562

556563

557-
def date_from_relative_day(base_date, time, dow):
564+
def date_from_relative_day(base_date: datetime, time: str, dow: str) -> datetime:
558565
"""
559566
Converts relative day to time
560567
Ex: this tuesday, last tuesday
@@ -577,7 +584,7 @@ def date_from_relative_day(base_date, time, dow):
577584
return next_week_day(base_date, num)
578585

579586

580-
def date_from_relative_week_year(base_date, time, dow, ordinal=1):
587+
def date_from_relative_week_year(base_date: datetime, time: str, dow: str, ordinal: int = 1) -> datetime:
581588
"""
582589
Converts relative day to time
583590
Eg. this tuesday, last tuesday
@@ -608,7 +615,10 @@ def date_from_relative_week_year(base_date, time, dow, ordinal=1):
608615
day = min(relative_date.day, calendar.monthrange(year, month)[1])
609616
return datetime(year, month, day)
610617
else:
611-
return datetime(relative_date.year, relative_date.month + ord, relative_date.day)
618+
# Base the day to valid range on the target month
619+
target_month = relative_date.month + ord
620+
day = min(relative_date.day, calendar.monthrange(relative_date.year, target_month)[1])
621+
return datetime(relative_date.year, target_month, day)
612622
elif time == 'end of the':
613623
return datetime(
614624
relative_date.year,
@@ -636,7 +646,7 @@ def date_from_relative_week_year(base_date, time, dow, ordinal=1):
636646
return datetime(relative_date.year, relative_date.month, relative_date.day, 23, 59, 59)
637647

638648

639-
def date_from_adverb(base_date, name):
649+
def date_from_adverb(base_date: datetime, name: str) -> datetime:
640650
"""
641651
Convert Day adverbs to dates
642652
Tomorrow => Date
@@ -645,14 +655,14 @@ def date_from_adverb(base_date, name):
645655
# Reset date to start of the day
646656
adverb_date = datetime(base_date.year, base_date.month, base_date.day)
647657
if name == 'today' or name == 'tonite' or name == 'tonight':
648-
return adverb_date.today().replace(hour=0, minute=0, second=0, microsecond=0)
658+
return adverb_date
649659
elif name == 'yesterday':
650660
return adverb_date - timedelta(days=1)
651661
elif name == 'tomorrow' or name == 'tom':
652662
return adverb_date + timedelta(days=1)
653663

654664

655-
def date_from_duration(base_date, number_as_string, unit, duration, base_time=None):
665+
def date_from_duration(base_date: datetime, number_as_string: str, unit: str, duration: str, base_time: str = None) -> datetime:
656666
"""
657667
Find dates from duration
658668
Eg: 20 days from now
@@ -682,7 +692,7 @@ def date_from_duration(base_date, number_as_string, unit, duration, base_time=No
682692
return base_date + timedelta(**args)
683693

684694

685-
def this_week_day(base_date, weekday):
695+
def this_week_day(base_date: datetime, weekday: int) -> datetime:
686696
"""
687697
Finds coming weekday
688698
"""
@@ -698,7 +708,7 @@ def this_week_day(base_date, weekday):
698708
return day
699709

700710

701-
def previous_week_day(base_date, weekday):
711+
def previous_week_day(base_date: datetime, weekday: int) -> datetime:
702712
"""
703713
Finds previous weekday
704714
"""
@@ -708,9 +718,9 @@ def previous_week_day(base_date, weekday):
708718
return day
709719

710720

711-
def next_week_day(base_date, weekday):
721+
def next_week_day(base_date: datetime, weekday: int) -> datetime:
712722
"""
713-
Finds next weekday
723+
Finds the next weekday.
714724
"""
715725
day_of_week = base_date.weekday()
716726
end_of_this_week = base_date + timedelta(days=6 - day_of_week)
@@ -720,7 +730,7 @@ def next_week_day(base_date, weekday):
720730
return day
721731

722732

723-
def datetime_parsing(text, base_date=datetime.now()):
733+
def datetime_parsing(text: str, base_date: datetime = datetime.now()) -> list[tuple[str, datetime, tuple[int, int]]]:
724734
"""
725735
Extract datetime objects from a string of text.
726736
"""

tests/test_chatbot.py

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@ def test_initialization_with_unmapped_spacy_model(self):
1616
does not exist for a language.
1717
"""
1818
with self.assertRaises(KeyError) as exc:
19-
chatbot = ChatBot(
19+
ChatBot(
2020
'Test Bot',
2121
tagger_language=languages.LAT
2222
)
@@ -32,7 +32,7 @@ def test_initialization_with_missing_spacy_model(self):
3232
has not been installed for the specified language.
3333
"""
3434
with self.assertRaises(ChatBot.ChatBotException) as exc:
35-
chatbot = ChatBot(
35+
ChatBot(
3636
'Test Bot',
3737
tagger_language=languages.NOR
3838
)
@@ -208,8 +208,9 @@ def test_get_response_additional_response_selection_parameters(self):
208208
response = self.chatbot.get_response(
209209
statement,
210210
additional_response_selection_parameters={
211-
'conversation': 'test_2'
212-
})
211+
'conversation': 'test_2'
212+
}
213+
)
213214

214215
self.assertEqual(response.text, 'C')
215216
self.assertEqual(response.conversation, 'test_3')

tests/test_parsing.py

Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -387,3 +387,200 @@ def test_this_week_day_after_day(self):
387387
result = parsing.this_week_day(base_date, weekday)
388388

389389
self.assertEqual(result, datetime(2016, 12, 14, 10, 10, 52, 85280))
390+
391+
def test_today_at_time_uses_base_date(self):
392+
"""
393+
The string 'today at [time]' should respect the base_date parameter.
394+
"""
395+
base_date = datetime(2018, 7, 14, 8, 52, 21) # July 14, 2018 at 8:52:21 AM
396+
input_text = 'Your dental appointment is scheduled today at 9:00pm.'
397+
parser = parsing.datetime_parsing(input_text, base_date)
398+
399+
self.assertEqual(len(parser), 1)
400+
self.assertIn('today at 9:00pm', parser[0])
401+
402+
parsed_datetime = parser[0][1]
403+
# Should be July 14, 2018 at 9:00 PM
404+
self.assertEqual(parsed_datetime.year, 2018)
405+
self.assertEqual(parsed_datetime.month, 7)
406+
self.assertEqual(parsed_datetime.day, 14)
407+
self.assertEqual(parsed_datetime.hour, 21) # 9 PM
408+
self.assertEqual(parsed_datetime.minute, 0)
409+
410+
def test_next_month_from_january_31st(self):
411+
"""
412+
Test 'next month' from Jan 31 handles February correctly
413+
"""
414+
base_date = datetime(2025, 1, 31, 12, 0, 0)
415+
input_text = 'next month'
416+
parser = parsing.datetime_parsing(input_text, base_date)
417+
418+
self.assertEqual(len(parser), 1)
419+
# February only has 28 days in 2025, should clamp to last valid day
420+
self.assertEqual(parser[0][1].year, 2025)
421+
self.assertEqual(parser[0][1].month, 2)
422+
self.assertEqual(parser[0][1].day, 28)
423+
424+
def test_next_3_months_crosses_year_boundary(self):
425+
"""
426+
Test 'next 3 months' crossing year boundary
427+
"""
428+
base_date = datetime(2025, 11, 15, 12, 0, 0)
429+
input_text = 'next 3 months'
430+
parser = parsing.datetime_parsing(input_text, base_date)
431+
432+
self.assertEqual(len(parser), 1)
433+
self.assertEqual(parser[0][1].year, 2026)
434+
self.assertEqual(parser[0][1].month, 2)
435+
self.assertEqual(parser[0][1].day, 15)
436+
437+
def test_next_month_from_march_31st(self):
438+
"""
439+
Test 'next month' from March 31 handles April correctly
440+
"""
441+
base_date = datetime(2025, 3, 31, 12, 0, 0)
442+
input_text = 'next month'
443+
parser = parsing.datetime_parsing(input_text, base_date)
444+
445+
self.assertEqual(len(parser), 1)
446+
# April only has 30 days, should pick the last valid day
447+
self.assertEqual(parser[0][1].year, 2025)
448+
self.assertEqual(parser[0][1].month, 4)
449+
self.assertEqual(parser[0][1].day, 30)
450+
451+
def test_next_month_from_may_31st(self):
452+
"""
453+
Test 'next month' from May 31 handles June correctly
454+
"""
455+
base_date = datetime(2025, 5, 31, 12, 0, 0)
456+
input_text = 'next month'
457+
parser = parsing.datetime_parsing(input_text, base_date)
458+
459+
self.assertEqual(len(parser), 1)
460+
# June only has 30 days, should pick the last valid day
461+
self.assertEqual(parser[0][1].year, 2025)
462+
self.assertEqual(parser[0][1].month, 6)
463+
self.assertEqual(parser[0][1].day, 30)
464+
465+
def test_multiple_datetime_expressions(self):
466+
"""
467+
Test parsing text with multiple date/time references
468+
"""
469+
base_date = datetime(2025, 10, 18, 12, 0, 0)
470+
input_text = 'Meeting today at 2pm and tomorrow at 3pm'
471+
parser = parsing.datetime_parsing(input_text, base_date)
472+
473+
self.assertEqual(len(parser), 2)
474+
# First: today at 2pm
475+
self.assertEqual(parser[0][1].year, 2025)
476+
self.assertEqual(parser[0][1].month, 10)
477+
self.assertEqual(parser[0][1].day, 18)
478+
self.assertEqual(parser[0][1].hour, 14)
479+
# Second: tomorrow at 3pm
480+
self.assertEqual(parser[1][1].year, 2025)
481+
self.assertEqual(parser[1][1].month, 10)
482+
self.assertEqual(parser[1][1].day, 19)
483+
self.assertEqual(parser[1][1].hour, 15)
484+
485+
def test_duration_from_yesterday(self):
486+
"""
487+
Test '2 days after yesterday' using base_time
488+
"""
489+
base_date = datetime(2025, 10, 18, 12, 0, 0)
490+
input_text = '2 days after yesterday'
491+
parser = parsing.datetime_parsing(input_text, base_date)
492+
493+
self.assertEqual(len(parser), 1)
494+
# Yesterday = Oct 17, + 2 days = Oct 19
495+
self.assertEqual(parser[0][1].year, 2025)
496+
self.assertEqual(parser[0][1].month, 10)
497+
self.assertEqual(parser[0][1].day, 19)
498+
499+
def test_duration_from_tomorrow(self):
500+
"""
501+
Test '3 days after tomorrow'
502+
"""
503+
base_date = datetime(2025, 10, 18, 12, 0, 0)
504+
input_text = '3 days after tomorrow'
505+
parser = parsing.datetime_parsing(input_text, base_date)
506+
507+
self.assertEqual(len(parser), 1)
508+
# Tomorrow = Oct 19, + 3 days = Oct 22
509+
self.assertEqual(parser[0][1].year, 2025)
510+
self.assertEqual(parser[0][1].month, 10)
511+
self.assertEqual(parser[0][1].day, 22)
512+
513+
def test_duration_from_today(self):
514+
"""
515+
Test '5 days before today'
516+
"""
517+
base_date = datetime(2025, 10, 18, 12, 0, 0)
518+
input_text = '5 days before today'
519+
parser = parsing.datetime_parsing(input_text, base_date)
520+
521+
self.assertEqual(len(parser), 1)
522+
# Today = Oct 18, - 5 days = Oct 13
523+
self.assertEqual(parser[0][1].year, 2025)
524+
self.assertEqual(parser[0][1].month, 10)
525+
self.assertEqual(parser[0][1].day, 13)
526+
527+
def test_noon_without_convention(self):
528+
"""
529+
Test '12:00' without AM/PM defaults to AM convention (midnight = 0)
530+
"""
531+
base_date = datetime(2025, 10, 18, 0, 0, 0)
532+
input_text = 'Meeting at 12:00'
533+
parser = parsing.datetime_parsing(input_text, base_date)
534+
535+
self.assertEqual(len(parser), 1)
536+
# No convention defaults to 'am', so 12:00 becomes 0 (midnight)
537+
self.assertEqual(parser[0][1].hour, 0)
538+
self.assertEqual(parser[0][1].minute, 0)
539+
540+
def test_twelve_pm(self):
541+
"""
542+
Test '12:00 pm' is noon (stays as 12)
543+
"""
544+
base_date = datetime(2025, 10, 18, 0, 0, 0)
545+
input_text = 'Meeting at 12:00 pm'
546+
parser = parsing.datetime_parsing(input_text, base_date)
547+
548+
self.assertEqual(len(parser), 1)
549+
self.assertEqual(parser[0][1].hour, 12)
550+
self.assertEqual(parser[0][1].minute, 0)
551+
552+
def test_twelve_am(self):
553+
"""
554+
Test '12:00 am' is midnight (converted to 0)
555+
"""
556+
base_date = datetime(2025, 10, 18, 0, 0, 0)
557+
input_text = 'Meeting at 12:00 am'
558+
parser = parsing.datetime_parsing(input_text, base_date)
559+
560+
self.assertEqual(len(parser), 1)
561+
self.assertEqual(parser[0][1].hour, 0)
562+
self.assertEqual(parser[0][1].minute, 0)
563+
564+
def test_one_am(self):
565+
"""
566+
Test '1:00 am' is 1:00
567+
"""
568+
base_date = datetime(2025, 10, 18, 0, 0, 0)
569+
input_text = 'Meeting at 1:00 am'
570+
parser = parsing.datetime_parsing(input_text, base_date)
571+
572+
self.assertEqual(len(parser), 1)
573+
self.assertEqual(parser[0][1].hour, 1)
574+
self.assertEqual(parser[0][1].minute, 0)
575+
576+
def test_one_pm(self):
577+
"""
578+
Test '1:00 pm' is 13:00
579+
"""
580+
base_date = datetime(2025, 10, 18, 0, 0, 0)
581+
input_text = 'Meeting at 1:00 pm'
582+
parser = parsing.datetime_parsing(input_text, base_date)
583+
584+
self.assertEqual(len(parser), 1)
585+
self.assertEqual(parser[0][1].hour, 13)
586+
self.assertEqual(parser[0][1].minute, 0)

0 commit comments

Comments
 (0)