From 16587efb4019c5d6ae4bbf79f1b20e329fb50904 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Nicol=C3=B2=20Boschi?= Date: Wed, 12 Aug 2026 07:47:17 +0200 Subject: [PATCH] fix(search): stop extreme relative date offsets from crashing recall (#3217) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Consolidation recalls with stored fact text as the query, so a phrase like "十万年前" (100,000 years ago) hit unguarded offset arithmetic in extract_period — which runs BEFORE analyze()'s dateparser guard — and escaped as ValueError('year -97974 is out of range'), deterministically failing every recall and consolidation touching the bank. The three years observed in #3217 (-534, -974, -97974) are exactly now.year - {2560, 3000, 100000}: query-time arithmetic, not stored rows (Python datetimes can't represent them, so no bad date can reach the DB through asyncpg in the first place). Three layers, mirroring the #2636 add_years fix: - extract_temporal_constraint (the recall choke point) degrades any analyzer failure to 'no temporal signal' with a warning; analyze() itself stays strict so parser bugs still surface in tests. - chinese_temporal_periods: add_months/subtract_months are now bounds-checked like add_years (returning None, plumbed through every call site), and day/week offsets go through the overflow-guarded add_days instead of raw timedelta addition. - temporal_periods: an explicit month + year 0000 match returns NO_TEMPORAL_CONSTRAINT instead of crashing datetime(). --- .../engine/chinese_temporal_periods.py | 75 +++++++++------ .../engine/search/temporal_extraction.py | 17 +++- .../hindsight_api/engine/temporal_periods.py | 9 +- .../tests/test_query_analyzer.py | 93 +++++++++++++++++++ 4 files changed, 166 insertions(+), 28 deletions(-) diff --git a/hindsight-api-slim/hindsight_api/engine/chinese_temporal_periods.py b/hindsight-api-slim/hindsight_api/engine/chinese_temporal_periods.py index e3de93ac72..befbdab52c 100644 --- a/hindsight-api-slim/hindsight_api/engine/chinese_temporal_periods.py +++ b/hindsight-api-slim/hindsight_api/engine/chinese_temporal_periods.py @@ -114,19 +114,17 @@ def safe_constraint(start: datetime | None, end: datetime | None) -> DateRange | return NO_TEMPORAL_CONSTRAINT return constraint(start, end) - def subtract_months(months: int) -> datetime: - month_index = reference_date.month - months - 1 - year = reference_date.year + month_index // 12 - month = month_index % 12 + 1 - day = min(reference_date.day, calendar.monthrange(year, month)[1]) - return reference_date.replace(year=year, month=month, day=day) + def subtract_months(months: int) -> datetime | None: + return add_months(reference_date, -months) def month_end(year: int, month: int) -> datetime: return datetime(year, month, calendar.monthrange(year, month)[1]) - def add_months(base_date: datetime, months: int) -> datetime: + def add_months(base_date: datetime, months: int) -> datetime | None: month_index = base_date.month + months - 1 year = base_date.year + month_index // 12 + if year < datetime.min.year or year > datetime.max.year: + return None month = month_index % 12 + 1 day = min(base_date.day, calendar.monthrange(year, month)[1]) return base_date.replace(year=year, month=month, day=day) @@ -373,7 +371,7 @@ def relative_weekend_period(period: str | None) -> DateRange: sat = start + timedelta(days=5) return constraint(sat, sat + timedelta(days=1)) - def relative_month_start(period: str | None) -> datetime: + def relative_month_start(period: str | None) -> datetime | None: return add_months(reference_date.replace(day=1), relative_period_offset(period)) def exact_day_constraint(year: int, month_text: str, day_text: str) -> DateRange | None: @@ -418,6 +416,8 @@ def relative_month_day_datetime(period: str, day_text: str) -> datetime | None: if day is None: return None start = relative_month_start(period) + if start is None: + return None if day > calendar.monthrange(start.year, start.month)[1]: return None return datetime(start.year, start.month, day) @@ -472,9 +472,9 @@ def since_from_day(day: datetime | None) -> DateRange | NoTemporalConstraintSent def relative_offset_datetime(amount: int, unit: str, direction: int) -> datetime | None: if unit in ("天", "日"): - return reference_date + timedelta(days=direction * amount) + return add_days(reference_date, direction * amount) if unit in ("周", "星期", "礼拜"): - return reference_date + timedelta(weeks=direction * amount) + return add_days(reference_date, direction * amount * 7) if unit == "月": return add_months(reference_date, direction * amount) return add_years(reference_date, direction * amount) @@ -616,6 +616,8 @@ def window_from_reference(amount: int, unit: str) -> DateRange | NoTemporalConst if relative_month_range_match: first = relative_month_start(relative_month_range_match.group(1)) second = relative_month_start(relative_month_range_match.group(2)) + if first is None or second is None: + return NO_TEMPORAL_CONSTRAINT start = min(first, second) end = max(first, second) return constraint(start, month_end(end.year, end.month)) @@ -839,7 +841,7 @@ def window_from_reference(amount: int, unit: str) -> DateRange | NoTemporalConst rf"(? DateRange | NoTemporalConst if chinese_search(r"一年半前"): d = subtract_months(18) - return constraint(d, d) + return safe_constraint(d, d) if chinese_search(r"([一二两三四五六七八九十]+)年半前"): match = chinese_search(r"([一二两三四五六七八九十]+)年半前") @@ -1043,15 +1045,15 @@ def window_from_reference(amount: int, unit: str) -> DateRange | NoTemporalConst years = parse_chinese_number(match.group(1)) if years is not None: d = subtract_months(years * 12 + 6) - return constraint(d, d) + return safe_constraint(d, d) if chinese_search(r"([0-9]+|[一二两三四五六七八九十]+)个?半月前"): match = chinese_search(r"([0-9]+|[一二两三四五六七八九十]+)个?半月前") if match is not None: months = parse_chinese_number(match.group(1)) if months is not None: - d = subtract_months(months) - timedelta(days=15) - return constraint(d, d) + d = add_days(subtract_months(months), -15) + return safe_constraint(d, d) if chinese_search(r"半个?月前"): d = reference_date - timedelta(days=15) @@ -1059,7 +1061,7 @@ def window_from_reference(amount: int, unit: str) -> DateRange | NoTemporalConst if chinese_search(r"半年前"): d = subtract_months(6) - return constraint(d, d) + return safe_constraint(d, d) future_year_half_match = chinese_search( rf"([0-9]+|[{_CHINESE_NUMERAL_CHARS}]+)年半{chinese_relative_future_suffix_pattern}" @@ -1068,7 +1070,7 @@ def window_from_reference(amount: int, unit: str) -> DateRange | NoTemporalConst years = parse_chinese_number(future_year_half_match.group(1)) if years is not None: d = add_months(reference_date, years * 12 + 6) - return constraint(d, d) + return safe_constraint(d, d) future_half_month_match = chinese_search( rf"([0-9]+|[{_CHINESE_NUMERAL_CHARS}]+)个?半月{chinese_relative_future_suffix_pattern}" @@ -1076,8 +1078,8 @@ def window_from_reference(amount: int, unit: str) -> DateRange | NoTemporalConst if future_half_month_match: months = parse_chinese_number(future_half_month_match.group(1)) if months is not None: - d = add_months(reference_date, months) + timedelta(days=15) - return constraint(d, d) + d = add_days(add_months(reference_date, months), 15) + return safe_constraint(d, d) if chinese_search(rf"半个?月{chinese_relative_future_suffix_pattern}"): d = reference_date + timedelta(days=15) @@ -1085,7 +1087,7 @@ def window_from_reference(amount: int, unit: str) -> DateRange | NoTemporalConst if chinese_search(rf"半年{chinese_relative_future_suffix_pattern}"): d = add_months(reference_date, 6) - return constraint(d, d) + return safe_constraint(d, d) adjacent_fuzzy_future_match = chinese_search( r"(? DateRange | NoTemporalConst unit = rolling_past_half_match.group(2) if unit == "月": return constraint(reference_date - timedelta(days=15), reference_date) - return constraint(subtract_months(6), reference_date) + return safe_constraint(subtract_months(6), reference_date) within_half_match = chinese_search(r"半个?(月|年)(?:以内|之内|内)") if within_half_match: unit = within_half_match.group(1) if unit == "月": return constraint(reference_date - timedelta(days=15), reference_date) - return constraint(subtract_months(6), reference_date) + return safe_constraint(subtract_months(6), reference_date) within_count_match = chinese_search( rf"([0-9]+|[{_CHINESE_NUMERAL_CHARS}]+)(个?)(天|日|周|星期|礼拜|月|年)(?:以内|之内|内)" @@ -1302,7 +1304,7 @@ def window_from_reference(amount: int, unit: str) -> DateRange | NoTemporalConst unit = rolling_future_half_match.group(2) if unit == "月": return constraint(reference_date, reference_date + timedelta(days=15)) - return constraint(reference_date, add_months(reference_date, 6)) + return safe_constraint(reference_date, add_months(reference_date, 6)) absolute_year_quarter_since_match = chinese_search( rf"({chinese_year_pattern})\s*年\s*(第?[一二三四1-4])季(?:度)?{chinese_since_suffix_pattern}" @@ -1439,6 +1441,8 @@ def window_from_reference(amount: int, unit: str) -> DateRange | NoTemporalConst ) if next_month_phase_since_match: start = add_months(reference_date.replace(day=1), 1) + if start is None: + return NO_TEMPORAL_CONSTRAINT return since_from_period(month_phase_period(start.year, start.month, next_month_phase_since_match.group(1))) second_next_month_phase_since_match = chinese_search( @@ -1447,6 +1451,8 @@ def window_from_reference(amount: int, unit: str) -> DateRange | NoTemporalConst ) if second_next_month_phase_since_match: start = add_months(reference_date.replace(day=1), 2) + if start is None: + return NO_TEMPORAL_CONSTRAINT return since_from_period( month_phase_period(start.year, start.month, second_next_month_phase_since_match.group(2)) ) @@ -1465,7 +1471,10 @@ def window_from_reference(amount: int, unit: str) -> DateRange | NoTemporalConst rf"({chinese_month_phase_pattern}){chinese_since_suffix_pattern}" ) if second_previous_month_phase_since_match: - start = subtract_months(2).replace(day=1) + start = subtract_months(2) + if start is None: + return NO_TEMPORAL_CONSTRAINT + start = start.replace(day=1) return since_from_period( month_phase_period(start.year, start.month, second_previous_month_phase_since_match.group(2)) ) @@ -1590,6 +1599,8 @@ def window_from_reference(amount: int, unit: str) -> DateRange | NoTemporalConst ) if next_month_phase_match: start = add_months(reference_date.replace(day=1), 1) + if start is None: + return NO_TEMPORAL_CONSTRAINT return month_phase_period(start.year, start.month, next_month_phase_match.group(1)) second_next_month_phase_match = chinese_search( @@ -1597,6 +1608,8 @@ def window_from_reference(amount: int, unit: str) -> DateRange | NoTemporalConst ) if second_next_month_phase_match: start = add_months(reference_date.replace(day=1), 2) + if start is None: + return NO_TEMPORAL_CONSTRAINT return month_phase_period(start.year, start.month, second_next_month_phase_match.group(2)) previous_month_phase_match = chinese_search( @@ -1611,7 +1624,10 @@ def window_from_reference(amount: int, unit: str) -> DateRange | NoTemporalConst rf"(? DateRange | NoTemporalConst rf"(? DateRange | NoTemporalConst if chinese_search( rf"(? datetime: return datetime(year, month, calendar.monthrange(year, month)[1]) -def _extract_non_chinese_period(query: str, reference_date: datetime) -> DateRange | None: +def _extract_non_chinese_period( + query: str, reference_date: datetime +) -> DateRange | NoTemporalConstraintSentinel | None: if re.search(r"\b(yesterday|ayer|ieri|hier|gestern|вчера)\b", query, re.IGNORECASE): d = reference_date - timedelta(days=1) return _constraint(d, d) @@ -162,6 +164,11 @@ def _extract_non_chinese_period(query: str, reference_date: datetime) -> DateRan match = re.search(rf"\b({pattern})\s+(\d{{4}})\b", query, re.IGNORECASE) if match: year = int(match.group(2)) + if year < datetime.min.year: + # "june 0000" — an explicit but unrepresentable year. Treat it + # as no constraint rather than crashing recall or letting the + # dateparser fallback invent a different date (issue #3217). + return NO_TEMPORAL_CONSTRAINT start = datetime(year, month_num, 1) return _constraint(start, _month_end(year, month_num)) diff --git a/hindsight-api-slim/tests/test_query_analyzer.py b/hindsight-api-slim/tests/test_query_analyzer.py index a2d312c43e..ca5b86e25d 100644 --- a/hindsight-api-slim/tests/test_query_analyzer.py +++ b/hindsight-api-slim/tests/test_query_analyzer.py @@ -1063,3 +1063,96 @@ def test_query_analyzer_explicit_date_with_restricted_language(query_analyzer): assert analysis.temporal_constraint is not None assert analysis.temporal_constraint.start_date.date() == datetime(2022, 10, 31).date() + + +# --- issue #3217: extreme relative offsets must never crash temporal analysis --- +# +# Consolidation recalls with stored fact text as the query, so a single phrase +# like "十万年前" (100,000 years ago → year -97974) deterministically failed +# every recall touching that bank. The offset arithmetic in extract_period runs +# BEFORE analyze()'s dateparser guard, so these used to escape as +# ValueError('year N is out of range') / OverflowError and kill the search. + + +@pytest.mark.parametrize( + "query", + [ + # the observed #3217 trio: 2560 / 3000 / 100000 years ago + "2560年前的事", + "3000年前", + "十万年前的人类", + # unbounded month offsets (add_months previously unguarded) + "三万个月前的事件", + "五万个月前", + "三万个月后", + "过去三万个月", + # unbounded day/week offsets (raw timedelta previously overflowed) + "一百万天前的历史", + "九十九万周前", + "一亿天前", + # half-year/half-month forms with unbounded parsed amounts + "三千年半后", + "十万个半月后", + ], +) +def test_query_analyzer_extreme_relative_offsets_no_crash(query_analyzer, query): + """Unrepresentable relative offsets degrade to no constraint (issue #3217).""" + reference_date = datetime(2026, 8, 6, 12, 0, 0) + + analysis = query_analyzer.analyze(query, reference_date) + + if analysis.temporal_constraint is not None: + # A constraint is acceptable only if it is a real, in-range date + # (e.g. "三千年半后" lands in year 5027, which is representable). + assert analysis.temporal_constraint.start_date.year >= 1 + + +@pytest.mark.parametrize( + ("query", "reference_date"), + [ + ("下个月的计划", datetime(9999, 12, 15)), + ("下下月", datetime(9999, 11, 15)), + ("上上月", datetime(1, 1, 15)), + ("半年前", datetime(1, 3, 15)), + ("半年后", datetime(9999, 9, 15)), + ("一年半前", datetime(1, 6, 15)), + ("下月以来", datetime(9999, 12, 15)), + ("上个月到下个月", datetime(1, 1, 15)), + ("下月中", datetime(9999, 12, 15)), + ], +) +def test_query_analyzer_month_offsets_at_year_bounds_no_crash(query_analyzer, query, reference_date): + """Month arithmetic at datetime year bounds degrades to no constraint.""" + analysis = query_analyzer.analyze(query, reference_date) + + assert analysis.temporal_constraint is None + + +@pytest.mark.parametrize("query", ["what happened in june 0000", "cosa accadde a gennaio 0000"]) +def test_query_analyzer_year_zero_month_pattern_no_crash(query_analyzer, query): + """An explicit month + year 0000 is unrepresentable → no constraint (issue #3217).""" + analysis = query_analyzer.analyze(query, datetime(2026, 8, 6, 12, 0, 0)) + + assert analysis.temporal_constraint is None + + +def test_extract_temporal_constraint_degrades_on_analyzer_crash(): + """The recall entry point must survive any analyzer failure (issue #3217). + + analyze() stays strict (see test_query_analyzer_period_valueerror_still_surfaces); + the recall choke point is where a parser bug degrades to 'no temporal signal' + instead of deterministically failing every recall/consolidation on the bank. + """ + from hindsight_api.engine.query_analyzer import QueryAnalysis, QueryAnalyzer + from hindsight_api.engine.search.temporal_extraction import extract_temporal_constraint + + class ExplodingAnalyzer(QueryAnalyzer): + def load(self) -> None: + pass + + def analyze(self, query: str, reference_date: datetime | None = None) -> QueryAnalysis: + raise ValueError("year -534 is out of range") + + result = extract_temporal_constraint("anything", analyzer=ExplodingAnalyzer()) + + assert result is None