feat(config,recall): people recall block in the envelope, reply log in мета/бобер/реплики
This commit is contained in:
@@ -45,6 +45,7 @@ from policy import (
|
|||||||
skill_tracker,
|
skill_tracker,
|
||||||
vault_zones,
|
vault_zones,
|
||||||
)
|
)
|
||||||
|
from recall import Recall, ReplyLog
|
||||||
|
|
||||||
TZ = "Europe/Warsaw"
|
TZ = "Europe/Warsaw"
|
||||||
VAULT = Path("/vault")
|
VAULT = Path("/vault")
|
||||||
@@ -57,6 +58,10 @@ SKILLS = BEAVER / "скиллы"
|
|||||||
DAYS = BEAVER / "дни"
|
DAYS = BEAVER / "дни"
|
||||||
DIGESTS = BEAVER / "выжимки"
|
DIGESTS = BEAVER / "выжимки"
|
||||||
INDEX = BEAVER / "индекс.md"
|
INDEX = BEAVER / "индекс.md"
|
||||||
|
NOTES = BEAVER / "наблюдения"
|
||||||
|
REPLIES = BEAVER / "реплики"
|
||||||
|
PEOPLE = VAULT / "👤 люди"
|
||||||
|
BOARDS = VAULT / "📆 доски"
|
||||||
STATE = BEAVER / "состояние.md"
|
STATE = BEAVER / "состояние.md"
|
||||||
STATE_MAX_LINES = 60
|
STATE_MAX_LINES = 60
|
||||||
# §4.5: ночное закрытие трогает только чаты с активностью после запуска.
|
# §4.5: ночное закрытие трогает только чаты с активностью после запуска.
|
||||||
@@ -545,11 +550,30 @@ watch = VaultWatch(
|
|||||||
"📶 ресерчи/**",
|
"📶 ресерчи/**",
|
||||||
"мета/бобер/**",
|
"мета/бобер/**",
|
||||||
),
|
),
|
||||||
ignore=("💬 чаты/**", "мета/**", "**/attachments/**", ".obsidian/**"),
|
ignore=(
|
||||||
|
"💬 чаты/**",
|
||||||
|
"мета/**",
|
||||||
|
"мета/бобер/реплики/**", # пишет сам gateway на каждое сообщение
|
||||||
|
"**/attachments/**",
|
||||||
|
".obsidian/**",
|
||||||
|
),
|
||||||
),
|
),
|
||||||
tz=TZ,
|
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 джобы. Хендлер только ставит работу в очередь и выходит.
|
# §4.5 джобы. Хендлер только ставит работу в очередь и выходит.
|
||||||
async def rotate(run: JobRun) -> None:
|
async def rotate(run: JobRun) -> None:
|
||||||
@@ -781,6 +805,8 @@ gateway = Gateway(
|
|||||||
# счётчику Claude Code), считаем как он - вход последнего API-вызова.
|
# счётчику Claude Code), считаем как он - вход последнего API-вызова.
|
||||||
rotation=RotationPolicy(tz=TZ, max_context_tokens=500_000),
|
rotation=RotationPolicy(tz=TZ, max_context_tokens=500_000),
|
||||||
watch=watch,
|
watch=watch,
|
||||||
|
recall=recall.block,
|
||||||
|
user_sink=replies.write,
|
||||||
budget=Budget(threshold=0.7),
|
budget=Budget(threshold=0.7),
|
||||||
distiller=Distiller(agent="beaver-distiller", dir=DIGESTS, index=INDEX),
|
distiller=Distiller(agent="beaver-distiller", dir=DIGESTS, index=INDEX),
|
||||||
tz=TZ,
|
tz=TZ,
|
||||||
|
|||||||
@@ -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"(?<!\w){re.escape(stem)}\w{{0,2}}(?!\w)", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def mentions(text: str, people: list[Person], *, limit: int = 3) -> 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"(?<!\w){re.escape(key)}(?!\w)", text, re.IGNORECASE)
|
||||||
|
elif len(key) >= 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")
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user