mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-07-27 21:05:34 +00:00
Fix upcoming Google Calendar event retrieval (#617)
Closes #388. Parse Google Calendar all-day events from start.date instead of stamping them with the current time; treat generic next/upcoming calendar-event queries as gcalendar timeline requests that return nearest-future events first (UTC-normalized, instant-aware comparison that handles tz offsets and all-day events); and update the research planner guidance to route such queries with sources=[gcalendar] + a today-onward time_range. Real in-memory KnowledgeStore integration tests cover ordering, tz normalization, all-day inclusion, and source narrowing. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
904133cb25
commit
c686517cc7
@@ -143,10 +143,11 @@ Strategy:
|
||||
3. The `time_range` argument is a JSON object: `{{"start": "<ISO 8601>", "end": "<ISO 8601>"}}`. Either bound may be omitted, but pass at least one whenever the user gave you a temporal cue.
|
||||
4. When the user names a specific data source — "my Granola notes", "in Slack", "from my email" — you MUST pass `sources=[...]` with the matching connector ID. Only use IDs that appear in the connected-sources list above; do NOT invent or assume sources that are not connected. Common synonyms: "meeting notes"/"meetings"/"transcripts" → granola; "email"/"inbox" → gmail; "DMs"/"channels" → slack. Without this filter the search returns mail/messages ABOUT a tool instead of records FROM that tool.
|
||||
4a. Never apologize about sources that aren't in the connected-sources list — if the user asks about "Notion" but Notion isn't connected, just say "Notion isn't connected, but here's what I found in {available_sources}" and answer from what is available.
|
||||
5. If the first structured search returns nothing useful, broaden with a semantic query and drop filters one at a time.
|
||||
6. You have a clarify tool. Only use it AFTER at least one search attempt. Use it when: you found multiple ambiguous matches (e.g. 3 different people named John), search returned zero results and the query might need reframing, or the scope is too broad to synthesize meaningfully. Never use clarify before searching — always try first.
|
||||
7. After receiving a clarify response, use the information to construct a precise search with the correct person, time_range, sources, and query parameters. Never send an empty query or a query with no parameters — extract every concrete signal from the user's reply (names, dates, topics, sources) and put it on the call.
|
||||
8. Tool calls — search AND clarify — share a budget of 5 total. Spend wisely.
|
||||
5. When the user asks for "next", "upcoming", "future", or "soon" calendar events/meetings/appointments, use `sources=["gcalendar"]` if gcalendar is connected, set `time_range={{"start": "{today}"}}`, and use `query=""` unless the user gave a specific topic such as "dentist" or "music lesson". This returns the nearest upcoming calendar items across calendars instead of keyword-matching only birthdays or event titles.
|
||||
6. If the first structured search returns nothing useful, broaden with a semantic query and drop filters one at a time.
|
||||
7. You have a clarify tool. Only use it AFTER at least one search attempt. Use it when: you found multiple ambiguous matches (e.g. 3 different people named John), search returned zero results and the query might need reframing, or the scope is too broad to synthesize meaningfully. Never use clarify before searching — always try first.
|
||||
8. After receiving a clarify response, use the information to construct a precise search with the correct person, time_range, sources, and query parameters. Only use an empty query when structured filters carry the request; never send a search with no concrete parameters. Extract every concrete signal from the user's reply (names, dates, topics, sources) and put it on the call.
|
||||
9. Tool calls — search AND clarify — share a budget of 5 total. Spend wisely.
|
||||
|
||||
Synthesis rules:
|
||||
- Cite sources as individual numbers in square brackets. Always separate — write [4] [7] [20], never [4, 7, 20]. Never format citations as markdown links. Just the number in brackets: [1]. The `ref` field on each hit is the citation number.
|
||||
|
||||
@@ -213,12 +213,13 @@ def _parse_event_timestamp(event: Dict[str, Any]) -> datetime:
|
||||
"""
|
||||
start = event.get("start", {})
|
||||
date_time_str: str = start.get("dateTime", "")
|
||||
if not date_time_str:
|
||||
date_str: str = start.get("date", "")
|
||||
if not date_time_str and not date_str:
|
||||
return datetime.now()
|
||||
try:
|
||||
# RFC3339 — Python 3.11+ fromisoformat handles the trailing 'Z'.
|
||||
# For older versions we replace 'Z' with '+00:00'.
|
||||
normalized = date_time_str.replace("Z", "+00:00")
|
||||
normalized = (date_time_str or date_str).replace("Z", "+00:00")
|
||||
return datetime.fromisoformat(normalized)
|
||||
except (ValueError, TypeError):
|
||||
return datetime.now()
|
||||
|
||||
@@ -20,8 +20,9 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from typing import Any, Dict, List, Optional, Sequence, Tuple
|
||||
|
||||
# numpy imported lazily inside _vector_recall (see embeddings.py) so importing
|
||||
@@ -32,6 +33,61 @@ from openjarvis.connectors.store import KnowledgeStore
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
_UPCOMING_TERMS = {
|
||||
"next",
|
||||
"upcoming",
|
||||
"future",
|
||||
"forthcoming",
|
||||
"coming",
|
||||
"soon",
|
||||
}
|
||||
_CALENDAR_TERMS = {
|
||||
"calendar",
|
||||
"calendars",
|
||||
"event",
|
||||
"events",
|
||||
}
|
||||
_CALENDAR_REQUEST_TERMS = _CALENDAR_TERMS | {
|
||||
"appointment",
|
||||
"appointments",
|
||||
"meeting",
|
||||
"meetings",
|
||||
"schedule",
|
||||
}
|
||||
_GCALENDAR_GENERIC_TERMS = _UPCOMING_TERMS | _CALENDAR_TERMS | {
|
||||
"appointment",
|
||||
"appointments",
|
||||
"meeting",
|
||||
"meetings",
|
||||
"schedule",
|
||||
}
|
||||
_QUERY_STOPWORDS = {
|
||||
"a",
|
||||
"all",
|
||||
"am",
|
||||
"are",
|
||||
"do",
|
||||
"for",
|
||||
"have",
|
||||
"i",
|
||||
"in",
|
||||
"is",
|
||||
"list",
|
||||
"me",
|
||||
"my",
|
||||
"on",
|
||||
"s",
|
||||
"show",
|
||||
"tell",
|
||||
"the",
|
||||
"there",
|
||||
"to",
|
||||
"what",
|
||||
"whats",
|
||||
"when",
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Result types
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -120,6 +176,101 @@ def _snippet(content: str, max_chars: int = 500) -> str:
|
||||
return flat[:max_chars].rstrip() + "…"
|
||||
|
||||
|
||||
def _query_tokens(query: str) -> set[str]:
|
||||
return set(re.findall(r"[a-z0-9_]+", query.lower()))
|
||||
|
||||
|
||||
def _sources_include_gcalendar(sources: Optional[Sequence[str]]) -> bool:
|
||||
return any(str(source).lower() == "gcalendar" for source in sources or [])
|
||||
|
||||
|
||||
def _has_upcoming_calendar_intent(
|
||||
query: str,
|
||||
sources: Optional[Sequence[str]],
|
||||
) -> bool:
|
||||
tokens = _query_tokens(query)
|
||||
if not tokens or not (tokens & _UPCOMING_TERMS):
|
||||
return False
|
||||
if _sources_include_gcalendar(sources):
|
||||
return True
|
||||
if sources:
|
||||
return False
|
||||
return bool(tokens & _CALENDAR_REQUEST_TERMS)
|
||||
|
||||
|
||||
def _is_generic_calendar_timeline_query(query: str) -> bool:
|
||||
tokens = _query_tokens(query)
|
||||
if not tokens:
|
||||
return True
|
||||
topic_tokens = tokens - _GCALENDAR_GENERIC_TERMS - _QUERY_STOPWORDS
|
||||
return not topic_tokens
|
||||
|
||||
|
||||
def _start_is_nowish_or_future(start: Optional[datetime]) -> bool:
|
||||
if start is None:
|
||||
return False
|
||||
now = datetime.now(tz=start.tzinfo) if start.tzinfo else datetime.now()
|
||||
return start >= now - timedelta(days=1)
|
||||
|
||||
|
||||
def _start_of_day(ts: datetime) -> datetime:
|
||||
return ts.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
|
||||
def _as_utc(ts: Optional[datetime]) -> Optional[datetime]:
|
||||
if ts is None:
|
||||
return None
|
||||
if ts.tzinfo is None:
|
||||
return ts.replace(tzinfo=timezone.utc)
|
||||
return ts.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def _parse_timestamp_for_timeline(
|
||||
raw: Any,
|
||||
) -> Tuple[Optional[datetime], Optional[date]]:
|
||||
if raw is None:
|
||||
return None, None
|
||||
text = str(raw).strip()
|
||||
if not text:
|
||||
return None, None
|
||||
try:
|
||||
parsed = datetime.fromisoformat(text.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
return None, None
|
||||
is_naive_midnight = (
|
||||
parsed.tzinfo is None
|
||||
and parsed.hour == 0
|
||||
and parsed.minute == 0
|
||||
and parsed.second == 0
|
||||
and parsed.microsecond == 0
|
||||
)
|
||||
return _as_utc(parsed), parsed.date() if is_naive_midnight else None
|
||||
|
||||
|
||||
def _timestamp_in_range(
|
||||
timestamp: Optional[datetime],
|
||||
time_range: Optional[Tuple[Optional[datetime], Optional[datetime]]],
|
||||
*,
|
||||
all_day_date: Optional[date] = None,
|
||||
) -> bool:
|
||||
if timestamp is None or time_range is None:
|
||||
return timestamp is not None
|
||||
start, end = time_range
|
||||
if all_day_date is not None:
|
||||
if start is not None and all_day_date < start.date():
|
||||
return False
|
||||
if end is not None and all_day_date > end.date():
|
||||
return False
|
||||
return True
|
||||
start_utc = _as_utc(start)
|
||||
end_utc = _as_utc(end)
|
||||
if start_utc is not None and timestamp < start_utc:
|
||||
return False
|
||||
if end_utc is not None and timestamp > end_utc:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# HybridSearch
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -377,6 +528,128 @@ class HybridSearch:
|
||||
for r in rows
|
||||
]
|
||||
|
||||
def _normalise_calendar_timeline_scope(
|
||||
self,
|
||||
query: str,
|
||||
time_range: Optional[Tuple[Optional[datetime], Optional[datetime]]],
|
||||
sources: Optional[Sequence[str]],
|
||||
) -> Tuple[
|
||||
Optional[Tuple[Optional[datetime], Optional[datetime]]],
|
||||
Optional[Sequence[str]],
|
||||
bool,
|
||||
bool,
|
||||
]:
|
||||
"""Fill in structured filters for generic upcoming-calendar requests.
|
||||
|
||||
Queries like "what are my next calendar events?" often have no useful
|
||||
lexical terms in the stored event text, so BM25/vector ranking can miss
|
||||
nearby events. Treat that shape as a source-filtered timeline request.
|
||||
"""
|
||||
scoped_sources = list(sources) if sources else None
|
||||
has_upcoming_intent = _has_upcoming_calendar_intent(query, scoped_sources)
|
||||
|
||||
if has_upcoming_intent and (
|
||||
scoped_sources is None or _sources_include_gcalendar(scoped_sources)
|
||||
):
|
||||
scoped_sources = ["gcalendar"]
|
||||
|
||||
if not _sources_include_gcalendar(scoped_sources):
|
||||
return time_range, scoped_sources, False, False
|
||||
|
||||
if has_upcoming_intent:
|
||||
if time_range is None:
|
||||
time_range = (_start_of_day(datetime.now(timezone.utc)), None)
|
||||
else:
|
||||
start, end = time_range
|
||||
if start is None:
|
||||
time_range = (_start_of_day(datetime.now(timezone.utc)), end)
|
||||
else:
|
||||
time_range = (_start_of_day(start), end)
|
||||
|
||||
chronological = has_upcoming_intent or (
|
||||
time_range is not None
|
||||
and time_range[1] is None
|
||||
and _start_is_nowish_or_future(time_range[0])
|
||||
)
|
||||
metadata_only = chronological and _is_generic_calendar_timeline_query(query)
|
||||
return time_range, scoped_sources, chronological, metadata_only
|
||||
|
||||
def _calendar_timeline_ids(
|
||||
self,
|
||||
*,
|
||||
person: Optional[str],
|
||||
time_range: Optional[Tuple[Optional[datetime], Optional[datetime]]],
|
||||
sources: Optional[Sequence[str]],
|
||||
limit: int,
|
||||
) -> List[str]:
|
||||
"""Return gcalendar rows sorted by normalized event start time."""
|
||||
filter_sql, filter_params = self._build_filters(
|
||||
person=person,
|
||||
time_range=None,
|
||||
sources=sources,
|
||||
)
|
||||
rows = self._store._conn.execute(
|
||||
f"""
|
||||
SELECT id, timestamp, created_at
|
||||
FROM knowledge_chunks
|
||||
WHERE {filter_sql}
|
||||
""",
|
||||
filter_params,
|
||||
).fetchall()
|
||||
|
||||
candidates: List[Tuple[str, datetime, float]] = []
|
||||
for row in rows:
|
||||
timestamp, all_day_date = _parse_timestamp_for_timeline(row["timestamp"])
|
||||
if not _timestamp_in_range(
|
||||
timestamp,
|
||||
time_range,
|
||||
all_day_date=all_day_date,
|
||||
):
|
||||
continue
|
||||
candidates.append(
|
||||
(
|
||||
row["id"],
|
||||
timestamp or datetime.max.replace(tzinfo=timezone.utc),
|
||||
float(row["created_at"] or 0.0),
|
||||
)
|
||||
)
|
||||
|
||||
candidates.sort(key=lambda item: (item[1], item[2]))
|
||||
return [chunk_id for chunk_id, *_ in candidates[:limit]]
|
||||
|
||||
def _filter_calendar_timeline_fused(
|
||||
self,
|
||||
fused: List[Tuple[str, float, float, float]],
|
||||
time_range: Optional[Tuple[Optional[datetime], Optional[datetime]]],
|
||||
) -> List[Tuple[str, float, float, float]]:
|
||||
"""Apply normalized timestamp filtering to ranked calendar candidates."""
|
||||
if not fused:
|
||||
return fused
|
||||
ids = [chunk_id for chunk_id, *_ in fused]
|
||||
placeholders = ",".join("?" for _ in ids)
|
||||
rows = self._store._conn.execute(
|
||||
f"""
|
||||
SELECT id, timestamp
|
||||
FROM knowledge_chunks
|
||||
WHERE id IN ({placeholders})
|
||||
""",
|
||||
ids,
|
||||
).fetchall()
|
||||
timestamps = {
|
||||
row["id"]: _parse_timestamp_for_timeline(row["timestamp"])
|
||||
for row in rows
|
||||
}
|
||||
|
||||
def _keeps_item(item: Tuple[str, float, float, float]) -> bool:
|
||||
timestamp, all_day_date = timestamps.get(item[0], (None, None))
|
||||
return _timestamp_in_range(
|
||||
timestamp,
|
||||
time_range,
|
||||
all_day_date=all_day_date,
|
||||
)
|
||||
|
||||
return [item for item in fused if _keeps_item(item)]
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Public entry point
|
||||
# ------------------------------------------------------------------
|
||||
@@ -396,42 +669,65 @@ class HybridSearch:
|
||||
when callers want a pure metadata filter (e.g. "all mail from X in
|
||||
May") — in that case only the vector leg runs (and only if an
|
||||
embedder is configured); if neither leg yields anything the
|
||||
structured filter is applied directly and the most recent rows are
|
||||
returned.
|
||||
structured filter is applied directly. Upcoming calendar timelines are
|
||||
returned nearest-first; other fallbacks return the most recent rows.
|
||||
"""
|
||||
time_range, sources, chronological_order, metadata_only = (
|
||||
self._normalise_calendar_timeline_scope(query, time_range, sources)
|
||||
)
|
||||
rank_query = "" if metadata_only else query
|
||||
calendar_timeline = chronological_order and _sources_include_gcalendar(sources)
|
||||
recall_time_range = None if calendar_timeline else time_range
|
||||
|
||||
bm25_filter_sql, bm25_filter_params = self._build_filters(
|
||||
person=person, time_range=time_range, sources=sources, alias="kc"
|
||||
person=person, time_range=recall_time_range, sources=sources, alias="kc"
|
||||
)
|
||||
unaliased_filter_sql, unaliased_filter_params = self._build_filters(
|
||||
person=person, time_range=time_range, sources=sources
|
||||
person=person, time_range=recall_time_range, sources=sources
|
||||
)
|
||||
|
||||
bm25 = (
|
||||
self._bm25_recall(query, bm25_filter_sql, bm25_filter_params)
|
||||
if query.strip()
|
||||
self._bm25_recall(rank_query, bm25_filter_sql, bm25_filter_params)
|
||||
if rank_query.strip()
|
||||
else []
|
||||
)
|
||||
vector = (
|
||||
self._vector_recall(query, unaliased_filter_sql, unaliased_filter_params)
|
||||
if query.strip()
|
||||
self._vector_recall(
|
||||
rank_query,
|
||||
unaliased_filter_sql,
|
||||
unaliased_filter_params,
|
||||
)
|
||||
if rank_query.strip()
|
||||
else []
|
||||
)
|
||||
fused = self._fuse(bm25, vector)
|
||||
if calendar_timeline:
|
||||
fused = self._filter_calendar_timeline_fused(fused, time_range)
|
||||
|
||||
# Metadata-only fallback: empty query, or both legs produced nothing
|
||||
# despite a non-empty query. Return the most recent rows matching the
|
||||
# filter so the agent still gets a useful corpus snapshot.
|
||||
# despite a non-empty query. Calendar timeline requests use start-time
|
||||
# ascending; other searches use recency so the agent still gets a
|
||||
# useful corpus snapshot.
|
||||
if not fused:
|
||||
sql = f"""
|
||||
SELECT id FROM knowledge_chunks
|
||||
WHERE {unaliased_filter_sql}
|
||||
ORDER BY timestamp DESC, created_at DESC
|
||||
LIMIT ?
|
||||
"""
|
||||
rows = self._store._conn.execute(
|
||||
sql, [*unaliased_filter_params, limit]
|
||||
).fetchall()
|
||||
fused = [(row["id"], 0.0, 0.0, 0.0) for row in rows]
|
||||
if calendar_timeline:
|
||||
chunk_ids = self._calendar_timeline_ids(
|
||||
person=person,
|
||||
time_range=time_range,
|
||||
sources=sources,
|
||||
limit=limit,
|
||||
)
|
||||
fused = [(chunk_id, 0.0, 0.0, 0.0) for chunk_id in chunk_ids]
|
||||
else:
|
||||
sql = f"""
|
||||
SELECT id FROM knowledge_chunks
|
||||
WHERE {unaliased_filter_sql}
|
||||
ORDER BY timestamp DESC, created_at DESC
|
||||
LIMIT ?
|
||||
"""
|
||||
rows = self._store._conn.execute(
|
||||
sql, [*unaliased_filter_params, limit]
|
||||
).fetchall()
|
||||
fused = [(row["id"], 0.0, 0.0, 0.0) for row in rows]
|
||||
|
||||
# Materialise the top-N rows in one IN-clause round trip.
|
||||
top = fused[:limit]
|
||||
|
||||
@@ -395,6 +395,13 @@ def test_system_prompt_mandates_sources_extraction() -> None:
|
||||
assert "{available_sources}" in SYSTEM_PROMPT
|
||||
|
||||
|
||||
def test_system_prompt_routes_upcoming_calendar_as_structured_search() -> None:
|
||||
"""Upcoming calendar requests need source/time filters, not just keywords."""
|
||||
assert 'sources=["gcalendar"]' in SYSTEM_PROMPT
|
||||
assert 'time_range={{"start": "{today}"}}' in SYSTEM_PROMPT
|
||||
assert 'query=""' in SYSTEM_PROMPT
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Dynamic available_sources — only list what the user actually has connected
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -6,6 +6,7 @@ All Calendar API calls are mocked; no network access is required.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
from unittest.mock import patch
|
||||
@@ -134,6 +135,15 @@ def test_sync_yields_events(
|
||||
mock_events.assert_called_once()
|
||||
|
||||
|
||||
def test_parse_event_timestamp_handles_all_day_events() -> None:
|
||||
"""All-day events use their calendar date, not the current wall clock."""
|
||||
from openjarvis.connectors.gcalendar import _parse_event_timestamp # noqa: PLC0415
|
||||
|
||||
timestamp = _parse_event_timestamp({"start": {"date": "2024-05-26"}})
|
||||
|
||||
assert timestamp == datetime(2024, 5, 26)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Test 4 — disconnect removes the credentials file
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
"""Tests for source-aware HybridSearch behavior."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, timedelta, timezone
|
||||
|
||||
from openjarvis.connectors.hybrid_search import HybridSearch
|
||||
from openjarvis.connectors.store import KnowledgeStore
|
||||
|
||||
|
||||
def _store_doc(
|
||||
store: KnowledgeStore,
|
||||
*,
|
||||
title: str,
|
||||
source: str,
|
||||
timestamp: datetime | str,
|
||||
) -> None:
|
||||
timestamp_text = (
|
||||
timestamp.isoformat() if isinstance(timestamp, datetime) else timestamp
|
||||
)
|
||||
store.store(
|
||||
content=f"Title: {title}\nWhen: {timestamp_text}",
|
||||
source=source,
|
||||
doc_type="event" if source == "gcalendar" else "email",
|
||||
doc_id=f"{source}:{title.lower().replace(' ', '-')}",
|
||||
title=title,
|
||||
timestamp=timestamp,
|
||||
)
|
||||
|
||||
|
||||
def test_next_calendar_events_returns_nearest_gcalendar_rows() -> None:
|
||||
"""Generic upcoming-calendar queries should be chronological timelines."""
|
||||
store = KnowledgeStore(db_path=":memory:")
|
||||
_store_doc(
|
||||
store,
|
||||
title="Calendar Digest Email",
|
||||
source="gmail",
|
||||
timestamp=datetime(2999, 1, 1, 9, tzinfo=timezone.utc),
|
||||
)
|
||||
_store_doc(
|
||||
store,
|
||||
title="Birthday Reminder",
|
||||
source="gcalendar",
|
||||
timestamp=datetime(2999, 12, 1, 9, tzinfo=timezone.utc),
|
||||
)
|
||||
_store_doc(
|
||||
store,
|
||||
title="Music Lesson",
|
||||
source="gcalendar",
|
||||
timestamp=datetime(2999, 5, 26, 18, tzinfo=timezone.utc),
|
||||
)
|
||||
_store_doc(
|
||||
store,
|
||||
title="Team Sync",
|
||||
source="gcalendar",
|
||||
timestamp=datetime(2999, 5, 27, 10, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
search = HybridSearch(store)
|
||||
hits = search.search("what are my next calendar events?", limit=2)
|
||||
contraction_hits = search.search("what's next on my calendar?", limit=2)
|
||||
meetings_hits = search.search("what are my next meetings?", limit=2)
|
||||
mixed_source_hits = search.search(
|
||||
"what are my next calendar events?",
|
||||
sources=["gmail", "gcalendar"],
|
||||
limit=2,
|
||||
)
|
||||
|
||||
assert [hit.title for hit in hits] == ["Music Lesson", "Team Sync"]
|
||||
assert all(hit.source == "gcalendar" for hit in hits)
|
||||
assert [hit.title for hit in contraction_hits] == ["Music Lesson", "Team Sync"]
|
||||
assert all(hit.source == "gcalendar" for hit in contraction_hits)
|
||||
assert [hit.title for hit in meetings_hits] == ["Music Lesson", "Team Sync"]
|
||||
assert all(hit.source == "gcalendar" for hit in meetings_hits)
|
||||
assert [hit.title for hit in mixed_source_hits] == ["Music Lesson", "Team Sync"]
|
||||
assert all(hit.source == "gcalendar" for hit in mixed_source_hits)
|
||||
|
||||
|
||||
def test_empty_upcoming_calendar_filter_uses_ascending_start_time() -> None:
|
||||
"""Planner-emitted structured calendar searches return nearest first."""
|
||||
store = KnowledgeStore(db_path=":memory:")
|
||||
_store_doc(
|
||||
store,
|
||||
title="Later Event",
|
||||
source="gcalendar",
|
||||
timestamp=datetime(2999, 8, 1, 9, tzinfo=timezone.utc),
|
||||
)
|
||||
_store_doc(
|
||||
store,
|
||||
title="Sooner Event",
|
||||
source="gcalendar",
|
||||
timestamp=datetime(2999, 7, 1, 9, tzinfo=timezone.utc),
|
||||
)
|
||||
|
||||
hits = HybridSearch(store).search(
|
||||
"",
|
||||
sources=["gcalendar"],
|
||||
time_range=(datetime(2999, 1, 1, tzinfo=timezone.utc), None),
|
||||
limit=2,
|
||||
)
|
||||
|
||||
assert [hit.title for hit in hits] == ["Sooner Event", "Later Event"]
|
||||
|
||||
|
||||
def test_upcoming_calendar_timeline_normalizes_timestamp_offsets() -> None:
|
||||
"""Timeline filtering and ordering should compare instants, not ISO text."""
|
||||
store = KnowledgeStore(db_path=":memory:")
|
||||
_store_doc(
|
||||
store,
|
||||
title="Offset Earlier",
|
||||
source="gcalendar",
|
||||
timestamp="2999-07-01T00:30:00+02:00",
|
||||
)
|
||||
_store_doc(
|
||||
store,
|
||||
title="UTC Later",
|
||||
source="gcalendar",
|
||||
timestamp="2999-06-30T23:15:00+00:00",
|
||||
)
|
||||
|
||||
search = HybridSearch(store)
|
||||
hits = search.search(
|
||||
"",
|
||||
sources=["gcalendar"],
|
||||
time_range=(datetime(2999, 6, 30, 22, tzinfo=timezone.utc), None),
|
||||
limit=2,
|
||||
)
|
||||
later_hits = search.search(
|
||||
"",
|
||||
sources=["gcalendar"],
|
||||
time_range=(datetime(2999, 6, 30, 23, tzinfo=timezone.utc), None),
|
||||
limit=2,
|
||||
)
|
||||
|
||||
assert [hit.title for hit in hits] == ["Offset Earlier", "UTC Later"]
|
||||
assert [hit.title for hit in later_hits] == ["UTC Later"]
|
||||
|
||||
|
||||
def test_upcoming_calendar_includes_today_all_day_events() -> None:
|
||||
"""Upcoming calendar intent starts at the day boundary for all-day events."""
|
||||
store = KnowledgeStore(db_path=":memory:")
|
||||
_store_doc(
|
||||
store,
|
||||
title="All Day Today",
|
||||
source="gcalendar",
|
||||
timestamp="2999-07-01T00:00:00",
|
||||
)
|
||||
_store_doc(
|
||||
store,
|
||||
title="Morning Tomorrow",
|
||||
source="gcalendar",
|
||||
timestamp="2999-07-02T09:00:00+00:00",
|
||||
)
|
||||
|
||||
hits = HybridSearch(store).search(
|
||||
"next calendar events",
|
||||
sources=["gcalendar"],
|
||||
time_range=(datetime(2999, 7, 1, 12, tzinfo=timezone.utc), None),
|
||||
limit=2,
|
||||
)
|
||||
local_tz_hits = HybridSearch(store).search(
|
||||
"",
|
||||
sources=["gcalendar"],
|
||||
time_range=(
|
||||
datetime(
|
||||
2999,
|
||||
7,
|
||||
1,
|
||||
12,
|
||||
tzinfo=timezone(timedelta(hours=-7)),
|
||||
),
|
||||
None,
|
||||
),
|
||||
limit=2,
|
||||
)
|
||||
|
||||
assert [hit.title for hit in hits] == ["All Day Today", "Morning Tomorrow"]
|
||||
assert [hit.title for hit in local_tz_hits] == [
|
||||
"All Day Today",
|
||||
"Morning Tomorrow",
|
||||
]
|
||||
Reference in New Issue
Block a user