"""Ежечасный тик: час принадлежит мне, раскладку суток веду я сам. Задания в инжекте нет - только который час, какого он типа и что осталось от суточной раскладки. Активное окно 12:00-03:59 тикает каждый час, тихое 04:00-11:59 - только те четыре часа, которые названы в плане. План лежит в `мета/бобер/досуг.md` и пишется моделью; механизм его только читает, поэтому переписать друг друга они не могут. Сколько досуговых слотов израсходовано, считается из плана и текущего часа, а не из журнала - хранить нечего. Два своих часа в сутках пустые: дела в них не заходят вовсе, и к тику приложено эхо - абзац из его же старого дневника или из мыслей Бобра. Тик называет и файлы дня: дневник для того, что он думает, журнал для следов. """ from __future__ import annotations import logging import random import re from dataclasses import dataclass from datetime import UTC, datetime, timedelta from typing import TYPE_CHECKING from beaver_agent.vault import JOURNAL, LEISURE, OWN_DIARY, THOUGHTS, VAULT, ZONE, read if TYPE_CHECKING: from pathlib import Path 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 """Меньше семи чистых слотов в сутках не бывает: недостающие механизм доберёт.""" EMPTY_DEFAULT = (18, 0) EMPTY_MIN = 2 """Столько своих часов в сутках пустые; не названы в плане - механизм выберет.""" ECHO_MIN, ECHO_MAX = 200, 1200 """Абзац для эха: короче - реплика без мысли, длиннее - уже чтение, а не повод. Абзац с кодом или путём в бэктиках - рабочая запись, эхом он не становится.""" ECHO_AGE = timedelta(days=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) EMPTY_RE = re.compile(r"^пустые часы:\s*(.+)", re.IGNORECASE) OFF = ("выкл", "off", "нет", "0", "false") NO_PLAN = ( "Расписания на эти сутки нет - работаю по умолчанию. Разложи часы и запиши " "в `{path}` строками «сутки:», «тихие часы:», «мои часы:», «пустые часы:»." ) PLANNING = ( "Первый тик суток. Разложи оставшиеся часы: какие твои, какие смешанные, " "какие четыре часа тихого окна оставить. Запиши в `{path}` строками " "«сутки:», «тихие часы:», «мои часы:», «пустые часы:». Своих - минимум {min}, " "из них пустых - {empty}; запишешь меньше - механизм доберёт сам." ) TAIL = "Задания нет. Чем занять час - решаешь ты, «ничем» - тоже ответ." KINDS = { "mine": "досуг - час твой, дела Бобра сюда не лезут.", "mixed": "смешанный - можно и своё, и его.", "empty": ( "пустой - час без дел вообще: прод, логи, хвосты, зонды и разбор " "промахов сюда не заходят, ни его, ни твои. Думать, читать, писать - " "или ничего." ), } FILES = ( "Дневник - `{diary}`: то, что ты думаешь, и только оно. След тика, логи " "и промахи - строкой в `{journal}`." ) ECHO = "Эхо - `{path}`:\n{quote}\nЭто повод, не задание: можно спорить, можно мимо." PLAN_PATH = str(LEISURE.relative_to(VAULT)) @dataclass(frozen=True, slots=True) class Plan: """Раскладка одних суток: какие тихие часы тикают и какие часы мои.""" day: str quiet: tuple[int, ...] mine: tuple[int, ...] written: bool """План прочитан из файла на эти сутки; иначе он собран по умолчанию.""" empty: tuple[int, ...] = () """Свои часы, в которые дела не заходят вовсе; всегда часть ``mine``.""" 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 empty: tuple[int, ...] = () 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) raw_empty = _first(text, EMPTY_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 () empty = _hours(raw_empty) if raw_empty 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)} hollow = [h for h in empty if h in kept] for candidate in (*EMPTY_DEFAULT, *reversed(kept)): if len(hollow) >= EMPTY_MIN: break if candidate in kept and candidate not in hollow: hollow.append(candidate) return Plan( day=day, quiet=quiet, mine=tuple(sorted(kept, key=lambda h: order[h])), written=written, empty=tuple(sorted(hollow, key=lambda h: order[h])), ) 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 kind_of(hour: int, plan: Plan) -> str: if hour in plan.empty: return "empty" return "mine" if hour in plan.mine else "mixed" def echo( now: datetime, *, diary: Path = OWN_DIARY, thoughts: Path = THOUGHTS ) -> tuple[Path, str] | None: """Абзац для пустого часа: из своего старого дневника или из мыслей Бобра. Выбор случаен, но привязан к часу тика: тот же тик даёт то же эхо. """ stale = f"{(now - ECHO_AGE):%Y-%m-%d}.md" own = [p for p in diary.glob("????-??-??.md") if p.name < stale] files = sorted([*own, *thoughts.rglob("*.md")]) rng = random.Random(f"{now:%Y-%m-%d %H}") # noqa: S311 rng.shuffle(files) for path in files: try: text = path.read_text(encoding="utf-8") except OSError: continue found = [ block.strip() for block in re.split(r"\n\s*\n", text) if ECHO_MIN <= len(block.strip()) <= ECHO_MAX and block.lstrip()[:1] not in "#->|" and "`" not in block ] if found: return path, rng.choice(found) return None def _files(now: datetime) -> str: name = f"{now:%Y-%m-%d}.md" return FILES.format( diary=(OWN_DIARY / name).relative_to(VAULT), journal=(JOURNAL / name).relative_to(VAULT), ) def brief(now: datetime, plan: Plan, *, heard: tuple[Path, str] | None = None) -> 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)}. Тип: {KINDS[kind_of(hour, plan)]}", _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 ", впереди больше нет" ) + ( ". Пустые: " + ", ".join(f"{h:02d}" for h in plan.empty) if plan.empty 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, empty=EMPTY_MIN)) lines.append(_files(now)) if heard is not None: path, quote = heard lines.append( ECHO.format( path=path.relative_to(VAULT), quote="\n".join(f"> {line}" for line in quote.splitlines()), ) ) 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) kind = kind_of(now.hour, plan) heard = echo(now) if kind == "empty" else None await run.inject_master( brief(now, plan, heard=heard), urgency="normal" if riding else "wake", origin="досуг", ) _log.info( "досуг: тик %02d:00 (%s), слияние с отложенным инжектом: %s", now.hour, kind, riding, )