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