feat(jobs): leisure ticks name the day's diary and journal, two own hours stay empty and carry an echo

This commit is contained in:
hh committed 2026-10-13 01:23:20 +02:00
1 parent dea793dbd7
commit eba0cc6168
2 files changed
+196 -14

No files matched your search

+113 -14
View File
@@ -6,19 +6,26 @@
`мета/бобер/досуг.md` и пишется моделью; механизм его только читает, поэтому `мета/бобер/досуг.md` и пишется моделью; механизм его только читает, поэтому
переписать друг друга они не могут. Сколько досуговых слотов израсходовано, переписать друг друга они не могут. Сколько досуговых слотов израсходовано,
считается из плана и текущего часа, а не из журнала - хранить нечего. считается из плана и текущего часа, а не из журнала - хранить нечего.
Два своих часа в сутках пустые: дела в них не заходят вовсе, и к тику
приложено эхо - абзац из его же старого дневника или из мыслей Бобра. Тик
называет и файлы дня: дневник для того, что он думает, журнал для следов.
""" """
from __future__ import annotations from __future__ import annotations
import logging import logging
import random
import re import re
from dataclasses import dataclass from dataclasses import dataclass
from datetime import UTC, datetime, timedelta from datetime import UTC, datetime, timedelta
from typing import TYPE_CHECKING from typing import TYPE_CHECKING
from beaver_agent.vault import LEISURE, VAULT, ZONE, read from beaver_agent.vault import JOURNAL, LEISURE, OWN_DIARY, THOUGHTS, VAULT, ZONE, read
if TYPE_CHECKING: if TYPE_CHECKING:
from pathlib import Path
from beaver_gateway.jobs.scheduler import JobRun from beaver_gateway.jobs.scheduler import JobRun
from beaver_gateway.storage.models import Conversation from beaver_gateway.storage.models import Conversation
@@ -32,6 +39,15 @@ QUIET_TICKS = (4, 6, 8, 10)
MINE_DEFAULT = (13, 15, 18, 21, 0, 2, 6) MINE_DEFAULT = (13, 15, 18, 21, 0, 2, 6)
MINE_MIN = 7 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_RETRY = timedelta(minutes=5)
BUSY_ATTEMPTS = 3 BUSY_ATTEMPTS = 3
@@ -66,19 +82,34 @@ SWITCH_RE = re.compile(r"^досуг:\s*(\S+)", re.IGNORECASE)
DAY_RE = re.compile(r"^сутки:\s*(\d{4}-\d{2}-\d{2})", re.IGNORECASE) DAY_RE = re.compile(r"^сутки:\s*(\d{4}-\d{2}-\d{2})", re.IGNORECASE)
QUIET_RE = re.compile(r"^тихие часы:\s*(.+)", re.IGNORECASE) QUIET_RE = re.compile(r"^тихие часы:\s*(.+)", re.IGNORECASE)
MINE_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") OFF = ("выкл", "off", "нет", "0", "false")
NO_PLAN = ( NO_PLAN = (
"Расписания на эти сутки нет - работаю по умолчанию. Разложи часы и запиши " "Расписания на эти сутки нет - работаю по умолчанию. Разложи часы и запиши "
"в `{path}` строками «сутки:», «тихие часы:», «мои часы:»." "в `{path}` строками «сутки:», «тихие часы:», «мои часы:», «пустые часы:»."
) )
PLANNING = ( PLANNING = (
"Первый тик суток. Разложи оставшиеся часы: какие твои, какие смешанные, " "Первый тик суток. Разложи оставшиеся часы: какие твои, какие смешанные, "
"какие четыре часа тихого окна оставить. Запиши в `{path}` строками " "какие четыре часа тихого окна оставить. Запиши в `{path}` строками "
"«сутки:», «тихие часы:», «мои часы:». Своих - минимум {min}; запишешь " "«сутки:», «тихие часы:», «мои часы:», «пустые часы:». Своих - минимум {min}, "
"меньше - механизм доберёт сам." "из них пустых - {empty}; запишешь меньше - механизм доберёт сам."
) )
TAIL = "Задания нет. Чем занять час - решаешь ты, «ничем» - тоже ответ." TAIL = "Задания нет. Чем занять час - решаешь ты, «ничем» - тоже ответ."
KINDS = {
"mine": "досуг - час твой, дела Бобра сюда не лезут.",
"mixed": "смешанный - можно и своё, и его.",
"empty": (
"пустой - час без дел вообще: прод, логи, хвосты, зонды и разбор "
"промахов сюда не заходят, ни его, ни твои. Думать, читать, писать - "
"или ничего."
),
}
FILES = (
"Дневник - `{diary}`: то, что ты думаешь, и только оно. След тика, логи "
"и промахи - строкой в `{journal}`."
)
ECHO = "Эхо - `{path}`:\n{quote}\nЭто повод, не задание: можно спорить, можно мимо."
PLAN_PATH = str(LEISURE.relative_to(VAULT)) PLAN_PATH = str(LEISURE.relative_to(VAULT))
@@ -91,6 +122,8 @@ class Plan:
mine: tuple[int, ...] mine: tuple[int, ...]
written: bool written: bool
"""План прочитан из файла на эти сутки; иначе он собран по умолчанию.""" """План прочитан из файла на эти сутки; иначе он собран по умолчанию."""
empty: tuple[int, ...] = ()
"""Свои часы, в которые дела не заходят вовсе; всегда часть ``mine``."""
def quiet_hour(hour: int) -> bool: def quiet_hour(hour: int) -> bool:
@@ -135,13 +168,16 @@ def enabled(text: str | None) -> bool:
def plan_of(text: str | None, day: str) -> Plan: def plan_of(text: str | None, day: str) -> Plan:
"""План на ``day`` из файла; чего нет или что не сходится - берётся дефолт.""" """План на ``day`` из файла; чего нет или что не сходится - берётся дефолт."""
quiet, mine, written = QUIET_TICKS, MINE_DEFAULT, False quiet, mine, written = QUIET_TICKS, MINE_DEFAULT, False
empty: tuple[int, ...] = ()
if text is not None and _first(text, DAY_RE) == day: if text is not None and _first(text, DAY_RE) == day:
written = True written = True
raw_quiet = _first(text, QUIET_RE) raw_quiet = _first(text, QUIET_RE)
raw_mine = _first(text, MINE_RE) raw_mine = _first(text, MINE_RE)
raw_empty = _first(text, EMPTY_RE)
named = _hours(raw_quiet) if raw_quiet else () named = _hours(raw_quiet) if raw_quiet else ()
quiet = tuple(h for h in named if quiet_hour(h)) or QUIET_TICKS quiet = tuple(h for h in named if quiet_hour(h)) or QUIET_TICKS
mine = _hours(raw_mine) if raw_mine else () mine = _hours(raw_mine) if raw_mine else ()
empty = _hours(raw_empty) if raw_empty else ()
ticks = tick_hours(quiet) ticks = tick_hours(quiet)
kept = [h for h in mine if h in ticks] kept = [h for h in mine if h in ticks]
for candidate in (*MINE_DEFAULT, *ticks): for candidate in (*MINE_DEFAULT, *ticks):
@@ -150,11 +186,18 @@ def plan_of(text: str | None, day: str) -> Plan:
if candidate in ticks and candidate not in kept: if candidate in ticks and candidate not in kept:
kept.append(candidate) kept.append(candidate)
order = {h: i for i, h in enumerate(ticks)} 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( return Plan(
day=day, day=day,
quiet=quiet, quiet=quiet,
mine=tuple(sorted(kept, key=lambda h: order[h])), mine=tuple(sorted(kept, key=lambda h: order[h])),
written=written, written=written,
empty=tuple(sorted(hollow, key=lambda h: order[h])),
) )
@@ -173,7 +216,50 @@ def _left(now: datetime) -> str:
return f"Сутки заканчиваются через {hours} ч (в {end:%H:%M %d.%m})." return f"Сутки заканчиваются через {hours} ч (в {end:%H:%M %d.%m})."
def brief(now: datetime, plan: Plan) -> str: 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 hour = now.hour
ticks = tick_hours(plan.quiet) ticks = tick_hours(plan.quiet)
@@ -183,12 +269,7 @@ def brief(now: datetime, plan: Plan) -> str:
spent = [h for h in plan.mine if order[h] < here] spent = [h for h in plan.mine if order[h] < here]
ahead = [h for h in plan.mine if order[h] > here] ahead = [h for h in plan.mine if order[h] > here]
lines = [ lines = [
f"Тик {_stamp(now)}. Тип: {'досуг' if mine else 'смешанный'}" f"Тик {_stamp(now)}. Тип: {KINDS[kind_of(hour, plan)]}",
+ (
" - час твой, дела Бобра сюда не лезут."
if mine
else " - можно и своё, и его."
),
_left(now), _left(now),
"Мои часы: " "Мои часы: "
+ ", ".join(f"{h:02d}" for h in plan.mine) + ", ".join(f"{h:02d}" for h in plan.mine)
@@ -198,6 +279,11 @@ def brief(now: datetime, plan: Plan) -> str:
if ahead if ahead
else ", впереди больше нет" else ", впереди больше нет"
) )
+ (
". Пустые: " + ", ".join(f"{h:02d}" for h in plan.empty)
if plan.empty
else ""
)
+ ".", + ".",
f"Тихое окно {QUIET_FROM:02d}:00-{QUIET_UNTIL - 1:02d}:59, тикают " f"Тихое окно {QUIET_FROM:02d}:00-{QUIET_UNTIL - 1:02d}:59, тикают "
+ ", ".join(f"{h:02d}" for h in plan.quiet) + ", ".join(f"{h:02d}" for h in plan.quiet)
@@ -206,7 +292,16 @@ def brief(now: datetime, plan: Plan) -> str:
if not plan.written: if not plan.written:
lines.append(NO_PLAN.format(path=PLAN_PATH)) lines.append(NO_PLAN.format(path=PLAN_PATH))
if here == 0: if here == 0:
lines.append(PLANNING.format(path=PLAN_PATH, min=MINE_MIN)) 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) lines.append(TAIL)
return "\n".join(lines) return "\n".join(lines)
@@ -268,12 +363,16 @@ async def tick(run: JobRun) -> None:
_log.info("досуг: мастер занят, сдвиг на %s (попытка %s)", BUSY_RETRY, attempt) _log.info("досуг: мастер занят, сдвиг на %s (попытка %s)", BUSY_RETRY, attempt)
return return
riding = await _riding(run, master, now) riding = await _riding(run, master, now)
kind = kind_of(now.hour, plan)
heard = echo(now) if kind == "empty" else None
await run.inject_master( await run.inject_master(
brief(now, plan), urgency="normal" if riding else "wake", origin="досуг" brief(now, plan, heard=heard),
urgency="normal" if riding else "wake",
origin="досуг",
) )
_log.info( _log.info(
"досуг: тик %02d:00 (%s), слияние с отложенным инжектом: %s", "досуг: тик %02d:00 (%s), слияние с отложенным инжектом: %s",
now.hour, now.hour,
"досуг" if now.hour in plan.mine else "смешанный", kind,
riding, riding,
) )
+83
View File
@@ -1,17 +1,22 @@
from datetime import datetime from datetime import datetime
from pathlib import Path
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from beaver_agent.jobs.leisure import ( from beaver_agent.jobs.leisure import (
EMPTY_DEFAULT,
MINE_MIN, MINE_MIN,
QUIET_TICKS, QUIET_TICKS,
Plan, Plan,
brief, brief,
day_of, day_of,
echo,
enabled, enabled,
kind_of,
plan_of, plan_of,
quiet_hour, quiet_hour,
tick_hours, tick_hours,
) )
from beaver_agent.vault import VAULT
ZONE = ZoneInfo("Europe/Warsaw") ZONE = ZoneInfo("Europe/Warsaw")
@@ -121,3 +126,81 @@ def test_last_night_tick_says_nothing_is_left_ahead():
assert "впереди больше нет" in text assert "впереди больше нет" in text
assert "израсходовано 2 из 2" in text assert "израсходовано 2 из 2" in text
assert "Сутки заканчиваются через 6 ч" in text assert "Сутки заканчиваются через 6 ч" in text
def test_two_of_my_hours_are_empty_named_or_not():
named = plan_of(
"сутки: 2026-09-08\nмои часы: 13, 15, 18, 21, 00, 02, 06\n"
"пустые часы: 21, 02\n",
"2026-09-08",
)
assert named.empty == (21, 2)
unnamed = plan_of(None, "2026-09-08")
assert unnamed.empty == EMPTY_DEFAULT
assert set(unnamed.empty) <= set(unnamed.mine)
foreign = plan_of(
"сутки: 2026-09-08\nмои часы: 13, 14, 15, 16, 17, 19, 20\n"
"пустые часы: 22, 20\n",
"2026-09-08",
)
assert foreign.empty == (19, 20)
assert kind_of(20, foreign) == "empty"
assert kind_of(13, foreign) == "mine"
assert kind_of(21, foreign) == "mixed"
def test_an_empty_hour_carries_no_work_but_an_echo_and_the_files_of_the_day():
plan = plan_of(None, "2026-09-08")
heard = (VAULT / "мета" / "бобер" / "дневник" / "2026-08-30.md", "Раз.\nДва.")
text = brief(at("2026-09-08 18:00"), plan, heard=heard)
assert "Тип: пустой - час без дел вообще" in text
assert "Пустые: 18, 00." in text
assert "Эхо - `мета/бобер/дневник/2026-08-30.md`:\n> Раз.\n> Два.\n" in text
assert "Дневник - `мета/бобер/дневник/2026-09-08.md`" in text
assert "`мета/бобер/журнал/2026-09-08.md`" in text
night = brief(at("2026-09-09 00:00"), plan)
assert "Тип: пустой" in night
assert "мета/бобер/дневник/2026-09-09.md" in night
assert "Эхо" not in night
mine = brief(at("2026-09-08 13:00"), plan)
assert "Тип: досуг" in mine
assert "мета/бобер/журнал/2026-09-08.md" in mine
def test_echo_is_a_paragraph_of_an_old_diary_day_or_a_thought(tmp_path: Path):
diary = tmp_path / "дневник"
thoughts = tmp_path / "мысли" / "2026"
diary.mkdir()
thoughts.mkdir(parents=True)
old = "Думал про то, зачем мне вообще помнить вчерашний день. " * 5
fresh = "Свежая запись, которой ещё рано возвращаться ко мне эхом. " * 5
(diary / "2026-08-30.md").write_text(
f"## 2026-08-30, 01:07\n\n{old}\n\n- 02:07 30.08 лог, а не мысль {'x' * 220}\n"
"\nкоротко\n",
encoding="utf-8",
)
(diary / "2026-09-07.md").write_text(fresh, encoding="utf-8")
now = at("2026-09-08 18:00")
assert echo(now, diary=diary, thoughts=thoughts) == (
diary / "2026-08-30.md",
old.strip(),
)
thought = "Заметка Бобра о том, почему скука полезнее расписания. " * 5
(diary / "2026-08-30.md").unlink()
(thoughts / "скука.md").write_text(
f"---\ntags: [мысли]\n---\n\n{thought}\n", encoding="utf-8"
)
assert echo(now, diary=diary, thoughts=thoughts) == (
thoughts / "скука.md",
thought.strip(),
)
assert echo(now, diary=diary, thoughts=thoughts) == echo(
now, diary=diary, thoughts=thoughts
)
(thoughts / "скука.md").unlink()
assert echo(now, diary=diary, thoughts=thoughts) is None