279 lines
13 KiB
Python
279 lines
13 KiB
Python
from datetime import UTC, date, datetime
|
|
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,
|
|
ReplyLog,
|
|
frontmatter_aliases,
|
|
mentions,
|
|
notes_head,
|
|
)
|
|
|
|
|
|
def _card(path: Path, aliases: list[str] | None = None) -> None:
|
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
fm = "aliases:\n" + "".join(f" - {a}\n" for a in aliases or [])
|
|
path.write_text(
|
|
f"---\n{fm}tags:\n - люди/друг\n---\n# 👤 {path.stem}\n", encoding="utf-8"
|
|
)
|
|
|
|
|
|
def _vault(tmp_path: Path, calendar: Calendars | None = None) -> Recall:
|
|
people = tmp_path / "👤 люди"
|
|
_card(people / "личное" / "Зина.md", ["Зина"])
|
|
_card(people / "личное" / "Прохор.md", ["Прохор Тестов"])
|
|
_card(
|
|
people / "профессиональное" / "программирование" / "Фёдор Плюшкин.md",
|
|
["Фёдор Плюшкин", "Фёдор"],
|
|
)
|
|
_card(people / "личное" / "архив" / "Глафира.md")
|
|
_card(people / "личное" / "рандомы" / "Устинья Пример.md")
|
|
diary = tmp_path / "📅 дни"
|
|
diary.mkdir()
|
|
(diary / "2026-08-31.md").write_text("с [[Зина|Зиной]] гуляли\n", encoding="utf-8")
|
|
(diary / "2026-08-26.md").write_text(
|
|
"[[Зина]] звонила, [[Фёдор Плюшкин|Фёдор]] рассказывал\n", encoding="utf-8"
|
|
)
|
|
(diary / "2026-08-13.md").write_text("обед с [[Зина|Зины]]\n", encoding="utf-8")
|
|
(diary / "2026-08-01.md").write_text("[[Зина]] ещё раз\n", encoding="utf-8")
|
|
notes = tmp_path / "мета" / "бобер" / "наблюдения"
|
|
notes.mkdir(parents=True)
|
|
(notes / "Зина - наблюдения.md").write_text(
|
|
"# Зина - наблюдения\n\n## сейчас\n- просила перезвонить вечером 26.08\n"
|
|
"- ждёт ответа про поездку до четверга\n\n## паттерны\n"
|
|
"- переносит встречи: [[2026-08-10]], [[2026-08-26]]\n\n## факты\n\n## открытое\n\n"
|
|
"## лента\n- 2026-08-26 · перенесла встречу на час · [[2026-08-26]]\n"
|
|
"- 2026-08-31 · прислала список покупок · транскрипт\n",
|
|
encoding="utf-8",
|
|
)
|
|
(notes / "Прохор - наблюдения.md").write_text(
|
|
"# Прохор - наблюдения\n\n## сейчас\n\n## лента\n- 2026-08-07 · приехал · [[2026-08-07]]\n",
|
|
encoding="utf-8",
|
|
)
|
|
boards = tmp_path / "📆 доски"
|
|
boards.mkdir()
|
|
(boards / "организация.md").write_text(
|
|
"## повседнев\n\n- [ ] оплатить счёт за свет 📅 2026-09-01\n- [ ] купить лампочки 📅 2026-09-02\n"
|
|
"- [ ] далеко 📅 2026-09-20\n- [x] сделано 📅 2026-08-30\n\n***\n\n## Archive\n\n- [ ] старое 📅 2025-01-01\n",
|
|
encoding="utf-8",
|
|
)
|
|
return Recall(
|
|
vault=tmp_path,
|
|
people_dir=people,
|
|
notes_dir=notes,
|
|
diary_dir=diary,
|
|
boards_dir=boards,
|
|
tz="Europe/Warsaw",
|
|
calendar=calendar,
|
|
)
|
|
|
|
|
|
def test_frontmatter_aliases_handles_empty_and_lists() -> None:
|
|
assert frontmatter_aliases("---\naliases:\ntags:\n---\n# x") == ()
|
|
assert frontmatter_aliases(
|
|
"---\naliases:\n - Фёдор Плюшкин\n - Фёдор\ntags:\n---\n"
|
|
) == ("Фёдор Плюшкин", "Фёдор")
|
|
assert frontmatter_aliases("нет фронтматтера") == ()
|
|
|
|
|
|
def test_mentions_by_case_and_link_but_archive_only_by_link(tmp_path: Path) -> None:
|
|
recall = _vault(tmp_path)
|
|
people = recall.people.all()
|
|
names = [p.name for p in mentions("созвон с Фёдором, потом к Зине", people)]
|
|
assert names == ["Фёдор Плюшкин", "Зина"]
|
|
assert [p.name for p in mentions("Глафира опять написала", people)] == []
|
|
assert [
|
|
p.name for p in mentions("глянь [[Глафира]] и [[Устинья Пример]]", people)
|
|
] == ["Глафира", "Устинья Пример"]
|
|
assert [p.name for p in mentions("Прохором обсуждали", people)] == ["Прохор"]
|
|
# "Ван"+2 буквы ловит "ванна" - цена падежей; на голое имя короче 4 букв не смотрим
|
|
assert [
|
|
p.name for p in mentions("машина без людей, эля тут ни при чём", people)
|
|
] == []
|
|
|
|
|
|
def test_block_points_to_card_notes_diary_and_deadlines_once(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
|
|
lines = block.splitlines()
|
|
assert lines[0] == LABEL
|
|
assert lines[1] == (
|
|
"👤 Зина - карточка `👤 люди/личное/Зина.md`; записки "
|
|
"`мета/бобер/наблюдения/Зина - наблюдения.md`: лента 2 стр., паттернов 1; "
|
|
"дневник: [[2026-08-31]], [[2026-08-26]], [[2026-08-13]]; сейчас:"
|
|
)
|
|
assert lines[2:4] == [
|
|
" - просила перезвонить вечером 26.08",
|
|
" - ждёт ответа про поездку до четверга",
|
|
]
|
|
assert lines[4:] == [
|
|
"📆 09-01 организация: оплатить счёт за свет",
|
|
"📆 09-02 организация: купить лампочки",
|
|
]
|
|
artem = recall.person_line(
|
|
next(p for p in recall.people.all() if p.name == "Прохор")
|
|
)
|
|
assert "лента 1 стр., последняя: 2026-08-07 · приехал" in artem
|
|
assert "сейчас:" not in artem
|
|
again = recall.block(RecallContext(text="и ещё про Зину", kind="master", now=now))
|
|
assert again is not None and "📆" not in again
|
|
branch = recall.block(RecallContext(text="Фёдор звонил", kind="branch", now=now))
|
|
assert branch is not None
|
|
assert (
|
|
"записок нет (`мета/бобер/наблюдения/Фёдор Плюшкин - наблюдения.md`)" in branch
|
|
)
|
|
assert "дневник: [[2026-08-26]]" in branch
|
|
assert (
|
|
recall.block(RecallContext(text="просто привет", kind="branch", now=now))
|
|
is None
|
|
)
|
|
|
|
|
|
def test_reply_log_appends_one_line_per_message_and_strips_seed(tmp_path: Path) -> None:
|
|
log = ReplyLog(tmp_path / "реплики", tz="Europe/Warsaw")
|
|
at = datetime(2026, 9, 1, 12, 4, tzinfo=UTC)
|
|
log.write(
|
|
UserSaid(
|
|
conversation_id="c1",
|
|
kind="master",
|
|
title=None,
|
|
text="привет\n\nты меня любишь",
|
|
at=at,
|
|
)
|
|
)
|
|
log.write(
|
|
UserSaid(
|
|
conversation_id="c2",
|
|
kind="branch",
|
|
title="крипто-карта",
|
|
text="[сид: clean] branch, 2026-09-01.\n\nсравни Trustee и RedotPay",
|
|
at=at,
|
|
)
|
|
)
|
|
log.write(
|
|
UserSaid(
|
|
conversation_id="c3",
|
|
kind="branch",
|
|
title="x",
|
|
text="[сид: brief] только сид",
|
|
at=at,
|
|
)
|
|
)
|
|
text = (tmp_path / "реплики" / "2026-09.md").read_text(encoding="utf-8")
|
|
assert text.startswith("# реплики Бобра, 2026-09\n")
|
|
assert text.endswith(
|
|
"- 2026-09-01 14:04 · мастер · привет ⏎ ты меня любишь\n"
|
|
"- 2026-09-01 14:04 · ветка «крипто-карта» · сравни Trustee и RedotPay\n"
|
|
)
|
|
assert date.fromisoformat("2026-09-01") # tz shift stays inside the month here
|
|
|
|
|
|
def test_notes_head_picks_sections_for_the_seed(tmp_path: Path) -> None:
|
|
recall = _vault(tmp_path)
|
|
path = recall.notes_dir / "Зина - наблюдения.md"
|
|
head = notes_head(path, ("сейчас", "паттерны"))
|
|
assert head == (
|
|
"## сейчас\n- просила перезвонить вечером 26.08\n"
|
|
"- ждёт ответа про поездку до четверга\n"
|
|
"## паттерны\n- переносит встречи: [[2026-08-10]], [[2026-08-26]]"
|
|
)
|
|
assert notes_head(path, ("факты",)) is 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
|