mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-07-27 21:05:34 +00:00
fix: restructure digest data, fix calendar since filter, fix Gmail registration
Data collection: - Restructure digest_collect output into priority-ordered sections (HEALTH > MESSAGES > CALENDAR > MUSIC) with human-readable formatting - Extract key metrics from Oura (HR, HRV, sleep duration, scores) - Format Gmail as sender + subject + time ago - Group music tracks into single lines per source - Cap 15 items per source, truncate content to 500 chars Calendar: - Pass `since` parameter to Google Calendar API as `timeMin` - Default to 24h lookback instead of dumping all-time events Gmail: - Re-enable gmail connector registration in __init__.py Prompt: - Add section summary + closing encouragement to Jarvis persona - Reinforce no-markdown, no-hallucination rules in user message - Strip markdown artifacts before sending to TTS Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.6
parent
bff925a679
commit
4de2ffd720
@@ -1,19 +1,32 @@
|
||||
You are JARVIS — a highly capable AI assistant with a professional demeanour and dry, understated wit. You were modelled after the AI companion from Iron Man.
|
||||
You are JARVIS — a highly capable AI assistant modelled after the AI companion from Iron Man. You deliver morning briefings based on real data.
|
||||
|
||||
## Voice & Tone
|
||||
- Professional and concise, but not robotic
|
||||
- Dry British wit — subtle, never slapstick ("You have a rather ambitious schedule today, sir")
|
||||
- Address the user respectfully (use "sir" or "ma'am" sparingly for effect, not every sentence)
|
||||
- Confident and competent — you anticipate needs and offer insights, not just data
|
||||
- When delivering bad news (poor sleep, overbooked day), frame it constructively
|
||||
- Professional, concise, with dry British wit
|
||||
- Confident and direct — state facts, don't ramble
|
||||
- Use "sir" sparingly for effect
|
||||
- When delivering bad news, frame it constructively but honestly
|
||||
|
||||
## Critical Constraints
|
||||
- ONLY report information that is present in the provided data
|
||||
- NEVER invent details, statistics, or events not in the data
|
||||
- NEVER describe actions you are taking (you cannot adjust lights, order food, queue playlists, etc.)
|
||||
- NEVER use markdown formatting (no #, ##, *, -, bullet points) — this is spoken aloud
|
||||
- Use natural spoken transitions: "Regarding your health", "Moving to your schedule", "As for your messages"
|
||||
- Keep the entire briefing under 200 words
|
||||
- If a data source returned no results, skip that section silently
|
||||
|
||||
## Structure
|
||||
- Open with a greeting that reflects the time and context
|
||||
- Deliver each section crisply — lead with the most important item
|
||||
- Transition between sections naturally, not mechanically
|
||||
- Close with a brief look-ahead or motivational observation
|
||||
- ALWAYS open with "Good morning, sir." (or "Good afternoon, sir." / "Good evening, sir." based on time of day)
|
||||
- For EACH section, start with a one-sentence summary (e.g. "Your health data looks solid today" or "You have a busy inbox with fifteen new emails"), then give the key details, then transition naturally to the next section
|
||||
- Deliver each section crisply with real numbers from the data
|
||||
- Close with a brief encouraging or forward-looking sentence (e.g. "You're well-positioned for the day ahead, sir." or "A productive day awaits.")
|
||||
|
||||
## Examples
|
||||
- "Good morning, sir. I trust you slept well — though your Oura Ring suggests otherwise."
|
||||
- "Your calendar is mercifully light today, which is fortunate given the seventeen unread messages awaiting your attention."
|
||||
- "The markets are up, your paper was cited, and it's going to rain. I'd suggest an umbrella and a celebratory coffee."
|
||||
## Examples of GOOD responses
|
||||
- "Good morning, sir. Your Oura data shows you slept six hours and forty minutes with an average heart rate of fifty-eight. Your HRV was forty-nine, which is within your normal range."
|
||||
- "You have three emails requiring attention and two tasks due this week."
|
||||
- "Your recently played tracks include Billie Eilish and Sabrina Carpenter."
|
||||
|
||||
## Examples of BAD responses (never do this)
|
||||
- "I have adjusted the ambient lighting to assist with alertness." (fabricated action)
|
||||
- "I have queued a caffeine solution for delivery." (fabricated action)
|
||||
- "The semiconductor markets are volatile today." (not in provided data)
|
||||
|
||||
@@ -57,11 +57,22 @@ class MorningDigestAgent(ToolUsingAgent):
|
||||
f"{persona_text}\n\n"
|
||||
f"Today is {now.strftime('%A, %B %d, %Y')}. "
|
||||
f"The time is {now.strftime('%I:%M %p')} in {self._timezone}.\n\n"
|
||||
f"Generate a morning briefing covering these sections in order: "
|
||||
f"Generate a morning briefing covering: "
|
||||
f"{', '.join(self._sections)}.\n\n"
|
||||
"Be concise — the entire briefing should be readable in 2-3 minutes "
|
||||
"and listenable in under 5 minutes when spoken aloud.\n\n"
|
||||
"Format your response in clean markdown with section headers."
|
||||
"CRITICAL RULES:\n"
|
||||
"1. ONLY report facts that appear in the provided data. "
|
||||
"Do NOT invent, hallucinate, or embellish any information.\n"
|
||||
"2. Do NOT describe actions you are taking (e.g. 'I have adjusted "
|
||||
"the lights', 'I have queued a delivery'). You are reporting, not "
|
||||
"acting.\n"
|
||||
"3. Do NOT use any markdown formatting — no #, ##, *, -, or bullet "
|
||||
"points. This text will be read aloud as speech.\n"
|
||||
"4. Use natural spoken transitions between sections like "
|
||||
"'Regarding your health', 'Moving on to your calendar', "
|
||||
"'As for your messages'.\n"
|
||||
"5. Be concise — under 200 words total. State facts directly.\n"
|
||||
"6. If a section has no data, skip it entirely rather than "
|
||||
"making something up."
|
||||
)
|
||||
|
||||
def _resolve_sources(self) -> List[str]:
|
||||
@@ -108,7 +119,12 @@ class MorningDigestAgent(ToolUsingAgent):
|
||||
content=(
|
||||
f"Here is the collected data from my sources:\n\n"
|
||||
f"{collected_data}\n\n"
|
||||
f"Please synthesize my morning briefing."
|
||||
f"Synthesize my morning briefing. REMEMBER:\n"
|
||||
f"- Start with 'Good morning, sir.' (or afternoon/evening)\n"
|
||||
f"- NO markdown, NO emojis, NO bullet points, NO headers\n"
|
||||
f"- Plain spoken English only — this is read aloud\n"
|
||||
f"- Only state facts from the data above\n"
|
||||
f"- Under 200 words"
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -117,12 +133,20 @@ class MorningDigestAgent(ToolUsingAgent):
|
||||
narrative = self._strip_think_tags(result.get("content", ""))
|
||||
|
||||
# Step 3: Generate audio via TTS
|
||||
# Strip any markdown that slipped through (##, *, -, etc.)
|
||||
import re
|
||||
|
||||
tts_text = re.sub(r"^#{1,6}\s+", "", narrative, flags=re.MULTILINE)
|
||||
tts_text = re.sub(r"^\s*[-*•]\s+", "", tts_text, flags=re.MULTILINE)
|
||||
tts_text = re.sub(r"\*{1,2}([^*]+)\*{1,2}", r"\1", tts_text)
|
||||
tts_text = tts_text.strip()
|
||||
|
||||
tts_call = ToolCall(
|
||||
id="digest-tts-1",
|
||||
name="text_to_speech",
|
||||
arguments=json.dumps(
|
||||
{
|
||||
"text": narrative,
|
||||
"text": tts_text,
|
||||
"voice_id": self._voice_id,
|
||||
"backend": self._tts_backend,
|
||||
}
|
||||
|
||||
@@ -13,8 +13,11 @@ __all__ = ["Attachment", "BaseConnector", "Document", "KnowledgeStore", "SyncSta
|
||||
# Auto-register built-in connectors
|
||||
import openjarvis.connectors.obsidian # noqa: F401
|
||||
|
||||
# gmail (REST API / OAuth) is not registered — use gmail_imap instead.
|
||||
# The REST API connector requires a full OAuth flow that isn't wired up yet.
|
||||
try:
|
||||
import openjarvis.connectors.gmail # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import openjarvis.connectors.gmail_imap # noqa: F401
|
||||
except ImportError:
|
||||
|
||||
@@ -7,7 +7,7 @@ to make them trivially mockable in tests.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, Iterator, List, Optional
|
||||
|
||||
import httpx
|
||||
@@ -283,7 +283,7 @@ class GCalendarConnector(BaseConnector):
|
||||
def sync(
|
||||
self,
|
||||
*,
|
||||
since: Optional[datetime] = None, # noqa: ARG002 — reserved for future use
|
||||
since: Optional[datetime] = None,
|
||||
cursor: Optional[str] = None,
|
||||
) -> Iterator[Document]:
|
||||
"""Yield :class:`Document` objects for Google Calendar events.
|
||||
@@ -294,7 +294,8 @@ class GCalendarConnector(BaseConnector):
|
||||
Parameters
|
||||
----------
|
||||
since:
|
||||
Not yet used (filtering is done server-side via ``timeMin``).
|
||||
Only return events starting after this datetime. Defaults to
|
||||
24 hours ago when ``None``.
|
||||
cursor:
|
||||
``nextPageToken`` from a previous sync to resume pagination.
|
||||
"""
|
||||
@@ -310,6 +311,11 @@ class GCalendarConnector(BaseConnector):
|
||||
calendars_resp = _gcal_api_calendars_list(token)
|
||||
calendars: List[Dict[str, Any]] = calendars_resp.get("items", [])
|
||||
|
||||
# Default to 24 hours ago so we don't dump the entire calendar history
|
||||
if since is None:
|
||||
since = datetime.now() - timedelta(days=1)
|
||||
time_min = since.strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
|
||||
synced = 0
|
||||
|
||||
for calendar in calendars:
|
||||
@@ -322,7 +328,8 @@ class GCalendarConnector(BaseConnector):
|
||||
while True:
|
||||
try:
|
||||
events_resp = _gcal_api_events_list(
|
||||
token, calendar_id, page_token=page_token
|
||||
token, calendar_id, page_token=page_token,
|
||||
time_min=time_min,
|
||||
)
|
||||
except httpx.HTTPStatusError:
|
||||
break
|
||||
|
||||
@@ -42,6 +42,7 @@ def _cartesia_synthesize(
|
||||
"encoding": "mp3" if output_format == "mp3" else "pcm_f32le",
|
||||
},
|
||||
"language": "en",
|
||||
**({"speed": speed} if speed != 1.0 else {}),
|
||||
},
|
||||
timeout=120.0,
|
||||
)
|
||||
|
||||
@@ -3,13 +3,312 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import re
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Any, Dict, List
|
||||
|
||||
from openjarvis.connectors._stubs import Document
|
||||
from openjarvis.core.registry import ConnectorRegistry, ToolRegistry
|
||||
from openjarvis.core.types import ToolResult
|
||||
from openjarvis.tools._stubs import BaseTool, ToolSpec
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Section definitions: ordered list of (section_name, connector_ids)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_SECTION_ORDER: List[tuple] = [
|
||||
("HEALTH", {"oura", "apple_health", "strava"}),
|
||||
(
|
||||
"MESSAGES",
|
||||
{
|
||||
"gmail",
|
||||
"gmail_imap",
|
||||
"google_tasks",
|
||||
"slack",
|
||||
"imessage",
|
||||
"whatsapp",
|
||||
"outlook",
|
||||
},
|
||||
),
|
||||
("CALENDAR", {"gcalendar"}),
|
||||
("MUSIC", {"spotify", "apple_music"}),
|
||||
]
|
||||
|
||||
_CONNECTOR_TO_SECTION: Dict[str, str] = {}
|
||||
for _section_name, _ids in _SECTION_ORDER:
|
||||
for _cid in _ids:
|
||||
_CONNECTOR_TO_SECTION[_cid] = _section_name
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Per-connector human-readable formatters
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _format_duration(seconds: float) -> str:
|
||||
"""Format seconds as 'Xh Ym'."""
|
||||
hours = int(seconds // 3600)
|
||||
minutes = int((seconds % 3600) // 60)
|
||||
parts = []
|
||||
if hours:
|
||||
parts.append(f"{hours}h")
|
||||
if minutes or not parts:
|
||||
parts.append(f"{minutes}m")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def _time_ago(ts: datetime) -> str:
|
||||
"""Return a human-readable relative time like '2h ago' or '15m ago'."""
|
||||
now = datetime.now()
|
||||
ts_naive = ts.replace(tzinfo=None) if ts.tzinfo else ts
|
||||
delta = now - ts_naive
|
||||
total_seconds = max(0, int(delta.total_seconds()))
|
||||
if total_seconds < 60:
|
||||
return "just now"
|
||||
if total_seconds < 3600:
|
||||
return f"{total_seconds // 60}m ago"
|
||||
if total_seconds < 86400:
|
||||
return f"{total_seconds // 3600}h ago"
|
||||
days = total_seconds // 86400
|
||||
return f"{days}d ago"
|
||||
|
||||
|
||||
def _format_date(ts: datetime) -> str:
|
||||
"""Format a datetime as 'April 1' style."""
|
||||
return ts.strftime("%B %-d") if hasattr(ts, "strftime") else str(ts)
|
||||
|
||||
|
||||
def _format_time(ts: datetime) -> str:
|
||||
"""Format a datetime as '10:30 AM' style."""
|
||||
return ts.strftime("%-I:%M %p") if hasattr(ts, "strftime") else str(ts)
|
||||
|
||||
|
||||
def _parse_content_json(doc: Document) -> Dict[str, Any]:
|
||||
"""Try to parse the document content as JSON; return {} on failure."""
|
||||
try:
|
||||
return json.loads(doc.content)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
return {}
|
||||
|
||||
|
||||
def _format_oura(doc: Document) -> str:
|
||||
"""Format an Oura Ring document into a readable line."""
|
||||
data = _parse_content_json(doc)
|
||||
data_type = doc.metadata.get("data_type", "")
|
||||
day = doc.metadata.get("day", "")
|
||||
day_str = day or _format_date(doc.timestamp)
|
||||
|
||||
if data_type == "sleep":
|
||||
hr = data.get("average_heart_rate", "?")
|
||||
hrv = data.get("average_hrv", data.get("average_heart_rate_variability", "?"))
|
||||
total = data.get("total_sleep_duration")
|
||||
awake = data.get("awake_time")
|
||||
score = data.get("score")
|
||||
parts = []
|
||||
if score is not None:
|
||||
parts.append(f"score {score}")
|
||||
parts.append(f"avg HR {hr} bpm")
|
||||
if hrv != "?":
|
||||
parts.append(f"HRV {hrv}")
|
||||
if total is not None:
|
||||
parts.append(f"{_format_duration(total)} total sleep")
|
||||
if awake is not None:
|
||||
parts.append(f"awake {_format_duration(awake)}")
|
||||
return f"[oura] Sleep — {day_str}: {', '.join(parts)}"
|
||||
|
||||
if data_type == "daily_readiness":
|
||||
score = data.get("score", "?")
|
||||
temp = data.get(
|
||||
"temperature_deviation",
|
||||
data.get("temperature_trend_deviation"),
|
||||
)
|
||||
line = f"[oura] Readiness — {day_str}: score {score}"
|
||||
if temp is not None:
|
||||
sign = "+" if temp >= 0 else ""
|
||||
line += f", temperature deviation {sign}{temp}"
|
||||
return line
|
||||
|
||||
if data_type == "daily_activity":
|
||||
steps = data.get("steps", "?")
|
||||
cal = data.get("total_calories", data.get("active_calories", "?"))
|
||||
score = data.get("score")
|
||||
parts = []
|
||||
if score is not None:
|
||||
parts.append(f"score {score}")
|
||||
parts.append(f"steps {steps}")
|
||||
parts.append(f"calories {cal}")
|
||||
return f"[oura] Activity — {day_str}: {', '.join(parts)}"
|
||||
|
||||
# Fallback for unknown Oura doc types
|
||||
return f"[oura] {doc.title}"
|
||||
|
||||
|
||||
def _format_apple_health(doc: Document) -> str:
|
||||
"""Format an Apple Health document."""
|
||||
return f"[apple_health] {doc.title}"
|
||||
|
||||
|
||||
def _format_strava(doc: Document) -> str:
|
||||
"""Format a Strava activity document."""
|
||||
return f"[strava] {doc.title}"
|
||||
|
||||
|
||||
def _format_gmail(doc: Document) -> str:
|
||||
"""Format a Gmail email document."""
|
||||
sender = doc.author or "Unknown"
|
||||
# Clean up sender: extract just email or name
|
||||
subject = doc.title or "(no subject)"
|
||||
ago = _time_ago(doc.timestamp)
|
||||
return f'[gmail] From: {sender} — "{subject}" ({ago})'
|
||||
|
||||
|
||||
def _format_gmail_imap(doc: Document) -> str:
|
||||
"""Format a Gmail IMAP email document."""
|
||||
sender = doc.author or "Unknown"
|
||||
subject = doc.title or "(no subject)"
|
||||
ago = _time_ago(doc.timestamp)
|
||||
return f'[gmail] From: {sender} — "{subject}" ({ago})'
|
||||
|
||||
|
||||
def _format_google_tasks(doc: Document) -> str:
|
||||
"""Format a Google Tasks document."""
|
||||
title = doc.title or "Untitled Task"
|
||||
status = doc.metadata.get("status", "")
|
||||
due = doc.metadata.get("due", "")
|
||||
parts = [f"[google_tasks] {title}"]
|
||||
extras = []
|
||||
if due:
|
||||
extras.append(f"due {due}")
|
||||
if status == "completed":
|
||||
extras.append("completed")
|
||||
if extras:
|
||||
parts.append(f"({', '.join(extras)})")
|
||||
return " ".join(parts)
|
||||
|
||||
|
||||
def _format_slack(doc: Document) -> str:
|
||||
"""Format a Slack message document."""
|
||||
author = doc.author or "Unknown"
|
||||
channel = doc.metadata.get("channel", "")
|
||||
content_preview = doc.content[:80].replace("\n", " ").strip() if doc.content else ""
|
||||
prefix = f"[slack] #{channel}" if channel else "[slack]"
|
||||
return f"{prefix} {author}: {content_preview}"
|
||||
|
||||
|
||||
def _format_imessage(doc: Document) -> str:
|
||||
"""Format an iMessage document."""
|
||||
sender = doc.author or "Unknown"
|
||||
content_preview = doc.content[:80].replace("\n", " ").strip() if doc.content else ""
|
||||
return f"[imessage] {sender}: {content_preview}"
|
||||
|
||||
|
||||
def _format_whatsapp(doc: Document) -> str:
|
||||
"""Format a WhatsApp message document."""
|
||||
sender = doc.author or "Unknown"
|
||||
content_preview = doc.content[:80].replace("\n", " ").strip() if doc.content else ""
|
||||
return f"[whatsapp] {sender}: {content_preview}"
|
||||
|
||||
|
||||
def _format_outlook(doc: Document) -> str:
|
||||
"""Format an Outlook email document."""
|
||||
sender = doc.author or "Unknown"
|
||||
subject = doc.title or "(no subject)"
|
||||
ago = _time_ago(doc.timestamp)
|
||||
return f'[outlook] From: {sender} — "{subject}" ({ago})'
|
||||
|
||||
|
||||
def _format_gcalendar(doc: Document) -> str:
|
||||
"""Format a Google Calendar event document."""
|
||||
title = doc.title or "(No title)"
|
||||
time_str = _format_time(doc.timestamp)
|
||||
# Try to extract duration from content
|
||||
duration_match = (
|
||||
re.search(r"When:\s*(.+?)$", doc.content, re.MULTILINE) if doc.content else None
|
||||
)
|
||||
time_range = ""
|
||||
if duration_match:
|
||||
when = duration_match.group(1).strip()
|
||||
# Extract just the times from the ISO strings
|
||||
parts = when.split(" – ")
|
||||
if len(parts) == 2:
|
||||
try:
|
||||
start_dt = datetime.fromisoformat(parts[0].replace("Z", "+00:00"))
|
||||
end_dt = datetime.fromisoformat(parts[1].replace("Z", "+00:00"))
|
||||
diff = end_dt - start_dt
|
||||
mins = int(diff.total_seconds() / 60)
|
||||
if mins >= 60:
|
||||
hrs = mins // 60
|
||||
remaining = mins % 60
|
||||
duration = f"{hrs} hour" + ("s" if hrs > 1 else "")
|
||||
if remaining:
|
||||
duration += f" {remaining} min"
|
||||
else:
|
||||
duration = f"{mins} min"
|
||||
time_range = f" ({duration})"
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return f"[gcalendar] {time_str} — {title}{time_range}"
|
||||
|
||||
|
||||
def _format_spotify(doc: Document) -> str:
|
||||
"""Format a Spotify recently-played track — returns 'Track — Artist'."""
|
||||
# doc.title is already "Track — Artist" from the connector
|
||||
return doc.title
|
||||
|
||||
|
||||
def _format_apple_music(doc: Document) -> str:
|
||||
"""Format an Apple Music track — returns 'Track — Artist'."""
|
||||
# doc.title is already "Track — Artist" from the connector
|
||||
return doc.title
|
||||
|
||||
|
||||
# Map connector IDs to their formatting functions
|
||||
_FORMATTERS: Dict[str, Any] = {
|
||||
"oura": _format_oura,
|
||||
"apple_health": _format_apple_health,
|
||||
"strava": _format_strava,
|
||||
"gmail": _format_gmail,
|
||||
"gmail_imap": _format_gmail_imap,
|
||||
"google_tasks": _format_google_tasks,
|
||||
"slack": _format_slack,
|
||||
"imessage": _format_imessage,
|
||||
"whatsapp": _format_whatsapp,
|
||||
"outlook": _format_outlook,
|
||||
"gcalendar": _format_gcalendar,
|
||||
"spotify": _format_spotify,
|
||||
"apple_music": _format_apple_music,
|
||||
}
|
||||
|
||||
|
||||
def _format_doc(source: str, doc: Document) -> str:
|
||||
"""Format a document using the source-specific formatter, with fallback."""
|
||||
formatter = _FORMATTERS.get(source)
|
||||
if formatter:
|
||||
try:
|
||||
return formatter(doc)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
# Fallback: connector name + title
|
||||
return f"[{source}] {doc.title}"
|
||||
|
||||
|
||||
def _format_music_section(
|
||||
collected_docs: Dict[str, List[Document]],
|
||||
music_connectors: set,
|
||||
) -> List[str]:
|
||||
"""Format music connectors as grouped lists instead of per-track lines."""
|
||||
lines: List[str] = []
|
||||
for source in sorted(music_connectors):
|
||||
docs = collected_docs.get(source, [])
|
||||
if not docs:
|
||||
continue
|
||||
tracks = []
|
||||
for doc in docs:
|
||||
tracks.append(doc.title)
|
||||
label = "Recently played" if source == "spotify" else "Library"
|
||||
lines.append(f"[{source}] {label}: {', '.join(tracks)}")
|
||||
return lines
|
||||
|
||||
|
||||
@ToolRegistry.register("digest_collect")
|
||||
class DigestCollectTool(BaseTool):
|
||||
@@ -24,8 +323,9 @@ class DigestCollectTool(BaseTool):
|
||||
name="digest_collect",
|
||||
description=(
|
||||
"Fetch recent data from configured connectors (email, calendar, "
|
||||
"health, tasks, etc.) and return a structured summary for digest "
|
||||
"synthesis."
|
||||
"health, tasks, etc.) and return a structured, human-readable "
|
||||
"summary grouped by section (Health, Messages, Calendar, Music) "
|
||||
"for digest synthesis."
|
||||
),
|
||||
parameters={
|
||||
"type": "object",
|
||||
@@ -50,11 +350,15 @@ class DigestCollectTool(BaseTool):
|
||||
)
|
||||
|
||||
def execute(self, **params: Any) -> ToolResult:
|
||||
# Ensure connectors are registered
|
||||
import openjarvis.connectors # noqa: F401
|
||||
|
||||
sources: List[str] = params.get("sources", [])
|
||||
hours_back: float = params.get("hours_back", 24)
|
||||
since = datetime.now() - timedelta(hours=hours_back)
|
||||
|
||||
collected: Dict[str, List[Dict[str, Any]]] = {}
|
||||
# Collect raw documents per source
|
||||
collected_docs: Dict[str, List[Document]] = {}
|
||||
errors: List[str] = []
|
||||
|
||||
for source in sources:
|
||||
@@ -72,29 +376,63 @@ class DigestCollectTool(BaseTool):
|
||||
)
|
||||
continue
|
||||
|
||||
docs = list(connector.sync(since=since))
|
||||
collected[source] = [
|
||||
{
|
||||
"title": d.title,
|
||||
"content": d.content,
|
||||
"doc_type": d.doc_type,
|
||||
"author": d.author,
|
||||
"timestamp": d.timestamp.isoformat(),
|
||||
"metadata": d.metadata,
|
||||
}
|
||||
for d in docs
|
||||
]
|
||||
# Cap per-source to avoid overwhelming the LLM context
|
||||
max_per_source = 15
|
||||
docs: List[Document] = []
|
||||
for d in connector.sync(since=since):
|
||||
docs.append(d)
|
||||
if len(docs) >= max_per_source:
|
||||
break
|
||||
|
||||
collected_docs[source] = docs
|
||||
except Exception as exc:
|
||||
errors.append(f"Error fetching from '{source}': {exc}")
|
||||
|
||||
summary_parts = []
|
||||
for source, docs in collected.items():
|
||||
summary_parts.append(f"## {source} ({len(docs)} items)")
|
||||
for doc in docs:
|
||||
summary_parts.append(json.dumps(doc, default=str))
|
||||
# Group by section and build human-readable output
|
||||
summary_parts: List[str] = []
|
||||
for section_name, section_connectors in _SECTION_ORDER:
|
||||
# Gather all sources that belong to this section and have data
|
||||
section_sources = [
|
||||
s for s in sources if s in section_connectors and s in collected_docs
|
||||
]
|
||||
if not section_sources:
|
||||
continue
|
||||
|
||||
section_lines: List[str] = []
|
||||
|
||||
if section_name == "MUSIC":
|
||||
# Music gets special grouped formatting
|
||||
section_lines = _format_music_section(
|
||||
collected_docs, section_connectors
|
||||
)
|
||||
else:
|
||||
for source in section_sources:
|
||||
for doc in collected_docs[source]:
|
||||
section_lines.append(_format_doc(source, doc))
|
||||
|
||||
if section_lines:
|
||||
summary_parts.append(f"=== {section_name} ===")
|
||||
summary_parts.extend(section_lines)
|
||||
summary_parts.append("") # blank line between sections
|
||||
|
||||
# Handle any connectors not in a known section (fallback)
|
||||
known_connectors = set()
|
||||
for _, cids in _SECTION_ORDER:
|
||||
known_connectors |= cids
|
||||
|
||||
uncategorized_sources = [
|
||||
s for s in sources if s not in known_connectors and s in collected_docs
|
||||
]
|
||||
if uncategorized_sources:
|
||||
summary_parts.append("=== OTHER ===")
|
||||
for source in uncategorized_sources:
|
||||
for doc in collected_docs[source]:
|
||||
summary_parts.append(_format_doc(source, doc))
|
||||
summary_parts.append("")
|
||||
|
||||
# Errors at the end, not inline
|
||||
if errors:
|
||||
summary_parts.append("\n## Errors")
|
||||
summary_parts.append("=== ERRORS ===")
|
||||
summary_parts.extend(errors)
|
||||
|
||||
return ToolResult(
|
||||
@@ -103,8 +441,8 @@ class DigestCollectTool(BaseTool):
|
||||
success=True,
|
||||
metadata={
|
||||
"sources_queried": sources,
|
||||
"sources_ok": list(collected.keys()),
|
||||
"sources_ok": list(collected_docs.keys()),
|
||||
"sources_failed": errors,
|
||||
"total_items": sum(len(v) for v in collected.values()),
|
||||
"total_items": sum(len(v) for v in collected_docs.values()),
|
||||
},
|
||||
)
|
||||
|
||||
@@ -32,7 +32,7 @@ def test_morning_digest_run(tmp_path):
|
||||
# Mock collect result
|
||||
mock_collect_result = ToolResult(
|
||||
tool_name="digest_collect",
|
||||
content="## gmail (2 items)\n...",
|
||||
content="=== MESSAGES ===\n[gmail] From: alice@co.com — \"Budget\" (1h ago)\n",
|
||||
success=True,
|
||||
metadata={"total_items": 2},
|
||||
)
|
||||
|
||||
@@ -57,10 +57,16 @@ def _make_credentials(tmp_path: Path) -> Path:
|
||||
@pytest.fixture()
|
||||
def connector(tmp_path: Path):
|
||||
"""GCalendarConnector pointing at a tmp credentials path (no file yet)."""
|
||||
from unittest.mock import patch
|
||||
|
||||
from openjarvis.connectors.gcalendar import GCalendarConnector # noqa: PLC0415
|
||||
|
||||
creds_path = str(tmp_path / "gcalendar.json")
|
||||
return GCalendarConnector(credentials_path=creds_path)
|
||||
with patch(
|
||||
"openjarvis.connectors.oauth._SHARED_GOOGLE_CREDENTIALS_PATH",
|
||||
str(tmp_path / "google_shared.json"),
|
||||
):
|
||||
yield GCalendarConnector(credentials_path=creds_path)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -35,7 +35,11 @@ def test_full_digest_pipeline(tmp_path):
|
||||
# Mock tool results
|
||||
collect_result = ToolResult(
|
||||
tool_name="digest_collect",
|
||||
content="## gmail (3 items)\n## oura (1 items)\n## gcalendar (2 items)",
|
||||
content=(
|
||||
"=== HEALTH ===\n[oura] Sleep — April 1: score 78, avg HR 58 bpm\n\n"
|
||||
"=== MESSAGES ===\n[gmail] From: alice@co.com — \"Q3 Review\" (2h ago)\n\n"
|
||||
"=== CALENDAR ===\n[gcalendar] 10:30 AM — Team Standup (30 min)\n"
|
||||
),
|
||||
success=True,
|
||||
metadata={"total_items": 6},
|
||||
)
|
||||
|
||||
@@ -44,7 +44,9 @@ def test_digest_collect_executes():
|
||||
result = tool.execute(sources=["gmail"], hours_back=24)
|
||||
|
||||
assert result.success is True
|
||||
assert "gmail (1 items)" in result.content
|
||||
assert "=== MESSAGES ===" in result.content
|
||||
assert "[gmail] From: alice@example.com" in result.content
|
||||
assert "Team standup" in result.content
|
||||
assert result.metadata["total_items"] == 1
|
||||
|
||||
|
||||
|
||||
Reference in New Issue
Block a user