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
62 changes: 44 additions & 18 deletions hindsight-api-slim/hindsight_api/engine/temporal_periods.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,15 @@ def _month_end(year: int, month: int) -> datetime:


def _extract_non_chinese_period(query: str, reference_date: datetime) -> DateRange | None:
if re.search(r"\b(yesterday|ayer|ieri|hier|gestern)\b", query, re.IGNORECASE):
if re.search(r"\b(yesterday|ayer|ieri|hier|gestern|вчера)\b", query, re.IGNORECASE):
d = reference_date - timedelta(days=1)
return _constraint(d, d)

if re.search(r"\b(today|hoy|oggi|aujourd\'?hui|heute)\b", query, re.IGNORECASE):
if re.search(r"\b(позавчера)\b", query, re.IGNORECASE):
d = reference_date - timedelta(days=2)
return _constraint(d, d)

if re.search(r"\b(today|hoy|oggi|aujourd\'?hui|heute|сегодня)\b", query, re.IGNORECASE):
return _constraint(reference_date, reference_date)

if re.search(r"\b(a\s+)?couple\s+(of\s+)?days?\s+ago\b", query, re.IGNORECASE):
Expand All @@ -64,28 +68,48 @@ def _extract_non_chinese_period(query: str, reference_date: datetime) -> DateRan
if re.search(r"\b(a\s+)?few\s+days?\s+ago\b", query, re.IGNORECASE):
return _constraint(reference_date - timedelta(days=5), reference_date - timedelta(days=2))

if re.search(r"\b(пару|пар[ыу]?)\s+дн(?:ей|я)\s+назад\b", query, re.IGNORECASE):
return _constraint(reference_date - timedelta(days=3), reference_date - timedelta(days=1))

if re.search(r"\bнесколько\s+дней\s+назад\b", query, re.IGNORECASE):
return _constraint(reference_date - timedelta(days=5), reference_date - timedelta(days=2))

if re.search(r"\b(a\s+)?couple\s+(of\s+)?weeks?\s+ago\b", query, re.IGNORECASE):
return _constraint(reference_date - timedelta(weeks=3), reference_date - timedelta(weeks=1))

if re.search(r"\b(a\s+)?few\s+weeks?\s+ago\b", query, re.IGNORECASE):
return _constraint(reference_date - timedelta(weeks=5), reference_date - timedelta(weeks=2))

if re.search(r"\b(пару|пар[ыу]?)\s+недель\s+назад\b", query, re.IGNORECASE):
return _constraint(reference_date - timedelta(weeks=3), reference_date - timedelta(weeks=1))

if re.search(r"\bнесколько\s+недель\s+назад\b", query, re.IGNORECASE):
return _constraint(reference_date - timedelta(weeks=5), reference_date - timedelta(weeks=2))

if re.search(r"\b(a\s+)?couple\s+(of\s+)?months?\s+ago\b", query, re.IGNORECASE):
return _constraint(reference_date - timedelta(days=90), reference_date - timedelta(days=30))

if re.search(r"\b(a\s+)?few\s+months?\s+ago\b", query, re.IGNORECASE):
return _constraint(reference_date - timedelta(days=150), reference_date - timedelta(days=60))

if re.search(r"\b(пару|пар[ыу]?)\s+месяцев\s+назад\b", query, re.IGNORECASE):
return _constraint(reference_date - timedelta(days=90), reference_date - timedelta(days=30))

if re.search(r"\bнесколько\s+месяцев\s+назад\b", query, re.IGNORECASE):
return _constraint(reference_date - timedelta(days=150), reference_date - timedelta(days=60))

if re.search(
r"\b(last\s+week|la\s+semana\s+pasada|la\s+settimana\s+scorsa|la\s+semaine\s+derni[eè]re|letzte\s+woche)\b",
r"\b(last\s+week|la\s+semana\s+pasada|la\s+settimana\s+scorsa|la\s+semaine\s+derni[eè]re|letzte\s+woche"
r"|(?:на\s+)?прошлой\s+неделе|прошлая\s+неделя)\b",
query,
re.IGNORECASE,
):
start = reference_date - timedelta(days=reference_date.weekday() + 7)
return _constraint(start, start + timedelta(days=6))

if re.search(
r"\b(last\s+month|el\s+mes\s+pasado|il\s+mese\s+scorso|le\s+mois\s+dernier|letzten?\s+monat)\b",
r"\b(last\s+month|el\s+mes\s+pasado|il\s+mese\s+scorso|le\s+mois\s+dernier|letzten?\s+monat"
r"|(?:в\s+)?прошлом\s+месяце|прошлый\s+месяц)\b",
query,
re.IGNORECASE,
):
Expand All @@ -95,15 +119,17 @@ def _extract_non_chinese_period(query: str, reference_date: datetime) -> DateRan
return _constraint(start, end)

if re.search(
r"\b(last\s+year|el\s+a[ñn]o\s+pasado|l\'anno\s+scorso|l\'ann[ée]e\s+derni[eè]re|letztes?\s+jahr)\b",
r"\b(last\s+year|el\s+a[ñn]o\s+pasado|l\'anno\s+scorso|l\'ann[ée]e\s+derni[eè]re|letztes?\s+jahr"
r"|(?:в\s+)?прошлом\s+году|прошлый\s+год)\b",
query,
re.IGNORECASE,
):
year = reference_date.year - 1
return _constraint(datetime(year, 1, 1), datetime(year, 12, 31))

if re.search(
r"\b(last\s+weekend|el\s+fin\s+de\s+semana\s+pasado|lo\s+scorso\s+fine\s+settimana|le\s+week-?end\s+dernier|letztes?\s+wochenende)\b",
r"\b(last\s+weekend|el\s+fin\s+de\s+semana\s+pasado|lo\s+scorso\s+fine\s+settimana|le\s+week-?end\s+dernier"
r"|letztes?\s+wochenende|(?:на\s+|в\s+)?прошлых?\s+выходных|прошлые\s+выходные)\b",
query,
re.IGNORECASE,
):
Expand All @@ -114,18 +140,18 @@ def _extract_non_chinese_period(query: str, reference_date: datetime) -> DateRan
return _constraint(sat, sat + timedelta(days=1))

month_patterns = {
"january|enero|gennaio|janvier|januar": 1,
"february|febrero|febbraio|f[ée]vrier|februar": 2,
"march|marzo|mars|m[äa]rz": 3,
"april|abril|aprile|avril": 4,
"may|mayo|maggio|mai": 5,
"june|junio|giugno|juin|juni": 6,
"july|julio|luglio|juillet|juli": 7,
"august|agosto|ao[uû]t": 8,
"september|septiembre|settembre|septembre": 9,
"october|octubre|ottobre|octobre|oktober": 10,
"november|noviembre|novembre": 11,
"december|diciembre|dicembre|d[ée]cembre|dezember": 12,
"january|enero|gennaio|janvier|januar|январ[ьяе]": 1,
"february|febrero|febbraio|f[ée]vrier|februar|феврал[ьяе]": 2,
"march|marzo|mars|m[äa]rz|март[ае]?": 3,
"april|abril|aprile|avril|апрел[ьяе]": 4,
"may|mayo|maggio|mai|ма[йяе]": 5,
"june|junio|giugno|juin|juni|июн[ьяе]": 6,
"july|julio|luglio|juillet|juli|июл[ьяе]": 7,
"august|agosto|ao[uû]t|август[ае]?": 8,
"september|septiembre|settembre|septembre|сентябр[ьяе]": 9,
"october|octubre|ottobre|octobre|oktober|октябр[ьяе]": 10,
"november|noviembre|novembre|ноябр[ьяе]": 11,
"december|diciembre|dicembre|d[ée]cembre|dezember|декабр[ьяе]": 12,
}
for pattern, month_num in month_patterns.items():
# Skip when a day number precedes the month ("13 июля 2026", "13 July 2026"):
Expand Down
60 changes: 58 additions & 2 deletions hindsight-api-slim/tests/test_query_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -903,13 +903,69 @@ def test_query_analyzer_keeps_real_month_with_leading_weak_word(query_analyzer):
assert analysis.temporal_constraint.start_date.month == 5


@pytest.mark.parametrize(
("query", "start", "end"),
[
# Relative days.
("что было вчера", datetime(2025, 1, 14), datetime(2025, 1, 14)),
("что было позавчера", datetime(2025, 1, 13), datetime(2025, 1, 13)),
("что сегодня делали", datetime(2025, 1, 15), datetime(2025, 1, 15)),
# Relative periods. Reference date is a Wednesday.
("что обсуждали на прошлой неделе", datetime(2025, 1, 6), datetime(2025, 1, 12)),
("что обсуждали прошлой неделе", datetime(2025, 1, 6), datetime(2025, 1, 12)),
("что было в прошлом месяце", datetime(2024, 12, 1), datetime(2024, 12, 31)),
("что было в прошлом году", datetime(2024, 1, 1), datetime(2024, 12, 31)),
("что делали на прошлых выходных", datetime(2025, 1, 11), datetime(2025, 1, 12)),
# Fuzzy spans, mirroring the English "couple/few ... ago" rules.
("что обсуждали пару дней назад", datetime(2025, 1, 12), datetime(2025, 1, 14)),
("что было несколько дней назад", datetime(2025, 1, 10), datetime(2025, 1, 13)),
("что обсуждали пару недель назад", datetime(2024, 12, 25), datetime(2025, 1, 8)),
("что было несколько месяцев назад", datetime(2024, 8, 18), datetime(2024, 11, 16)),
# Month + year. Russian months inflect: dateparser only resolves the nominative
# ("май"), but the prepositional ("в мае") is the only correct form after "в",
# and the genitive ("мая") is what appears in explicit dates.
("что обсуждали в мае 2024", datetime(2024, 5, 1), datetime(2024, 5, 31)),
("что было в июне 2024 года", datetime(2024, 6, 1), datetime(2024, 6, 30)),
("события января 2024", datetime(2024, 1, 1), datetime(2024, 1, 31)),
("декабрь 2023", datetime(2023, 12, 1), datetime(2023, 12, 31)),
],
)
def test_query_analyzer_russian_periods(query_analyzer, query, start, end):
"""Test deterministic Russian period extraction."""
reference_date = datetime(2025, 1, 15, 12, 0, 0)

analysis = query_analyzer.analyze(query, reference_date)

assert analysis.temporal_constraint is not None
assert analysis.temporal_constraint.start_date.date() == start.date()
assert analysis.temporal_constraint.end_date.date() == end.date()


@pytest.mark.parametrize(
"query",
[
"вчерашние новости не нужны",
"расскажи про майонез",
"мартовские тезисы",
],
)
def test_query_analyzer_russian_no_false_positives(query_analyzer, query):
"""Russian month/day stems inside longer words must not match."""
reference_date = datetime(2025, 1, 15, 12, 0, 0)

analysis = query_analyzer.analyze(query, reference_date)

assert analysis.temporal_constraint is None


@pytest.mark.parametrize(
("query", "expected"),
[
# A day number before the month means an exact date, not the whole month.
# The period table runs before dateparser, so without the day-number guard
# this collapsed to a month range. This is language-agnostic — it affects
# every language in the period table; the English case is shown here.
# these collapsed to a month range. Affects every language in the table.
("что обсуждали 13 июля 2024", datetime(2024, 7, 13)),
("встреча 3 июня 2024", datetime(2024, 6, 3)),
("meeting on 13 July 2024", datetime(2024, 7, 13)),
],
)
Expand Down