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

1"""Temporal keyword detection and date range filtering for search results. 

2 

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""" 

7 

8from __future__ import annotations 

9 

10import re 

11from collections.abc import Callable 

12from datetime import UTC, datetime, timedelta 

13from typing import NamedTuple 

14 

15 

16class DateRange(NamedTuple): 

17 """A date range for temporal filtering.""" 

18 

19 start: datetime 

20 end: datetime 

21 

22 

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} 

37 

38_KEYWORD_PATTERN = re.compile( 

39 r"\b(" + "|".join(re.escape(k) for k in _TEMPORAL_KEYWORDS) + r")\b", 

40 re.IGNORECASE, 

41) 

42 

43 

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 

50 

51 

52def _today(now: datetime, today_start: datetime) -> DateRange: 

53 return DateRange(start=today_start, end=now) 

54 

55 

56def _yesterday(now: datetime, today_start: datetime) -> DateRange: 

57 return DateRange(start=today_start - timedelta(days=1), end=today_start) 

58 

59 

60def _this_week(now: datetime, today_start: datetime) -> DateRange: 

61 return DateRange(start=today_start - timedelta(days=now.weekday()), end=now) 

62 

63 

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) 

67 

68 

69def _this_month(now: datetime, today_start: datetime) -> DateRange: 

70 return DateRange(start=today_start.replace(day=1), end=now) 

71 

72 

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 ) 

78 

79 

80def _recent(now: datetime, today_start: datetime) -> DateRange: 

81 return DateRange(start=now - timedelta(days=30), end=now) 

82 

83 

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} 

93 

94 

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)