feat(jobs,vault): hourly leisure tick, the day plan lives in the vault
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
Reference in New Issue
Block a user