From ae0a16bc1783575e4836db67b8a9a9e4426d9a78 Mon Sep 17 00:00:00 2001 From: h Date: Wed, 9 Sep 2026 08:24:53 +0000 Subject: [PATCH] fix(hands,memory): the daily routine folds into one line and stops crowding out single events --- beaver_agent/hands/calendars.py | 50 +++++++++++++++++++-- beaver_agent/memory/recall.py | 40 +++++++++++++---- tests/test_calendars.py | 29 +++++++++++++ tests/test_recall.py | 77 +++++++++++++++++++++++++-------- 4 files changed, 165 insertions(+), 31 deletions(-) diff --git a/beaver_agent/hands/calendars.py b/beaver_agent/hands/calendars.py index 1f6609a..9a5f38a 100644 --- a/beaver_agent/hands/calendars.py +++ b/beaver_agent/hands/calendars.py @@ -10,7 +10,8 @@ FREQ=DAILY/WEEKLY/MONTHLY/YEARLY с INTERVAL, BYDAY, BYMONTH, COUNT и UNTIL; EXDATE и перенесённые экземпляры (RECURRENCE-ID) вычитаются. Шаг считается по настенным часам события, а не в абсолютном времени: еженедельная встреча -переживает перевод часов. +переживает перевод часов. Событие, порождённое ежедневным правилом, помечается +`routine`: в справке такие едут одной строкой, чтобы не вытеснять разовые. """ from __future__ import annotations @@ -32,7 +33,16 @@ if TYPE_CHECKING: from collections.abc import Iterator from datetime import tzinfo -__all__ = ["Calendars", "Event", "Feed", "Upcoming", "calendars", "feeds", "short"] +__all__ = [ + "Calendars", + "Event", + "Feed", + "Upcoming", + "calendars", + "feeds", + "is_routine", + "short", +] _log = logging.getLogger(__name__) @@ -108,6 +118,8 @@ class Event: title: str place: str all_day: bool + routine: bool = False + """Порождено правилом, повторяющимся раз в день или чаще: см. `is_routine`.""" @dataclass(frozen=True, slots=True) @@ -425,6 +437,25 @@ def _until(value: str | None, zone: tzinfo) -> datetime | None: return stop + timedelta(days=1) if all_day else stop +ROUTINE_DAYS = 5 +"""Со скольких дней недели в BYDAY повтор перестаёт быть событием и делается бытом.""" + + +def is_routine(rule: dict[str, str]) -> bool: + """Правило, срабатывающее раз в день или чаще: завтрак, зарядка, wind down. + + Такие повторы за неделю дают десятки строк и топят в себе то единственное, + ради чего справку и читают, - экзамен, встречу, приём. В конверт они едут + одной свёрнутой строкой, а не списком. + """ + freq = rule.get("FREQ", "").upper() + if max(_int(rule.get("INTERVAL"), 1), 1) != 1: + return False + if freq == "DAILY": + return True + return freq == "WEEKLY" and len(_byday(rule.get("BYDAY", ""))) >= ROUTINE_DAYS + + def place_of(event: Vevent) -> str: """Зал из описания и адрес из LOCATION - то, что помогает дойти.""" parts: list[str] = [] @@ -471,8 +502,11 @@ def events_in(text: str, *, zone: tzinfo, since: datetime, days: int) -> list[Ev if rrule is None or "RECURRENCE-ID" in event: starts = [start] skip: set[datetime] = set() + routine = False else: - starts = occurrences(start, _rule(rrule.value), zone, end=end) + rule = _rule(rrule.value) + starts = occurrences(start, rule, zone, end=end) + routine = is_routine(rule) uid = _first(event, "UID") skip = _instants(event, "EXDATE", zone) | moved.get( uid.value if uid else "", set() @@ -483,7 +517,15 @@ def events_in(text: str, *, zone: tzinfo, since: datetime, days: int) -> list[Ev 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)) + out.append( + Event( + start=moving, + title=title, + place=place, + all_day=all_day, + routine=routine, + ) + ) return out diff --git a/beaver_agent/memory/recall.py b/beaver_agent/memory/recall.py index 9a39c8f..015fee2 100644 --- a/beaver_agent/memory/recall.py +++ b/beaver_agent/memory/recall.py @@ -35,7 +35,10 @@ LABEL = "справка (указатели по сообщению, не выв VOWELS = "аеёиоуыэюя" KINDS = {"master": "мастер", "branch": "ветка"} CALENDAR = "🗓 календарь" -CALENDAR_LIMIT = 15 +CALENDAR_LIMIT = 40 +"""Предохранитель на разовые события; повседневные повторы под него не идут.""" +CALENDAR_NAMES = 5 +"""Сколько названий показывать в свёрнутой строке повседневных повторов.""" CALENDAR_ERROR_MAX = 90 @@ -339,21 +342,42 @@ class Recall: 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}:" - ] + singles = [event for event in found.events if not event.routine] + routine = [event for event in found.events if event.routine] + shown = singles[:CALENDAR_LIMIT] + head = ( + f"{CALENDAR} ({days} дней), разовых {len(singles)}, " + f"повседневных {len(routine)}, данные на {stamp}:" + ) + lines = [head] lines += [f" - {event_line(event, zone)}" for event in shown] - if len(found.events) > len(shown): - lines.append(f" … и ещё {len(found.events) - len(shown)} в окне") + if len(singles) > len(shown): + lines.append(f" … и ещё {len(singles) - len(shown)} разовых в окне") + if routine: + lines.append( + f" - повседневных повторов: {len(routine)} ({routine_names(routine)})" + ) return lines +def routine_names(events: list[Event], *, limit: int = CALENDAR_NAMES) -> str: + """Уникальные названия бытовых повторов по первому появлению, до `limit`.""" + names = list(dict.fromkeys(event.title for event in events)) + return ", ".join(names[:limit]) + (" и др." if len(names) > limit else "") + + def event_line(event: Event, zone: ZoneInfo) -> str: """Дата, начало, название и место - одной строкой в зоне агента.""" start = event.start.astimezone(zone) diff --git a/tests/test_calendars.py b/tests/test_calendars.py index 21a11db..94e268f 100644 --- a/tests/test_calendars.py +++ b/tests/test_calendars.py @@ -9,6 +9,7 @@ from beaver_agent.hands.calendars import ( calendars, events_in, feeds, + is_routine, short, unfold, ) @@ -122,6 +123,34 @@ def test_daily_repeat_stops_on_until_and_count(): assert starts(yearly) == ["09-06 08:00 часы"] +def test_a_working_week_is_routine_but_one_weekday_a_week_is_an_event(): + text = ics( + "SUMMARY:пара\nDTSTART;TZID=Europe/Warsaw:20260831T100000\n" + "RRULE:FREQ=WEEKLY;BYDAY=MO", + "SUMMARY:зарядка\nDTSTART;TZID=Europe/Warsaw:20260831T070000\n" + "RRULE:FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR", + ) + found = events_in(text, zone=ZONE, since=NOW.astimezone(ZONE), days=7) + assert {e.title for e in found if e.routine} == {"зарядка"} + assert {e.title for e in found if not e.routine} == {"пара"} + assert len([e for e in found if e.routine]) == 5 + + +def test_routine_is_a_rule_that_fires_once_a_day_or_more_often(): + assert is_routine({"FREQ": "DAILY"}) + assert is_routine({"FREQ": "WEEKLY", "BYDAY": "MO,TU,WE,TH,FR,SA"}) + assert not is_routine({"FREQ": "WEEKLY", "BYDAY": "MO,WE,FR"}) + assert not is_routine({"FREQ": "WEEKLY"}) + assert not is_routine({"FREQ": "MONTHLY"}) + # через день - это уже не быт, такое видно поимённо + assert not is_routine({"FREQ": "DAILY", "INTERVAL": "2"}) + + +def test_a_single_event_is_never_routine(): + text = ics("SUMMARY:Egzamin\nDTSTART:20260903T090000Z") + assert not events_in(text, zone=ZONE, since=NOW.astimezone(ZONE), days=7)[0].routine + + def test_a_feed_that_fails_becomes_a_line_not_an_exception(): class Broken(Calendars): def fetch(self, url: str) -> str: diff --git a/tests/test_recall.py b/tests/test_recall.py index af9a67a..29d05d8 100644 --- a/tests/test_recall.py +++ b/tests/test_recall.py @@ -4,7 +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.hands.calendars import Calendars, Event, Feed from beaver_agent.memory.recall import ( LABEL, Recall, @@ -12,6 +12,7 @@ from beaver_agent.memory.recall import ( frontmatter_aliases, mentions, notes_head, + routine_names, ) @@ -207,42 +208,80 @@ def _calendar(text: str | None) -> Calendars: return Fake((Feed("личный", "https://cal.example/x.ics"),), tz="Europe/Warsaw") -BUSY = _ics( +def _daily(title: str, clock: str) -> str: + return f"SUMMARY:{title}\nDTSTART;TZID=Europe/Warsaw:20260902T{clock}\nRRULE:FREQ=DAILY" + + +SINGLES = ( "SUMMARY:Созвон\nDTSTART:20260902T160000Z", + "SUMMARY:Врач\nDTSTART:20260903T090000Z", "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", ) +ROUTINE = ( + _daily("Завтрак", "080000"), + _daily("Обед", "150000"), + _daily("Ужин", "200000"), + _daily("Перекус", "180000"), + _daily("Wind down", "223000"), +) +BUSY = _ics(*SINGLES, *ROUTINE) -def test_calendar_section_sorts_the_week_and_says_what_did_not_fit( +def _section(recall: Recall, now: datetime, text: str = "привет") -> list[str]: + block = recall.block(RecallContext(text=text, kind="master", now=now)) + assert block is not None + return [ln for ln in block.splitlines() if "календар" in ln or ln.startswith(" -")] + + +def test_calendar_shows_every_single_event_and_folds_the_daily_routine( 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 завтрак", + lines = _section(recall, now) + assert lines == [ + "🗓 календарь (7 дней), разовых 3, повседневных 31, данные на 2026-09-01 14:00:", " - 09-02 18:00 Созвон", - " - 09-02 20:00 ужин", + " - 09-03 11:00 Врач", + " - 09-05 11:00 Analiza matematyczna II - Egzamin · Sala: 3, ul. Piotrowo 2, Poznań", + " - повседневных повторов: 31 (Завтрак, Обед, Перекус, Ужин, Wind down)", ] - 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_no_amount_of_routine_can_push_out_a_single_event(tmp_path: Path) -> None: + noise = tuple(_daily(f"быт {i}", f"{i:02d}0000") for i in range(9, 24)) + recall = _vault(tmp_path, _calendar(_ics(*SINGLES, *noise))) + now = datetime(2026, 9, 1, 12, 0, tzinfo=UTC) + lines = _section(recall, now) + assert lines[0].startswith("🗓 календарь (7 дней), разовых 3, повседневных 96") + assert [ln for ln in lines if "Egzamin" in ln] + assert ( + len([ln for ln in lines if ln.startswith(" - ")]) == 4 + ) # 3 разовых + свёртка + assert lines[-1].startswith(" - повседневных повторов: 96") + assert "и др." in lines[-1] + + +def test_routine_line_counts_and_names_up_to_five(tmp_path: Path) -> None: + events = [ + Event( + start=datetime(2026, 9, 2, 8, tzinfo=UTC), + title=t, + place="", + all_day=False, + routine=True, + ) + for t in ("Завтрак", "Обед", "Завтрак", "Ужин") + ] + assert routine_names(events) == "Завтрак, Обед, Ужин" + assert routine_names(events, limit=2) == "Завтрак, Обед и др." + + def test_calendar_section_is_printed_even_when_the_week_is_empty( tmp_path: Path, ) -> None: