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
75 changes: 49 additions & 26 deletions hindsight-api-slim/hindsight_api/engine/chinese_temporal_periods.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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))
Expand Down Expand Up @@ -839,7 +841,7 @@ def window_from_reference(amount: int, unit: str) -> DateRange | NoTemporalConst
rf"(?<![上下大小])(上上|大上|上|这|本|当|下下|大下|下){_CHINESE_OPTIONAL_PERIOD_MARKER}月{chinese_since_suffix_pattern}"
)
if month_since_match:
return since_constraint(relative_month_start(month_since_match.group(1)))
return safe_since_constraint(relative_month_start(month_since_match.group(1)))

absolute_year_month_since_match = chinese_search(
rf"({chinese_year_pattern})\s*年\s*({chinese_month_pattern})\s*月{chinese_since_suffix_pattern}"
Expand Down Expand Up @@ -1035,31 +1037,31 @@ def window_from_reference(amount: int, unit: str) -> 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"([一二两三四五六七八九十]+)年半前")
if match is not None:
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)
return constraint(d, d)

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}"
Expand All @@ -1068,24 +1070,24 @@ 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}"
)
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)
return constraint(d, d)

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"(?<![一二三四五六七八九十百千万零\d后])"
Expand Down Expand Up @@ -1232,14 +1234,14 @@ def window_from_reference(amount: int, unit: str) -> 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}]+)(个?)(天|日|周|星期|礼拜|月|年)(?:以内|之内|内)"
Expand Down Expand Up @@ -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}"
Expand Down Expand Up @@ -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(
Expand All @@ -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))
)
Expand All @@ -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))
)
Expand Down Expand Up @@ -1590,13 +1599,17 @@ 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(
rf"(?<![下大])(下下|大下){_CHINESE_OPTIONAL_PERIOD_MARKER}月份?\s*({chinese_month_phase_pattern})"
)
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(
Expand All @@ -1611,7 +1624,10 @@ def window_from_reference(amount: int, unit: str) -> DateRange | NoTemporalConst
rf"(?<![上大])(上上|大上){_CHINESE_OPTIONAL_PERIOD_MARKER}月份?\s*({chinese_month_phase_pattern})"
)
if second_previous_month_phase_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 month_phase_period(start.year, start.month, second_previous_month_phase_match.group(2))

bare_specific_month_phase_match = chinese_search(
Expand Down Expand Up @@ -1730,10 +1746,14 @@ def window_from_reference(amount: int, unit: str) -> DateRange | NoTemporalConst
rf"(?<![下大])(下下|大下){_CHINESE_OPTIONAL_PERIOD_MARKER}月(?!{chinese_month_boundary_suffix_pattern})"
):
start = add_months(reference_date.replace(day=1), 2)
if start is None:
return NO_TEMPORAL_CONSTRAINT
return constraint(start, month_end(start.year, start.month))

if chinese_search(rf"(?<![下大])下{_CHINESE_OPTIONAL_PERIOD_MARKER}月(?!{chinese_month_boundary_suffix_pattern})"):
start = add_months(reference_date.replace(day=1), 1)
if start is None:
return NO_TEMPORAL_CONSTRAINT
return constraint(start, month_end(start.year, start.month))

if chinese_search(rf"(下一个年度|下一年度|下年度|下一年|明年)(?!{chinese_boundary_suffix_pattern})"):
Expand Down Expand Up @@ -1763,7 +1783,10 @@ def window_from_reference(amount: int, unit: str) -> DateRange | NoTemporalConst
if chinese_search(
rf"(?<![上大])(上上|大上){_CHINESE_OPTIONAL_PERIOD_MARKER}月(?!{chinese_month_boundary_suffix_pattern})"
):
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 constraint(start, month_end(start.year, start.month))

if chinese_search(rf"前一个?(周|星期|礼拜)(?!{chinese_boundary_suffix_pattern})"):
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,22 @@ def extract_temporal_constraint(
if analyzer is None:
analyzer = get_default_analyzer()

analysis = analyzer.analyze(query, reference_date)
# Recall must never fail because temporal analysis choked on the query text.
# Consolidation recalls with stored fact text as the query, so a single
# pathological phrase (e.g. "十万年前" → year -97974) would otherwise fail
# every recall touching that bank, deterministically (issue #3217). Degrade
# to "no temporal signal" here — the one entry point the recall path uses —
# while analyze() itself stays strict so parser bugs still surface in tests
# and to direct callers.
try:
analysis = analyzer.analyze(query, reference_date)
except Exception as e:
logger.warning(
"Temporal query analysis raised %s (treating as no temporal constraint): %s",
type(e).__name__,
e,
)
return None

if analysis.temporal_constraint:
result = (analysis.temporal_constraint.start_date, analysis.temporal_constraint.end_date)
Expand Down
9 changes: 8 additions & 1 deletion hindsight-api-slim/hindsight_api/engine/temporal_periods.py
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,9 @@ def _month_end(year: int, month: int) -> 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)
Expand Down Expand Up @@ -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))

Expand Down
93 changes: 93 additions & 0 deletions hindsight-api-slim/tests/test_query_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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