feat(hands,memory): the recall carries the next week of both calendars daily

This commit is contained in:
hh
2026-09-09 08:21:03 +00:00
parent f3a625087f
commit 8d0f80440e
7 changed files with 775 additions and 4 deletions
+91 -1
View File
@@ -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