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
74 changes: 47 additions & 27 deletions hindsight-api-slim/hindsight_api/engine/chinese_temporal_periods.py
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,11 @@ def constraint(start: datetime, end: datetime) -> DateRange:
end.replace(hour=23, minute=59, second=59, microsecond=999999),
)

def safe_constraint(start: datetime | None, end: datetime | None) -> DateRange | NoTemporalConstraintSentinel:
if start is None or end is None:
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
Expand All @@ -126,11 +131,21 @@ def add_months(base_date: datetime, months: int) -> datetime:
day = min(base_date.day, calendar.monthrange(year, month)[1])
return base_date.replace(year=year, month=month, day=day)

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

def add_days(base_date: datetime | None, days: int) -> datetime | None:
if base_date is None:
return None
try:
return base_date + timedelta(days=days)
except OverflowError:
return None

def has_chinese_temporal_context(match: re.Match[str]) -> bool:
if match.end() >= len(query):
return True
Expand Down Expand Up @@ -438,6 +453,11 @@ def since_constraint(start: datetime) -> DateRange | NoTemporalConstraintSentine
return NO_TEMPORAL_CONSTRAINT
return constraint(start, reference_date)

def safe_since_constraint(start: datetime | None) -> DateRange | NoTemporalConstraintSentinel:
if start is None:
return NO_TEMPORAL_CONSTRAINT
return since_constraint(start)

def since_from_period(
period: DateRange | None,
) -> DateRange | NoTemporalConstraintSentinel | None:
Expand All @@ -450,7 +470,7 @@ def since_from_day(day: datetime | None) -> DateRange | NoTemporalConstraintSent
return None
return since_constraint(day)

def relative_offset_datetime(amount: int, unit: str, direction: int) -> datetime:
def relative_offset_datetime(amount: int, unit: str, direction: int) -> datetime | None:
if unit in ("天", "日"):
return reference_date + timedelta(days=direction * amount)
if unit in ("周", "星期", "礼拜"):
Expand All @@ -459,15 +479,15 @@ def relative_offset_datetime(amount: int, unit: str, direction: int) -> datetime
return add_months(reference_date, direction * amount)
return add_years(reference_date, direction * amount)

def point_constraint_at_offset(amount: int, unit: str, direction: int) -> DateRange:
def point_constraint_at_offset(amount: int, unit: str, direction: int) -> DateRange | NoTemporalConstraintSentinel:
d = relative_offset_datetime(amount, unit, direction)
return constraint(d, d)
return safe_constraint(d, d)

def window_to_reference(amount: int, unit: str) -> DateRange:
return constraint(relative_offset_datetime(amount, unit, -1), reference_date)
def window_to_reference(amount: int, unit: str) -> DateRange | NoTemporalConstraintSentinel:
return safe_constraint(relative_offset_datetime(amount, unit, -1), reference_date)

def window_from_reference(amount: int, unit: str) -> DateRange:
return constraint(reference_date, relative_offset_datetime(amount, unit, 1))
def window_from_reference(amount: int, unit: str) -> DateRange | NoTemporalConstraintSentinel:
return safe_constraint(reference_date, relative_offset_datetime(amount, unit, 1))

# Chinese rule guide
#
Expand Down Expand Up @@ -781,8 +801,8 @@ def window_from_reference(amount: int, unit: str) -> DateRange:
if relative_year_fixed_day_since_match:
year = relative_year_number(relative_year_fixed_day_since_match.group(1))
base = add_years(reference_date, year - reference_date.year)
d = base + timedelta(days=fixed_day_offset(relative_year_fixed_day_since_match.group(2)))
return since_constraint(d)
d = add_days(base, fixed_day_offset(relative_year_fixed_day_since_match.group(2)))
return safe_since_constraint(d)

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

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

daypart_since_match = chinese_search(
rf"(昨晚|昨夜|前晚|前夜|今晚|今早|今晨|明早|明晚|明夜){chinese_since_suffix_pattern}"
Expand All @@ -915,17 +935,17 @@ def window_from_reference(amount: int, unit: str) -> DateRange:
if relative_year_daypart_match:
year = relative_year_number(relative_year_daypart_match.group(1))
base = add_years(reference_date, year - reference_date.year)
d = base + timedelta(days=daypart_day_offset(relative_year_daypart_match.group(2)))
return constraint(d, d)
d = add_days(base, daypart_day_offset(relative_year_daypart_match.group(2)))
return safe_constraint(d, d)

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

if chinese_search(r"前晚|前夜"):
d = reference_date + timedelta(days=daypart_day_offset("前晚"))
return constraint(d, d)
d = add_days(reference_date, daypart_day_offset("前晚"))
return safe_constraint(d, d)

if chinese_search(r"今晚|今早|今晨"):
return constraint(reference_date, reference_date)
Expand All @@ -941,8 +961,8 @@ def window_from_reference(amount: int, unit: str) -> DateRange:
if relative_year_fixed_day_match:
year = relative_year_number(relative_year_fixed_day_match.group(1))
base = add_years(reference_date, year - reference_date.year)
d = base + timedelta(days=fixed_day_offset(relative_year_fixed_day_match.group(2)))
return constraint(d, d)
d = add_days(base, fixed_day_offset(relative_year_fixed_day_match.group(2)))
return safe_constraint(d, d)

if chinese_search(r"昨天|昨日"):
d = reference_date - timedelta(days=1)
Expand Down Expand Up @@ -1085,15 +1105,15 @@ def window_from_reference(amount: int, unit: str) -> DateRange:
end_amount = parse_chinese_number(amount_text[-1])
unit = adjacent_fuzzy_future_match.group(2)
if start_amount is not None and end_amount is not None:
return constraint(
return safe_constraint(
relative_offset_datetime(start_amount, unit, 1),
relative_offset_datetime(end_amount, unit, 1),
)

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

exact_future_match = chinese_search(
rf"(?<![{_CHINESE_NUMERAL_PREFIX_CHARS}])([0-9]+|[{_CHINESE_NUMERAL_CHARS}]+)个?(天|日|周|星期|礼拜|月|年){chinese_relative_future_suffix_pattern}"
Expand All @@ -1113,7 +1133,7 @@ def window_from_reference(amount: int, unit: str) -> DateRange:
second_amount = parse_chinese_number(adjacent_fuzzy_past_match.group(2))
unit = adjacent_fuzzy_past_match.group(3)
if first_amount is not None and second_amount is not None and second_amount == first_amount + 1:
return constraint(
return safe_constraint(
relative_offset_datetime(second_amount, unit, -1),
relative_offset_datetime(first_amount, unit, -1),
)
Expand Down Expand Up @@ -1144,7 +1164,7 @@ def window_from_reference(amount: int, unit: str) -> DateRange:
return constraint(reference_date - timedelta(days=150), reference_date - timedelta(days=60))

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

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

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

rolling_past_few_match = chinese_search(r"(过去|近|最近)几个?(天|日|周|星期|礼拜|月|年)")
if rolling_past_few_match:
Expand Down
65 changes: 65 additions & 0 deletions hindsight-api-slim/tests/test_query_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -649,6 +649,71 @@ def test_query_analyzer_chinese_rolling_windows(query_analyzer, query, start, en
assert analysis.temporal_constraint.end_date.date() == end.date()


def test_query_analyzer_chinese_rolling_year_underflow_returns_no_constraint(query_analyzer):
"""Impossible Chinese rolling windows should not block retrieval."""
reference_date = datetime(1, 1, 15, 12, 0, 0)

analysis = query_analyzer.analyze("过去一年做了什么", reference_date)

assert analysis.temporal_constraint is None


@pytest.mark.parametrize(
("query", "reference_date"),
[
("去年今天做了什么", datetime(1, 1, 15, 12, 0, 0)),
("大前年今天做了什么", datetime(1, 1, 15, 12, 0, 0)),
("去年昨天做了什么", datetime(1, 1, 15, 12, 0, 0)),
("昨晚做了什么", datetime(1, 1, 1, 12, 0, 0)),
("前晚做了什么", datetime(1, 1, 1, 12, 0, 0)),
],
)
def test_query_analyzer_chinese_fixed_day_underflow_returns_no_constraint(
query_analyzer,
query,
reference_date,
):
"""Impossible Chinese fixed-day shifts should not block retrieval."""
analysis = query_analyzer.analyze(query, reference_date)

assert analysis.temporal_constraint is None


def test_query_analyzer_chinese_rolling_year_low_safe_boundary_still_extracts(query_analyzer):
"""Valid low-year Chinese rolling windows should keep their constraint."""
reference_date = datetime(2, 1, 15, 12, 0, 0)

analysis = query_analyzer.analyze("过去一年做了什么", reference_date)

assert analysis.temporal_constraint is not None
assert analysis.temporal_constraint.start_date.date() == datetime(1, 1, 15).date()
assert analysis.temporal_constraint.end_date.date() == datetime(2, 1, 15).date()


def test_query_analyzer_chinese_fixed_day_low_safe_boundary_still_extracts(query_analyzer):
"""Valid low-year Chinese fixed-day shifts should keep their constraint."""
reference_date = datetime(2, 1, 15, 12, 0, 0)

analysis = query_analyzer.analyze("去年今天做了什么", reference_date)

assert analysis.temporal_constraint is not None
assert analysis.temporal_constraint.start_date.date() == datetime(1, 1, 15).date()
assert analysis.temporal_constraint.end_date.date() == datetime(1, 1, 15).date()


def test_query_analyzer_period_valueerror_still_surfaces(query_analyzer, monkeypatch):
"""Only impossible Chinese date shifts degrade to no constraint."""
from hindsight_api.engine import query_analyzer as query_analyzer_module

def fail_extract_period(query, reference_date):
raise ValueError("synthetic extraction bug")

monkeypatch.setattr(query_analyzer_module, "extract_period", fail_extract_period)

with pytest.raises(ValueError, match="synthetic extraction bug"):
query_analyzer.analyze("past week", datetime(2025, 1, 15, 12, 0, 0))


@pytest.mark.parametrize("query", ["三两天前提到的菜是什么"])
def test_query_analyzer_chinese_exact_relative_boundaries(query_analyzer, query):
"""Test malformed Chinese numerals are not truncated into exact relative rules."""
Expand Down