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:
Elliot Slusky
2026-06-30 14:18:09 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 904133cb25
commit c686517cc7
6 changed files with 523 additions and 27 deletions
+5 -4
View File
@@ -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.
+3 -2
View File
@@ -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()
+317 -21
View File
@@ -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]
+7
View File
@@ -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
# ---------------------------------------------------------------------------
+10
View File
@@ -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
# ---------------------------------------------------------------------------
+181
View 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",
]