fix(hands,memory): the daily routine folds into one line and stops crowding out single events
This commit is contained in:
@@ -10,7 +10,8 @@
|
|||||||
FREQ=DAILY/WEEKLY/MONTHLY/YEARLY с INTERVAL, BYDAY, BYMONTH, COUNT и UNTIL;
|
FREQ=DAILY/WEEKLY/MONTHLY/YEARLY с INTERVAL, BYDAY, BYMONTH, COUNT и UNTIL;
|
||||||
EXDATE и перенесённые экземпляры (RECURRENCE-ID) вычитаются. Шаг считается по
|
EXDATE и перенесённые экземпляры (RECURRENCE-ID) вычитаются. Шаг считается по
|
||||||
настенным часам события, а не в абсолютном времени: еженедельная встреча
|
настенным часам события, а не в абсолютном времени: еженедельная встреча
|
||||||
переживает перевод часов.
|
переживает перевод часов. Событие, порождённое ежедневным правилом, помечается
|
||||||
|
`routine`: в справке такие едут одной строкой, чтобы не вытеснять разовые.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -32,7 +33,16 @@ if TYPE_CHECKING:
|
|||||||
from collections.abc import Iterator
|
from collections.abc import Iterator
|
||||||
from datetime import tzinfo
|
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__)
|
_log = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -108,6 +118,8 @@ class Event:
|
|||||||
title: str
|
title: str
|
||||||
place: str
|
place: str
|
||||||
all_day: bool
|
all_day: bool
|
||||||
|
routine: bool = False
|
||||||
|
"""Порождено правилом, повторяющимся раз в день или чаще: см. `is_routine`."""
|
||||||
|
|
||||||
|
|
||||||
@dataclass(frozen=True, slots=True)
|
@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
|
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:
|
def place_of(event: Vevent) -> str:
|
||||||
"""Зал из описания и адрес из LOCATION - то, что помогает дойти."""
|
"""Зал из описания и адрес из LOCATION - то, что помогает дойти."""
|
||||||
parts: list[str] = []
|
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:
|
if rrule is None or "RECURRENCE-ID" in event:
|
||||||
starts = [start]
|
starts = [start]
|
||||||
skip: set[datetime] = set()
|
skip: set[datetime] = set()
|
||||||
|
routine = False
|
||||||
else:
|
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")
|
uid = _first(event, "UID")
|
||||||
skip = _instants(event, "EXDATE", zone) | moved.get(
|
skip = _instants(event, "EXDATE", zone) | moved.get(
|
||||||
uid.value if uid else "", set()
|
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
|
floor = day_start if all_day else since
|
||||||
if moving < floor or moving > end or moving.astimezone(UTC) in skip:
|
if moving < floor or moving > end or moving.astimezone(UTC) in skip:
|
||||||
continue
|
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
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -35,7 +35,10 @@ LABEL = "справка (указатели по сообщению, не выв
|
|||||||
VOWELS = "аеёиоуыэюя"
|
VOWELS = "аеёиоуыэюя"
|
||||||
KINDS = {"master": "мастер", "branch": "ветка"}
|
KINDS = {"master": "мастер", "branch": "ветка"}
|
||||||
CALENDAR = "🗓 календарь"
|
CALENDAR = "🗓 календарь"
|
||||||
CALENDAR_LIMIT = 15
|
CALENDAR_LIMIT = 40
|
||||||
|
"""Предохранитель на разовые события; повседневные повторы под него не идут."""
|
||||||
|
CALENDAR_NAMES = 5
|
||||||
|
"""Сколько названий показывать в свёрнутой строке повседневных повторов."""
|
||||||
CALENDAR_ERROR_MAX = 90
|
CALENDAR_ERROR_MAX = 90
|
||||||
|
|
||||||
|
|
||||||
@@ -339,21 +342,42 @@ class Recall:
|
|||||||
return lines or [f"{CALENDAR}: ОШИБКА фиды не ответили"]
|
return lines or [f"{CALENDAR}: ОШИБКА фиды не ответили"]
|
||||||
|
|
||||||
def calendar_window(self, found: Upcoming, days: int) -> list[str]:
|
def calendar_window(self, found: Upcoming, days: int) -> list[str]:
|
||||||
"""Окно ответивших фидов: заголовок с временем данных и события строками."""
|
"""Окно ответивших фидов: разовые событиями, повседневный быт - одной строкой.
|
||||||
|
|
||||||
|
Разовых за неделю физически мало, и ровно они и есть сигнал: экзамен,
|
||||||
|
встреча, приём. Их видно целиком; лимит тут - предохранитель от абсурда,
|
||||||
|
а не способ уместиться. Ежедневные повторы считаются и называются, но
|
||||||
|
своими строками разовые не вытесняют - иначе секция выглядит здоровой и
|
||||||
|
при этом показывает двое суток вместо недели.
|
||||||
|
"""
|
||||||
stamp = f"{found.at:%Y-%m-%d %H:%M}"
|
stamp = f"{found.at:%Y-%m-%d %H:%M}"
|
||||||
if not found.events:
|
if not found.events:
|
||||||
return [f"{CALENDAR} ({days} дней): событий нет, данные на {stamp}"]
|
return [f"{CALENDAR} ({days} дней): событий нет, данные на {stamp}"]
|
||||||
zone = ZoneInfo(self.tz)
|
zone = ZoneInfo(self.tz)
|
||||||
shown = found.events[:CALENDAR_LIMIT]
|
singles = [event for event in found.events if not event.routine]
|
||||||
lines = [
|
routine = [event for event in found.events if event.routine]
|
||||||
f"{CALENDAR} ({days} дней), событий {len(found.events)}, данные на {stamp}:"
|
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]
|
lines += [f" - {event_line(event, zone)}" for event in shown]
|
||||||
if len(found.events) > len(shown):
|
if len(singles) > len(shown):
|
||||||
lines.append(f" … и ещё {len(found.events) - len(shown)} в окне")
|
lines.append(f" … и ещё {len(singles) - len(shown)} разовых в окне")
|
||||||
|
if routine:
|
||||||
|
lines.append(
|
||||||
|
f" - повседневных повторов: {len(routine)} ({routine_names(routine)})"
|
||||||
|
)
|
||||||
return lines
|
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:
|
def event_line(event: Event, zone: ZoneInfo) -> str:
|
||||||
"""Дата, начало, название и место - одной строкой в зоне агента."""
|
"""Дата, начало, название и место - одной строкой в зоне агента."""
|
||||||
start = event.start.astimezone(zone)
|
start = event.start.astimezone(zone)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ from beaver_agent.hands.calendars import (
|
|||||||
calendars,
|
calendars,
|
||||||
events_in,
|
events_in,
|
||||||
feeds,
|
feeds,
|
||||||
|
is_routine,
|
||||||
short,
|
short,
|
||||||
unfold,
|
unfold,
|
||||||
)
|
)
|
||||||
@@ -122,6 +123,34 @@ def test_daily_repeat_stops_on_until_and_count():
|
|||||||
assert starts(yearly) == ["09-06 08:00 часы"]
|
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():
|
def test_a_feed_that_fails_becomes_a_line_not_an_exception():
|
||||||
class Broken(Calendars):
|
class Broken(Calendars):
|
||||||
def fetch(self, url: str) -> str:
|
def fetch(self, url: str) -> str:
|
||||||
|
|||||||
+58
-19
@@ -4,7 +4,7 @@ from pathlib import Path
|
|||||||
from beaver_gateway.conversations.texts import UserSaid
|
from beaver_gateway.conversations.texts import UserSaid
|
||||||
from beaver_gateway.conversations.envelope import RecallContext
|
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 (
|
from beaver_agent.memory.recall import (
|
||||||
LABEL,
|
LABEL,
|
||||||
Recall,
|
Recall,
|
||||||
@@ -12,6 +12,7 @@ from beaver_agent.memory.recall import (
|
|||||||
frontmatter_aliases,
|
frontmatter_aliases,
|
||||||
mentions,
|
mentions,
|
||||||
notes_head,
|
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")
|
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:20260902T160000Z",
|
||||||
|
"SUMMARY:Врач\nDTSTART:20260903T090000Z",
|
||||||
"SUMMARY:Analiza matematyczna II - Egzamin\nDTSTART;VALUE=DATE-TIME:20260905T110000\n"
|
"SUMMARY:Analiza matematyczna II - Egzamin\nDTSTART;VALUE=DATE-TIME:20260905T110000\n"
|
||||||
"DESCRIPTION:Sala: 3\\nA23 - CW\\n\nLOCATION:ul. Piotrowo 2\\, Poznań",
|
"DESCRIPTION:Sala: 3\\nA23 - CW\\n\nLOCATION:ul. Piotrowo 2\\, Poznań",
|
||||||
"SUMMARY:Врач\nDTSTART:20260903T090000Z",
|
|
||||||
"SUMMARY:за окном\nDTSTART:20260925T080000Z",
|
"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,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
recall = _vault(tmp_path, _calendar(BUSY))
|
recall = _vault(tmp_path, _calendar(BUSY))
|
||||||
now = datetime(2026, 9, 1, 12, 0, tzinfo=UTC)
|
now = datetime(2026, 9, 1, 12, 0, tzinfo=UTC)
|
||||||
block = recall.block(RecallContext(text="привет", kind="master", now=now))
|
lines = _section(recall, now)
|
||||||
assert block is not None
|
assert lines == [
|
||||||
lines = [ln for ln in block.splitlines() if "календар" in ln or ln.startswith(" ")]
|
"🗓 календарь (7 дней), разовых 3, повседневных 31, данные на 2026-09-01 14:00:",
|
||||||
assert lines[0] == ("🗓 календарь (7 дней), событий 16, данные на 2026-09-01 14:00:")
|
|
||||||
assert lines[1:4] == [
|
|
||||||
" - 09-02 08:00 завтрак",
|
|
||||||
" - 09-02 18: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))
|
again = recall.block(RecallContext(text="и ещё", kind="master", now=now))
|
||||||
assert again is None or "календарь" not in again
|
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(
|
def test_calendar_section_is_printed_even_when_the_week_is_empty(
|
||||||
tmp_path: Path,
|
tmp_path: Path,
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
Reference in New Issue
Block a user