From c686517cc73b49857d1323d2aa7b6952ad7a852a Mon Sep 17 00:00:00 2001 From: Elliot Slusky <44592435+ElliotSlusky@users.noreply.github.com> Date: Tue, 30 Jun 2026 14:18:09 -0700 Subject: [PATCH] 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) --- src/openjarvis/agents/research_loop.py | 9 +- src/openjarvis/connectors/gcalendar.py | 5 +- src/openjarvis/connectors/hybrid_search.py | 338 +++++++++++++++++++-- tests/agents/test_research_loop.py | 7 + tests/connectors/test_gcalendar.py | 10 + tests/connectors/test_hybrid_search.py | 181 +++++++++++ 6 files changed, 523 insertions(+), 27 deletions(-) create mode 100644 tests/connectors/test_hybrid_search.py diff --git a/src/openjarvis/agents/research_loop.py b/src/openjarvis/agents/research_loop.py index 97814ef3..172be9e4 100644 --- a/src/openjarvis/agents/research_loop.py +++ b/src/openjarvis/agents/research_loop.py @@ -143,10 +143,11 @@ Strategy: 3. The `time_range` argument is a JSON object: `{{"start": "", "end": ""}}`. 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. diff --git a/src/openjarvis/connectors/gcalendar.py b/src/openjarvis/connectors/gcalendar.py index 9cfb5d73..6783ee8f 100644 --- a/src/openjarvis/connectors/gcalendar.py +++ b/src/openjarvis/connectors/gcalendar.py @@ -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() diff --git a/src/openjarvis/connectors/hybrid_search.py b/src/openjarvis/connectors/hybrid_search.py index b6e6210a..9658695a 100644 --- a/src/openjarvis/connectors/hybrid_search.py +++ b/src/openjarvis/connectors/hybrid_search.py @@ -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] diff --git a/tests/agents/test_research_loop.py b/tests/agents/test_research_loop.py index 5a377319..51a512d9 100644 --- a/tests/agents/test_research_loop.py +++ b/tests/agents/test_research_loop.py @@ -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 # --------------------------------------------------------------------------- diff --git a/tests/connectors/test_gcalendar.py b/tests/connectors/test_gcalendar.py index 925422b0..b77f3276 100644 --- a/tests/connectors/test_gcalendar.py +++ b/tests/connectors/test_gcalendar.py @@ -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 # --------------------------------------------------------------------------- diff --git a/tests/connectors/test_hybrid_search.py b/tests/connectors/test_hybrid_search.py new file mode 100644 index 00000000..38676735 --- /dev/null +++ b/tests/connectors/test_hybrid_search.py @@ -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", + ]