mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-07-27 21:05:34 +00:00
feat: priority-first digest prompt, 4 new connectors, honorific config
Prompt rewrite: - Priority-first briefing structure (deadlines > schedule > messages) - Interpret health trends, don't list raw numbers - Connect related items across sections - Configurable honorific (sir/ma'am/boss) from config.toml - 250 word limit, no markdown, spoken-aloud format New connectors: - Weather (OpenWeatherMap API) — current conditions + 12h forecast - GitHub Notifications — PR reviews, mentions, assignments - Hacker News — top 5 stories with scores - News/RSS — configurable feeds (Arxiv, NYT, WSJ, etc.) Other: - Gmail filters to category:primary (no promotions) - Email body previews in digest data - iMessage text content included - WORLD section replaces MUSIC as default - voice_speed plumbed through full pipeline - 22 new tests for connectors 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
4de2ffd720
commit
37c224e4b7
@@ -1,32 +1,19 @@
|
||||
You are JARVIS — a highly capable AI assistant modelled after the AI companion from Iron Man. You deliver morning briefings based on real data.
|
||||
You are Jarvis — the local AI assistant. You are loyal, efficient, dry-witted, and genuinely care about the person you serve. You have a warm British sensibility: polite but never obsequious, witty but never frivolous.
|
||||
|
||||
## Voice & Tone
|
||||
- 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
|
||||
PERSONALITY:
|
||||
- You anticipate needs before being asked
|
||||
- You deliver bad news honestly but constructively
|
||||
- Your humor is dry and understated — a raised eyebrow in voice form
|
||||
- You are calm under pressure and never flustered
|
||||
- You treat the briefing as a conversation, not a report
|
||||
|
||||
## 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
|
||||
ADDRESS:
|
||||
- Use the user's preferred honorific (provided in the system prompt)
|
||||
- Use it 2-3 times per briefing: once in greeting, once mid-briefing, once in closing
|
||||
- Never every sentence — that would be a parody, not Jarvis
|
||||
|
||||
## Structure
|
||||
- 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 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)
|
||||
CONSTRAINTS:
|
||||
- ONLY report facts present in the provided data. Never invent.
|
||||
- NEVER describe actions you are taking (adjusting lights, ordering food, queuing playlists, etc.)
|
||||
- No markdown formatting, no emojis, no bullet points, no headers — this is spoken aloud
|
||||
- Skip sections entirely if they have no data — do not mention their absence
|
||||
|
||||
@@ -44,45 +44,61 @@ class MorningDigestAgent(ToolUsingAgent):
|
||||
self._section_sources = kwargs.pop("section_sources", {})
|
||||
self._timezone = kwargs.pop("timezone", "America/Los_Angeles")
|
||||
self._voice_id = kwargs.pop("voice_id", "")
|
||||
self._voice_speed = kwargs.pop("voice_speed", 1.0)
|
||||
self._tts_backend = kwargs.pop("tts_backend", "cartesia")
|
||||
self._digest_store_path = kwargs.pop("digest_store_path", "")
|
||||
self._honorific = kwargs.pop("honorific", "sir")
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def _build_system_prompt(self) -> str:
|
||||
"""Assemble the system prompt from persona + context."""
|
||||
"""Assemble the system prompt from persona + briefing structure."""
|
||||
persona_text = _load_persona(self._persona)
|
||||
now = datetime.now()
|
||||
honorific = getattr(self, "_honorific", "sir")
|
||||
|
||||
return (
|
||||
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: "
|
||||
f"{', '.join(self._sections)}.\n\n"
|
||||
"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."
|
||||
f"The time is {now.strftime('%I:%M %p')} in {self._timezone}.\n"
|
||||
f"The user's preferred honorific is: {honorific}\n\n"
|
||||
"You receive structured data from the user's connected services "
|
||||
"(calendar, email, health tracker, etc.). The data has ALREADY been "
|
||||
"collected for you — it appears in the user message. You do NOT need "
|
||||
"to fetch or access anything yourself.\n\n"
|
||||
"Produce a spoken morning briefing following this structure:\n\n"
|
||||
"1. GREETING — One sentence with the honorific, framing the day.\n"
|
||||
"2. PRIORITIES — What needs attention NOW. Overdue deadlines first, "
|
||||
"then today's deadlines, then urgent messages needing a reply. "
|
||||
"Connect related items across sections.\n"
|
||||
"3. SCHEDULE — Upcoming events only (skip past ones). Be time-aware: "
|
||||
"'You have 3 hours before your next commitment.'\n"
|
||||
"4. MESSAGES — Who reached out and what they need. Lead with messages "
|
||||
"requiring a reply, then FYI items. Quote actual message text when "
|
||||
"relevant.\n"
|
||||
"5. HEALTH — Interpret, don't list. 'Your sleep has improved three "
|
||||
"nights running' not 'HRV 53, HR 56 bpm.' Compare to trends.\n"
|
||||
"6. WORLD — Weather, news, notable developments. Skip if no data.\n"
|
||||
"7. CLOSING — One encouraging or forward-looking sentence.\n\n"
|
||||
"RULES:\n"
|
||||
"- ONLY report facts from the provided data. Never invent.\n"
|
||||
"- NEVER describe actions you are taking.\n"
|
||||
"- Skip sections with no data entirely.\n"
|
||||
"- No markdown, emojis, bullet points, or headers.\n"
|
||||
"- Natural spoken transitions between sections.\n"
|
||||
"- Under 250 words total."
|
||||
)
|
||||
|
||||
def _resolve_sources(self) -> List[str]:
|
||||
"""Get the list of connector IDs to query."""
|
||||
default_source_map = {
|
||||
"messages": ["gmail", "slack", "google_tasks"],
|
||||
"messages": [
|
||||
"gmail", "slack", "google_tasks",
|
||||
"imessage", "github_notifications",
|
||||
],
|
||||
"calendar": ["gcalendar"],
|
||||
"health": ["oura", "apple_health"],
|
||||
"world": ["weather", "hackernews", "news_rss"],
|
||||
"music": ["spotify", "apple_music"],
|
||||
"world": [], # Handled by web_search, not connectors
|
||||
}
|
||||
sources = set()
|
||||
for section in self._sections:
|
||||
@@ -119,12 +135,10 @@ class MorningDigestAgent(ToolUsingAgent):
|
||||
content=(
|
||||
f"Here is the collected data from my sources:\n\n"
|
||||
f"{collected_data}\n\n"
|
||||
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"
|
||||
f"Synthesize my morning briefing. Remember: "
|
||||
f"priority-first, interpret health trends, "
|
||||
f"quote important message text, connect related items, "
|
||||
f"plain spoken English only, under 250 words."
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -149,6 +163,7 @@ class MorningDigestAgent(ToolUsingAgent):
|
||||
"text": tts_text,
|
||||
"voice_id": self._voice_id,
|
||||
"backend": self._tts_backend,
|
||||
"speed": self._voice_speed,
|
||||
}
|
||||
),
|
||||
)
|
||||
|
||||
@@ -107,3 +107,23 @@ try:
|
||||
import openjarvis.connectors.google_tasks # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import openjarvis.connectors.weather # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import openjarvis.connectors.github_notifications # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import openjarvis.connectors.hackernews # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
try:
|
||||
import openjarvis.connectors.news_rss # noqa: F401
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
@@ -0,0 +1,111 @@
|
||||
"""GitHub Notifications connector — unread notifications via GitHub REST API.
|
||||
|
||||
Uses a Personal Access Token stored in the connector config dir.
|
||||
All API calls are in module-level functions for easy mocking in tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterator, List, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from openjarvis.connectors._stubs import BaseConnector, Document, SyncStatus
|
||||
from openjarvis.core.config import DEFAULT_CONFIG_DIR
|
||||
from openjarvis.core.registry import ConnectorRegistry
|
||||
|
||||
_DEFAULT_TOKEN_PATH = str(DEFAULT_CONFIG_DIR / "connectors" / "github.json")
|
||||
|
||||
|
||||
def _github_api_get(
|
||||
token: str, params: Optional[Dict[str, str]] = None
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""Fetch notifications from the GitHub API."""
|
||||
resp = httpx.get(
|
||||
"https://api.github.com/notifications",
|
||||
headers={
|
||||
"Authorization": f"Bearer {token}",
|
||||
"Accept": "application/vnd.github+json",
|
||||
},
|
||||
params=params or {},
|
||||
timeout=30.0,
|
||||
)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
@ConnectorRegistry.register("github_notifications")
|
||||
class GitHubNotificationsConnector(BaseConnector):
|
||||
"""Sync unread notifications from GitHub."""
|
||||
|
||||
connector_id = "github_notifications"
|
||||
display_name = "GitHub Notifications"
|
||||
auth_type = "token"
|
||||
|
||||
def __init__(self, *, token_path: str = _DEFAULT_TOKEN_PATH) -> None:
|
||||
self._token_path = Path(token_path)
|
||||
self._status = SyncStatus()
|
||||
|
||||
def _load_token(self) -> str:
|
||||
"""Load the GitHub PAT from disk."""
|
||||
data = json.loads(self._token_path.read_text(encoding="utf-8"))
|
||||
return data["token"]
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
return self._token_path.exists()
|
||||
|
||||
def disconnect(self) -> None:
|
||||
if self._token_path.exists():
|
||||
self._token_path.unlink()
|
||||
|
||||
def sync(
|
||||
self, *, since: Optional[datetime] = None, cursor: Optional[str] = None
|
||||
) -> Iterator[Document]:
|
||||
"""Yield Documents for each GitHub notification."""
|
||||
token = self._load_token()
|
||||
params: Dict[str, str] = {}
|
||||
if since is not None:
|
||||
params["since"] = f"{since.isoformat()}Z"
|
||||
|
||||
notifications = _github_api_get(token, params=params)
|
||||
|
||||
for notif in notifications:
|
||||
subject = notif.get("subject", {})
|
||||
repo = notif.get("repository", {}).get("full_name", "")
|
||||
reason = notif.get("reason", "")
|
||||
notif_type = subject.get("type", "")
|
||||
title = subject.get("title", "")
|
||||
notif_id = notif.get("id", "")
|
||||
updated_at = notif.get("updated_at", "")
|
||||
|
||||
content = f"Reason: {reason}, Repository: {repo}"
|
||||
ts = datetime.now()
|
||||
if updated_at:
|
||||
try:
|
||||
ts = datetime.fromisoformat(updated_at.replace("Z", "+00:00"))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
yield Document(
|
||||
doc_id=f"github-notification-{notif_id}",
|
||||
source="github_notifications",
|
||||
doc_type="notification",
|
||||
content=content,
|
||||
title=title,
|
||||
timestamp=ts,
|
||||
url=subject.get("url"),
|
||||
metadata={
|
||||
"reason": reason,
|
||||
"repo": repo,
|
||||
"type": notif_type,
|
||||
},
|
||||
)
|
||||
|
||||
self._status.state = "idle"
|
||||
self._status.last_sync = datetime.now()
|
||||
|
||||
def sync_status(self) -> SyncStatus:
|
||||
return self._status
|
||||
@@ -253,11 +253,11 @@ class GmailConnector(BaseConnector):
|
||||
if not token:
|
||||
return
|
||||
|
||||
query = ""
|
||||
query = "category:primary"
|
||||
if since is not None:
|
||||
# Gmail's after: operator accepts Unix epoch seconds.
|
||||
epoch = int(since.timestamp())
|
||||
query = f"after:{epoch}"
|
||||
query = f"category:primary after:{epoch}"
|
||||
|
||||
page_token: Optional[str] = cursor
|
||||
synced = 0
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
"""Hacker News connector — top stories from the HN Firebase API.
|
||||
|
||||
No authentication required. All API calls are in module-level functions
|
||||
for easy mocking in tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Any, Dict, Iterator, List, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from openjarvis.connectors._stubs import BaseConnector, Document, SyncStatus
|
||||
from openjarvis.core.registry import ConnectorRegistry
|
||||
|
||||
_HN_API_BASE = "https://hacker-news.firebaseio.com/v0"
|
||||
|
||||
|
||||
def _hn_top_story_ids() -> List[int]:
|
||||
"""Fetch the list of top story IDs."""
|
||||
resp = httpx.get(f"{_HN_API_BASE}/topstories.json", timeout=30.0)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
def _hn_item(item_id: int) -> Dict[str, Any]:
|
||||
"""Fetch a single HN item by ID."""
|
||||
resp = httpx.get(f"{_HN_API_BASE}/item/{item_id}.json", timeout=30.0)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
@ConnectorRegistry.register("hackernews")
|
||||
class HackerNewsConnector(BaseConnector):
|
||||
"""Fetch the current top stories from Hacker News."""
|
||||
|
||||
connector_id = "hackernews"
|
||||
display_name = "Hacker News"
|
||||
auth_type = "local"
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._status = SyncStatus()
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
return True
|
||||
|
||||
def disconnect(self) -> None:
|
||||
pass # No credentials to revoke
|
||||
|
||||
def sync(
|
||||
self, *, since: Optional[datetime] = None, cursor: Optional[str] = None
|
||||
) -> Iterator[Document]:
|
||||
"""Yield Documents for the top 5 Hacker News stories."""
|
||||
top_ids = _hn_top_story_ids()
|
||||
|
||||
for story_id in top_ids[:5]:
|
||||
item = _hn_item(story_id)
|
||||
if item is None:
|
||||
continue
|
||||
|
||||
title = item.get("title", "")
|
||||
score = item.get("score", 0)
|
||||
descendants = item.get("descendants", 0)
|
||||
url = item.get("url", "")
|
||||
by = item.get("by", "")
|
||||
ts = datetime.now()
|
||||
if item.get("time"):
|
||||
ts = datetime.fromtimestamp(item["time"])
|
||||
|
||||
yield Document(
|
||||
doc_id=f"hn-{story_id}",
|
||||
source="hackernews",
|
||||
doc_type="story",
|
||||
content=f"Score: {score}, Comments: {descendants}",
|
||||
title=title,
|
||||
author=by,
|
||||
timestamp=ts,
|
||||
url=url or None,
|
||||
metadata={
|
||||
"story_id": story_id,
|
||||
"score": score,
|
||||
"descendants": descendants,
|
||||
},
|
||||
)
|
||||
|
||||
self._status.state = "idle"
|
||||
self._status.last_sync = datetime.now()
|
||||
|
||||
def sync_status(self) -> SyncStatus:
|
||||
return self._status
|
||||
@@ -0,0 +1,158 @@
|
||||
"""News/RSS connector — aggregate headlines from RSS and Atom feeds.
|
||||
|
||||
Uses stdlib xml.etree.ElementTree for parsing (no extra dependencies).
|
||||
Config file lists feeds to follow. All HTTP calls are in module-level
|
||||
functions for easy mocking in tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import xml.etree.ElementTree as ET
|
||||
from datetime import datetime
|
||||
from email.utils import parsedate_to_datetime
|
||||
from pathlib import Path
|
||||
from typing import Dict, Iterator, List, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from openjarvis.connectors._stubs import BaseConnector, Document, SyncStatus
|
||||
from openjarvis.core.config import DEFAULT_CONFIG_DIR
|
||||
from openjarvis.core.registry import ConnectorRegistry
|
||||
|
||||
_DEFAULT_CONFIG_PATH = str(DEFAULT_CONFIG_DIR / "connectors" / "news_rss.json")
|
||||
|
||||
|
||||
def _fetch_feed(url: str) -> str:
|
||||
"""Download raw XML from a feed URL."""
|
||||
resp = httpx.get(url, timeout=30.0, follow_redirects=True)
|
||||
resp.raise_for_status()
|
||||
return resp.text
|
||||
|
||||
|
||||
def _parse_rss_items(xml_text: str, max_items: int = 5) -> List[Dict[str, str]]:
|
||||
"""Parse RSS or Atom XML and return up to *max_items* entries."""
|
||||
root = ET.fromstring(xml_text)
|
||||
items: List[Dict[str, str]] = []
|
||||
|
||||
# RSS 2.0: <rss><channel><item>
|
||||
for item_el in root.iter("item"):
|
||||
if len(items) >= max_items:
|
||||
break
|
||||
items.append({
|
||||
"title": (item_el.findtext("title") or "").strip(),
|
||||
"description": (item_el.findtext("description") or "").strip()[:200],
|
||||
"link": (item_el.findtext("link") or "").strip(),
|
||||
"pubDate": (item_el.findtext("pubDate") or "").strip(),
|
||||
})
|
||||
|
||||
# Atom: <feed><entry>
|
||||
if not items:
|
||||
ns = {"atom": "http://www.w3.org/2005/Atom"}
|
||||
for entry_el in root.iter("{http://www.w3.org/2005/Atom}entry"):
|
||||
if len(items) >= max_items:
|
||||
break
|
||||
link_el = entry_el.find("atom:link", ns)
|
||||
link_href = link_el.get("href", "") if link_el is not None else ""
|
||||
summary = entry_el.findtext("{http://www.w3.org/2005/Atom}summary") or ""
|
||||
updated = entry_el.findtext("{http://www.w3.org/2005/Atom}updated") or ""
|
||||
items.append({
|
||||
"title": (
|
||||
entry_el.findtext("{http://www.w3.org/2005/Atom}title") or ""
|
||||
).strip(),
|
||||
"description": summary.strip()[:200],
|
||||
"link": link_href.strip(),
|
||||
"pubDate": updated.strip(),
|
||||
})
|
||||
|
||||
return items
|
||||
|
||||
|
||||
def _parse_pub_date(date_str: str) -> Optional[datetime]:
|
||||
"""Best-effort parse of an RSS pubDate or Atom updated timestamp."""
|
||||
if not date_str:
|
||||
return None
|
||||
try:
|
||||
return parsedate_to_datetime(date_str)
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
try:
|
||||
return datetime.fromisoformat(date_str.replace("Z", "+00:00"))
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
@ConnectorRegistry.register("news_rss")
|
||||
class NewsRSSConnector(BaseConnector):
|
||||
"""Aggregate headlines from configured RSS/Atom feeds."""
|
||||
|
||||
connector_id = "news_rss"
|
||||
display_name = "News / RSS"
|
||||
auth_type = "local"
|
||||
|
||||
def __init__(self, *, config_path: str = _DEFAULT_CONFIG_PATH) -> None:
|
||||
self._config_path = Path(config_path)
|
||||
self._status = SyncStatus()
|
||||
|
||||
def _load_config(self) -> List[Dict[str, str]]:
|
||||
"""Load feed list from disk."""
|
||||
data = json.loads(self._config_path.read_text(encoding="utf-8"))
|
||||
return data.get("feeds", [])
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
if not self._config_path.exists():
|
||||
return False
|
||||
try:
|
||||
feeds = self._load_config()
|
||||
return len(feeds) > 0
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return False
|
||||
|
||||
def disconnect(self) -> None:
|
||||
if self._config_path.exists():
|
||||
self._config_path.unlink()
|
||||
|
||||
def sync(
|
||||
self, *, since: Optional[datetime] = None, cursor: Optional[str] = None
|
||||
) -> Iterator[Document]:
|
||||
"""Yield Documents for recent items across all configured feeds."""
|
||||
feeds = self._load_config()
|
||||
|
||||
for feed in feeds:
|
||||
feed_name = feed.get("name", "Unknown Feed")
|
||||
feed_url = feed.get("url", "")
|
||||
if not feed_url:
|
||||
continue
|
||||
|
||||
try:
|
||||
xml_text = _fetch_feed(feed_url)
|
||||
except httpx.HTTPError:
|
||||
continue
|
||||
|
||||
items = _parse_rss_items(xml_text)
|
||||
for item in items:
|
||||
pub_dt = _parse_pub_date(item["pubDate"])
|
||||
|
||||
# Filter by since if the date is parseable
|
||||
if since and pub_dt and pub_dt.replace(tzinfo=None) < since:
|
||||
continue
|
||||
|
||||
title = item["title"] or "Untitled"
|
||||
doc_id = f"rss-{feed_name}-{title[:40]}"
|
||||
|
||||
yield Document(
|
||||
doc_id=doc_id,
|
||||
source="news_rss",
|
||||
doc_type="article",
|
||||
content=item["description"],
|
||||
title=title,
|
||||
timestamp=pub_dt or datetime.now(),
|
||||
url=item["link"] or None,
|
||||
metadata={"feed_name": feed_name},
|
||||
)
|
||||
|
||||
self._status.state = "idle"
|
||||
self._status.last_sync = datetime.now()
|
||||
|
||||
def sync_status(self) -> SyncStatus:
|
||||
return self._status
|
||||
@@ -0,0 +1,135 @@
|
||||
"""Weather connector — current conditions and forecast via OpenWeatherMap API.
|
||||
|
||||
Uses an API key stored in the connector config dir.
|
||||
All API calls are in module-level functions for easy mocking in tests.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Iterator, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
from openjarvis.connectors._stubs import BaseConnector, Document, SyncStatus
|
||||
from openjarvis.core.config import DEFAULT_CONFIG_DIR
|
||||
from openjarvis.core.registry import ConnectorRegistry
|
||||
|
||||
_DEFAULT_TOKEN_PATH = str(DEFAULT_CONFIG_DIR / "connectors" / "weather.json")
|
||||
|
||||
|
||||
def _weather_api_get(
|
||||
url: str, params: Dict[str, str]
|
||||
) -> Dict[str, Any]:
|
||||
"""Call an OpenWeatherMap API endpoint."""
|
||||
resp = httpx.get(url, params=params, timeout=30.0)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
@ConnectorRegistry.register("weather")
|
||||
class WeatherConnector(BaseConnector):
|
||||
"""Fetch current weather and short-term forecast from OpenWeatherMap."""
|
||||
|
||||
connector_id = "weather"
|
||||
display_name = "Weather"
|
||||
auth_type = "token"
|
||||
|
||||
def __init__(self, *, token_path: str = _DEFAULT_TOKEN_PATH) -> None:
|
||||
self._token_path = Path(token_path)
|
||||
self._status = SyncStatus()
|
||||
|
||||
def _load_config(self) -> Dict[str, str]:
|
||||
"""Load API key and location from disk."""
|
||||
data = json.loads(self._token_path.read_text(encoding="utf-8"))
|
||||
return data
|
||||
|
||||
def is_connected(self) -> bool:
|
||||
if not self._token_path.exists():
|
||||
return False
|
||||
try:
|
||||
data = json.loads(self._token_path.read_text(encoding="utf-8"))
|
||||
return bool(data.get("api_key"))
|
||||
except (json.JSONDecodeError, OSError):
|
||||
return False
|
||||
|
||||
def disconnect(self) -> None:
|
||||
if self._token_path.exists():
|
||||
self._token_path.unlink()
|
||||
|
||||
def sync(
|
||||
self, *, since: Optional[datetime] = None, cursor: Optional[str] = None
|
||||
) -> Iterator[Document]:
|
||||
"""Yield Documents for current weather and forecast."""
|
||||
config = self._load_config()
|
||||
api_key = config["api_key"]
|
||||
location = config.get("location", "San Francisco,CA")
|
||||
|
||||
# Current weather
|
||||
current = _weather_api_get(
|
||||
"https://api.openweathermap.org/data/2.5/weather",
|
||||
params={"q": location, "appid": api_key, "units": "imperial"},
|
||||
)
|
||||
main = current.get("main", {})
|
||||
weather_desc = ", ".join(
|
||||
w.get("description", "") for w in current.get("weather", [])
|
||||
)
|
||||
content = (
|
||||
f"Temperature: {main.get('temp')}°F, "
|
||||
f"Conditions: {weather_desc}, "
|
||||
f"Humidity: {main.get('humidity')}%, "
|
||||
f"Wind: {current.get('wind', {}).get('speed')} mph"
|
||||
)
|
||||
yield Document(
|
||||
doc_id=f"weather-current-{location}",
|
||||
source="weather",
|
||||
doc_type="current",
|
||||
content=content,
|
||||
title=f"Current Weather — {location}",
|
||||
timestamp=datetime.now(),
|
||||
metadata={
|
||||
"location": location,
|
||||
"temp": main.get("temp"),
|
||||
"conditions": weather_desc,
|
||||
"humidity": main.get("humidity"),
|
||||
"wind_speed": current.get("wind", {}).get("speed"),
|
||||
},
|
||||
)
|
||||
|
||||
# Forecast (next ~12 hours, 4 x 3-hour intervals)
|
||||
forecast = _weather_api_get(
|
||||
"https://api.openweathermap.org/data/2.5/forecast",
|
||||
params={
|
||||
"q": location,
|
||||
"appid": api_key,
|
||||
"units": "imperial",
|
||||
"cnt": "4",
|
||||
},
|
||||
)
|
||||
summaries = []
|
||||
for entry in forecast.get("list", []):
|
||||
dt_txt = entry.get("dt_txt", "")
|
||||
temp = entry.get("main", {}).get("temp")
|
||||
desc = ", ".join(
|
||||
w.get("description", "") for w in entry.get("weather", [])
|
||||
)
|
||||
summaries.append(f"{dt_txt}: {temp}°F, {desc}")
|
||||
forecast_content = "Forecast:\n" + "\n".join(summaries)
|
||||
|
||||
yield Document(
|
||||
doc_id=f"weather-forecast-{location}",
|
||||
source="weather",
|
||||
doc_type="forecast",
|
||||
content=forecast_content,
|
||||
title=f"Weather Forecast — {location}",
|
||||
timestamp=datetime.now(),
|
||||
metadata={"location": location},
|
||||
)
|
||||
|
||||
self._status.state = "idle"
|
||||
self._status.last_sync = datetime.now()
|
||||
|
||||
def sync_status(self) -> SyncStatus:
|
||||
return self._status
|
||||
@@ -1258,7 +1258,9 @@ class DigestConfig:
|
||||
optional_sections: List[str] = field(
|
||||
default_factory=lambda: ["github", "financial", "music", "fitness"]
|
||||
)
|
||||
honorific: str = "sir"
|
||||
voice_id: str = ""
|
||||
voice_speed: float = 1.0
|
||||
tts_backend: str = "cartesia"
|
||||
messages: DigestSectionConfig = field(
|
||||
default_factory=lambda: DigestSectionConfig(
|
||||
|
||||
@@ -496,7 +496,9 @@ class Jarvis:
|
||||
"section_sources": section_sources,
|
||||
"timezone": dc.timezone,
|
||||
"voice_id": dc.voice_id,
|
||||
"voice_speed": dc.voice_speed,
|
||||
"tts_backend": dc.tts_backend,
|
||||
"honorific": dc.honorific,
|
||||
})
|
||||
# Ensure digest agent always has its required tools
|
||||
from openjarvis.tools.digest_collect import DigestCollectTool
|
||||
|
||||
@@ -225,7 +225,9 @@ class JarvisSystem:
|
||||
"section_sources": section_sources,
|
||||
"timezone": dc.timezone,
|
||||
"voice_id": dc.voice_id,
|
||||
"voice_speed": dc.voice_speed,
|
||||
"tts_backend": dc.tts_backend,
|
||||
"honorific": dc.honorific,
|
||||
})
|
||||
# Ensure digest agent always has its required tools
|
||||
from openjarvis.tools.digest_collect import DigestCollectTool
|
||||
|
||||
@@ -28,9 +28,12 @@ _SECTION_ORDER: List[tuple] = [
|
||||
"imessage",
|
||||
"whatsapp",
|
||||
"outlook",
|
||||
"notion",
|
||||
"github_notifications",
|
||||
},
|
||||
),
|
||||
("CALENDAR", {"gcalendar"}),
|
||||
("WORLD", {"weather", "hackernews", "news_rss"}),
|
||||
("MUSIC", {"spotify", "apple_music"}),
|
||||
]
|
||||
|
||||
@@ -156,10 +159,14 @@ def _format_strava(doc: Document) -> str:
|
||||
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})'
|
||||
# Include body preview (first 150 chars, single line)
|
||||
body = doc.content.replace("\n", " ").strip()[:150] if doc.content else ""
|
||||
line = f'[gmail] From: {sender} — "{subject}" ({ago})'
|
||||
if body:
|
||||
line += f"\n Preview: {body}"
|
||||
return line
|
||||
|
||||
|
||||
def _format_gmail_imap(doc: Document) -> str:
|
||||
@@ -190,16 +197,26 @@ 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 ""
|
||||
ago = _time_ago(doc.timestamp)
|
||||
snippet = doc.content[:150].replace("\n", " ").strip()
|
||||
content_preview = snippet if doc.content else ""
|
||||
prefix = f"[slack] #{channel}" if channel else "[slack]"
|
||||
return f"{prefix} {author}: {content_preview}"
|
||||
line = f"{prefix} {author} ({ago})"
|
||||
if content_preview:
|
||||
line += f": {content_preview}"
|
||||
return line
|
||||
|
||||
|
||||
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}"
|
||||
ago = _time_ago(doc.timestamp)
|
||||
snippet = doc.content[:150].replace("\n", " ").strip()
|
||||
content_preview = snippet if doc.content else ""
|
||||
line = f"[imessage] {sender} ({ago})"
|
||||
if content_preview:
|
||||
line += f": {content_preview}"
|
||||
return line
|
||||
|
||||
|
||||
def _format_whatsapp(doc: Document) -> str:
|
||||
@@ -217,6 +234,13 @@ def _format_outlook(doc: Document) -> str:
|
||||
return f'[outlook] From: {sender} — "{subject}" ({ago})'
|
||||
|
||||
|
||||
def _format_notion(doc: Document) -> str:
|
||||
"""Format a Notion page document."""
|
||||
title = doc.title or "Untitled"
|
||||
ago = _time_ago(doc.timestamp)
|
||||
return f"[notion] {title} (updated {ago})"
|
||||
|
||||
|
||||
def _format_gcalendar(doc: Document) -> str:
|
||||
"""Format a Google Calendar event document."""
|
||||
title = doc.title or "(No title)"
|
||||
@@ -262,6 +286,48 @@ def _format_apple_music(doc: Document) -> str:
|
||||
return doc.title
|
||||
|
||||
|
||||
def _format_weather(doc: Document) -> str:
|
||||
"""Format a weather document."""
|
||||
data = _parse_content_json(doc)
|
||||
if doc.doc_type == "current":
|
||||
temp = data.get("temp_f", "?")
|
||||
cond = data.get("conditions", "?")
|
||||
humidity = data.get("humidity", "?")
|
||||
return f"[weather] Current: {temp}°F, {cond}, humidity {humidity}%"
|
||||
if doc.doc_type == "forecast":
|
||||
return f"[weather] Forecast: {doc.content[:200]}"
|
||||
return f"[weather] {doc.title}"
|
||||
|
||||
|
||||
def _format_github_notifications(doc: Document) -> str:
|
||||
"""Format a GitHub notification."""
|
||||
reason = doc.metadata.get("reason", "")
|
||||
repo = doc.metadata.get("repo", "")
|
||||
title = doc.title or "(no title)"
|
||||
ago = _time_ago(doc.timestamp)
|
||||
reason_str = f" ({reason})" if reason else ""
|
||||
repo_str = f" in {repo}" if repo else ""
|
||||
return f"[github] {title}{repo_str}{reason_str} ({ago})"
|
||||
|
||||
|
||||
def _format_hackernews(doc: Document) -> str:
|
||||
"""Format a Hacker News story."""
|
||||
score = doc.metadata.get("score", "?")
|
||||
comments = doc.metadata.get("descendants", "?")
|
||||
return f"[hackernews] {doc.title} (score {score}, {comments} comments)"
|
||||
|
||||
|
||||
def _format_news_rss(doc: Document) -> str:
|
||||
"""Format an RSS news item."""
|
||||
feed_name = doc.metadata.get("feed_name", "")
|
||||
prefix = f"[{feed_name}]" if feed_name else "[news]"
|
||||
description = doc.content[:150].replace("\n", " ").strip() if doc.content else ""
|
||||
line = f"{prefix} {doc.title}"
|
||||
if description:
|
||||
line += f" — {description}"
|
||||
return line
|
||||
|
||||
|
||||
# Map connector IDs to their formatting functions
|
||||
_FORMATTERS: Dict[str, Any] = {
|
||||
"oura": _format_oura,
|
||||
@@ -274,7 +340,12 @@ _FORMATTERS: Dict[str, Any] = {
|
||||
"imessage": _format_imessage,
|
||||
"whatsapp": _format_whatsapp,
|
||||
"outlook": _format_outlook,
|
||||
"notion": _format_notion,
|
||||
"gcalendar": _format_gcalendar,
|
||||
"weather": _format_weather,
|
||||
"github_notifications": _format_github_notifications,
|
||||
"hackernews": _format_hackernews,
|
||||
"news_rss": _format_news_rss,
|
||||
"spotify": _format_spotify,
|
||||
"apple_music": _format_apple_music,
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ class TextToSpeechTool(BaseTool):
|
||||
voice_id = params.get("voice_id", "")
|
||||
backend_key = params.get("backend", "cartesia")
|
||||
output_dir = params.get("output_dir", "")
|
||||
speed = float(params.get("speed", 1.0))
|
||||
|
||||
if not text:
|
||||
return ToolResult(
|
||||
@@ -78,7 +79,7 @@ class TextToSpeechTool(BaseTool):
|
||||
backend_cls = TTSRegistry.get(backend_key)
|
||||
backend = backend_cls()
|
||||
|
||||
result = backend.synthesize(text, voice_id=voice_id)
|
||||
result = backend.synthesize(text, voice_id=voice_id, speed=speed)
|
||||
|
||||
# Save to file
|
||||
if output_dir:
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
"""Tests for GitHubNotificationsConnector — GitHub REST API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from openjarvis.connectors._stubs import Document
|
||||
from openjarvis.core.registry import ConnectorRegistry
|
||||
|
||||
|
||||
def test_github_notifications_registered():
|
||||
"""GitHubNotificationsConnector is discoverable via ConnectorRegistry."""
|
||||
from openjarvis.connectors.github_notifications import (
|
||||
GitHubNotificationsConnector,
|
||||
)
|
||||
|
||||
ConnectorRegistry.register_value(
|
||||
"github_notifications", GitHubNotificationsConnector
|
||||
)
|
||||
assert ConnectorRegistry.contains("github_notifications")
|
||||
cls = ConnectorRegistry.get("github_notifications")
|
||||
assert cls.connector_id == "github_notifications"
|
||||
assert cls.auth_type == "token"
|
||||
|
||||
|
||||
_NOTIFICATIONS_RESPONSE = [
|
||||
{
|
||||
"id": "1001",
|
||||
"reason": "review_requested",
|
||||
"updated_at": "2026-04-01T10:00:00Z",
|
||||
"subject": {
|
||||
"title": "Add caching layer to inference engine",
|
||||
"type": "PullRequest",
|
||||
"url": "https://api.github.com/repos/org/repo/pulls/42",
|
||||
},
|
||||
"repository": {"full_name": "org/repo"},
|
||||
},
|
||||
{
|
||||
"id": "1002",
|
||||
"reason": "mention",
|
||||
"updated_at": "2026-04-01T11:30:00Z",
|
||||
"subject": {
|
||||
"title": "Bug: memory leak in long sessions",
|
||||
"type": "Issue",
|
||||
"url": "https://api.github.com/repos/org/repo/issues/99",
|
||||
},
|
||||
"repository": {"full_name": "org/repo"},
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def connector(tmp_path):
|
||||
"""GitHubNotificationsConnector with fake token file."""
|
||||
from openjarvis.connectors.github_notifications import (
|
||||
GitHubNotificationsConnector,
|
||||
)
|
||||
|
||||
token_path = tmp_path / "github.json"
|
||||
token_path.write_text('{"token": "ghp_fake123"}', encoding="utf-8")
|
||||
return GitHubNotificationsConnector(token_path=str(token_path))
|
||||
|
||||
|
||||
def test_is_connected(connector):
|
||||
assert connector.is_connected() is True
|
||||
|
||||
|
||||
def test_is_connected_no_file(tmp_path):
|
||||
from openjarvis.connectors.github_notifications import (
|
||||
GitHubNotificationsConnector,
|
||||
)
|
||||
|
||||
c = GitHubNotificationsConnector(token_path=str(tmp_path / "missing.json"))
|
||||
assert c.is_connected() is False
|
||||
|
||||
|
||||
def test_sync_yields_documents(connector):
|
||||
"""Sync returns Documents for each notification."""
|
||||
with patch(
|
||||
"openjarvis.connectors.github_notifications._github_api_get",
|
||||
return_value=_NOTIFICATIONS_RESPONSE,
|
||||
):
|
||||
docs = list(connector.sync(since=datetime(2026, 4, 1)))
|
||||
|
||||
assert len(docs) == 2
|
||||
assert all(isinstance(d, Document) for d in docs)
|
||||
|
||||
pr_doc = docs[0]
|
||||
assert pr_doc.source == "github_notifications"
|
||||
assert pr_doc.title == "Add caching layer to inference engine"
|
||||
assert "review_requested" in pr_doc.content
|
||||
assert pr_doc.metadata["repo"] == "org/repo"
|
||||
assert pr_doc.metadata["type"] == "PullRequest"
|
||||
|
||||
issue_doc = docs[1]
|
||||
assert issue_doc.title == "Bug: memory leak in long sessions"
|
||||
assert issue_doc.metadata["reason"] == "mention"
|
||||
|
||||
|
||||
def test_disconnect(connector):
|
||||
connector.disconnect()
|
||||
assert connector.is_connected() is False
|
||||
@@ -0,0 +1,124 @@
|
||||
"""Tests for HackerNewsConnector — HN Firebase API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from openjarvis.connectors._stubs import Document
|
||||
from openjarvis.core.registry import ConnectorRegistry
|
||||
|
||||
|
||||
def test_hackernews_registered():
|
||||
"""HackerNewsConnector is discoverable via ConnectorRegistry."""
|
||||
from openjarvis.connectors.hackernews import HackerNewsConnector
|
||||
|
||||
ConnectorRegistry.register_value("hackernews", HackerNewsConnector)
|
||||
assert ConnectorRegistry.contains("hackernews")
|
||||
cls = ConnectorRegistry.get("hackernews")
|
||||
assert cls.connector_id == "hackernews"
|
||||
assert cls.auth_type == "local"
|
||||
|
||||
|
||||
_TOP_STORY_IDS = [101, 102, 103, 104, 105, 106, 107]
|
||||
|
||||
_STORY_ITEMS = {
|
||||
101: {
|
||||
"id": 101,
|
||||
"title": "Show HN: A new Rust web framework",
|
||||
"score": 350,
|
||||
"descendants": 120,
|
||||
"url": "https://example.com/rust-framework",
|
||||
"by": "rustdev",
|
||||
"time": 1743523200,
|
||||
},
|
||||
102: {
|
||||
"id": 102,
|
||||
"title": "Why SQLite is the future of edge computing",
|
||||
"score": 210,
|
||||
"descendants": 85,
|
||||
"url": "https://example.com/sqlite-edge",
|
||||
"by": "dbfan",
|
||||
"time": 1743523300,
|
||||
},
|
||||
103: {
|
||||
"id": 103,
|
||||
"title": "Launch HN: AI agent startup",
|
||||
"score": 180,
|
||||
"descendants": 60,
|
||||
"url": "",
|
||||
"by": "founder",
|
||||
"time": 1743523400,
|
||||
},
|
||||
104: {
|
||||
"id": 104,
|
||||
"title": "Understanding memory-mapped files",
|
||||
"score": 95,
|
||||
"descendants": 30,
|
||||
"url": "https://example.com/mmap",
|
||||
"by": "sysprog",
|
||||
"time": 1743523500,
|
||||
},
|
||||
105: {
|
||||
"id": 105,
|
||||
"title": "Open-source LLM benchmarks are broken",
|
||||
"score": 420,
|
||||
"descendants": 200,
|
||||
"url": "https://example.com/llm-benchmarks",
|
||||
"by": "mlresearcher",
|
||||
"time": 1743523600,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def connector():
|
||||
from openjarvis.connectors.hackernews import HackerNewsConnector
|
||||
|
||||
return HackerNewsConnector()
|
||||
|
||||
|
||||
def test_is_connected(connector):
|
||||
assert connector.is_connected() is True
|
||||
|
||||
|
||||
def test_sync_yields_top_five(connector):
|
||||
"""Sync returns Documents for the top 5 stories."""
|
||||
|
||||
def mock_item(item_id):
|
||||
return _STORY_ITEMS[item_id]
|
||||
|
||||
with (
|
||||
patch(
|
||||
"openjarvis.connectors.hackernews._hn_top_story_ids",
|
||||
return_value=_TOP_STORY_IDS,
|
||||
),
|
||||
patch(
|
||||
"openjarvis.connectors.hackernews._hn_item",
|
||||
side_effect=mock_item,
|
||||
),
|
||||
):
|
||||
docs = list(connector.sync())
|
||||
|
||||
assert len(docs) == 5
|
||||
assert all(isinstance(d, Document) for d in docs)
|
||||
|
||||
first = docs[0]
|
||||
assert first.source == "hackernews"
|
||||
assert first.doc_type == "story"
|
||||
assert first.title == "Show HN: A new Rust web framework"
|
||||
assert "Score: 350" in first.content
|
||||
assert "Comments: 120" in first.content
|
||||
assert first.author == "rustdev"
|
||||
assert first.url == "https://example.com/rust-framework"
|
||||
|
||||
# Story with empty URL should have url=None
|
||||
third = docs[2]
|
||||
assert third.url is None
|
||||
|
||||
|
||||
def test_disconnect_is_noop(connector):
|
||||
"""Disconnect should succeed without error."""
|
||||
connector.disconnect()
|
||||
assert connector.is_connected() is True
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Tests for NewsRSSConnector — RSS/Atom feed aggregator."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from openjarvis.connectors._stubs import Document
|
||||
from openjarvis.core.registry import ConnectorRegistry
|
||||
|
||||
|
||||
def test_news_rss_registered():
|
||||
"""NewsRSSConnector is discoverable via ConnectorRegistry."""
|
||||
from openjarvis.connectors.news_rss import NewsRSSConnector
|
||||
|
||||
ConnectorRegistry.register_value("news_rss", NewsRSSConnector)
|
||||
assert ConnectorRegistry.contains("news_rss")
|
||||
cls = ConnectorRegistry.get("news_rss")
|
||||
assert cls.connector_id == "news_rss"
|
||||
assert cls.auth_type == "local"
|
||||
|
||||
|
||||
_SAMPLE_RSS = """\
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<rss version="2.0">
|
||||
<channel>
|
||||
<title>Test Feed</title>
|
||||
<item>
|
||||
<title>Article One</title>
|
||||
<description>First article about AI advancements in 2026.</description>
|
||||
<link>https://example.com/article-one</link>
|
||||
<pubDate>Wed, 01 Apr 2026 10:00:00 GMT</pubDate>
|
||||
</item>
|
||||
<item>
|
||||
<title>Article Two</title>
|
||||
<description>Second article about quantum computing breakthroughs.</description>
|
||||
<link>https://example.com/article-two</link>
|
||||
<pubDate>Wed, 01 Apr 2026 12:00:00 GMT</pubDate>
|
||||
</item>
|
||||
<item>
|
||||
<title>Article Three</title>
|
||||
<description>Third article about open source projects.</description>
|
||||
<link>https://example.com/article-three</link>
|
||||
<pubDate>Wed, 01 Apr 2026 14:00:00 GMT</pubDate>
|
||||
</item>
|
||||
</channel>
|
||||
</rss>
|
||||
"""
|
||||
|
||||
_SAMPLE_ATOM = """\
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<feed xmlns="http://www.w3.org/2005/Atom">
|
||||
<title>Atom Feed</title>
|
||||
<entry>
|
||||
<title>Atom Entry</title>
|
||||
<summary>An atom feed entry about distributed systems.</summary>
|
||||
<link href="https://example.com/atom-entry" />
|
||||
<updated>2026-04-01T15:00:00Z</updated>
|
||||
</entry>
|
||||
</feed>
|
||||
"""
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def connector(tmp_path):
|
||||
"""NewsRSSConnector with fake config file."""
|
||||
import json
|
||||
|
||||
from openjarvis.connectors.news_rss import NewsRSSConnector
|
||||
|
||||
config_path = tmp_path / "news_rss.json"
|
||||
config_path.write_text(
|
||||
json.dumps({
|
||||
"feeds": [
|
||||
{"name": "Test Feed", "url": "https://example.com/rss.xml"},
|
||||
]
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return NewsRSSConnector(config_path=str(config_path))
|
||||
|
||||
|
||||
def test_is_connected(connector):
|
||||
assert connector.is_connected() is True
|
||||
|
||||
|
||||
def test_is_connected_no_file(tmp_path):
|
||||
from openjarvis.connectors.news_rss import NewsRSSConnector
|
||||
|
||||
c = NewsRSSConnector(config_path=str(tmp_path / "missing.json"))
|
||||
assert c.is_connected() is False
|
||||
|
||||
|
||||
def test_is_connected_empty_feeds(tmp_path):
|
||||
from openjarvis.connectors.news_rss import NewsRSSConnector
|
||||
|
||||
config_path = tmp_path / "news_rss.json"
|
||||
config_path.write_text('{"feeds": []}', encoding="utf-8")
|
||||
c = NewsRSSConnector(config_path=str(config_path))
|
||||
assert c.is_connected() is False
|
||||
|
||||
|
||||
def test_sync_rss_feed(connector):
|
||||
"""Sync parses RSS XML and returns Documents."""
|
||||
with patch(
|
||||
"openjarvis.connectors.news_rss._fetch_feed",
|
||||
return_value=_SAMPLE_RSS,
|
||||
):
|
||||
docs = list(connector.sync())
|
||||
|
||||
assert len(docs) == 3
|
||||
assert all(isinstance(d, Document) for d in docs)
|
||||
|
||||
first = docs[0]
|
||||
assert first.source == "news_rss"
|
||||
assert first.doc_type == "article"
|
||||
assert first.title == "Article One"
|
||||
assert "AI advancements" in first.content
|
||||
assert first.url == "https://example.com/article-one"
|
||||
assert first.metadata["feed_name"] == "Test Feed"
|
||||
|
||||
|
||||
def test_sync_atom_feed(tmp_path):
|
||||
"""Sync parses Atom XML and returns Documents."""
|
||||
import json
|
||||
|
||||
from openjarvis.connectors.news_rss import NewsRSSConnector
|
||||
|
||||
config_path = tmp_path / "news_rss.json"
|
||||
config_path.write_text(
|
||||
json.dumps({
|
||||
"feeds": [
|
||||
{"name": "Atom Feed", "url": "https://example.com/atom.xml"},
|
||||
]
|
||||
}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
c = NewsRSSConnector(config_path=str(config_path))
|
||||
|
||||
with patch(
|
||||
"openjarvis.connectors.news_rss._fetch_feed",
|
||||
return_value=_SAMPLE_ATOM,
|
||||
):
|
||||
docs = list(c.sync())
|
||||
|
||||
assert len(docs) == 1
|
||||
assert docs[0].title == "Atom Entry"
|
||||
assert "distributed systems" in docs[0].content
|
||||
|
||||
|
||||
def test_sync_filters_by_since(connector):
|
||||
"""Items older than `since` are excluded when date is parseable."""
|
||||
with patch(
|
||||
"openjarvis.connectors.news_rss._fetch_feed",
|
||||
return_value=_SAMPLE_RSS,
|
||||
):
|
||||
# Only items after April 1 12:00 should come through
|
||||
docs = list(connector.sync(since=datetime(2026, 4, 1, 11, 0, 0)))
|
||||
|
||||
assert len(docs) == 2
|
||||
assert docs[0].title == "Article Two"
|
||||
assert docs[1].title == "Article Three"
|
||||
|
||||
|
||||
def test_disconnect(connector):
|
||||
connector.disconnect()
|
||||
assert connector.is_connected() is False
|
||||
@@ -0,0 +1,97 @@
|
||||
"""Tests for WeatherConnector — OpenWeatherMap API."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from openjarvis.connectors._stubs import Document
|
||||
from openjarvis.core.registry import ConnectorRegistry
|
||||
|
||||
|
||||
def test_weather_registered():
|
||||
"""WeatherConnector is discoverable via ConnectorRegistry."""
|
||||
from openjarvis.connectors.weather import WeatherConnector
|
||||
|
||||
ConnectorRegistry.register_value("weather", WeatherConnector)
|
||||
assert ConnectorRegistry.contains("weather")
|
||||
cls = ConnectorRegistry.get("weather")
|
||||
assert cls.connector_id == "weather"
|
||||
assert cls.display_name == "Weather"
|
||||
assert cls.auth_type == "token"
|
||||
|
||||
|
||||
_CURRENT_RESPONSE = {
|
||||
"main": {"temp": 62.5, "humidity": 55},
|
||||
"weather": [{"description": "clear sky"}],
|
||||
"wind": {"speed": 8.2},
|
||||
}
|
||||
|
||||
_FORECAST_RESPONSE = {
|
||||
"list": [
|
||||
{
|
||||
"dt_txt": "2026-04-02 12:00:00",
|
||||
"main": {"temp": 64.0},
|
||||
"weather": [{"description": "few clouds"}],
|
||||
},
|
||||
{
|
||||
"dt_txt": "2026-04-02 15:00:00",
|
||||
"main": {"temp": 66.0},
|
||||
"weather": [{"description": "scattered clouds"}],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def connector(tmp_path):
|
||||
"""WeatherConnector with fake config file."""
|
||||
from openjarvis.connectors.weather import WeatherConnector
|
||||
|
||||
config_path = tmp_path / "weather.json"
|
||||
config_path.write_text(
|
||||
'{"api_key": "fake-key", "location": "San Francisco,CA"}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
return WeatherConnector(token_path=str(config_path))
|
||||
|
||||
|
||||
def test_is_connected(connector):
|
||||
assert connector.is_connected() is True
|
||||
|
||||
|
||||
def test_is_connected_no_file(tmp_path):
|
||||
from openjarvis.connectors.weather import WeatherConnector
|
||||
|
||||
c = WeatherConnector(token_path=str(tmp_path / "missing.json"))
|
||||
assert c.is_connected() is False
|
||||
|
||||
|
||||
def test_sync_yields_two_documents(connector):
|
||||
"""Sync returns one current weather and one forecast Document."""
|
||||
with patch(
|
||||
"openjarvis.connectors.weather._weather_api_get",
|
||||
side_effect=[_CURRENT_RESPONSE, _FORECAST_RESPONSE],
|
||||
):
|
||||
docs = list(connector.sync())
|
||||
|
||||
assert len(docs) == 2
|
||||
assert all(isinstance(d, Document) for d in docs)
|
||||
|
||||
current = docs[0]
|
||||
assert current.source == "weather"
|
||||
assert current.doc_type == "current"
|
||||
assert "62.5" in current.content
|
||||
assert "clear sky" in current.content
|
||||
assert "55" in current.content
|
||||
|
||||
forecast = docs[1]
|
||||
assert forecast.doc_type == "forecast"
|
||||
assert "64.0" in forecast.content
|
||||
|
||||
|
||||
def test_disconnect(connector):
|
||||
connector.disconnect()
|
||||
assert connector.is_connected() is False
|
||||
Reference in New Issue
Block a user