refactor(heartbeat): smart calendar with daily summary and dedup reminders

Calendar no longer bypasses quiet hours. First run after quiet hours
sends full daily summary, subsequent runs only remind for next event
within 45 min with deduplication. Calendar cooldown set to 30 min.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
MoltBot Service
2026-02-15 11:09:59 +00:00
parent 9c1f9f94e7
commit f8ff971627
3 changed files with 185 additions and 59 deletions

View File

@@ -38,7 +38,7 @@
},
"cooldowns": {
"email": 1800,
"calendar": 0,
"calendar": 1800,
"kb_index": 14400,
"git": 14400
}

View File

@@ -8,7 +8,7 @@ a Claude CLI session is triggered to handle them.
import json
import logging
import subprocess
from datetime import datetime, timezone
from datetime import datetime, date, timezone
from pathlib import Path
log = logging.getLogger(__name__)
@@ -63,11 +63,11 @@ def run_heartbeat(config: dict | None = None) -> str:
results.append(email_result)
checks["email"] = now.isoformat()
# Check 2: Calendar (critical — pierces quiet hours)
if check_flags.get("calendar"):
cal_result = _check_calendar(state)
# Check 2: Calendar — daily summary + next-event reminder (no quiet hours bypass)
if check_flags.get("calendar") and not is_quiet and _should_run("calendar", checks, now, cooldowns):
cal_result = _check_calendar_smart(state, quiet_hours)
if cal_result:
critical.append(cal_result)
results.append(cal_result)
checks["calendar"] = now.isoformat()
# Check 3: KB index freshness + auto-reindex
@@ -171,14 +171,77 @@ def _check_email(state: dict) -> str | None:
return None
def _check_calendar(state: dict) -> str | None:
"""Check upcoming calendar events via tools/calendar_check.py. Parses JSON."""
def _check_calendar_smart(state: dict, quiet_hours: tuple) -> str | None:
"""Smart calendar check: daily summary once, then only next-event reminders.
- First run after quiet hours: full day summary (all events today)
- Subsequent runs: only the nearest event within 45 min, deduplicated
"""
script = TOOLS_DIR / "calendar_check.py"
if not script.exists():
return None
today_str = date.today().isoformat()
cal_state = state.setdefault("calendar", {})
sent_summary_date = cal_state.get("daily_summary_date")
reminded_events = cal_state.get("reminded_events", {})
# Clean old reminded_events (from previous days)
reminded_events = {
k: v for k, v in reminded_events.items() if v == today_str
}
cal_state["reminded_events"] = reminded_events
is_first_run_today = sent_summary_date != today_str
if is_first_run_today:
# Daily summary: all events for today
return _calendar_daily_summary(cal_state, today_str)
else:
# Next-event reminder (within 45 min, deduplicated)
return _calendar_next_reminder(cal_state, today_str, reminded_events)
def _calendar_daily_summary(cal_state: dict, today_str: str) -> str | None:
"""Fetch all today's events and return a daily summary."""
script = TOOLS_DIR / "calendar_check.py"
try:
result = subprocess.run(
["python3", str(script), "soon"],
["python3", str(script), "today"],
capture_output=True, text=True, timeout=30,
cwd=str(PROJECT_ROOT)
)
if result.returncode != 0:
return None
output = result.stdout.strip()
if not output:
return None
data = json.loads(output)
today_events = data.get("today", [])
if not today_events:
cal_state["daily_summary_date"] = today_str
return None
parts = []
for event in today_events:
time = event.get("time", "")
name = event.get("summary", "?")
parts.append(f" {time}{name}")
cal_state["daily_summary_date"] = today_str
return "📅 Program azi:\n" + "\n".join(parts)
except Exception as e:
log.warning("Calendar daily summary failed: %s", e)
return None
def _calendar_next_reminder(cal_state: dict, today_str: str,
reminded_events: dict) -> str | None:
"""Remind only for the next upcoming event within 45 min, if not already reminded."""
script = TOOLS_DIR / "calendar_check.py"
try:
result = subprocess.run(
["python3", str(script), "soon", "1"],
capture_output=True, text=True, timeout=30,
cwd=str(PROJECT_ROOT)
)
@@ -191,21 +254,28 @@ def _check_calendar(state: dict) -> str | None:
upcoming = data.get("upcoming", [])
if not upcoming:
return None
parts = []
for event in upcoming:
mins = event.get("minutes_until", "?")
name = event.get("summary", "?")
time = event.get("time", "")
parts.append(f"in {mins} min — {name} ({time})")
return "Calendar: " + "; ".join(parts)
except json.JSONDecodeError:
# Fallback: treat as plain text
output = result.stdout.strip()
if output:
return f"Calendar: {output}"
return None
event = upcoming[0]
mins = event.get("minutes_until", 999)
name = event.get("summary", "?")
time = event.get("time", "")
# Only remind if within 45 minutes
if isinstance(mins, (int, float)) and mins > 45:
return None
# Dedup key: event name + time
dedup_key = f"{name}@{time}"
if dedup_key in reminded_events:
return None
# Mark as reminded
reminded_events[dedup_key] = today_str
cal_state["reminded_events"] = reminded_events
return f"⏰ in {mins} min — {name} ({time})"
except Exception as e:
log.warning("Calendar check failed: %s", e)
log.warning("Calendar next reminder failed: %s", e)
return None

View File

@@ -2,14 +2,16 @@
import json
import time
from datetime import datetime, timedelta, timezone
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from unittest.mock import MagicMock, patch
import pytest
from src.heartbeat import (
_check_calendar,
_calendar_daily_summary,
_calendar_next_reminder,
_check_calendar_smart,
_check_email,
_check_git,
_check_kb_index,
@@ -207,52 +209,113 @@ class TestCheckEmail:
# ---------------------------------------------------------------------------
class TestCheckCalendar:
"""Test calendar check via tools/calendar_check.py with JSON parsing."""
class TestCheckCalendarSmart:
"""Test smart calendar: daily summary + next-event reminders with dedup."""
def test_no_script(self, tmp_env):
assert _check_calendar({}) is None
assert _check_calendar_smart({}, (23, 8)) is None
def test_json_with_upcoming(self, tmp_env):
def test_first_run_sends_daily_summary(self, tmp_env):
"""First run after quiet hours should return full day summary."""
script = tmp_env["tools"] / "calendar_check.py"
script.write_text("pass")
output = json.dumps({
"today": [
{"summary": "Standup", "time": "09:00"},
{"summary": "Lunch", "time": "12:00"},
],
"tomorrow": []
})
mock_result = MagicMock(returncode=0, stdout=output)
state = {}
with patch("src.heartbeat.subprocess.run", return_value=mock_result):
result = _check_calendar_smart(state, (23, 8))
assert "Program azi" in result
assert "09:00 — Standup" in result
assert "12:00 — Lunch" in result
assert state["calendar"]["daily_summary_date"] == date.today().isoformat()
def test_second_run_sends_next_event_only(self, tmp_env):
"""After daily summary, only remind for next event within 45 min."""
script = tmp_env["tools"] / "calendar_check.py"
script.write_text("pass")
output = json.dumps({
"upcoming": [
{"summary": "NLP Session", "minutes_until": 45, "time": "15:00"},
{"summary": "Meeting", "minutes_until": 30, "time": "10:00"},
]
})
mock_result = MagicMock(returncode=0, stdout=output)
state = {"calendar": {"daily_summary_date": date.today().isoformat(), "reminded_events": {}}}
with patch("src.heartbeat.subprocess.run", return_value=mock_result):
result = _check_calendar({})
assert result == "Calendar: in 45 min — NLP Session (15:00)"
result = _check_calendar_smart(state, (23, 8))
assert "in 30 min — Meeting (10:00)" in result
assert "Meeting@10:00" in state["calendar"]["reminded_events"]
def test_json_no_upcoming(self, tmp_env):
def test_dedup_prevents_repeat_reminder(self, tmp_env):
"""Same event should not be reminded twice."""
script = tmp_env["tools"] / "calendar_check.py"
script.write_text("pass")
output = json.dumps({"upcoming": []})
output = json.dumps({
"upcoming": [
{"summary": "Meeting", "minutes_until": 25, "time": "10:00"},
]
})
mock_result = MagicMock(returncode=0, stdout=output)
today_str = date.today().isoformat()
state = {"calendar": {
"daily_summary_date": today_str,
"reminded_events": {"Meeting@10:00": today_str}
}}
with patch("src.heartbeat.subprocess.run", return_value=mock_result):
assert _check_calendar({}) is None
result = _check_calendar_smart(state, (23, 8))
assert result is None
def test_plaintext_fallback(self, tmp_env):
def test_event_beyond_45_min_not_reminded(self, tmp_env):
"""Events more than 45 min away should not trigger a reminder."""
script = tmp_env["tools"] / "calendar_check.py"
script.write_text("pass")
mock_result = MagicMock(returncode=0, stdout="Meeting at 3pm\n")
output = json.dumps({
"upcoming": [
{"summary": "Later", "minutes_until": 90, "time": "14:00"},
]
})
mock_result = MagicMock(returncode=0, stdout=output)
state = {"calendar": {"daily_summary_date": date.today().isoformat(), "reminded_events": {}}}
with patch("src.heartbeat.subprocess.run", return_value=mock_result):
assert _check_calendar({}) == "Calendar: Meeting at 3pm"
result = _check_calendar_smart(state, (23, 8))
assert result is None
def test_empty_output(self, tmp_env):
def test_no_events_today(self, tmp_env):
"""No events returns None and still marks summary as sent."""
script = tmp_env["tools"] / "calendar_check.py"
script.write_text("pass")
mock_result = MagicMock(returncode=0, stdout="\n")
output = json.dumps({"today": [], "tomorrow": []})
mock_result = MagicMock(returncode=0, stdout=output)
state = {}
with patch("src.heartbeat.subprocess.run", return_value=mock_result):
assert _check_calendar({}) is None
result = _check_calendar_smart(state, (23, 8))
assert result is None
assert state["calendar"]["daily_summary_date"] == date.today().isoformat()
def test_old_reminded_events_cleaned(self, tmp_env):
"""Reminded events from previous days are cleaned up."""
script = tmp_env["tools"] / "calendar_check.py"
script.write_text("pass")
output = json.dumps({"today": [{"summary": "X", "time": "09:00"}], "tomorrow": []})
mock_result = MagicMock(returncode=0, stdout=output)
state = {"calendar": {
"daily_summary_date": "2020-01-01",
"reminded_events": {"OldEvent@09:00": "2020-01-01"}
}}
with patch("src.heartbeat.subprocess.run", return_value=mock_result):
_check_calendar_smart(state, (23, 8))
assert "OldEvent@09:00" not in state["calendar"]["reminded_events"]
def test_subprocess_exception(self, tmp_env):
script = tmp_env["tools"] / "calendar_check.py"
script.write_text("pass")
with patch("src.heartbeat.subprocess.run", side_effect=OSError("fail")):
assert _check_calendar({}) is None
assert _check_calendar_smart({}, (23, 8)) is None
# ---------------------------------------------------------------------------
@@ -401,7 +464,7 @@ class TestRunHeartbeat:
def test_all_ok(self, tmp_env):
with patch("src.heartbeat._check_email", return_value=None), \
patch("src.heartbeat._check_calendar", return_value=None), \
patch("src.heartbeat._check_calendar_smart", return_value=None), \
patch("src.heartbeat._check_kb_index", return_value=None), \
patch("src.heartbeat._check_git", return_value=None), \
patch("src.heartbeat._run_claude_extra", return_value=None):
@@ -410,7 +473,7 @@ class TestRunHeartbeat:
def test_with_results(self, tmp_env):
with patch("src.heartbeat._check_email", return_value="Email: 2 necitite (X, Y)"), \
patch("src.heartbeat._check_calendar", return_value=None), \
patch("src.heartbeat._check_calendar_smart", return_value=None), \
patch("src.heartbeat._check_kb_index", return_value="KB: 1 fișiere reindexate"), \
patch("src.heartbeat._check_git", return_value=None), \
patch("src.heartbeat._is_quiet_hour", return_value=False), \
@@ -418,29 +481,22 @@ class TestRunHeartbeat:
result = run_heartbeat()
assert result == "Email: 2 necitite (X, Y) | KB: 1 fișiere reindexate"
def test_quiet_hours_suppresses_normal(self, tmp_env):
def test_quiet_hours_suppresses_all(self, tmp_env):
"""During quiet hours, calendar is also suppressed (no longer critical)."""
with patch("src.heartbeat._check_email", return_value="Email: 5 necitite (A)"), \
patch("src.heartbeat._check_calendar", return_value=None), \
patch("src.heartbeat._check_calendar_smart", return_value="Calendar stuff") as mock_cal, \
patch("src.heartbeat._check_kb_index", return_value=None), \
patch("src.heartbeat._check_git", return_value="Git: 2 uncommitted"), \
patch("src.heartbeat._is_quiet_hour", return_value=True):
result = run_heartbeat()
assert result == "HEARTBEAT_OK"
def test_quiet_hours_allows_critical_calendar(self, tmp_env):
with patch("src.heartbeat._check_email", return_value="Email: 5 necitite (A)"), \
patch("src.heartbeat._check_calendar", return_value="Calendar: in 30 min — Meeting (15:00)"), \
patch("src.heartbeat._check_kb_index", return_value=None), \
patch("src.heartbeat._check_git", return_value="Git: 2 uncommitted"), \
patch("src.heartbeat._is_quiet_hour", return_value=True):
result = run_heartbeat()
assert result == "Calendar: in 30 min — Meeting (15:00)"
mock_cal.assert_not_called()
def test_config_disables_check(self, tmp_env):
"""Checks can be disabled via config."""
config = {"heartbeat": {"checks": {"git": False}}}
with patch("src.heartbeat._check_email", return_value=None), \
patch("src.heartbeat._check_calendar", return_value=None), \
patch("src.heartbeat._check_calendar_smart", return_value=None), \
patch("src.heartbeat._check_kb_index", return_value=None), \
patch("src.heartbeat._check_git", return_value="Git: 5 uncommitted") as mock_git, \
patch("src.heartbeat._run_claude_extra", return_value=None):
@@ -452,7 +508,7 @@ class TestRunHeartbeat:
"""Quiet hours can be overridden via config."""
config = {"heartbeat": {"quiet_hours": [0, 1]}} # only 0-1 is quiet
with patch("src.heartbeat._check_email", return_value=None), \
patch("src.heartbeat._check_calendar", return_value=None), \
patch("src.heartbeat._check_calendar_smart", return_value=None), \
patch("src.heartbeat._check_kb_index", return_value=None), \
patch("src.heartbeat._check_git", return_value="Git: 3 uncommitted"), \
patch("src.heartbeat._is_quiet_hour", return_value=False), \
@@ -462,7 +518,7 @@ class TestRunHeartbeat:
def test_saves_state_after_run(self, tmp_env):
with patch("src.heartbeat._check_email", return_value=None), \
patch("src.heartbeat._check_calendar", return_value=None), \
patch("src.heartbeat._check_calendar_smart", return_value=None), \
patch("src.heartbeat._check_kb_index", return_value=None), \
patch("src.heartbeat._check_git", return_value=None), \
patch("src.heartbeat._run_claude_extra", return_value=None):
@@ -473,7 +529,7 @@ class TestRunHeartbeat:
def test_saves_check_timestamps(self, tmp_env):
with patch("src.heartbeat._check_email", return_value=None), \
patch("src.heartbeat._check_calendar", return_value=None), \
patch("src.heartbeat._check_calendar_smart", return_value=None), \
patch("src.heartbeat._check_kb_index", return_value=None), \
patch("src.heartbeat._check_git", return_value=None), \
patch("src.heartbeat._run_claude_extra", return_value=None):