Skip to content

Commit d28b852

Browse files
fix(query-analyzer): pick strongest dateparser match, not the leftmost (#2768) (#2772)
dateparser.search_dates over-matches: short common words that are weekday or month abbreviations in some language ("we"/"me"/"did" resolve to a weekday, "do" to Sunday) come back as bogus dates. The analyzer took the first valid match, so when a false positive appeared before the real date the query got a plausible-but-wrong temporal window — worse than none, since the constraint is non-null and nothing downstream can tell that extraction failed. The previous defence was a hard-coded blacklist of such words, which is a moving target (every short word dateparser resolves is a new instance of the same bug) and was already partly dead code: the `len(text) > 3` escape hatch re-admitted every multi-character entry, so only the <=3-char words did any work. The bug also depends on the dateparser version — 1.4.1 (the version shipped in the published image) added "we" as an English Wednesday abbreviation that survives `languages=["en"]` scoping, while the locked 1.2.2 does not — so language scoping is not a stable fix either. Replace the blacklist + leftmost selection with a signal score: each match is scored by the date content it actually carries (a digit is strongest, then explicit month/relative words, then weekday/period words). Matches with no signal (bare abbreviations) score zero and are rejected; among the rest the strongest wins, ties broken by longest span. This subsumes the entire blacklist and is independent of language and dateparser version. Tested (Friday reference date, where these abbreviations resolve): - "what did we discuss" -> no constraint (was 07-12/07-15) - "tell me what we decided on 2026-06-10" -> 2026-06-10 (was 07-15) - "what did we discuss in May" -> May (unchanged, now robust) Regression tests assert analyzer output, never raw dateparser spans, so they hold across dateparser versions. Co-authored-by: Nicolò Boschi <boschi1997@gmail.com>
1 parent 6e03dd2 commit d28b852

2 files changed

Lines changed: 190 additions & 9 deletions

File tree

hindsight-api-slim/hindsight_api/engine/query_analyzer.py

Lines changed: 110 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
"""
77

88
import logging
9+
import re
910
from abc import ABC, abstractmethod
1011
from datetime import datetime, timedelta
1112

@@ -19,6 +20,103 @@
1920

2021
logger = logging.getLogger(__name__)
2122

23+
# dateparser.search_dates over-matches: short common words that happen to be
24+
# weekday/month abbreviations in *some* language ("we"/"me"/"did" -> a weekday,
25+
# "do" -> Sunday) come back as bogus dates. When such a false positive appears
26+
# *before* the real date in the query, taking the first match (or a hard-coded
27+
# blacklist of such words) silently produces a wrong temporal window — worse
28+
# than none, because the constraint is non-null so nothing downstream can tell
29+
# extraction failed. See issue #2768.
30+
#
31+
# Instead of blacklisting words one at a time (a moving target — every short
32+
# word dateparser resolves is a new instance of the same bug), we score each
33+
# match by the date signal it actually carries and keep only matches with a
34+
# real signal, preferring the strongest. A bare weekday abbreviation carries no
35+
# day/month/year and scores zero, so it is rejected regardless of language or
36+
# dateparser version.
37+
_TOKEN_RE = re.compile(r"[a-z0-9]+")
38+
_MONTH_WORDS = {
39+
"january",
40+
"february",
41+
"march",
42+
"april",
43+
"may",
44+
"june",
45+
"july",
46+
"august",
47+
"september",
48+
"october",
49+
"november",
50+
"december",
51+
}
52+
_RELATIVE_WORDS = {"today", "yesterday", "tomorrow", "tonight", "now"}
53+
_WEEKDAY_WORDS = {
54+
"monday",
55+
"tuesday",
56+
"wednesday",
57+
"thursday",
58+
"friday",
59+
"saturday",
60+
"sunday",
61+
}
62+
_PERIOD_WORDS = {
63+
"last",
64+
"next",
65+
"this",
66+
"past",
67+
"coming",
68+
"ago",
69+
"week",
70+
"weeks",
71+
"month",
72+
"months",
73+
"year",
74+
"years",
75+
"day",
76+
"days",
77+
"hour",
78+
"hours",
79+
"minute",
80+
"minutes",
81+
"quarter",
82+
"decade",
83+
"century",
84+
"weekend",
85+
"morning",
86+
"afternoon",
87+
"evening",
88+
"night",
89+
"noon",
90+
"midnight",
91+
}
92+
93+
94+
def _date_match_score(text: str) -> int:
95+
"""Score how strong a temporal signal a matched span carries.
96+
97+
A score of 0 means the span is a bare token with no explicit date content
98+
(the false-positive class from issue #2768) and should be rejected. Higher
99+
scores mean a stronger, less ambiguous date reference. A digit is the
100+
strongest signal (day/year/ISO date); an explicit English month/relative
101+
word next; weekday names and period words weakest but still explicit.
102+
"""
103+
tokens = _TOKEN_RE.findall(text.lower())
104+
if not tokens:
105+
return 0
106+
score = 0
107+
if any(any(ch.isdigit() for ch in tok) for tok in tokens):
108+
score += 100
109+
token_set = set(tokens)
110+
if token_set & _MONTH_WORDS:
111+
score += 50
112+
if token_set & _RELATIVE_WORDS:
113+
score += 50
114+
if token_set & _WEEKDAY_WORDS:
115+
score += 30
116+
if token_set & _PERIOD_WORDS:
117+
score += 20
118+
return score
119+
22120

23121
class TemporalConstraint(BaseModel):
24122
"""
@@ -164,20 +262,23 @@ def analyze(self, query: str, reference_date: datetime | None = None) -> QueryAn
164262
if not results:
165263
return QueryAnalysis(temporal_constraint=None)
166264

167-
# Filter out false positives (common words parsed as dates)
168-
false_positives = {"do", "may", "march", "will", "can", "sat", "sun", "mon", "tue", "wed", "thu", "fri"}
169-
valid_results = [
170-
(text, date)
265+
# Score each match by the date signal it carries and keep only those
266+
# with a real signal, rejecting bare weekday/month-abbreviation false
267+
# positives ("we"/"me"/"did"). Prefer the strongest match, breaking ties
268+
# by longest span, so an explicit date ("in May", "2026-06-10") always
269+
# beats an earlier weak word regardless of position. See issue #2768.
270+
scored_results = [
271+
(_date_match_score(text), len(text), date)
171272
for text, date in results
172-
if (text.lower() not in false_positives or len(text) > 3)
173-
and not is_embedded_cjk_dateparser_match(query, text)
273+
if not is_embedded_cjk_dateparser_match(query, text)
174274
]
275+
scored_results = [entry for entry in scored_results if entry[0] > 0]
175276

176-
if not valid_results:
277+
if not scored_results:
177278
return QueryAnalysis(temporal_constraint=None)
178279

179-
# Use the first valid date found
180-
_, parsed_date = valid_results[0]
280+
# Highest signal score wins; ties broken by the longest matched span.
281+
_, _, parsed_date = max(scored_results, key=lambda entry: (entry[0], entry[1]))
181282

182283
# Create constraint for single day
183284
start_date = parsed_date.replace(hour=0, minute=0, second=0, microsecond=0)

hindsight-api-slim/tests/test_query_analyzer.py

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -827,6 +827,86 @@ def boom(*args, **kwargs):
827827
assert any("dateparser" in rec.message for rec in caplog.records), "Should log a warning when dateparser fails"
828828

829829

830+
# ---------------------------------------------------------------------------
831+
# Regression tests for issue #2768: query_analyzer must pick the strongest
832+
# dateparser match, not the leftmost one, and reject bare weekday/month
833+
# abbreviations ("we"/"me"/"did") that carry no real date signal.
834+
# ---------------------------------------------------------------------------
835+
836+
837+
@pytest.mark.parametrize(
838+
"text",
839+
["we", "me", "did", "do", "wed", "sat", "will", "can"],
840+
)
841+
def test_date_match_score_rejects_bare_false_positives(text):
842+
"""Bare words that dateparser resolves to weekday abbreviations carry no
843+
real date signal and must score zero (so they are rejected)."""
844+
from hindsight_api.engine.query_analyzer import _date_match_score
845+
846+
assert _date_match_score(text) == 0
847+
848+
849+
@pytest.mark.parametrize(
850+
"text",
851+
["in May", "March 2024", "2026-06-10", "yesterday", "last week", "on Wednesday"],
852+
)
853+
def test_date_match_score_keeps_real_signals(text):
854+
"""Spans carrying an explicit date signal (digit, month, relative word,
855+
weekday name, period word) must score above zero."""
856+
from hindsight_api.engine.query_analyzer import _date_match_score
857+
858+
assert _date_match_score(text) > 0
859+
860+
861+
def test_date_match_score_prefers_digit_over_weak_word():
862+
"""An explicit date (digit) must outscore a weekday/month word so ranking
863+
picks it even when it appears later in the query."""
864+
from hindsight_api.engine.query_analyzer import _date_match_score
865+
866+
assert _date_match_score("2026-06-10") > _date_match_score("on Wednesday")
867+
868+
869+
def test_query_analyzer_rejects_bare_word_false_positive(query_analyzer):
870+
"""#2768 rejection: a query whose only dateparser match is a bare word
871+
("we"/"did" depending on version) must yield no temporal constraint rather
872+
than a plausible-but-wrong date. Reference date is a Friday, which is when
873+
these weekday abbreviations resolve."""
874+
reference_date = datetime(2026, 7, 17, 12, 0, 0) # Friday
875+
876+
analysis = query_analyzer.analyze("what did we discuss", reference_date)
877+
878+
assert analysis.temporal_constraint is None, (
879+
"A bare false-positive word must not produce a temporal constraint"
880+
)
881+
882+
883+
def test_query_analyzer_prefers_explicit_date_over_leading_weak_word(query_analyzer):
884+
"""#2768 ranking: an explicit date must win over an earlier weak word
885+
("me"/"we") regardless of position in the query."""
886+
reference_date = datetime(2026, 7, 17, 12, 0, 0) # Friday
887+
888+
analysis = query_analyzer.analyze(
889+
"tell me what we decided on 2026-06-10", reference_date
890+
)
891+
892+
assert analysis.temporal_constraint is not None, "Should extract the explicit date"
893+
assert analysis.temporal_constraint.start_date.year == 2026
894+
assert analysis.temporal_constraint.start_date.month == 6
895+
assert analysis.temporal_constraint.start_date.day == 10
896+
897+
898+
def test_query_analyzer_keeps_real_month_with_leading_weak_word(query_analyzer):
899+
"""#2768: the real month must survive even when a weak word precedes it in
900+
the query."""
901+
reference_date = datetime(2026, 7, 17, 12, 0, 0) # Friday
902+
903+
analysis = query_analyzer.analyze("what did we discuss in May", reference_date)
904+
905+
assert analysis.temporal_constraint is not None, "Should extract the real month"
906+
assert analysis.temporal_constraint.start_date.year == 2026
907+
assert analysis.temporal_constraint.start_date.month == 5
908+
909+
830910
@pytest.mark.parametrize(
831911
("query", "expected"),
832912
[

0 commit comments

Comments
 (0)