From dd649e568a122934dd7a775947bdde485d4d1336 Mon Sep 17 00:00:00 2001 From: h Date: Tue, 1 Sep 2026 16:08:55 +0200 Subject: [PATCH] =?UTF-8?q?feat(config,recall):=20people=20recall=20block?= =?UTF-8?q?=20in=20the=20envelope,=20reply=20log=20in=20=D0=BC=D0=B5=D1=82?= =?UTF-8?q?=D0=B0/=D0=B1=D0=BE=D0=B1=D0=B5=D1=80/=D1=80=D0=B5=D0=BF=D0=BB?= =?UTF-8?q?=D0=B8=D0=BA=D0=B8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config.py | 28 ++++- recall.py | 294 +++++++++++++++++++++++++++++++++++++++++++ tests/test_recall.py | 150 ++++++++++++++++++++++ 3 files changed, 471 insertions(+), 1 deletion(-) create mode 100644 recall.py create mode 100644 tests/test_recall.py diff --git a/config.py b/config.py index c00dd65..aaf43ed 100644 --- a/config.py +++ b/config.py @@ -45,6 +45,7 @@ from policy import ( skill_tracker, vault_zones, ) +from recall import Recall, ReplyLog TZ = "Europe/Warsaw" VAULT = Path("/vault") @@ -57,6 +58,10 @@ SKILLS = BEAVER / "скиллы" DAYS = BEAVER / "дни" DIGESTS = BEAVER / "выжимки" INDEX = BEAVER / "индекс.md" +NOTES = BEAVER / "наблюдения" +REPLIES = BEAVER / "реплики" +PEOPLE = VAULT / "👤 люди" +BOARDS = VAULT / "📆 доски" STATE = BEAVER / "состояние.md" STATE_MAX_LINES = 60 # §4.5: ночное закрытие трогает только чаты с активностью после запуска. @@ -545,11 +550,30 @@ watch = VaultWatch( "📶 ресерчи/**", "мета/бобер/**", ), - ignore=("💬 чаты/**", "мета/**", "**/attachments/**", ".obsidian/**"), + ignore=( + "💬 чаты/**", + "мета/**", + "мета/бобер/реплики/**", # пишет сам gateway на каждое сообщение + "**/attachments/**", + ".obsidian/**", + ), ), tz=TZ, ) +# §3.3: справка под сообщением - карточка и записки агента по упомянутым людям, +# дни, где они встречались, раз в день сроки из досок; лог реплик Бобра - +# единственное grep-абельное место для сказанного в телеге (§6.0 «спящее»). +recall = Recall( + vault=VAULT, + people_dir=PEOPLE, + notes_dir=NOTES, + diary_dir=DIARY, + boards_dir=BOARDS, + tz=TZ, +) +replies = ReplyLog(REPLIES, tz=TZ) + # §4.5 джобы. Хендлер только ставит работу в очередь и выходит. async def rotate(run: JobRun) -> None: @@ -781,6 +805,8 @@ gateway = Gateway( # счётчику Claude Code), считаем как он - вход последнего API-вызова. rotation=RotationPolicy(tz=TZ, max_context_tokens=500_000), watch=watch, + recall=recall.block, + user_sink=replies.write, budget=Budget(threshold=0.7), distiller=Distiller(agent="beaver-distiller", dir=DIGESTS, index=INDEX), tz=TZ, diff --git a/recall.py b/recall.py new file mode 100644 index 0000000..6020f43 --- /dev/null +++ b/recall.py @@ -0,0 +1,294 @@ +"""Справка под сообщением Бобра и лог его реплик (архитектура §3.3, инвариант 8). + +Gateway путей не знает; здесь - что искать по тексту сообщения и куда писать +сказанное. ``Recall.block`` отдаёт указатели, не выводы: карточка упомянутого +человека, записки агента о нём (`мета/бобер/наблюдения/`), дни дневника, где +он встречался, и раз в день - подошедшие сроки из досок. ``ReplyLog.write`` +кладёт каждую реплику Бобра из мастера и веток в `мета/бобер/реплики/YYYY-MM.md` +- единственное место, где они grep-абельны: транскрипт живёт в Postgres, +а глубокие чаты и так лежат файлами в `💬 чаты/`. +""" + +from __future__ import annotations + +import re +import time +from dataclasses import dataclass, field +from datetime import date, timedelta +from typing import TYPE_CHECKING +from zoneinfo import ZoneInfo + +if TYPE_CHECKING: + from pathlib import Path + + from beaver_gateway.core.conversations import UserSaid + from beaver_gateway.core.envelope import RecallContext + +LABEL = "справка (указатели по сообщению, не выводы; открой, если относится к вопросу):" +VOWELS = "аеёиоуыэюя" +KINDS = {"master": "мастер", "branch": "ветка"} + + +@dataclass(frozen=True, slots=True) +class Person: + name: str + """Имя файла карточки без `.md` - им же называется файл записок.""" + aliases: tuple[str, ...] + path: str + """Путь карточки от корня vault.""" + plain: bool + """Искать по голому имени в тексте (ближний круг), а не только по `[[ссылке]]`.""" + + @property + def keys(self) -> tuple[str, ...]: + return (self.name, *self.aliases) + + +def frontmatter_aliases(text: str) -> tuple[str, ...]: + """`aliases:` из фронтматтера карточки - список строк или пусто.""" + if not text.startswith("---"): + return () + end = text.find("\n---", 3) + if end < 0: + return () + out: list[str] = [] + inside = False + for line in text[3:end].splitlines(): + if line.startswith("aliases:"): + inside = True + rest = line[len("aliases:") :].strip() + if rest and rest != "[]": + out += [a.strip().strip("\"'") for a in rest.strip("[]").split(",")] + continue + if inside: + stripped = line.strip() + if stripped.startswith("- "): + out.append(stripped[2:].strip().strip("\"'")) + continue + if stripped: + inside = False + return tuple(a for a in out if a) + + +class People: + """Индекс карточек `👤 люди/`: имя, алиасы, путь; пересобирается раз в `ttl`.""" + + def __init__( + self, + root: Path, + vault: Path, + *, + plain_dirs: tuple[str, ...] = ("личное", "профессиональное"), + skip_dirs: tuple[str, ...] = ("архив", "рандомы", "группы"), + ttl: float = 600.0, + ) -> None: + self._root = root + self._vault = vault + self._plain_dirs = plain_dirs + self._skip_dirs = skip_dirs + self._ttl = ttl + self._built = 0.0 + self._people: list[Person] = [] + + def all(self) -> list[Person]: + if time.monotonic() - self._built > self._ttl: + self._people = self._scan() + self._built = time.monotonic() + return self._people + + def _scan(self) -> list[Person]: + if not self._root.exists(): + return [] + out: list[Person] = [] + for card in sorted(self._root.rglob("*.md")): + parts = card.relative_to(self._root).parts[:-1] + plain = bool(parts) and parts[0] in self._plain_dirs + plain = plain and not any(p in self._skip_dirs for p in parts) + try: + head = card.read_text(encoding="utf-8", errors="ignore")[:2000] + except OSError: + continue + out.append( + Person( + name=card.stem, + aliases=frontmatter_aliases(head), + path=str(card.relative_to(self._vault)), + plain=plain, + ) + ) + return out + + +def _stem_pattern(key: str) -> re.Pattern[str]: + """Имя с падежом: `Зина` → Зина/Зине/Зину/Зиной, `Прохор` → Прохора/Прохором.""" + stem = key[:-1] if key[-1].lower() in VOWELS and len(key) > 3 else key + return re.compile(rf"(? list[Person]: + """Кто упомянут: `[[ссылкой]]` - любой, голым именем - только ближний круг.""" + found: dict[str, int] = {} + by_key = {k.casefold(): p for p in people for k in p.keys} + for m in re.finditer(r"\[\[([^\]|#]+)", text): + target = m.group(1).strip().split("/")[-1].casefold() + target = target.removesuffix(".md") + person = by_key.get(target) + if person is not None: + found.setdefault(person.name, m.start()) + for person in people: + if not person.plain or person.name in found: + continue + for key in person.keys: + if " " in key: + m = re.search(rf"(?= 4: + m = _stem_pattern(key).search(text) + else: + continue + if m: + found.setdefault(person.name, m.start()) + break + order = sorted(found.items(), key=lambda kv: kv[1])[:limit] + by_name = {p.name: p for p in people} + return [by_name[name] for name, _ in order] + + +@dataclass +class Recall: + """Справка по сообщению: указатели в vault, которые gateway не может знать.""" + + vault: Path + people_dir: Path + notes_dir: Path + diary_dir: Path + boards_dir: Path + tz: str = "Europe/Warsaw" + diary_scan: int = 400 + """Сколько последних дней дневника просматривать на упоминания.""" + _people: People | None = field(default=None, init=False, repr=False) + _diary_cache: dict[str, tuple[float, list[str]]] = field( + default_factory=dict, init=False, repr=False + ) + _deadlines_day: date | None = field(default=None, init=False, repr=False) + + @property + def people(self) -> People: + if self._people is None: + self._people = People(self.people_dir, self.vault) + return self._people + + def block(self, ctx: RecallContext) -> str | None: + today = ctx.now.astimezone(ZoneInfo(self.tz)).date() + lines = [self.person_line(p) for p in mentions(ctx.text, self.people.all())] + if ctx.kind == "master": + lines += self.deadlines(today) + if not lines: + return None + return "\n".join([LABEL, *lines]) + + def person_line(self, person: Person) -> str: + parts = [f"👤 {person.name} - карточка `{person.path}`"] + notes = self.notes_dir / f"{person.name}.md" + rel = notes.relative_to(self.vault) + if notes.exists(): + entries = [ + ln.strip() + for ln in notes.read_text( + encoding="utf-8", errors="ignore" + ).splitlines() + if ln.strip().startswith("- ") + ] + if entries: + last = entries[-1][2:] + last = last if len(last) <= 110 else last[:109] + "…" + parts.append(f"записки `{rel}`: {len(entries)} стр., последняя: {last}") + else: + parts.append(f"записки `{rel}`: пусто") + else: + parts.append(f"записок нет (`{rel}`)") + days = self.diary_hits(person) + if days: + parts.append("дневник: " + ", ".join(f"[[{d}]]" for d in days)) + return "; ".join(parts) + + def diary_hits( + self, person: Person, *, limit: int = 3, ttl: float = 600.0 + ) -> list[str]: + cached = self._diary_cache.get(person.name) + if cached and time.monotonic() - cached[0] < ttl: + return cached[1] + keys = [f"[[{k.casefold()}" for k in person.keys] + hits: list[str] = [] + files = sorted(self.diary_dir.glob("????-??-??.md"), reverse=True) + for path in files[: self.diary_scan]: + try: + text = path.read_text(encoding="utf-8", errors="ignore").casefold() + except OSError: + continue + if any(k in text for k in keys): + hits.append(path.stem) + if len(hits) >= limit: + break + self._diary_cache[person.name] = (time.monotonic(), hits) + return hits + + def deadlines( + self, today: date, *, horizon_days: int = 1, limit: int = 8 + ) -> list[str]: + """Открытые задачи досок с 📅 не позже завтра - раз в день, первым конвертом.""" + if self._deadlines_day == today: + return [] + self._deadlines_day = today + found: list[tuple[date, str, str]] = [] + for board in ( + sorted(self.boards_dir.glob("*.md")) if self.boards_dir.exists() else [] + ): + live = board.read_text(encoding="utf-8", errors="ignore").split("\n***", 1)[ + 0 + ] + for m in re.finditer( + r"^\s*- \[ \] (.+?)\s*📅 (\d{4}-\d{2}-\d{2})", live, re.MULTILINE + ): + try: + due = date.fromisoformat(m.group(2)) + except ValueError: + continue + if due <= today + timedelta(days=horizon_days): + found.append((due, board.stem, m.group(1).strip())) + found.sort() + return [f"📆 {due:%m-%d} {board}: {task}" for due, board, task in found[:limit]] + + +@dataclass +class ReplyLog: + """`мета/бобер/реплики/YYYY-MM.md`: строка на реплику Бобра из мастера и веток.""" + + dir: Path + tz: str = "Europe/Warsaw" + max_chars: int = 600 + + def write(self, said: UserSaid) -> None: + text = said.text + if text.startswith("[сид:"): + # спавн ветки с текстом: строка сида едет над самим сообщением + text = text.split("\n\n", 1)[1] if "\n\n" in text else "" + flat = " ⏎ ".join(ln.strip() for ln in text.strip().splitlines() if ln.strip()) + if not flat: + return + if len(flat) > self.max_chars: + flat = flat[: self.max_chars - 1] + "…" + local = said.at.astimezone(ZoneInfo(self.tz)) + where = KINDS.get(said.kind, said.kind) + if said.kind != "master" and said.title: + where = f"{where} «{said.title}»" + path = self.dir / f"{local:%Y-%m}.md" + self.dir.mkdir(parents=True, exist_ok=True) + if not path.exists(): + path.write_text( + f"# реплики Бобра, {local:%Y-%m}\n\n" + "> пишет gateway: что Бобёр сказал в мастере и ветках, " + "строка на сообщение. Глубокие чаты лежат в `💬 чаты/` сами.\n\n", + encoding="utf-8", + ) + with path.open("a", encoding="utf-8") as fh: + fh.write(f"- {local:%Y-%m-%d %H:%M} · {where} · {flat}\n") diff --git a/tests/test_recall.py b/tests/test_recall.py new file mode 100644 index 0000000..4da8f09 --- /dev/null +++ b/tests/test_recall.py @@ -0,0 +1,150 @@ +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