diff --git a/hindsight-api-slim/hindsight_api/engine/temporal_periods.py b/hindsight-api-slim/hindsight_api/engine/temporal_periods.py index fb13cb2084..19a834a3dd 100644 --- a/hindsight-api-slim/hindsight_api/engine/temporal_periods.py +++ b/hindsight-api-slim/hindsight_api/engine/temporal_periods.py @@ -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): @@ -64,20 +68,39 @@ 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, ): @@ -85,7 +108,8 @@ def _extract_non_chinese_period(query: str, reference_date: datetime) -> DateRan 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, ): @@ -95,7 +119,8 @@ 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, ): @@ -103,7 +128,8 @@ def _extract_non_chinese_period(query: str, reference_date: datetime) -> DateRan 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, ): @@ -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"): diff --git a/hindsight-api-slim/tests/test_query_analyzer.py b/hindsight-api-slim/tests/test_query_analyzer.py index fc0f5f9f8c..e401687260 100644 --- a/hindsight-api-slim/tests/test_query_analyzer.py +++ b/hindsight-api-slim/tests/test_query_analyzer.py @@ -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)), ], )