from datetime import UTC, date, datetime from pathlib import Path from beaver_gateway.core.conversations import UserSaid from beaver_gateway.core.envelope import RecallContext from recall import LABEL, Recall, ReplyLog, frontmatter_aliases, mentions 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) -> 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- 2026-08-26 · перенесла встречу на час · [[2026-08-26]]\n" "- 2026-08-31 · прислала список покупок · транскрипт\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", ) 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].startswith( "👤 Зина - карточка `👤 люди/личное/Зина.md`; записки `мета/бобер/наблюдения/Зина - наблюдения.md`: 2 стр., последняя: 2026-08-31 · четыре смягчения" ) assert lines[1].endswith("дневник: [[2026-08-31]], [[2026-08-26]], [[2026-08-13]]") assert lines[2:] == [ "📆 09-01 организация: заполнить файрфлай", "📆 09-02 организация: витамины", ] 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