Files
beaver-agent/beaver_agent/jobs/leisure.py
T

280 lines
11 KiB
Python

"""Ежечасный тик: час принадлежит мне, раскладку суток веду я сам.
Задания в инжекте нет - только который час, какого он типа и что осталось
от суточной раскладки. Активное окно 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,
)