feat(hands,memory): the recall carries the next week of both calendars daily

This commit is contained in:
hh
2026-09-09 08:21:03 +00:00
parent f3a625087f
commit 8d0f80440e
7 changed files with 775 additions and 4 deletions
+60 -1
View File
@@ -3,7 +3,8 @@
Gateway путей не знает; здесь - что искать по тексту сообщения и куда писать
сказанное. ``Recall.block`` отдаёт указатели, не выводы: карточка упомянутого
человека, его записки о нём (`мета/бобер/наблюдения/<имя> - наблюдения.md`),
дни дневника, где он встречался, и раз в день - подошедшие сроки из досок.
дни дневника, где он встречался, и раз в день - подошедшие сроки из досок и
ближайшая неделя календарей.
``ReplyLog.write`` кладёт каждую реплику Бобра из мастера и веток в
`мета/бобер/реплики/YYYY-MM.md` - единственное место, где они grep-абельны:
транскрипт живёт в Postgres, а глубокие чаты и так лежат файлами в `💬 чаты/`.
@@ -11,6 +12,7 @@ Gateway путей не знает; здесь - что искать по тек
from __future__ import annotations
import logging
import re
import time
from dataclasses import dataclass, field
@@ -19,14 +21,22 @@ from typing import TYPE_CHECKING
from zoneinfo import ZoneInfo
if TYPE_CHECKING:
from datetime import datetime
from pathlib import Path
from beaver_gateway.conversations.envelope import RecallContext
from beaver_gateway.conversations.texts import UserSaid
from beaver_agent.hands.calendars import Calendars, Event, Upcoming
_log = logging.getLogger(__name__)
LABEL = "справка (указатели по сообщению, не выводы; открой, если относится к вопросу):"
VOWELS = "аеёиоуыэюя"
KINDS = {"master": "мастер", "branch": "ветка"}
CALENDAR = "🗓 календарь"
CALENDAR_LIMIT = 15
CALENDAR_ERROR_MAX = 90
@dataclass(frozen=True, slots=True)
@@ -200,11 +210,14 @@ class Recall:
"""Сколько последних дней дневника просматривать на упоминания."""
mute: frozenset[str] = frozenset()
"""Агенты, которым справку не показывают: чужие люди, чужие уши."""
calendar: Calendars | None = None
"""Ical-фиды календарей; None - календари не настроены, секции нет вовсе."""
_people: People | None = field(default=None, init=False, repr=False)
_diary_cache: dict[str, tuple[float, list[str]]] = field(
default_factory=dict, init=False, repr=False
)
_deadlines_day: date | None = field(default=None, init=False, repr=False)
_calendar_day: date | None = field(default=None, init=False, repr=False)
@property
def people(self) -> People:
@@ -219,6 +232,7 @@ class Recall:
lines = [self.person_line(p) for p in mentions(ctx.text, self.people.all())]
if ctx.kind == "master":
lines += self.deadlines(today)
lines += self.calendar_lines(ctx.now, today)
if not lines:
return None
return "\n".join([LABEL, *lines])
@@ -302,6 +316,51 @@ class Recall:
found.sort()
return [f"📆 {due:%m-%d} {board}: {task}" for due, board, task in found[:limit]]
def calendar_lines(self, now: datetime, today: date) -> list[str]:
"""Ближайшая неделя календарей - раз в день, следом за сроками досок.
Секция печатается всегда, когда до неё дошло: пустое окно - строкой
«событий нет», упавший фид - строкой «ОШИБКА» и записью в лог. Молча
пропасть она не может, иначе отказ канала неотличим от спокойного
календаря - так однажды и пролежала незамеченной сессия в USOS.
"""
if self.calendar is None or self._calendar_day == today:
return []
self._calendar_day = today
try:
found = self.calendar.upcoming(now)
except Exception as exc: # noqa: BLE001
_log.exception("календарь: справка не собралась")
reason = " ".join(f"{type(exc).__name__}: {exc}".split())
return [f"{CALENDAR}: ОШИБКА {reason[:CALENDAR_ERROR_MAX]}"]
lines = [f"{CALENDAR}: ОШИБКА {reason}" for reason in found.errors]
if found.read:
lines += self.calendar_window(found, self.calendar.days)
return lines or [f"{CALENDAR}: ОШИБКА фиды не ответили"]
def calendar_window(self, found: Upcoming, days: int) -> list[str]:
"""Окно ответивших фидов: заголовок с временем данных и события строками."""
stamp = f"{found.at:%Y-%m-%d %H:%M}"
if not found.events:
return [f"{CALENDAR} ({days} дней): событий нет, данные на {stamp}"]
zone = ZoneInfo(self.tz)
shown = found.events[:CALENDAR_LIMIT]
lines = [
f"{CALENDAR} ({days} дней), событий {len(found.events)}, данные на {stamp}:"
]
lines += [f" - {event_line(event, zone)}" for event in shown]
if len(found.events) > len(shown):
lines.append(f" … и ещё {len(found.events) - len(shown)} в окне")
return lines
def event_line(event: Event, zone: ZoneInfo) -> str:
"""Дата, начало, название и место - одной строкой в зоне агента."""
start = event.start.astimezone(zone)
when = "весь день" if event.all_day else f"{start:%H:%M}"
place = f" · {event.place}" if event.place else ""
return f"{start:%m-%d} {when} {event.title}{place}"
@dataclass
class ReplyLog: