Files
beaver-agent/beaver_agent/memory/curator.py
T

140 lines
5.5 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Куратор памяти: что нового с прошлого прогона .
Крон дёргает детерминированный код, модель судит: здесь собирается брифинг
для служебного тёрна `beaver-curator` - новые реплики Бобра целиком и имена
файлов, изменившихся с прошлого прогона. Время прошлого прогона куратор
сам пишет первой строкой `мета/бобер/куратор.md`, запись о прогоне - в файл
дня `мета/бобер/куратор/YYYY-MM-DD.md`; нет ничего нового - брифинга нет и
джоб не спавнится.
"""
from __future__ import annotations
import re
from dataclasses import dataclass
from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING
from zoneinfo import ZoneInfo
if TYPE_CHECKING:
from pathlib import Path
LAST_RUN_RE = re.compile(r"^последний прогон:\s*(\S+)")
REPLY_RE = re.compile(r"^- (\d{4}-\d{2}-\d{2} \d{2}:\d{2}) · ")
MAX_REPLY_CHARS = 60_000
@dataclass(frozen=True)
class Watched:
label: str
root: Path
pattern: str = "**/*.md"
def last_run(journal: Path, *, default: datetime) -> datetime:
"""`последний прогон: <ISO>` из начала журнала, иначе ``default``."""
if not journal.exists():
return default
for line in journal.read_text(encoding="utf-8", errors="ignore").splitlines()[:5]:
m = LAST_RUN_RE.match(line.strip())
if m:
try:
stamp = datetime.fromisoformat(m.group(1))
except ValueError:
return default
return stamp if stamp.tzinfo else stamp.replace(tzinfo=UTC)
return default
def new_replies(replies_dir: Path, since: datetime, tz: str) -> list[str]:
"""Строки `реплики/YYYY-MM-DD.md` позже ``since`` (время в строках локальное)."""
zone = ZoneInfo(tz)
local_since = since.astimezone(zone).replace(tzinfo=None)
out: list[str] = []
if not replies_dir.exists():
return out
first = f"{local_since:%Y-%m-%d}.md"
for path in sorted(replies_dir.glob("????-??-??.md")):
if path.name < first:
continue
for line in path.read_text(encoding="utf-8", errors="ignore").splitlines():
m = REPLY_RE.match(line)
if not m:
continue
try:
at = datetime.strptime(m.group(1), "%Y-%m-%d %H:%M") # noqa: DTZ007
except ValueError:
continue
if at > local_since:
out.append(line)
return out
def changed_files(
watched: list[Watched], since: datetime, vault: Path, *, skip: tuple[str, ...] = ()
) -> list[str]:
"""`путь (размер)` для файлов с mtime позже ``since``; ``skip`` - подстроки."""
out: list[tuple[str, str]] = []
stamp = since.timestamp()
for w in watched:
if not w.root.exists():
continue
for path in w.root.glob(w.pattern):
if not path.is_file() or path.name.startswith("."):
continue
rel = str(path.relative_to(vault))
if any(s in rel for s in skip):
continue
if path.stat().st_mtime > stamp:
out.append((w.label, f"`{rel}` ({path.stat().st_size // 1024} КБ)"))
out.sort()
return [f"{label}: {desc}" for label, desc in out]
def briefing(
*,
vault: Path,
journal: Path,
runs: Path,
replies_dir: Path,
watched: list[Watched],
tz: str,
now: datetime | None = None,
default_window: timedelta = timedelta(hours=24),
skip: tuple[Path, ...] = (),
) -> str | None:
"""Первое сообщение куратору или ``None``, если с прошлого прогона ничего нет."""
now = now or datetime.now(UTC)
since = last_run(journal, default=now - default_window)
replies = new_replies(replies_dir, since, tz)
changed = changed_files(
watched,
since,
vault,
skip=tuple(str(p.relative_to(vault)) for p in (journal, *skip)),
)
if not replies and not changed:
return None
zone = ZoneInfo(tz)
local = now.astimezone(zone)
head = (
f"Прогон куратора {local:%Y-%m-%d %H:%M} ({tz}); прошлый - "
f"{since.astimezone(zone):%Y-%m-%d %H:%M}. Ниже - что изменилось с тех пор; "
"остальное читай сам по слоям. В конце запись о прогоне - в "
f"`{(runs / f'{local:%Y-%m-%d}.md').relative_to(vault)}`, а в "
f"`{journal.relative_to(vault)}` только первая строка: `последний прогон: "
f"{local.isoformat(timespec='seconds')}`."
)
parts = [head]
if replies:
text = "\n".join(replies)
if len(text) > MAX_REPLY_CHARS:
text = text[-MAX_REPLY_CHARS:]
text = "…(обрезано, начало - в файле)\n" + text[text.find("\n") + 1 :]
parts.append(f"Новые реплики Бобра ({len(replies)}):\n{text}")
else:
parts.append("Новых реплик Бобра нет.")
if changed:
parts.append("Изменились файлы:\n" + "\n".join(f"- {c}" for c in changed[:80]))
return "\n\n".join(parts)