129 lines
5.1 KiB
Python
129 lines
5.1 KiB
Python
"""Куратор памяти: что нового с прошлого прогона (архитектура §4.5, §6.0).
|
|
|
|
Крон дёргает детерминированный код, модель судит: здесь собирается брифинг
|
|
для служебного тёрна `beaver-curator` - новые реплики Бобра целиком и имена
|
|
файлов, изменившихся с прошлого прогона. Время прошлого прогона куратор
|
|
сам пишет первой строкой `мета/бобер/куратор.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.md` позже ``since`` (время в строках локальное)."""
|
|
zone = ZoneInfo(tz)
|
|
local_since = since.astimezone(zone).replace(tzinfo=None)
|
|
out: list[str] = []
|
|
if not replies_dir.exists():
|
|
return out
|
|
for path in sorted(replies_dir.glob("????-??.md"))[-2:]:
|
|
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,
|
|
replies_dir: Path,
|
|
watched: list[Watched],
|
|
tz: str,
|
|
now: datetime | None = None,
|
|
default_window: timedelta = timedelta(hours=24),
|
|
) -> 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=(str(journal.relative_to(vault)),)
|
|
)
|
|
if not replies and not changed:
|
|
return None
|
|
zone = ZoneInfo(tz)
|
|
head = (
|
|
f"Прогон куратора {now.astimezone(zone):%Y-%m-%d %H:%M} ({tz}); прошлый - "
|
|
f"{since.astimezone(zone):%Y-%m-%d %H:%M}. Ниже - что изменилось с тех пор; "
|
|
"остальное читай сам по слоям. В конце обнови первую строку "
|
|
f"`{journal.relative_to(vault)}`: `последний прогон: "
|
|
f"{now.astimezone(zone).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)
|