From 8d0f80440e00c54a6d5d1c6d0980e44d98aab370 Mon Sep 17 00:00:00 2001 From: h Date: Wed, 9 Sep 2026 08:21:03 +0000 Subject: [PATCH] feat(hands,memory): the recall carries the next week of both calendars daily --- .env.example | 3 + beaver_agent/hands/__init__.py | 6 +- beaver_agent/hands/calendars.py | 475 +++++++++++++++++++++++++++++++- beaver_agent/memory/recall.py | 61 +++- beaver_agent/memory/watch.py | 2 + tests/test_calendars.py | 140 ++++++++++ tests/test_recall.py | 92 ++++++- 7 files changed, 775 insertions(+), 4 deletions(-) create mode 100644 tests/test_calendars.py diff --git a/.env.example b/.env.example index 75ef3bb..2c63a85 100644 --- a/.env.example +++ b/.env.example @@ -62,6 +62,9 @@ FIREFLY_PAT= # Календари: name=url,name=url (пусто - без календарей). # В url лежит приватный ical-фид (google `/private-<токен>/`, usos `key=`) - # это секрет: утёк url = утёк календарь, лечится перевыпуском фида. +# Модели календари даются MCP-руками, а справка конверта раз в день читает +# тот же ical напрямую - урл фида она берёт из параметра `icsUrl` этих же +# записей; записи без `icsUrl` остаются рукой, но в справку не попадают. CALENDAR_MCPS= # MCP beavergram (telegram-history) BEAVERGRAM_MCP= diff --git a/beaver_agent/hands/__init__.py b/beaver_agent/hands/__init__.py index 197673b..95d54c8 100644 --- a/beaver_agent/hands/__init__.py +++ b/beaver_agent/hands/__init__.py @@ -12,7 +12,7 @@ from pathlib import Path from beaver_gateway.agents.base import ExposedMcp from beaver_gateway.mcp.types import McpServer, McpServerT -from beaver_agent.hands.calendars import calendars +from beaver_agent.hands.calendars import Calendars, calendars, feeds from beaver_agent.hands.homeassistant import HomeAssistant from beaver_agent.hands.komodo import Komodo from beaver_agent.hands.vibegram import Vibegram @@ -52,6 +52,10 @@ telegram = ( calendar_servers = calendars(env("CALENDAR_MCPS", "")) calendar_exposed = tuple(ExposedMcp(name=m.name) for m in calendar_servers) +CALENDAR_FEEDS = feeds(env("CALENDAR_MCPS", "")) +CALENDARS = Calendars(CALENDAR_FEEDS, tz=TZ) if CALENDAR_FEEDS else None +"""Те же фиды, но напрямую: ближайшие события едут в справку без модели.""" + KOMODO = ( Komodo( url=os.environ["KOMODO_URL"], diff --git a/beaver_agent/hands/calendars.py b/beaver_agent/hands/calendars.py index b025ad2..1f6609a 100644 --- a/beaver_agent/hands/calendars.py +++ b/beaver_agent/hands/calendars.py @@ -1,9 +1,60 @@ -"""Календари из `CALENDAR_MCPS=name=url,name=url`; в url - приватный фид с токеном.""" +"""Календари: MCP-руки для модели и тот же ical-фид, читаемый gateway напрямую. + +`CALENDAR_MCPS=name=url,name=url`; в url - приватный фид с токеном, и там же, +параметром `icsUrl`, лежит сам ical. Модель ходит в календари через MCP, а +справка конверта читает фид сама: канал, о котором никто не спрашивает, молчит +незаметно - так экзаменационная сессия и пролежала невидимой. + +Парсер здесь свой и маленький: в образе gateway ical-библиотеки нет, а нужно +немного - когда начинается, как называется, где. Повторы разворачиваются по +FREQ=DAILY/WEEKLY/MONTHLY/YEARLY с INTERVAL, BYDAY, BYMONTH, COUNT и UNTIL; +EXDATE и перенесённые экземпляры (RECURRENCE-ID) вычитаются. Шаг считается по +настенным часам события, а не в абсолютном времени: еженедельная встреча +переживает перевод часов. +""" from __future__ import annotations +import logging +import re +import urllib.error +import urllib.request +from calendar import monthrange +from dataclasses import dataclass, field +from datetime import UTC, date, datetime, timedelta +from typing import TYPE_CHECKING +from urllib.parse import parse_qs, urlsplit +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError + from beaver_gateway.mcp.types import HttpMcp, McpServer +if TYPE_CHECKING: + from collections.abc import Iterator + from datetime import tzinfo + +__all__ = ["Calendars", "Event", "Feed", "Upcoming", "calendars", "feeds", "short"] + +_log = logging.getLogger(__name__) + +USER_AGENT = "beaver-gateway (ical)" +MAX_BYTES = 4_000_000 +"""Больше четырёх мегабайт фида - это уже не календарь, а чей-то экспорт всего.""" +STEPS_CAP = 20_000 +"""Потолок шагов разворачивания одного повтора: страховка от вечного цикла.""" +PLACE_MAX = 70 +TITLE_MAX = 90 +ERROR_MAX = 90 +NO_TITLE = "(без названия)" + +WEEKDAYS = {"MO": 0, "TU": 1, "WE": 2, "TH": 3, "FR": 4, "SA": 5, "SU": 6} +DT_RE = re.compile(r"^(\d{4})(\d{2})(\d{2})(?:T(\d{2})(\d{2})(\d{2})(Z)?)?$") +BYDAY_RE = re.compile(r"^([+-]?\d{1,2})?(MO|TU|WE|TH|FR|SA|SU)$") +ESCAPE_RE = re.compile(r"\\([\\;,nN])") +PLACE_RE = re.compile( + r"^\s*(?:sala|room|зал|аудитория)\b\s*[:.]?\s*(.+)$", re.IGNORECASE | re.MULTILINE +) +"""Зал USOS живёт в описании (`Sala: 315`), а LOCATION - это адрес корпуса.""" + def calendars(raw: str) -> list[HttpMcp]: servers: list[HttpMcp] = [] @@ -17,3 +68,425 @@ def calendars(raw: str) -> list[HttpMcp]: raise ValueError(msg) servers.append(McpServer.http(name=f"calendar-{name.strip()}", url=url.strip())) return servers + + +@dataclass(frozen=True, slots=True) +class Feed: + name: str + url: str + """Ical по подписке: приватный, с токеном внутри - в логи не писать.""" + + +def feeds(raw: str) -> tuple[Feed, ...]: + """Ical-фиды из тех же записей `CALENDAR_MCPS`: урл лежит в `icsUrl`. + + Запись без ical-урла просто не даёт фида: MCP-рука у модели остаётся, + справка про этот календарь молчит - конфигурация, а не отказ канала. + """ + out: list[Feed] = [] + for raw_entry in raw.split(","): + name, sep, url = raw_entry.strip().partition("=") + if not sep or not url.strip(): + continue + ics = _ics_url(url.strip()) + if ics: + out.append(Feed(name=name.strip(), url=ics)) + return tuple(out) + + +def _ics_url(url: str) -> str | None: + inner = parse_qs(urlsplit(url).query).get("icsUrl") or [] + if inner and inner[0].strip(): + return inner[0].strip() + return url if urlsplit(url).path.endswith(".ics") else None + + +@dataclass(frozen=True, slots=True) +class Event: + start: datetime + """С зоной: у события своя (TZID), плавающее время читается в зоне агента.""" + title: str + place: str + all_day: bool + + +@dataclass(frozen=True, slots=True) +class Upcoming: + at: datetime + """Момент запроса - его и показывает справка: «данные на …».""" + events: tuple[Event, ...] = () + errors: tuple[str, ...] = () + """По строке на фид, который не ответил; пустое окно - это не ошибка.""" + read: int = 0 + """Сколько фидов ответило: ноль - показывать нечего, кроме отказа.""" + + +@dataclass(frozen=True, slots=True) +class Calendars: + """Ближайшие события всех фидов - одним списком, отсортированным по началу.""" + + feeds: tuple[Feed, ...] + tz: str = "Europe/Warsaw" + timeout: float = 8.0 + days: int = 7 + + def upcoming(self, now: datetime) -> Upcoming: + """Окно `days` от `now`; отказ фида - строка в `errors`, не исключение.""" + zone = ZoneInfo(self.tz) + since = now.astimezone(zone) + events: list[Event] = [] + errors: list[str] = [] + read = 0 + for feed in self.feeds: + try: + text = self.fetch(feed.url) + found = events_in(text, zone=zone, since=since, days=self.days) + except Exception as exc: # noqa: BLE001 + _log.warning("календарь %s: %s", feed.name, short(exc), exc_info=True) + errors.append(f"{feed.name}: {short(exc)}") + continue + read += 1 + events.extend(found) + events.sort(key=lambda e: (e.start, e.title)) + return Upcoming(at=since, events=tuple(events), errors=tuple(errors), read=read) + + def fetch(self, url: str) -> str: + if urlsplit(url).scheme not in ("http", "https"): + msg = "фид не по http(s)" + raise ValueError(msg) + request = urllib.request.Request(url, headers={"User-Agent": USER_AGENT}) # noqa: S310 + with urllib.request.urlopen(request, timeout=self.timeout) as response: # noqa: S310 + raw: bytes = response.read(MAX_BYTES) + return raw.decode("utf-8", errors="replace") + + +def short(exc: BaseException) -> str: + """Причина отказа одной строкой: в конверт едет она, урл фида - никогда.""" + if isinstance(exc, urllib.error.HTTPError): + reason = f"HTTP {exc.code}" + elif isinstance(exc, TimeoutError): + reason = "таймаут" + elif isinstance(exc, urllib.error.URLError): + reason = f"сеть: {exc.reason}" + else: + reason = f"{type(exc).__name__}: {exc}".strip(": ") + return " ".join(reason.split())[:ERROR_MAX] + + +@dataclass(frozen=True, slots=True) +class Prop: + value: str + params: dict[str, str] = field(default_factory=dict) + + +Vevent = dict[str, list[Prop]] + + +def unfold(text: str) -> list[str]: + """Ical-строки, склеенные обратно: продолжение начинается с пробела или таба.""" + lines: list[str] = [] + for raw in text.replace("\r\n", "\n").replace("\r", "\n").split("\n"): + if lines and raw[:1] in (" ", "\t"): + lines[-1] += raw[1:] + else: + lines.append(raw) + return lines + + +def _split(line: str) -> tuple[str, str] | None: + """`NAME;PARAM=x:значение` - двоеточие вне кавычек делит имя и значение.""" + quoted = False + for i, char in enumerate(line): + if char == '"': + quoted = not quoted + elif char == ":" and not quoted: + return line[:i], line[i + 1 :] + return None + + +def _prop(line: str) -> tuple[str, Prop] | None: + parts = _split(line) + if parts is None: + return None + head, value = parts + chunks = head.split(";") + name = chunks[0].strip().upper() + if not name: + return None + params: dict[str, str] = {} + for chunk in chunks[1:]: + key, sep, raw = chunk.partition("=") + if sep: + params[key.strip().upper()] = raw.strip().strip('"') + return name, Prop(value=value, params=params) + + +def vevents(lines: list[str]) -> list[Vevent]: + """Только свойства самих VEVENT: VALARM внутри них пропускается целиком.""" + out: list[Vevent] = [] + current: Vevent | None = None + depth = 0 + for line in lines: + parsed = _prop(line) + if parsed is None: + continue + name, prop = parsed + block = prop.value.strip().upper() + if name == "BEGIN": + if block == "VEVENT" and current is None: + current = {} + elif current is not None: + depth += 1 + continue + if name == "END": + if block == "VEVENT" and current is not None and depth == 0: + out.append(current) + current = None + elif current is not None and depth: + depth -= 1 + continue + if current is not None and depth == 0: + current.setdefault(name, []).append(prop) + return out + + +def unescape(value: str) -> str: + return ESCAPE_RE.sub(lambda m: "\n" if m.group(1) in "nN" else m.group(1), value) + + +def _flat(value: str, limit: int) -> str: + flat = " ".join(unescape(value).split()) + return flat if len(flat) <= limit else flat[: limit - 1] + "…" + + +def _first(event: Vevent, name: str) -> Prop | None: + props = event.get(name) + return props[0] if props else None + + +def _zone_of(prop: Prop, default: tzinfo) -> tzinfo: + tzid = prop.params.get("TZID") + if not tzid: + return default + try: + return ZoneInfo(tzid) + except (ZoneInfoNotFoundError, ValueError): + return default + + +def moment(prop: Prop, value: str, zone: tzinfo) -> tuple[datetime, bool] | None: + """Значение DTSTART/EXDATE в момент времени; второе - «на весь день».""" + match = DT_RE.match(value.strip()) + if match is None: + return None + year, month, day = (int(match.group(i)) for i in (1, 2, 3)) + if match.group(4) is None or prop.params.get("VALUE", "").upper() == "DATE": + return datetime(year, month, day, tzinfo=zone), True + hour, minute, second = (int(match.group(i)) for i in (4, 5, 6)) + where = UTC if match.group(7) else _zone_of(prop, zone) + return datetime(year, month, day, hour, minute, second, tzinfo=where), False + + +def _instants(event: Vevent, name: str, zone: tzinfo) -> set[datetime]: + """Даты-исключения в UTC: значений в одной строке может быть несколько.""" + out: set[datetime] = set() + for prop in event.get(name, []): + for chunk in prop.value.split(","): + parsed = moment(prop, chunk, zone) + if parsed is not None: + out.add(parsed[0].astimezone(UTC)) + return out + + +def _rule(value: str) -> dict[str, str]: + out: dict[str, str] = {} + for chunk in value.split(";"): + key, sep, raw = chunk.partition("=") + if sep: + out[key.strip().upper()] = raw.strip() + return out + + +def _int(value: str | None, default: int) -> int: + try: + return int(value) if value else default + except ValueError: + return default + + +def _byday(value: str) -> list[tuple[int, int]]: + """`-1SU,2TU` → [(-1, воскресенье), (2, вторник)]; без числа - каждый такой день.""" + out: list[tuple[int, int]] = [] + for chunk in value.split(","): + match = BYDAY_RE.match(chunk.strip().upper()) + if match: + out.append((_int(match.group(1), 0), WEEKDAYS[match.group(2)])) + return out + + +def _month_days( + year: int, month: int, days: list[tuple[int, int]], clock: datetime +) -> list[datetime]: + total = monthrange(year, month)[1] + out: list[datetime] = [] + for nth, weekday in days: + matching = [ + day + for day in range(1, total + 1) + if date(year, month, day).weekday() == weekday + ] + if not nth: + picked = matching + elif abs(nth) <= len(matching): + picked = [matching[nth - 1] if nth > 0 else matching[nth]] + else: + picked = [] + out += [ + datetime(year, month, day, clock.hour, clock.minute, clock.second) # noqa: DTZ001 + for day in picked + ] + return sorted(out) + + +def _candidates( + naive: datetime, freq: str, interval: int, days: list[tuple[int, int]], month: int +) -> Iterator[datetime]: + """Настенные времена повтора после первого - по возрастанию, бесконечно.""" + if freq == "DAILY": + moving = naive + while True: + moving += timedelta(days=interval) + yield moving + elif freq == "WEEKLY" and not days: + moving = naive + while True: + moving += timedelta(weeks=interval) + yield moving + elif freq == "WEEKLY": + start = naive.date() - timedelta(days=naive.weekday()) + step = 0 + while True: + base = start + timedelta(weeks=interval * step) + for _, weekday in sorted(days, key=lambda d: d[1]): + yield datetime.combine(base + timedelta(days=weekday), naive.time()) + step += 1 + elif freq in ("MONTHLY", "YEARLY"): + step = 0 + while True: + step += 1 + if freq == "MONTHLY": + index = naive.year * 12 + naive.month - 1 + interval * step + year, number = divmod(index, 12) + number += 1 + else: + year, number = naive.year + interval * step, month or naive.month + if days: + yield from _month_days(year, number, days, naive) + elif naive.day <= monthrange(year, number)[1]: + yield naive.replace(year=year, month=number) + + +def occurrences( + start: datetime, rule: dict[str, str], zone: tzinfo, *, end: datetime +) -> list[datetime]: + """Начала повтора от `start` до `end`; правило, которого мы не знаем, - разовое.""" + freq = rule.get("FREQ", "").upper() + if freq not in ("DAILY", "WEEKLY", "MONTHLY", "YEARLY"): + return [start] + where = start.tzinfo or zone + naive = start.replace(tzinfo=None) + interval = max(_int(rule.get("INTERVAL"), 1), 1) + count = _int(rule.get("COUNT"), 0) + until = _until(rule.get("UNTIL"), where) + days = _byday(rule.get("BYDAY", "")) + out = [start] + stream = _candidates(naive, freq, interval, days, _int(rule.get("BYMONTH"), 0)) + for step, candidate in enumerate(stream): + if step > STEPS_CAP: + break + if candidate <= naive: + continue + moving = candidate.replace(tzinfo=where) + if moving > end or (until is not None and moving > until): + break + out.append(moving) + if count and len(out) >= count: + break + return out + + +def _until(value: str | None, zone: tzinfo) -> datetime | None: + if not value: + return None + parsed = moment(Prop(value=value), value, zone) + if parsed is None: + return None + stop, all_day = parsed + return stop + timedelta(days=1) if all_day else stop + + +def place_of(event: Vevent) -> str: + """Зал из описания и адрес из LOCATION - то, что помогает дойти.""" + parts: list[str] = [] + description = _first(event, "DESCRIPTION") + if description is not None: + match = PLACE_RE.search(unescape(description.value)) + if match: + parts.append(" ".join(match.group(0).split())) + location = _first(event, "LOCATION") + if location is not None: + flat = " ".join(unescape(location.value).split()) + if flat: + parts.append(flat) + return _flat(", ".join(parts), PLACE_MAX) + + +def events_in(text: str, *, zone: tzinfo, since: datetime, days: int) -> list[Event]: + """События фида, начинающиеся в окне `[since, since + days]`, с повторами.""" + end = since + timedelta(days=days) + day_start = since.astimezone(zone).replace( + hour=0, minute=0, second=0, microsecond=0 + ) + parsed = vevents(unfold(text)) + moved: dict[str, set[datetime]] = {} + for event in parsed: + uid = _first(event, "UID") + if uid is not None and "RECURRENCE-ID" in event: + moved.setdefault(uid.value, set()).update( + _instants(event, "RECURRENCE-ID", zone) + ) + out: list[Event] = [] + for event in parsed: + status = _first(event, "STATUS") + dtstart = _first(event, "DTSTART") + if dtstart is None or ( + status is not None and status.value.upper() == "CANCELLED" + ): + continue + parsed_start = moment(dtstart, dtstart.value, zone) + if parsed_start is None: + continue + start, all_day = parsed_start + rrule = _first(event, "RRULE") + if rrule is None or "RECURRENCE-ID" in event: + starts = [start] + skip: set[datetime] = set() + else: + starts = occurrences(start, _rule(rrule.value), zone, end=end) + uid = _first(event, "UID") + skip = _instants(event, "EXDATE", zone) | moved.get( + uid.value if uid else "", set() + ) + title = _flat(_text(event, "SUMMARY"), TITLE_MAX) or NO_TITLE + place = place_of(event) + for moving in starts: + floor = day_start if all_day else since + if moving < floor or moving > end or moving.astimezone(UTC) in skip: + continue + out.append(Event(start=moving, title=title, place=place, all_day=all_day)) + return out + + +def _text(event: Vevent, name: str) -> str: + prop = _first(event, name) + return prop.value if prop is not None else "" diff --git a/beaver_agent/memory/recall.py b/beaver_agent/memory/recall.py index cfb87c0..9a39c8f 100644 --- a/beaver_agent/memory/recall.py +++ b/beaver_agent/memory/recall.py @@ -3,7 +3,8 @@ Gateway путей не знает; здесь - что искать по тексту сообщения и куда писать сказанное. ``Recall.block`` отдаёт указатели, не выводы: карточка упомянутого человека, его записки о нём (`мета/бобер/наблюдения/<имя> - наблюдения.md`), -дни дневника, где он встречался, и раз в день - подошедшие сроки из досок. +дни дневника, где он встречался, и раз в день - подошедшие сроки из досок и +ближайшая неделя календарей. ``ReplyLog.write`` кладёт каждую реплику Бобра из мастера и веток в `мета/бобер/реплики/YYYY-MM.md` - единственное место, где они grep-абельны: транскрипт живёт в Postgres, а глубокие чаты и так лежат файлами в `💬 чаты/`. @@ -11,6 +12,7 @@ Gateway путей не знает; здесь - что искать по тек from __future__ import annotations +import logging import re import time from dataclasses import dataclass, field @@ -19,14 +21,22 @@ from typing import TYPE_CHECKING from zoneinfo import ZoneInfo if TYPE_CHECKING: + from datetime import datetime from pathlib import Path from beaver_gateway.conversations.envelope import RecallContext from beaver_gateway.conversations.texts import UserSaid + from beaver_agent.hands.calendars import Calendars, Event, Upcoming + +_log = logging.getLogger(__name__) + LABEL = "справка (указатели по сообщению, не выводы; открой, если относится к вопросу):" VOWELS = "аеёиоуыэюя" KINDS = {"master": "мастер", "branch": "ветка"} +CALENDAR = "🗓 календарь" +CALENDAR_LIMIT = 15 +CALENDAR_ERROR_MAX = 90 @dataclass(frozen=True, slots=True) @@ -200,11 +210,14 @@ class Recall: """Сколько последних дней дневника просматривать на упоминания.""" mute: frozenset[str] = frozenset() """Агенты, которым справку не показывают: чужие люди, чужие уши.""" + calendar: Calendars | None = None + """Ical-фиды календарей; None - календари не настроены, секции нет вовсе.""" _people: People | None = field(default=None, init=False, repr=False) _diary_cache: dict[str, tuple[float, list[str]]] = field( default_factory=dict, init=False, repr=False ) _deadlines_day: date | None = field(default=None, init=False, repr=False) + _calendar_day: date | None = field(default=None, init=False, repr=False) @property def people(self) -> People: @@ -219,6 +232,7 @@ class Recall: lines = [self.person_line(p) for p in mentions(ctx.text, self.people.all())] if ctx.kind == "master": lines += self.deadlines(today) + lines += self.calendar_lines(ctx.now, today) if not lines: return None return "\n".join([LABEL, *lines]) @@ -302,6 +316,51 @@ class Recall: found.sort() return [f"📆 {due:%m-%d} {board}: {task}" for due, board, task in found[:limit]] + def calendar_lines(self, now: datetime, today: date) -> list[str]: + """Ближайшая неделя календарей - раз в день, следом за сроками досок. + + Секция печатается всегда, когда до неё дошло: пустое окно - строкой + «событий нет», упавший фид - строкой «ОШИБКА» и записью в лог. Молча + пропасть она не может, иначе отказ канала неотличим от спокойного + календаря - так однажды и пролежала незамеченной сессия в USOS. + """ + if self.calendar is None or self._calendar_day == today: + return [] + self._calendar_day = today + try: + found = self.calendar.upcoming(now) + except Exception as exc: # noqa: BLE001 + _log.exception("календарь: справка не собралась") + reason = " ".join(f"{type(exc).__name__}: {exc}".split()) + return [f"{CALENDAR}: ОШИБКА {reason[:CALENDAR_ERROR_MAX]}"] + lines = [f"{CALENDAR}: ОШИБКА {reason}" for reason in found.errors] + if found.read: + lines += self.calendar_window(found, self.calendar.days) + return lines or [f"{CALENDAR}: ОШИБКА фиды не ответили"] + + def calendar_window(self, found: Upcoming, days: int) -> list[str]: + """Окно ответивших фидов: заголовок с временем данных и события строками.""" + stamp = f"{found.at:%Y-%m-%d %H:%M}" + if not found.events: + return [f"{CALENDAR} ({days} дней): событий нет, данные на {stamp}"] + zone = ZoneInfo(self.tz) + shown = found.events[:CALENDAR_LIMIT] + lines = [ + f"{CALENDAR} ({days} дней), событий {len(found.events)}, данные на {stamp}:" + ] + lines += [f" - {event_line(event, zone)}" for event in shown] + if len(found.events) > len(shown): + lines.append(f" … и ещё {len(found.events) - len(shown)} в окне") + return lines + + +def event_line(event: Event, zone: ZoneInfo) -> str: + """Дата, начало, название и место - одной строкой в зоне агента.""" + start = event.start.astimezone(zone) + when = "весь день" if event.all_day else f"{start:%H:%M}" + place = f" · {event.place}" if event.place else "" + return f"{start:%m-%d} {when} {event.title}{place}" + @dataclass class ReplyLog: diff --git a/beaver_agent/memory/watch.py b/beaver_agent/memory/watch.py index 13594de..12a2a25 100644 --- a/beaver_agent/memory/watch.py +++ b/beaver_agent/memory/watch.py @@ -4,6 +4,7 @@ from __future__ import annotations from beaver_gateway.vault.watch import VaultWatch, WatchRules +from beaver_agent.hands import CALENDARS from beaver_agent.memory.curator import Watched from beaver_agent.memory.recall import Recall, ReplyLog from beaver_agent.vault import ( @@ -54,6 +55,7 @@ recall = Recall( boards_dir=BOARDS, tz=TZ, mute=frozenset({VOICE}), + calendar=CALENDARS, ) """Голосовому агенту справки нет: за колонкой может стоять кто угодно.""" diff --git a/tests/test_calendars.py b/tests/test_calendars.py new file mode 100644 index 0000000..21a11db --- /dev/null +++ b/tests/test_calendars.py @@ -0,0 +1,140 @@ +import urllib.error +from email.message import Message +from datetime import UTC, datetime +from zoneinfo import ZoneInfo + +from beaver_agent.hands.calendars import ( + Calendars, + Feed, + calendars, + events_in, + feeds, + short, + unfold, +) + +ZONE = ZoneInfo("Europe/Warsaw") +NOW = datetime(2026, 9, 1, 12, 0, tzinfo=UTC) # 14:00 в Варшаве + +MCPS = ( + "personal=https://calendar-mcp.com/api/mcp?email=b%40b.com" + "&icsUrl=https%3A%2F%2Fcalendar.google.com%2Fical%2Fprivate-abc%2Fbasic.ics," + "student=https://calendar-mcp.com/api/mcp?icsUrl=https%3A%2F%2Fusos.pl%2Fics%3Fkey%3Dz," + "bare=https://calendar-mcp.com/mcp/AAAA" +) + + +def ics(*events: str) -> str: + body = "".join(f"BEGIN:VEVENT\n{e.strip()}\nEND:VEVENT\n" for e in events) + return f"BEGIN:VCALENDAR\nVERSION:2.0\nPRODID:-//test//EN\n{body}END:VCALENDAR\n" + + +def starts(text: str, *, days: int = 7) -> list[str]: + found = events_in(text, zone=ZONE, since=NOW.astimezone(ZONE), days=days) + return [ + f"{e.start.astimezone(ZONE):%m-%d %H:%M} {e.title}" + for e in sorted(found, key=lambda e: e.start) + ] + + +def test_feeds_take_the_ical_url_out_of_the_same_mcp_urls(): + assert [f.name for f in feeds(MCPS)] == ["personal", "student"] + assert feeds(MCPS)[0].url == ( + "https://calendar.google.com/ical/private-abc/basic.ics" + ) + assert feeds(MCPS)[1].url == "https://usos.pl/ics?key=z" + assert feeds("") == () + assert feeds("plain=https://example.com/plan.ics")[0].url.endswith("plan.ics") + # MCP-рука остаётся у модели, даже если ical-фида в записи нет + assert [m.name for m in calendars(MCPS)] == [ + "calendar-personal", + "calendar-student", + "calendar-bare", + ] + + +def test_folded_lines_are_glued_back(): + assert unfold("SUMMARY:длин\r\n ное\r\nUID:x") == ["SUMMARY:длинное", "UID:x"] + + +def test_window_sorts_by_start_and_keeps_only_the_next_days(): + text = ics( + "SUMMARY:позже\nDTSTART:20260904T080000Z", + "SUMMARY:раньше\nDTSTART:20260902T080000Z", + "SUMMARY:за окном\nDTSTART:20260920T080000Z", + "SUMMARY:уже прошло\nDTSTART:20260801T080000Z", + ) + assert starts(text) == ["09-02 10:00 раньше", "09-04 10:00 позже"] + + +def test_all_day_event_of_today_still_counts_as_upcoming(): + text = ics("SUMMARY:отпуск\nDTSTART;VALUE=DATE:20260901\nDTEND;VALUE=DATE:20260902") + found = events_in(text, zone=ZONE, since=NOW.astimezone(ZONE), days=7) + assert len(found) == 1 + assert found[0].all_day + assert f"{found[0].start:%H:%M}" == "00:00" + + +def test_place_takes_the_room_from_the_description_and_the_address_from_location(): + text = ics( + "SUMMARY:Analiza matematyczna II - Egzamin\n" + "DTSTART;VALUE=DATE-TIME:20260903T110000\n" + "DESCRIPTION:Sala: 3\\nA23 - CW - Centrum Wykładowe\\n\n" + "LOCATION:ul. Piotrowo 2\\, 61-138 Poznań\\, Polska" + ) + found = events_in(text, zone=ZONE, since=NOW.astimezone(ZONE), days=7) + assert found[0].place == "Sala: 3, ul. Piotrowo 2, 61-138 Poznań, Polska" + # плавающее время читается в зоне агента, а не в UTC + assert f"{found[0].start:%H:%M}" == "11:00" + + +def test_cancelled_events_are_skipped(): + text = ics("SUMMARY:отменено\nDTSTART:20260902T080000Z\nSTATUS:CANCELLED") + assert starts(text) == [] + + +def test_weekly_repeat_expands_and_exdate_with_moved_instance_are_subtracted(): + series = ( + "UID:w1\nSUMMARY:польский\nDTSTART;TZID=Europe/Warsaw:20260824T100000\n" + "RRULE:FREQ=WEEKLY;BYDAY=MO,WE" + ) + assert starts(ics(series)) == ["09-02 10:00 польский", "09-07 10:00 польский"] + moved = ics( + series + "\nEXDATE;TZID=Europe/Warsaw:20260902T100000", + "UID:w1\nSUMMARY:польский (перенос)\n" + "RECURRENCE-ID;TZID=Europe/Warsaw:20260907T100000\n" + "DTSTART;TZID=Europe/Warsaw:20260907T173000", + ) + assert starts(moved) == ["09-07 17:30 польский (перенос)"] + + +def test_daily_repeat_stops_on_until_and_count(): + until = ics( + "SUMMARY:завтрак\nDTSTART:20260801T060000Z\n" + "RRULE:FREQ=DAILY;UNTIL=20260903T060000Z" + ) + assert starts(until) == ["09-02 08:00 завтрак", "09-03 08:00 завтрак"] + counted = ics("SUMMARY:курс\nDTSTART:20260902T060000Z\nRRULE:FREQ=DAILY;COUNT=3") + assert len(starts(counted)) == 3 + yearly = ics( + "SUMMARY:часы\nDTSTART:20200906T060000Z\nRRULE:FREQ=YEARLY;BYMONTH=9;BYDAY=1SU" + ) + assert starts(yearly) == ["09-06 08:00 часы"] + + +def test_a_feed_that_fails_becomes_a_line_not_an_exception(): + class Broken(Calendars): + def fetch(self, url: str) -> str: + raise urllib.error.HTTPError(url, 403, "Forbidden", Message(), None) + + broken = Broken((Feed("student", "https://usos.pl/ics"),), tz="Europe/Warsaw") + found = broken.upcoming(NOW) + assert found.read == 0 + assert found.events == () + assert found.errors == ("student: HTTP 403",) + + +def test_short_reason_is_one_line_and_never_carries_the_url(): + assert short(TimeoutError()) == "таймаут" + assert short(urllib.error.URLError("no route")).startswith("сеть:") + assert short(ValueError("а\nб")) == "ValueError: а б" diff --git a/tests/test_recall.py b/tests/test_recall.py index 7a4f757..af9a67a 100644 --- a/tests/test_recall.py +++ b/tests/test_recall.py @@ -4,6 +4,7 @@ from pathlib import Path from beaver_gateway.conversations.texts import UserSaid from beaver_gateway.conversations.envelope import RecallContext +from beaver_agent.hands.calendars import Calendars, Feed from beaver_agent.memory.recall import ( LABEL, Recall, @@ -22,7 +23,7 @@ def _card(path: Path, aliases: list[str] | None = None) -> None: ) -def _vault(tmp_path: Path) -> Recall: +def _vault(tmp_path: Path, calendar: Calendars | None = None) -> Recall: people = tmp_path / "👤 люди" _card(people / "личное" / "Зина.md", ["Зина"]) _card(people / "личное" / "Прохор.md", ["Прохор Тестов"]) @@ -68,6 +69,7 @@ def _vault(tmp_path: Path) -> Recall: diary_dir=diary, boards_dir=boards, tz="Europe/Warsaw", + calendar=calendar, ) @@ -186,3 +188,91 @@ def test_notes_head_picks_sections_for_the_seed(tmp_path: Path) -> None: assert notes_head(recall.notes_dir / "нет.md", ("сейчас",)) is None short = notes_head(path, ("сейчас", "паттерны"), cap=2) assert short is not None and short.endswith("… ещё 3 строк в файле") + + +def _ics(*events: str) -> str: + body = "".join(f"BEGIN:VEVENT\n{e}\nEND:VEVENT\n" for e in events) + return f"BEGIN:VCALENDAR\nVERSION:2.0\n{body}END:VCALENDAR\n" + + +def _calendar(text: str | None) -> Calendars: + """Фид, который отдаёт готовый ical; None - фид, который падает.""" + + class Fake(Calendars): + def fetch(self, url: str) -> str: + if text is None: + raise OSError("фид не ответил") + return text + + return Fake((Feed("личный", "https://cal.example/x.ics"),), tz="Europe/Warsaw") + + +BUSY = _ics( + "SUMMARY:Созвон\nDTSTART:20260902T160000Z", + "SUMMARY:Analiza matematyczna II - Egzamin\nDTSTART;VALUE=DATE-TIME:20260905T110000\n" + "DESCRIPTION:Sala: 3\\nA23 - CW\\n\nLOCATION:ul. Piotrowo 2\\, Poznań", + "SUMMARY:Врач\nDTSTART:20260903T090000Z", + "SUMMARY:за окном\nDTSTART:20260925T080000Z", + "SUMMARY:завтрак\nDTSTART;TZID=Europe/Warsaw:20260902T080000\nRRULE:FREQ=DAILY", + "SUMMARY:ужин\nDTSTART;TZID=Europe/Warsaw:20260902T200000\nRRULE:FREQ=DAILY", +) + + +def test_calendar_section_sorts_the_week_and_says_what_did_not_fit( + tmp_path: Path, +) -> None: + recall = _vault(tmp_path, _calendar(BUSY)) + now = datetime(2026, 9, 1, 12, 0, tzinfo=UTC) + block = recall.block(RecallContext(text="привет", kind="master", now=now)) + assert block is not None + lines = [ln for ln in block.splitlines() if "календар" in ln or ln.startswith(" ")] + assert lines[0] == ("🗓 календарь (7 дней), событий 16, данные на 2026-09-01 14:00:") + assert lines[1:4] == [ + " - 09-02 08:00 завтрак", + " - 09-02 18:00 Созвон", + " - 09-02 20:00 ужин", + ] + assert ( + " - 09-05 11:00 Analiza matematyczna II - Egzamin · Sala: 3, ul. Piotrowo 2, Poznań" + in lines + ) + assert len([ln for ln in lines if ln.startswith(" - ")]) == 15 + assert lines[-1] == " … и ещё 1 в окне" + # секция дневная, как и сроки досок: второй конверт за те же сутки её не несёт + again = recall.block(RecallContext(text="и ещё", kind="master", now=now)) + assert again is None or "календарь" not in again + + +def test_calendar_section_is_printed_even_when_the_week_is_empty( + tmp_path: Path, +) -> None: + recall = _vault( + tmp_path, _calendar(_ics("SUMMARY:потом\nDTSTART:20261101T080000Z")) + ) + now = datetime(2026, 9, 1, 12, 0, tzinfo=UTC) + block = recall.block(RecallContext(text="просто привет", kind="master", now=now)) + assert block is not None + assert ( + "🗓 календарь (7 дней): событий нет, данные на 2026-09-01 14:00" + in block.splitlines() + ) + + +def test_calendar_failure_shows_up_in_the_envelope_instead_of_disappearing( + tmp_path: Path, +) -> None: + recall = _vault(tmp_path, _calendar(None)) + now = datetime(2026, 9, 1, 12, 0, tzinfo=UTC) + block = recall.block(RecallContext(text="просто привет", kind="master", now=now)) + assert block is not None + assert "🗓 календарь: ОШИБКА личный: OSError: фид не ответил" in block.splitlines() + # ветке справка календарь не носит, как и сроки досок + branch = recall.block(RecallContext(text="просто привет", kind="branch", now=now)) + assert branch is None + + +def test_without_configured_feeds_there_is_no_calendar_section(tmp_path: Path) -> None: + recall = _vault(tmp_path) + now = datetime(2026, 9, 1, 12, 0, tzinfo=UTC) + block = recall.block(RecallContext(text="просто привет", kind="master", now=now)) + assert block is not None and "календарь" not in block