140 lines
5.5 KiB
Python
140 lines
5.5 KiB
Python
"""Куратор памяти: что нового с прошлого прогона .
|
||
|
||
Крон дёргает детерминированный код, модель судит: здесь собирается брифинг
|
||
для служебного тёрна `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)
|