Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 25 additions & 15 deletions chatterbot/parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -503,7 +503,7 @@
]


def convert_string_to_number(value):
def convert_string_to_number(value: str) -> int:
"""
Convert strings to numbers
"""
Expand All @@ -517,7 +517,7 @@ def convert_string_to_number(value):
return sum(num_list)


def convert_time_to_hour_minute(hour, minute, convention):
def convert_time_to_hour_minute(hour: str, minute: str, convention: str) -> dict:
"""
Convert time to hour, minute
"""
Expand All @@ -532,12 +532,19 @@ def convert_time_to_hour_minute(hour, minute, convention):
minute = int(minute)

if convention.lower() == 'pm':
hour += 12
# Handle 12 PM (noon) - it stays as 12
# Handle 1-11 PM - add 12
if hour != 12:
hour += 12
else:
# Handle 12 AM (midnight) - convert to 0
if hour == 12:
hour = 0

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


def date_from_quarter(base_date, ordinal, year):
def date_from_quarter(base_date: datetime, ordinal: int, year: int) -> list[datetime]:
"""
Extract date from quarter of a year
"""
Expand All @@ -554,7 +561,7 @@ def date_from_quarter(base_date, ordinal, year):
]


def date_from_relative_day(base_date, time, dow):
def date_from_relative_day(base_date: datetime, time: str, dow: str) -> datetime:
"""
Converts relative day to time
Ex: this tuesday, last tuesday
Expand All @@ -577,7 +584,7 @@ def date_from_relative_day(base_date, time, dow):
return next_week_day(base_date, num)


def date_from_relative_week_year(base_date, time, dow, ordinal=1):
def date_from_relative_week_year(base_date: datetime, time: str, dow: str, ordinal: int = 1) -> datetime:
"""
Converts relative day to time
Eg. this tuesday, last tuesday
Expand Down Expand Up @@ -608,7 +615,10 @@ def date_from_relative_week_year(base_date, time, dow, ordinal=1):
day = min(relative_date.day, calendar.monthrange(year, month)[1])
return datetime(year, month, day)
else:
return datetime(relative_date.year, relative_date.month + ord, relative_date.day)
# Base the day to valid range on the target month
target_month = relative_date.month + ord
day = min(relative_date.day, calendar.monthrange(relative_date.year, target_month)[1])
return datetime(relative_date.year, target_month, day)
elif time == 'end of the':
return datetime(
relative_date.year,
Expand Down Expand Up @@ -636,7 +646,7 @@ def date_from_relative_week_year(base_date, time, dow, ordinal=1):
return datetime(relative_date.year, relative_date.month, relative_date.day, 23, 59, 59)


def date_from_adverb(base_date, name):
def date_from_adverb(base_date: datetime, name: str) -> datetime:
"""
Convert Day adverbs to dates
Tomorrow => Date
Expand All @@ -645,14 +655,14 @@ def date_from_adverb(base_date, name):
# Reset date to start of the day
adverb_date = datetime(base_date.year, base_date.month, base_date.day)
if name == 'today' or name == 'tonite' or name == 'tonight':
return adverb_date.today().replace(hour=0, minute=0, second=0, microsecond=0)
return adverb_date
elif name == 'yesterday':
return adverb_date - timedelta(days=1)
elif name == 'tomorrow' or name == 'tom':
return adverb_date + timedelta(days=1)


def date_from_duration(base_date, number_as_string, unit, duration, base_time=None):
def date_from_duration(base_date: datetime, number_as_string: str, unit: str, duration: str, base_time: str = None) -> datetime:
"""
Find dates from duration
Eg: 20 days from now
Expand Down Expand Up @@ -682,7 +692,7 @@ def date_from_duration(base_date, number_as_string, unit, duration, base_time=No
return base_date + timedelta(**args)


def this_week_day(base_date, weekday):
def this_week_day(base_date: datetime, weekday: int) -> datetime:
"""
Finds coming weekday
"""
Expand All @@ -698,7 +708,7 @@ def this_week_day(base_date, weekday):
return day


def previous_week_day(base_date, weekday):
def previous_week_day(base_date: datetime, weekday: int) -> datetime:
"""
Finds previous weekday
"""
Expand All @@ -708,9 +718,9 @@ def previous_week_day(base_date, weekday):
return day


def next_week_day(base_date, weekday):
def next_week_day(base_date: datetime, weekday: int) -> datetime:
"""
Finds next weekday
Finds the next weekday.
"""
day_of_week = base_date.weekday()
end_of_this_week = base_date + timedelta(days=6 - day_of_week)
Expand All @@ -720,7 +730,7 @@ def next_week_day(base_date, weekday):
return day


def datetime_parsing(text, base_date=datetime.now()):
def datetime_parsing(text: str, base_date: datetime = datetime.now()) -> list[tuple[str, datetime, tuple[int, int]]]:
"""
Extract datetime objects from a string of text.
"""
Expand Down
9 changes: 5 additions & 4 deletions tests/test_chatbot.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@ def test_initialization_with_unmapped_spacy_model(self):
does not exist for a language.
"""
with self.assertRaises(KeyError) as exc:
chatbot = ChatBot(
ChatBot(
'Test Bot',
tagger_language=languages.LAT
)
Expand All @@ -32,7 +32,7 @@ def test_initialization_with_missing_spacy_model(self):
has not been installed for the specified language.
"""
with self.assertRaises(ChatBot.ChatBotException) as exc:
chatbot = ChatBot(
ChatBot(
'Test Bot',
tagger_language=languages.NOR
)
Expand Down Expand Up @@ -208,8 +208,9 @@ def test_get_response_additional_response_selection_parameters(self):
response = self.chatbot.get_response(
statement,
additional_response_selection_parameters={
'conversation': 'test_2'
})
'conversation': 'test_2'
}
)

self.assertEqual(response.text, 'C')
self.assertEqual(response.conversation, 'test_3')
Expand Down
197 changes: 197 additions & 0 deletions tests/test_parsing.py
Original file line number Diff line number Diff line change
Expand Up @@ -387,3 +387,200 @@ def test_this_week_day_after_day(self):
result = parsing.this_week_day(base_date, weekday)

self.assertEqual(result, datetime(2016, 12, 14, 10, 10, 52, 85280))

def test_today_at_time_uses_base_date(self):
"""
The string 'today at [time]' should respect the base_date parameter.
"""
base_date = datetime(2018, 7, 14, 8, 52, 21) # July 14, 2018 at 8:52:21 AM
input_text = 'Your dental appointment is scheduled today at 9:00pm.'
parser = parsing.datetime_parsing(input_text, base_date)

self.assertEqual(len(parser), 1)
self.assertIn('today at 9:00pm', parser[0])

parsed_datetime = parser[0][1]
# Should be July 14, 2018 at 9:00 PM
self.assertEqual(parsed_datetime.year, 2018)
self.assertEqual(parsed_datetime.month, 7)
self.assertEqual(parsed_datetime.day, 14)
self.assertEqual(parsed_datetime.hour, 21) # 9 PM
self.assertEqual(parsed_datetime.minute, 0)

def test_next_month_from_january_31st(self):
"""
Test 'next month' from Jan 31 handles February correctly
"""
base_date = datetime(2025, 1, 31, 12, 0, 0)
input_text = 'next month'
parser = parsing.datetime_parsing(input_text, base_date)

self.assertEqual(len(parser), 1)
# February only has 28 days in 2025, should clamp to last valid day
self.assertEqual(parser[0][1].year, 2025)
self.assertEqual(parser[0][1].month, 2)
self.assertEqual(parser[0][1].day, 28)

def test_next_3_months_crosses_year_boundary(self):
"""
Test 'next 3 months' crossing year boundary
"""
base_date = datetime(2025, 11, 15, 12, 0, 0)
input_text = 'next 3 months'
parser = parsing.datetime_parsing(input_text, base_date)

self.assertEqual(len(parser), 1)
self.assertEqual(parser[0][1].year, 2026)
self.assertEqual(parser[0][1].month, 2)
self.assertEqual(parser[0][1].day, 15)

def test_next_month_from_march_31st(self):
"""
Test 'next month' from March 31 handles April correctly
"""
base_date = datetime(2025, 3, 31, 12, 0, 0)
input_text = 'next month'
parser = parsing.datetime_parsing(input_text, base_date)

self.assertEqual(len(parser), 1)
# April only has 30 days, should pick the last valid day
self.assertEqual(parser[0][1].year, 2025)
self.assertEqual(parser[0][1].month, 4)
self.assertEqual(parser[0][1].day, 30)

def test_next_month_from_may_31st(self):
"""
Test 'next month' from May 31 handles June correctly
"""
base_date = datetime(2025, 5, 31, 12, 0, 0)
input_text = 'next month'
parser = parsing.datetime_parsing(input_text, base_date)

self.assertEqual(len(parser), 1)
# June only has 30 days, should pick the last valid day
self.assertEqual(parser[0][1].year, 2025)
self.assertEqual(parser[0][1].month, 6)
self.assertEqual(parser[0][1].day, 30)

def test_multiple_datetime_expressions(self):
"""
Test parsing text with multiple date/time references
"""
base_date = datetime(2025, 10, 18, 12, 0, 0)
input_text = 'Meeting today at 2pm and tomorrow at 3pm'
parser = parsing.datetime_parsing(input_text, base_date)

self.assertEqual(len(parser), 2)
# First: today at 2pm
self.assertEqual(parser[0][1].year, 2025)
self.assertEqual(parser[0][1].month, 10)
self.assertEqual(parser[0][1].day, 18)
self.assertEqual(parser[0][1].hour, 14)
# Second: tomorrow at 3pm
self.assertEqual(parser[1][1].year, 2025)
self.assertEqual(parser[1][1].month, 10)
self.assertEqual(parser[1][1].day, 19)
self.assertEqual(parser[1][1].hour, 15)

def test_duration_from_yesterday(self):
"""
Test '2 days after yesterday' using base_time
"""
base_date = datetime(2025, 10, 18, 12, 0, 0)
input_text = '2 days after yesterday'
parser = parsing.datetime_parsing(input_text, base_date)

self.assertEqual(len(parser), 1)
# Yesterday = Oct 17, + 2 days = Oct 19
self.assertEqual(parser[0][1].year, 2025)
self.assertEqual(parser[0][1].month, 10)
self.assertEqual(parser[0][1].day, 19)

def test_duration_from_tomorrow(self):
"""
Test '3 days after tomorrow'
"""
base_date = datetime(2025, 10, 18, 12, 0, 0)
input_text = '3 days after tomorrow'
parser = parsing.datetime_parsing(input_text, base_date)

self.assertEqual(len(parser), 1)
# Tomorrow = Oct 19, + 3 days = Oct 22
self.assertEqual(parser[0][1].year, 2025)
self.assertEqual(parser[0][1].month, 10)
self.assertEqual(parser[0][1].day, 22)

def test_duration_from_today(self):
"""
Test '5 days before today'
"""
base_date = datetime(2025, 10, 18, 12, 0, 0)
input_text = '5 days before today'
parser = parsing.datetime_parsing(input_text, base_date)

self.assertEqual(len(parser), 1)
# Today = Oct 18, - 5 days = Oct 13
self.assertEqual(parser[0][1].year, 2025)
self.assertEqual(parser[0][1].month, 10)
self.assertEqual(parser[0][1].day, 13)

def test_noon_without_convention(self):
"""
Test '12:00' without AM/PM defaults to AM convention (midnight = 0)
"""
base_date = datetime(2025, 10, 18, 0, 0, 0)
input_text = 'Meeting at 12:00'
parser = parsing.datetime_parsing(input_text, base_date)

self.assertEqual(len(parser), 1)
# No convention defaults to 'am', so 12:00 becomes 0 (midnight)
self.assertEqual(parser[0][1].hour, 0)
self.assertEqual(parser[0][1].minute, 0)

def test_twelve_pm(self):
"""
Test '12:00 pm' is noon (stays as 12)
"""
base_date = datetime(2025, 10, 18, 0, 0, 0)
input_text = 'Meeting at 12:00 pm'
parser = parsing.datetime_parsing(input_text, base_date)

self.assertEqual(len(parser), 1)
self.assertEqual(parser[0][1].hour, 12)
self.assertEqual(parser[0][1].minute, 0)

def test_twelve_am(self):
"""
Test '12:00 am' is midnight (converted to 0)
"""
base_date = datetime(2025, 10, 18, 0, 0, 0)
input_text = 'Meeting at 12:00 am'
parser = parsing.datetime_parsing(input_text, base_date)

self.assertEqual(len(parser), 1)
self.assertEqual(parser[0][1].hour, 0)
self.assertEqual(parser[0][1].minute, 0)

def test_one_am(self):
"""
Test '1:00 am' is 1:00
"""
base_date = datetime(2025, 10, 18, 0, 0, 0)
input_text = 'Meeting at 1:00 am'
parser = parsing.datetime_parsing(input_text, base_date)

self.assertEqual(len(parser), 1)
self.assertEqual(parser[0][1].hour, 1)
self.assertEqual(parser[0][1].minute, 0)

def test_one_pm(self):
"""
Test '1:00 pm' is 13:00
"""
base_date = datetime(2025, 10, 18, 0, 0, 0)
input_text = 'Meeting at 1:00 pm'
parser = parsing.datetime_parsing(input_text, base_date)

self.assertEqual(len(parser), 1)
self.assertEqual(parser[0][1].hour, 13)
self.assertEqual(parser[0][1].minute, 0)
Loading