Skip to content

Commit f687776

Browse files
committed
fix(search): guard Chinese rolling year underflow
1 parent 82e6731 commit f687776

2 files changed

Lines changed: 73 additions & 23 deletions

File tree

hindsight-api-slim/hindsight_api/engine/chinese_temporal_periods.py

Lines changed: 40 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -109,6 +109,11 @@ def constraint(start: datetime, end: datetime) -> DateRange:
109109
end.replace(hour=23, minute=59, second=59, microsecond=999999),
110110
)
111111

112+
def safe_constraint(start: datetime | None, end: datetime | None) -> DateRange | NoTemporalConstraintSentinel:
113+
if start is None or end is None:
114+
return NO_TEMPORAL_CONSTRAINT
115+
return constraint(start, end)
116+
112117
def subtract_months(months: int) -> datetime:
113118
month_index = reference_date.month - months - 1
114119
year = reference_date.year + month_index // 12
@@ -126,11 +131,18 @@ def add_months(base_date: datetime, months: int) -> datetime:
126131
day = min(base_date.day, calendar.monthrange(year, month)[1])
127132
return base_date.replace(year=year, month=month, day=day)
128133

129-
def add_years(base_date: datetime, years: int) -> datetime:
134+
def add_years(base_date: datetime, years: int) -> datetime | None:
130135
year = base_date.year + years
136+
if year < datetime.min.year or year > datetime.max.year:
137+
return None
131138
day = min(base_date.day, calendar.monthrange(year, base_date.month)[1])
132139
return base_date.replace(year=year, day=day)
133140

141+
def add_days(base_date: datetime | None, days: int) -> datetime | None:
142+
if base_date is None:
143+
return None
144+
return base_date + timedelta(days=days)
145+
134146
def has_chinese_temporal_context(match: re.Match[str]) -> bool:
135147
if match.end() >= len(query):
136148
return True
@@ -438,6 +450,11 @@ def since_constraint(start: datetime) -> DateRange | NoTemporalConstraintSentine
438450
return NO_TEMPORAL_CONSTRAINT
439451
return constraint(start, reference_date)
440452

453+
def safe_since_constraint(start: datetime | None) -> DateRange | NoTemporalConstraintSentinel:
454+
if start is None:
455+
return NO_TEMPORAL_CONSTRAINT
456+
return since_constraint(start)
457+
441458
def since_from_period(
442459
period: DateRange | None,
443460
) -> DateRange | NoTemporalConstraintSentinel | None:
@@ -450,7 +467,7 @@ def since_from_day(day: datetime | None) -> DateRange | NoTemporalConstraintSent
450467
return None
451468
return since_constraint(day)
452469

453-
def relative_offset_datetime(amount: int, unit: str, direction: int) -> datetime:
470+
def relative_offset_datetime(amount: int, unit: str, direction: int) -> datetime | None:
454471
if unit in ("天", "日"):
455472
return reference_date + timedelta(days=direction * amount)
456473
if unit in ("周", "星期", "礼拜"):
@@ -459,15 +476,15 @@ def relative_offset_datetime(amount: int, unit: str, direction: int) -> datetime
459476
return add_months(reference_date, direction * amount)
460477
return add_years(reference_date, direction * amount)
461478

462-
def point_constraint_at_offset(amount: int, unit: str, direction: int) -> DateRange:
479+
def point_constraint_at_offset(amount: int, unit: str, direction: int) -> DateRange | NoTemporalConstraintSentinel:
463480
d = relative_offset_datetime(amount, unit, direction)
464-
return constraint(d, d)
481+
return safe_constraint(d, d)
465482

466-
def window_to_reference(amount: int, unit: str) -> DateRange:
467-
return constraint(relative_offset_datetime(amount, unit, -1), reference_date)
483+
def window_to_reference(amount: int, unit: str) -> DateRange | NoTemporalConstraintSentinel:
484+
return safe_constraint(relative_offset_datetime(amount, unit, -1), reference_date)
468485

469-
def window_from_reference(amount: int, unit: str) -> DateRange:
470-
return constraint(reference_date, relative_offset_datetime(amount, unit, 1))
486+
def window_from_reference(amount: int, unit: str) -> DateRange | NoTemporalConstraintSentinel:
487+
return safe_constraint(reference_date, relative_offset_datetime(amount, unit, 1))
471488

472489
# Chinese rule guide
473490
#
@@ -781,8 +798,8 @@ def window_from_reference(amount: int, unit: str) -> DateRange:
781798
if relative_year_fixed_day_since_match:
782799
year = relative_year_number(relative_year_fixed_day_since_match.group(1))
783800
base = add_years(reference_date, year - reference_date.year)
784-
d = base + timedelta(days=fixed_day_offset(relative_year_fixed_day_since_match.group(2)))
785-
return since_constraint(d)
801+
d = add_days(base, fixed_day_offset(relative_year_fixed_day_since_match.group(2)))
802+
return safe_since_constraint(d)
786803

787804
fixed_day_since_match = chinese_search(
788805
rf"(大大后天|大后天|后天|明天|明日|今天|今日|本日|当日|当天|昨天|昨日|大大前天|大前天|前天)"
@@ -799,7 +816,7 @@ def window_from_reference(amount: int, unit: str) -> DateRange:
799816
amount = parse_chinese_number(exact_relative_since_match.group(1))
800817
unit = exact_relative_since_match.group(2)
801818
if amount is not None:
802-
return since_constraint(relative_offset_datetime(amount, unit, -1))
819+
return safe_since_constraint(relative_offset_datetime(amount, unit, -1))
803820

804821
weekend_since_match = chinese_search(
805822
rf"(?<![上下大小每个各隔])"
@@ -899,8 +916,8 @@ def window_from_reference(amount: int, unit: str) -> DateRange:
899916
if relative_year_daypart_since_match:
900917
year = relative_year_number(relative_year_daypart_since_match.group(1))
901918
base = add_years(reference_date, year - reference_date.year)
902-
d = base + timedelta(days=daypart_day_offset(relative_year_daypart_since_match.group(2)))
903-
return since_constraint(d)
919+
d = add_days(base, daypart_day_offset(relative_year_daypart_since_match.group(2)))
920+
return safe_since_constraint(d)
904921

905922
daypart_since_match = chinese_search(
906923
rf"(昨晚|昨夜|前晚|前夜|今晚|今早|今晨|明早|明晚|明夜){chinese_since_suffix_pattern}"
@@ -915,13 +932,13 @@ def window_from_reference(amount: int, unit: str) -> DateRange:
915932
if relative_year_daypart_match:
916933
year = relative_year_number(relative_year_daypart_match.group(1))
917934
base = add_years(reference_date, year - reference_date.year)
918-
d = base + timedelta(days=daypart_day_offset(relative_year_daypart_match.group(2)))
919-
return constraint(d, d)
935+
d = add_days(base, daypart_day_offset(relative_year_daypart_match.group(2)))
936+
return safe_constraint(d, d)
920937

921938
# Day-part abbreviations still resolve only to date granularity.
922939
if chinese_search(r"昨晚|昨夜"):
923940
d = reference_date + timedelta(days=daypart_day_offset("昨晚"))
924-
return constraint(d, d)
941+
return safe_constraint(d, d)
925942

926943
if chinese_search(r"前晚|前夜"):
927944
d = reference_date + timedelta(days=daypart_day_offset("前晚"))
@@ -941,7 +958,7 @@ def window_from_reference(amount: int, unit: str) -> DateRange:
941958
if relative_year_fixed_day_match:
942959
year = relative_year_number(relative_year_fixed_day_match.group(1))
943960
base = add_years(reference_date, year - reference_date.year)
944-
d = base + timedelta(days=fixed_day_offset(relative_year_fixed_day_match.group(2)))
961+
d = add_days(base, fixed_day_offset(relative_year_fixed_day_match.group(2)))
945962
return constraint(d, d)
946963

947964
if chinese_search(r"昨天|昨日"):
@@ -1085,15 +1102,15 @@ def window_from_reference(amount: int, unit: str) -> DateRange:
10851102
end_amount = parse_chinese_number(amount_text[-1])
10861103
unit = adjacent_fuzzy_future_match.group(2)
10871104
if start_amount is not None and end_amount is not None:
1088-
return constraint(
1105+
return safe_constraint(
10891106
relative_offset_datetime(start_amount, unit, 1),
10901107
relative_offset_datetime(end_amount, unit, 1),
10911108
)
10921109

10931110
few_future_match = chinese_search(rf"[几数]个?(天|日|周|星期|礼拜|月|年){chinese_relative_future_suffix_pattern}")
10941111
if few_future_match:
10951112
unit = few_future_match.group(1)
1096-
return constraint(relative_offset_datetime(2, unit, 1), relative_offset_datetime(5, unit, 1))
1113+
return safe_constraint(relative_offset_datetime(2, unit, 1), relative_offset_datetime(5, unit, 1))
10971114

10981115
exact_future_match = chinese_search(
10991116
rf"(?<![{_CHINESE_NUMERAL_PREFIX_CHARS}])([0-9]+|[{_CHINESE_NUMERAL_CHARS}]+)个?(天|日|周|星期|礼拜|月|年){chinese_relative_future_suffix_pattern}"
@@ -1113,7 +1130,7 @@ def window_from_reference(amount: int, unit: str) -> DateRange:
11131130
second_amount = parse_chinese_number(adjacent_fuzzy_past_match.group(2))
11141131
unit = adjacent_fuzzy_past_match.group(3)
11151132
if first_amount is not None and second_amount is not None and second_amount == first_amount + 1:
1116-
return constraint(
1133+
return safe_constraint(
11171134
relative_offset_datetime(second_amount, unit, -1),
11181135
relative_offset_datetime(first_amount, unit, -1),
11191136
)
@@ -1144,7 +1161,7 @@ def window_from_reference(amount: int, unit: str) -> DateRange:
11441161
return constraint(reference_date - timedelta(days=150), reference_date - timedelta(days=60))
11451162

11461163
if chinese_search(r"一两年前|[两二]三年前|三两年前"):
1147-
return constraint(add_years(reference_date, -3), add_years(reference_date, -1))
1164+
return safe_constraint(add_years(reference_date, -3), add_years(reference_date, -1))
11481165

11491166
rolling_this_adjacent_match = chinese_search(
11501167
r"这(一两|[两二]三|三两|三四|四五|五六|六七|七八|八九|九十)个?(天|日|周|星期|礼拜|月|年)"
@@ -1154,7 +1171,7 @@ def window_from_reference(amount: int, unit: str) -> DateRange:
11541171
end_amount = 3 if amount_text in ("一两", "三两") else parse_chinese_number(amount_text[-1])
11551172
unit = rolling_this_adjacent_match.group(2)
11561173
if end_amount is not None:
1157-
return constraint(relative_offset_datetime(end_amount, unit, -1), reference_date)
1174+
return safe_constraint(relative_offset_datetime(end_amount, unit, -1), reference_date)
11581175

11591176
rolling_this_count_match = chinese_search(rf"这([0-9]+|[{_CHINESE_NUMERAL_CHARS}]+)个?(天|日|周|星期|礼拜|月|年)")
11601177
if rolling_this_count_match:
@@ -1191,7 +1208,7 @@ def window_from_reference(amount: int, unit: str) -> DateRange:
11911208
end_amount = 3 if amount_text in ("一两", "三两") else parse_chinese_number(amount_text[-1])
11921209
unit = rolling_past_adjacent_match.group(3)
11931210
if end_amount is not None:
1194-
return constraint(relative_offset_datetime(end_amount, unit, -1), reference_date)
1211+
return safe_constraint(relative_offset_datetime(end_amount, unit, -1), reference_date)
11951212

11961213
rolling_past_few_match = chinese_search(r"(过去|近|最近)几个?(天|日|周|星期|礼拜|月|年)")
11971214
if rolling_past_few_match:

hindsight-api-slim/tests/test_query_analyzer.py

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -649,6 +649,39 @@ def test_query_analyzer_chinese_rolling_windows(query_analyzer, query, start, en
649649
assert analysis.temporal_constraint.end_date.date() == end.date()
650650

651651

652+
def test_query_analyzer_chinese_rolling_year_underflow_returns_no_constraint(query_analyzer):
653+
"""Impossible Chinese rolling windows should not block retrieval."""
654+
reference_date = datetime(1, 1, 15, 12, 0, 0)
655+
656+
analysis = query_analyzer.analyze("过去一年做了什么", reference_date)
657+
658+
assert analysis.temporal_constraint is None
659+
660+
661+
def test_query_analyzer_chinese_rolling_year_low_safe_boundary_still_extracts(query_analyzer):
662+
"""Valid low-year Chinese rolling windows should keep their constraint."""
663+
reference_date = datetime(2, 1, 15, 12, 0, 0)
664+
665+
analysis = query_analyzer.analyze("过去一年做了什么", reference_date)
666+
667+
assert analysis.temporal_constraint is not None
668+
assert analysis.temporal_constraint.start_date.date() == datetime(1, 1, 15).date()
669+
assert analysis.temporal_constraint.end_date.date() == datetime(2, 1, 15).date()
670+
671+
672+
def test_query_analyzer_period_valueerror_still_surfaces(query_analyzer, monkeypatch):
673+
"""Only impossible Chinese date shifts degrade to no constraint."""
674+
from hindsight_api.engine import query_analyzer as query_analyzer_module
675+
676+
def fail_extract_period(query, reference_date):
677+
raise ValueError("synthetic extraction bug")
678+
679+
monkeypatch.setattr(query_analyzer_module, "extract_period", fail_extract_period)
680+
681+
with pytest.raises(ValueError, match="synthetic extraction bug"):
682+
query_analyzer.analyze("past week", datetime(2025, 1, 15, 12, 0, 0))
683+
684+
652685
@pytest.mark.parametrize("query", ["三两天前提到的菜是什么"])
653686
def test_query_analyzer_chinese_exact_relative_boundaries(query_analyzer, query):
654687
"""Test malformed Chinese numerals are not truncated into exact relative rules."""

0 commit comments

Comments
 (0)