From eba0cc6168efb37cd03ee6cc0591bcd93574f3a7 Mon Sep 17 00:00:00 2001 From: h Date: Tue, 13 Oct 2026 01:23:20 +0200 Subject: [PATCH] feat(jobs): leisure ticks name the day's diary and journal, two own hours stay empty and carry an echo --- beaver_agent/jobs/leisure.py | 127 +++++++++++++++++++++++++++++++---- tests/test_leisure.py | 83 +++++++++++++++++++++++ 2 files changed, 196 insertions(+), 14 deletions(-) diff --git a/beaver_agent/jobs/leisure.py b/beaver_agent/jobs/leisure.py index 7ba6d4a..97fc1b8 100644 --- a/beaver_agent/jobs/leisure.py +++ b/beaver_agent/jobs/leisure.py @@ -6,19 +6,26 @@ `мета/бобер/досуг.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 LEISURE, VAULT, ZONE, read +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 @@ -32,6 +39,15 @@ 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 @@ -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) 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}` строками «сутки:», «тихие часы:», «мои часы:»." + "в `{path}` строками «сутки:», «тихие часы:», «мои часы:», «пустые часы:»." ) PLANNING = ( "Первый тик суток. Разложи оставшиеся часы: какие твои, какие смешанные, " "какие четыре часа тихого окна оставить. Запиши в `{path}` строками " - "«сутки:», «тихие часы:», «мои часы:». Своих - минимум {min}; запишешь " - "меньше - механизм доберёт сам." + "«сутки:», «тихие часы:», «мои часы:», «пустые часы:». Своих - минимум {min}, " + "из них пустых - {empty}; запишешь меньше - механизм доберёт сам." ) TAIL = "Задания нет. Чем занять час - решаешь ты, «ничем» - тоже ответ." +KINDS = { + "mine": "досуг - час твой, дела Бобра сюда не лезут.", + "mixed": "смешанный - можно и своё, и его.", + "empty": ( + "пустой - час без дел вообще: прод, логи, хвосты, зонды и разбор " + "промахов сюда не заходят, ни его, ни твои. Думать, читать, писать - " + "или ничего." + ), +} +FILES = ( + "Дневник - `{diary}`: то, что ты думаешь, и только оно. След тика, логи " + "и промахи - строкой в `{journal}`." +) +ECHO = "Эхо - `{path}`:\n{quote}\nЭто повод, не задание: можно спорить, можно мимо." PLAN_PATH = str(LEISURE.relative_to(VAULT)) @@ -91,6 +122,8 @@ class Plan: mine: tuple[int, ...] written: bool """План прочитан из файла на эти сутки; иначе он собран по умолчанию.""" + empty: tuple[int, ...] = () + """Свои часы, в которые дела не заходят вовсе; всегда часть ``mine``.""" 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: """План на ``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): @@ -150,11 +186,18 @@ def plan_of(text: str | None, day: str) -> Plan: 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])), ) @@ -173,7 +216,50 @@ def _left(now: datetime) -> str: 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 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] ahead = [h for h in plan.mine if order[h] > here] lines = [ - f"Тик {_stamp(now)}. Тип: {'досуг' if mine else 'смешанный'}" - + ( - " - час твой, дела Бобра сюда не лезут." - if mine - else " - можно и своё, и его." - ), + f"Тик {_stamp(now)}. Тип: {KINDS[kind_of(hour, plan)]}", _left(now), "Мои часы: " + ", ".join(f"{h:02d}" for h in plan.mine) @@ -198,6 +279,11 @@ def brief(now: datetime, plan: Plan) -> str: 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) @@ -206,7 +292,16 @@ def brief(now: datetime, plan: Plan) -> str: 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(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) @@ -268,12 +363,16 @@ async def tick(run: JobRun) -> None: _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), urgency="normal" if riding else "wake", origin="досуг" + brief(now, plan, heard=heard), + urgency="normal" if riding else "wake", + origin="досуг", ) _log.info( "досуг: тик %02d:00 (%s), слияние с отложенным инжектом: %s", now.hour, - "досуг" if now.hour in plan.mine else "смешанный", + kind, riding, ) diff --git a/tests/test_leisure.py b/tests/test_leisure.py index c9e00d6..909ce92 100644 --- a/tests/test_leisure.py +++ b/tests/test_leisure.py @@ -1,17 +1,22 @@ from datetime import datetime +from pathlib import Path from zoneinfo import ZoneInfo from beaver_agent.jobs.leisure import ( + EMPTY_DEFAULT, MINE_MIN, QUIET_TICKS, Plan, brief, day_of, + echo, enabled, + kind_of, plan_of, quiet_hour, tick_hours, ) +from beaver_agent.vault import VAULT ZONE = ZoneInfo("Europe/Warsaw") @@ -121,3 +126,81 @@ def test_last_night_tick_says_nothing_is_left_ahead(): assert "впереди больше нет" in text assert "израсходовано 2 из 2" 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