fix(hands,memory): the daily routine folds into one line and stops crowding out single events

This commit is contained in:
hh
2026-09-09 08:24:53 +00:00
parent 8d0f80440e
commit ae0a16bc17
4 changed files with 165 additions and 31 deletions
+29
View File
@@ -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:
+58 -19
View File
@@ -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: