Skip to content
Closed
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
11 changes: 11 additions & 0 deletions hindsight-api-slim/hindsight_api/engine/query_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,14 @@

logger = logging.getLogger(__name__)

# Temporal parsing is synchronous regex-heavy work. Recall normally receives
# query-sized inputs, but internal callers such as consolidation can pass a
# full memory as the query. For the repetition reported in issue #3134, 512
# characters parsed in under a second on reference hardware while 4096 took
# roughly seven seconds. Temporal filtering is optional, so longer inputs fail
# open to non-temporal retrieval; accepted inputs are also parsed off-loop.
_MAX_TEMPORAL_ANALYSIS_CHARS = 512

# dateparser.search_dates over-matches: short common words that happen to be
# weekday/month abbreviations in *some* language ("we"/"me"/"did" -> a weekday,
# "do" -> Sunday) come back as bogus dates. When such a false positive appears
Expand Down Expand Up @@ -221,6 +229,9 @@ def analyze(self, query: str, reference_date: datetime | None = None) -> QueryAn
Returns:
QueryAnalysis with temporal_constraint if found
"""
if len(query) > _MAX_TEMPORAL_ANALYSIS_CHARS:
return QueryAnalysis(temporal_constraint=None)

if reference_date is None:
reference_date = datetime.now()

Expand Down
78 changes: 77 additions & 1 deletion hindsight-api-slim/hindsight_api/engine/search/retrieval.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,11 @@
import asyncio
import logging
import re
from concurrent.futures import Future, ThreadPoolExecutor
from dataclasses import dataclass, field
from datetime import UTC, datetime
from functools import partial
from threading import BoundedSemaphore, Event
from typing import TYPE_CHECKING, Any, Optional

from ...config import DEFAULT_BM25_MAX_QUERY_TERMS, DEFAULT_TEMPORAL_SEMANTIC_MIN_SIMILARITY, get_config
Expand All @@ -29,6 +32,26 @@

logger = logging.getLogger(__name__)

_TEMPORAL_ANALYSIS_WORKERS = 2
_TEMPORAL_ANALYSIS_CAPACITY = 4
_TEMPORAL_ANALYSIS_TIMEOUT_SECONDS = 1.0
_TEMPORAL_ANALYSIS_EXECUTOR = ThreadPoolExecutor(
max_workers=_TEMPORAL_ANALYSIS_WORKERS,
thread_name_prefix="hindsight-temporal",
)
_TEMPORAL_ANALYSIS_SLOTS = BoundedSemaphore(_TEMPORAL_ANALYSIS_CAPACITY)
_TEMPORAL_ANALYSIS_DISABLED = Event()


def _release_temporal_analysis_slot(_future: Future[Any], *, slots: BoundedSemaphore) -> None:
"""Release capacity only when parser work actually stops.

Cancelling the awaiting recall cannot stop a running thread. Releasing from
the concurrent future's callback prevents cancelled work from admitting a
replacement and silently creating an unbounded executor queue.
"""
slots.release()


def tokenize_query(query_text: str) -> list[str]:
"""Normalize query text and split into BM25 tokens.
Expand Down Expand Up @@ -881,9 +904,62 @@ async def retrieve_all_fact_types_parallel(
# Step 1: Extract temporal constraint first (CPU work, no DB)
# Do this before DB queries so we know if we need temporal retrieval
temporal_extraction_start = time.time()
from ..query_analyzer import _MAX_TEMPORAL_ANALYSIS_CHARS, DateparserQueryAnalyzer
from .temporal_extraction import extract_temporal_constraint

temporal_constraint = extract_temporal_constraint(query_text, reference_date=question_date, analyzer=query_analyzer)
# dateparser performs synchronous regex-heavy work. Keep it in an isolated
# executor with a two-item bounded queue. Normal short bursts can wait for
# a worker, while overload or slow parsing fails open after a short deadline
# instead of starving unrelated executor users or the event loop.
analysis_slots = _TEMPORAL_ANALYSIS_SLOTS
uses_default_dateparser = query_analyzer is None or type(query_analyzer) is DateparserQueryAnalyzer
if not uses_default_dateparser:
# Custom analyzers predate the bounded dateparser path. Preserve their
# synchronous calling and threading semantics because the interface
# does not require implementations to be thread-safe.
temporal_constraint = extract_temporal_constraint(
query_text,
reference_date=question_date,
analyzer=query_analyzer,
)
elif (
len(query_text) > _MAX_TEMPORAL_ANALYSIS_CHARS
or _TEMPORAL_ANALYSIS_DISABLED.is_set()
or not analysis_slots.acquire(blocking=False)
):
temporal_constraint = None
else:
try:
temporal_future = _TEMPORAL_ANALYSIS_EXECUTOR.submit(
extract_temporal_constraint,
query_text,
reference_date=question_date,
analyzer=query_analyzer,
)
except RuntimeError:
# submit() may have enqueued before worker startup failed. Keep the
# slot reserved and trip a circuit breaker rather than admitting
# replacement work into an ambiguous queue.
_TEMPORAL_ANALYSIS_DISABLED.set()
logger.error("Temporal analysis executor unavailable; disabling temporal analysis")
temporal_constraint = None
else:
temporal_future.add_done_callback(partial(_release_temporal_analysis_slot, slots=analysis_slots))
try:
temporal_constraint = await asyncio.wait_for(
asyncio.shield(asyncio.wrap_future(temporal_future)),
timeout=_TEMPORAL_ANALYSIS_TIMEOUT_SECONDS,
)
except TimeoutError:
# Shielding keeps queued or running work tied to its capacity
# slot until the concurrent-future callback sees it really end.
temporal_constraint = None
except Exception as exc:
logger.warning(
"Temporal analysis failed with %s; continuing without a temporal filter",
type(exc).__name__,
)
temporal_constraint = None
temporal_extraction_time = time.time() - temporal_extraction_start
timings["temporal_extraction"] = temporal_extraction_time

Expand Down
32 changes: 32 additions & 0 deletions hindsight-api-slim/tests/test_query_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,38 @@ def test_query_analyzer_no_temporal(query_analyzer):
assert analysis.temporal_constraint is None, "Should not extract temporal constraint"


def test_query_analyzer_skips_oversized_queries_before_parsing(query_analyzer, monkeypatch):
from hindsight_api.engine import query_analyzer as query_analyzer_module

def fail_extract_period(query, reference_date):
pytest.fail("oversized query reached temporal period parsing")

monkeypatch.setattr(query_analyzer_module, "extract_period", fail_extract_period)
monkeypatch.setattr(
query_analyzer,
"load",
lambda: pytest.fail("oversized query reached dateparser"),
)

query = "x" * (query_analyzer_module._MAX_TEMPORAL_ANALYSIS_CHARS + 1)
analysis = query_analyzer.analyze(query, datetime(2025, 1, 15, 12, 0, 0))

assert analysis.temporal_constraint is None


@pytest.mark.timeout(5)
def test_query_analyzer_pathological_query_at_limit_is_bounded(query_analyzer):
from hindsight_api.engine import query_analyzer as query_analyzer_module

repeated_word = "mandatory "
repetitions = query_analyzer_module._MAX_TEMPORAL_ANALYSIS_CHARS // len(repeated_word) + 1
query = (repeated_word * repetitions)[: query_analyzer_module._MAX_TEMPORAL_ANALYSIS_CHARS]

analysis = query_analyzer.analyze(query, datetime(2025, 1, 15, 12, 0, 0))

assert analysis.temporal_constraint is None


def test_query_analyzer_activities_june_2024(query_analyzer):
reference_date = datetime(2025, 1, 15, 12, 0, 0)

Expand Down
Loading