Coverage for src / lilbee / temporal.py: 100%
38 statements
« prev ^ index » next coverage.py v7.13.4, created at 2026-04-29 19:16 +0000
« prev ^ index » next coverage.py v7.13.4, created at 2026-04-29 19:16 +0000
1"""Temporal keyword detection and date range filtering for search results.
3Detects natural language temporal expressions (e.g., "recent", "last week",
4"yesterday") and converts them to date ranges for filtering search results
5by document ingestion date or frontmatter date.
6"""
8from __future__ import annotations
10import re
11from collections.abc import Callable
12from datetime import UTC, datetime, timedelta
13from typing import NamedTuple
16class DateRange(NamedTuple):
17 """A date range for temporal filtering."""
19 start: datetime
20 end: datetime
23# Temporal keywords mapped to date range generators.
24# Each value is a callable taking "now" and returning a DateRange.
25_TEMPORAL_KEYWORDS: dict[str, str] = {
26 "today": "today",
27 "yesterday": "yesterday",
28 "this week": "this_week",
29 "last week": "last_week",
30 "this month": "this_month",
31 "last month": "last_month",
32 "recent": "recent",
33 "recently": "recent",
34 "latest": "recent",
35 "newest": "recent",
36}
38_KEYWORD_PATTERN = re.compile(
39 r"\b(" + "|".join(re.escape(k) for k in _TEMPORAL_KEYWORDS) + r")\b",
40 re.IGNORECASE,
41)
44def detect_temporal(query: str) -> str | None:
45 """Detect temporal keywords in a query. Returns the keyword or None."""
46 match = _KEYWORD_PATTERN.search(query)
47 if match:
48 return _TEMPORAL_KEYWORDS.get(match.group(1).lower())
49 return None
52def _today(now: datetime, today_start: datetime) -> DateRange:
53 return DateRange(start=today_start, end=now)
56def _yesterday(now: datetime, today_start: datetime) -> DateRange:
57 return DateRange(start=today_start - timedelta(days=1), end=today_start)
60def _this_week(now: datetime, today_start: datetime) -> DateRange:
61 return DateRange(start=today_start - timedelta(days=now.weekday()), end=now)
64def _last_week(now: datetime, today_start: datetime) -> DateRange:
65 this_week_start = today_start - timedelta(days=now.weekday())
66 return DateRange(start=this_week_start - timedelta(weeks=1), end=this_week_start)
69def _this_month(now: datetime, today_start: datetime) -> DateRange:
70 return DateRange(start=today_start.replace(day=1), end=now)
73def _last_month(now: datetime, today_start: datetime) -> DateRange:
74 this_month_start = today_start.replace(day=1)
75 return DateRange(
76 start=(this_month_start - timedelta(days=1)).replace(day=1), end=this_month_start
77 )
80def _recent(now: datetime, today_start: datetime) -> DateRange:
81 return DateRange(start=now - timedelta(days=30), end=now)
84_RANGE_RESOLVERS: dict[str, Callable[[datetime, datetime], DateRange]] = {
85 "today": _today,
86 "yesterday": _yesterday,
87 "this_week": _this_week,
88 "last_week": _last_week,
89 "this_month": _this_month,
90 "last_month": _last_month,
91 "recent": _recent,
92}
95def resolve_date_range(keyword: str, now: datetime | None = None) -> DateRange:
96 """Convert a temporal keyword to a concrete date range."""
97 if now is None:
98 now = datetime.now(tz=UTC)
99 today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
100 resolver = _RANGE_RESOLVERS.get(keyword, _recent)
101 return resolver(now, today_start)