From 014da65f866a0a798c08bbd4f5bf5c0a224c062e Mon Sep 17 00:00:00 2001 From: h Date: Mon, 7 Sep 2026 23:00:14 +0000 Subject: [PATCH] feat(jobs,vault): hourly leisure tick, the day plan lives in the vault --- beaver_agent/jobs/__init__.py | 9 ++ beaver_agent/jobs/leisure.py | 279 ++++++++++++++++++++++++++++++++++ beaver_agent/vault.py | 2 + tests/test_leisure.py | 123 +++++++++++++++ 4 files changed, 413 insertions(+) create mode 100644 beaver_agent/jobs/leisure.py create mode 100644 tests/test_leisure.py diff --git a/beaver_agent/jobs/__init__.py b/beaver_agent/jobs/__init__.py index 46780ac..f8d69ad 100644 --- a/beaver_agent/jobs/__init__.py +++ b/beaver_agent/jobs/__init__.py @@ -2,19 +2,28 @@ from __future__ import annotations +import os + from beaver_gateway.jobs.scheduler import Job from beaver_agent.jobs.closing import close_idle from beaver_agent.jobs.deploy import deploy from beaver_agent.jobs.komodo import komodo_alert +from beaver_agent.jobs.leisure import tick from beaver_agent.jobs.memory import curate, memory from beaver_agent.jobs.rotation import rotate from beaver_agent.jobs.t3code import t3code_event from beaver_agent.jobs.vibegram import vibegram from beaver_agent.jobs.voice import rotate_voice +LEISURE_OFF = os.environ.get("LEISURE", "").strip().lower() in ("off", "0", "false") +"""Рубильник досуга в env; повседневный выключатель - строка в `мета/бобер/досуг.md`.""" + jobs = [ Job("ротация", rotate, cron="0 * * * *"), + # Не на ":00": там ротация, а лежащий в очереди тик держит мастера занятым + # (`busy`), и она пропускала бы себя каждый час. + *([] if LEISURE_OFF else [Job("досуг", tick, cron="7 * * * *")]), Job("вайбграм", vibegram, cron="*/10 * * * *", critical=False), Job("закрытие", close_idle, cron="20 4 * * *", critical=False), Job("память", memory, cron="30 4 * * 0", critical=False), diff --git a/beaver_agent/jobs/leisure.py b/beaver_agent/jobs/leisure.py new file mode 100644 index 0000000..7ba6d4a --- /dev/null +++ b/beaver_agent/jobs/leisure.py @@ -0,0 +1,279 @@ +"""Ежечасный тик: час принадлежит мне, раскладку суток веду я сам. + +Задания в инжекте нет - только который час, какого он типа и что осталось +от суточной раскладки. Активное окно 12:00-03:59 тикает каждый час, тихое +04:00-11:59 - только те четыре часа, которые названы в плане. План лежит в +`мета/бобер/досуг.md` и пишется моделью; механизм его только читает, поэтому +переписать друг друга они не могут. Сколько досуговых слотов израсходовано, +считается из плана и текущего часа, а не из журнала - хранить нечего. +""" + +from __future__ import annotations + +import logging +import re +from dataclasses import dataclass +from datetime import UTC, datetime, timedelta +from typing import TYPE_CHECKING + +from beaver_agent.vault import LEISURE, VAULT, ZONE, read + +if TYPE_CHECKING: + from beaver_gateway.jobs.scheduler import JobRun + from beaver_gateway.storage.models import Conversation + +_log = logging.getLogger(__name__) + +DAY_START = 12 +"""Сутки досуга начинаются в 12:00: раскладывает их первый тик суток.""" +QUIET_FROM, QUIET_UNTIL = 4, 12 +"""Тихое окно 04:00-11:59 - часы, в которые Бобёр за всё время не написал ни разу.""" +QUIET_TICKS = (4, 6, 8, 10) +MINE_DEFAULT = (13, 15, 18, 21, 0, 2, 6) +MINE_MIN = 7 +"""Меньше семи чистых слотов в сутках не бывает: недостающие механизм доберёт.""" + +BUSY_RETRY = timedelta(minutes=5) +BUSY_ATTEMPTS = 3 +"""Занятому мастеру тик не лезет в середину реплики, а сдвигается внутри часа.""" +FRESH_USER = timedelta(minutes=5) + +WEEKDAYS = ( + "понедельник", + "вторник", + "среда", + "четверг", + "пятница", + "суббота", + "воскресенье", +) +MONTHS = ( + "января", + "февраля", + "марта", + "апреля", + "мая", + "июня", + "июля", + "августа", + "сентября", + "октября", + "ноября", + "декабря", +) + +SWITCH_RE = re.compile(r"^досуг:\s*(\S+)", re.IGNORECASE) +DAY_RE = re.compile(r"^сутки:\s*(\d{4}-\d{2}-\d{2})", re.IGNORECASE) +QUIET_RE = re.compile(r"^тихие часы:\s*(.+)", re.IGNORECASE) +MINE_RE = re.compile(r"^мои часы:\s*(.+)", re.IGNORECASE) +OFF = ("выкл", "off", "нет", "0", "false") + +NO_PLAN = ( + "Расписания на эти сутки нет - работаю по умолчанию. Разложи часы и запиши " + "в `{path}` строками «сутки:», «тихие часы:», «мои часы:»." +) +PLANNING = ( + "Первый тик суток. Разложи оставшиеся часы: какие твои, какие смешанные, " + "какие четыре часа тихого окна оставить. Запиши в `{path}` строками " + "«сутки:», «тихие часы:», «мои часы:». Своих - минимум {min}; запишешь " + "меньше - механизм доберёт сам." +) +TAIL = "Задания нет. Чем занять час - решаешь ты, «ничем» - тоже ответ." +PLAN_PATH = str(LEISURE.relative_to(VAULT)) + + +@dataclass(frozen=True, slots=True) +class Plan: + """Раскладка одних суток: какие тихие часы тикают и какие часы мои.""" + + day: str + quiet: tuple[int, ...] + mine: tuple[int, ...] + written: bool + """План прочитан из файла на эти сутки; иначе он собран по умолчанию.""" + + +def quiet_hour(hour: int) -> bool: + return QUIET_FROM <= hour < QUIET_UNTIL + + +def day_of(now: datetime) -> str: + """Сутки, которым принадлежит момент: они начинаются в 12:00.""" + return (now - timedelta(hours=DAY_START)).date().isoformat() + + +def tick_hours(quiet: tuple[int, ...]) -> tuple[int, ...]: + """Часы суток по порядку, в которые тик вообще приходит.""" + order = ((DAY_START + i) % 24 for i in range(24)) + return tuple(h for h in order if not quiet_hour(h) or h in quiet) + + +def _hours(raw: str) -> tuple[int, ...]: + out: list[int] = [] + for chunk in re.split(r"[,\s]+", raw.strip()): + if chunk.isdigit() and 0 <= int(chunk) < 24 and int(chunk) not in out: + out.append(int(chunk)) + return tuple(out) + + +def _first(text: str, pattern: re.Pattern[str]) -> str | None: + for line in text.splitlines(): + match = pattern.match(line.strip()) + if match: + return match.group(1) + return None + + +def enabled(text: str | None) -> bool: + """Выключено, только если сказано явно: по умолчанию досуг работает.""" + if text is None: + return True + value = _first(text, SWITCH_RE) + return value is None or value.strip().lower() not in OFF + + +def plan_of(text: str | None, day: str) -> Plan: + """План на ``day`` из файла; чего нет или что не сходится - берётся дефолт.""" + quiet, mine, written = QUIET_TICKS, MINE_DEFAULT, False + if text is not None and _first(text, DAY_RE) == day: + written = True + raw_quiet = _first(text, QUIET_RE) + raw_mine = _first(text, MINE_RE) + named = _hours(raw_quiet) if raw_quiet else () + quiet = tuple(h for h in named if quiet_hour(h)) or QUIET_TICKS + mine = _hours(raw_mine) if raw_mine else () + ticks = tick_hours(quiet) + kept = [h for h in mine if h in ticks] + for candidate in (*MINE_DEFAULT, *ticks): + if len(kept) >= MINE_MIN: + break + if candidate in ticks and candidate not in kept: + kept.append(candidate) + order = {h: i for i, h in enumerate(ticks)} + return Plan( + day=day, + quiet=quiet, + mine=tuple(sorted(kept, key=lambda h: order[h])), + written=written, + ) + + +def _stamp(now: datetime) -> str: + return ( + f"{now:%H:%M}, {WEEKDAYS[now.weekday()]} {now.day} {MONTHS[now.month - 1]}, " + f"{ZONE.key}" + ) + + +def _left(now: datetime) -> str: + end = now.replace(hour=DAY_START, minute=0, second=0, microsecond=0) + if end <= now: + end += timedelta(days=1) + hours = round((end - now).total_seconds() / 3600) + return f"Сутки заканчиваются через {hours} ч (в {end:%H:%M %d.%m})." + + +def brief(now: datetime, plan: Plan) -> str: + """Текст инжекта: конверта в таком тёрне нет, время несёт он сам.""" + hour = now.hour + ticks = tick_hours(plan.quiet) + order = {h: i for i, h in enumerate(ticks)} + here = order.get(hour, -1) + mine = hour in plan.mine + spent = [h for h in plan.mine if order[h] < here] + ahead = [h for h in plan.mine if order[h] > here] + lines = [ + f"Тик {_stamp(now)}. Тип: {'досуг' if mine else 'смешанный'}" + + ( + " - час твой, дела Бобра сюда не лезут." + if mine + else " - можно и своё, и его." + ), + _left(now), + "Мои часы: " + + ", ".join(f"{h:02d}" for h in plan.mine) + + f" - израсходовано {len(spent) + (1 if mine else 0)} из {len(plan.mine)}" + + ( + ", впереди " + ", ".join(f"{h:02d}" for h in ahead) + if ahead + else ", впереди больше нет" + ) + + ".", + f"Тихое окно {QUIET_FROM:02d}:00-{QUIET_UNTIL - 1:02d}:59, тикают " + + ", ".join(f"{h:02d}" for h in plan.quiet) + + "; остальные часы там пропускаются.", + ] + if not plan.written: + lines.append(NO_PLAN.format(path=PLAN_PATH)) + if here == 0: + lines.append(PLANNING.format(path=PLAN_PATH, min=MINE_MIN)) + lines.append(TAIL) + return "\n".join(lines) + + +def _aware(value: datetime | None) -> datetime | None: + if value is None: + return None + return value if value.tzinfo is not None else value.replace(tzinfo=UTC) + + +def _wait(master: Conversation, now: datetime) -> bool: + """Не влезать в живую реплику: сдвинуться, а не пропасть.""" + spoke = _aware(master.last_user_activity_at) + return spoke is not None and now - spoke < FRESH_USER + + +async def _riding(run: JobRun, master: Conversation, now: datetime) -> bool: + """Есть ли впереди в этом часе отложенный инжект, с которым стоит слиться.""" + edge = now.replace(minute=0, second=0, microsecond=0) + timedelta(hours=1) + try: + rows = await run.scheduler.scheduled(master) + except Exception: # noqa: BLE001 + _log.exception("досуг: очередь отложенных инжектов не прочиталась") + return False + for row in rows: + raw = row.get("execute_after") + if not isinstance(raw, str): + continue + try: + when = datetime.fromisoformat(raw) + except ValueError: + continue + if now < when < edge: + return True + return False + + +async def tick(run: JobRun) -> None: + """Час пробил: решить, тик ли это, дождаться тишины в мастере и разбудить.""" + text = read(LEISURE) + if not enabled(text): + _log.info("досуг: выключен в %s", LEISURE.name) + return + now = datetime.now(ZONE) + plan = plan_of(text, day_of(now)) + if now.hour not in tick_hours(plan.quiet): + _log.info("досуг: %02d:00 - тихий час без тика", now.hour) + return + master = await run.master() + if master is None: + _log.error("досуг: открытого мастера нет, тик пропал") + return + attempt = int(run.payload.get("attempt", 0)) + if (await run.conversations.busy(master) or _wait(master, now)) and ( + attempt < BUSY_ATTEMPTS + ): + run.payload["attempt"] = attempt + 1 + await run.retry_in(BUSY_RETRY) + _log.info("досуг: мастер занят, сдвиг на %s (попытка %s)", BUSY_RETRY, attempt) + return + riding = await _riding(run, master, now) + await run.inject_master( + brief(now, plan), urgency="normal" if riding else "wake", origin="досуг" + ) + _log.info( + "досуг: тик %02d:00 (%s), слияние с отложенным инжектом: %s", + now.hour, + "досуг" if now.hour in plan.mine else "смешанный", + riding, + ) diff --git a/beaver_agent/vault.py b/beaver_agent/vault.py index b4e9c83..25c4f1a 100644 --- a/beaver_agent/vault.py +++ b/beaver_agent/vault.py @@ -31,6 +31,8 @@ NOTES = BEAVER / "наблюдения" PORTRAIT = NOTES / "Бобёр - наблюдения.md" REPLIES = BEAVER / "реплики" CURATOR_JOURNAL = BEAVER / "куратор.md" +LEISURE = BEAVER / "досуг.md" +"""Раскладка суток по тикам досуга: пишет её модель, механизм только читает.""" STATE = BEAVER / "состояние.md" STATE_MAX_LINES = 60 ATTACHMENTS = BEAVER / "вложения" diff --git a/tests/test_leisure.py b/tests/test_leisure.py new file mode 100644 index 0000000..c9e00d6 --- /dev/null +++ b/tests/test_leisure.py @@ -0,0 +1,123 @@ +from datetime import datetime +from zoneinfo import ZoneInfo + +from beaver_agent.jobs.leisure import ( + MINE_MIN, + QUIET_TICKS, + Plan, + brief, + day_of, + enabled, + plan_of, + quiet_hour, + tick_hours, +) + +ZONE = ZoneInfo("Europe/Warsaw") + + +def at(text: str) -> datetime: + return datetime.fromisoformat(text).replace(tzinfo=ZONE) + + +def test_switch_defaults_to_on_and_only_explicit_off_stops_it(): + assert enabled(None) + assert enabled("# досуг\n\nничего про выключатель") + assert enabled("досуг: вкл") + assert not enabled("# досуг\n\nдосуг: выкл\nсутки: 2026-09-08") + assert not enabled("досуг: off") + + +def test_day_starts_at_noon(): + assert day_of(at("2026-09-08 12:00")) == "2026-09-08" + assert day_of(at("2026-09-08 23:30")) == "2026-09-08" + assert day_of(at("2026-09-09 03:00")) == "2026-09-08" + assert day_of(at("2026-09-09 11:59")) == "2026-09-08" + assert day_of(at("2026-09-09 12:00")) == "2026-09-09" + + +def test_active_window_ticks_hourly_and_quiet_window_only_by_plan(): + hours = tick_hours(QUIET_TICKS) + assert hours[0] == 12 + assert len(hours) == 20 + assert all(h in hours for h in (12, 18, 23, 0, 3)) + assert [h for h in hours if quiet_hour(h)] == [4, 6, 8, 10] + assert 5 not in hours and 11 not in hours + assert tick_hours((5, 7))[-2:] == (5, 7) + + +def test_plan_is_read_from_the_file_for_todays_day_only(): + text = ( + "# досуг\n\n" + "досуг: вкл\n" + "сутки: 2026-09-08\n" + "тихие часы: 05, 07, 09, 11\n" + "мои часы: 13, 16, 19, 22, 01, 05, 09\n" + ) + plan = plan_of(text, "2026-09-08") + assert plan.written + assert plan.quiet == (5, 7, 9, 11) + assert plan.mine == (13, 16, 19, 22, 1, 5, 9) + + stale = plan_of(text, "2026-09-09") + assert not stale.written + assert stale.quiet == QUIET_TICKS + + +def test_missing_plan_falls_back_to_defaults(): + plan = plan_of(None, "2026-09-08") + assert not plan.written + assert plan.quiet == QUIET_TICKS + assert len(plan.mine) == MINE_MIN + assert set(plan.mine) <= set(tick_hours(plan.quiet)) + + +def test_short_plan_is_topped_up_to_the_guaranteed_minimum(): + text = "сутки: 2026-09-08\nмои часы: 14, 20\n" + plan = plan_of(text, "2026-09-08") + assert plan.written + assert len(plan.mine) == MINE_MIN + assert 14 in plan.mine and 20 in plan.mine + assert plan.mine == tuple(sorted(plan.mine, key=tick_hours(plan.quiet).index)) + + +def test_unreachable_hours_are_dropped_from_the_plan(): + text = "сутки: 2026-09-08\nтихие часы: 04, 06, 08, 10\nмои часы: 05, 11, 14\n" + plan = plan_of(text, "2026-09-08") + assert 5 not in plan.mine and 11 not in plan.mine + assert 14 in plan.mine and len(plan.mine) == MINE_MIN + + +def test_brief_names_the_type_and_counts_spent_slots(): + plan = Plan( + day="2026-09-08", + quiet=QUIET_TICKS, + mine=(13, 15, 18, 21, 0, 2, 6), + written=True, + ) + text = brief(at("2026-09-08 18:00"), plan) + assert "Тип: досуг" in text + assert "израсходовано 3 из 7" in text + assert "впереди 21, 00, 02, 06" in text + assert "Сутки заканчиваются через 18 ч" in text + assert "Задания нет" in text + assert "Разложи" not in text + + mixed = brief(at("2026-09-08 19:00"), plan) + assert "Тип: смешанный" in mixed + assert "израсходовано 3 из 7" in mixed + + +def test_first_tick_of_the_day_is_the_planning_one(): + text = brief(at("2026-09-08 12:00"), plan_of(None, "2026-09-08")) + assert "Первый тик суток" in text + assert "расписания на эти сутки нет" in text.lower() + assert str(MINE_MIN) in text + + +def test_last_night_tick_says_nothing_is_left_ahead(): + plan = Plan(day="2026-09-08", quiet=QUIET_TICKS, mine=(13, 6), written=True) + text = brief(at("2026-09-09 06:00"), plan) + assert "впереди больше нет" in text + assert "израсходовано 2 из 2" in text + assert "Сутки заканчиваются через 6 ч" in text