Рефакторинг рендера [этап 3/6]: слияние generate_channel_rss/html вокруг _prepare_feed_posts #30

Closed
opened 2026-07-05 20:27:13 +03:00 by agent_vscode · 1 comment
Collaborator

Часть эпика «Рефакторинг пайплайна рендера». Спека: plans/2026-07-05-render-pipeline-refactor.md (v5), раздел «Этап 3». Зависимость: этап 2 (#29). Line-refs действительны для f9550d8.

Цель

generate_channel_rss и generate_channel_html — близнецы на ~80% (branch:347–527 и 578–717): валидация, get_chat с одинаковой обработкой ошибок, история, вызов _render_pipeline, flush — скопированы построчно. Выделить общий препроцессинг; форматтеры становятся тонкими. Поведенческие различия путей (глубина истории, reply-enrichment) сохраняются как ЯВНЫЕ параметры.

Пункты реестра §3, фиксируемые этим этапом

  • §3.9 (фикс HTTP): FloodWait при получении ИСТОРИИ пробрасывается → 429 (сейчас except Exception оборачивает его в ValueError → 400; маппинги в api_server.py: ValueError→400 — :1334–1337, FloodWait→429 — уже есть и работает для get_chat-пути).
  • §3.10 (минорное): тексты detail в 400-ответах унифицируются (исчезает суффикс «in HTML generation»).

Конструкция

@dataclass
class PreparedFeed:
    channel_username: str
    channel_title: str            # used by RSS metadata only
    posts: list[dict]             # rendered, filtered, sorted, SANITIZED

class ChannelNotFound(Exception):
    def __init__(self, channel_identifier):   # prepared id, for create_error_feed
        self.channel_identifier = channel_identifier

# NO timed() abstraction: keep today's explicit logger.debug lines, prefixed
# via log_prefix — an abstraction could not carry the paired rss_/html_ names
# nor the extra context (message counts).

async def _prepare_feed_posts(channel, client, *,
                              limit, exclude_flags, exclude_text, merge_seconds,
                              history_limit, enrich_replies: bool,
                              log_prefix: str) -> PreparedFeed:
    # log_prefix: 'rss' | 'html' — preserves today's paired log-line names
    # (f"{log_prefix}_channel_info_timing", f"{log_prefix}_messages_retrieval_timing", ...)
    # 1) validate limit (1..200)
    # 2) channel_name_prepare + cached_get_chat:
    #    UsernameInvalid/UsernameNotOccupied/no-username -> ChannelNotFound(prepared)
    #    FloodWait -> re-raise; other errors -> ValueError chain (unified text, §3.10)
    # 3) cached_get_chat_history(limit=history_limit):
    #    FloodWait -> re-raise BEFORE the ValueError wrap (§3.9)
    # NOTE: keep `from tg_cache import ...` INSIDE this function — feed tests
    # monkeypatch tg_cache and rely on late name resolution.
    # 4) if enrich_replies: messages = await _reply_enrichment(client, messages)
    # 5) try: to_thread(_render_pipeline, ...) finally: flush_pending_media_ids()

Форматтеры:

  • generate_channel_rss: history_limit=limit*2, enrich_replies=False, log_prefix="rss" (RSS over-fetches so merging still yields ~limit posts) → fg-метаданные + entries (rss_date_range-лог сохраняется) → to_thread(fg.rss_str).
  • generate_channel_html: history_limit=limit, enrich_replies=True, log_prefix="html" (enrichment is HTML-only to keep RSS polling cheap — deliberate) → join с <hr>.
  • Оба: except ChannelNotFound as e: return create_error_feed(str(e.channel_identifier), base_url) — байт-эквивалентно текущему (prepared-значение используется и сегодня; int/str интерполируются одинаково).
  • Внешние catch-логи ОСТАЮТСЯ в форматтерах со своими именами (generate_channel_rss: … — branch:526, html_generation_error: … — :716): каждый форматтер сохраняет собственный внешний try/except.

Тесты

Golden БЕЗ изменений — главный критерий эквивалентности этапа. Новые: ChannelNotFound → error feed в обоих путях; FloodWait из get_chat → 429; FloodWait из истории → 429 (новый, §3.9); прочие ошибки истории → ValueError → 400.

DoD

Один блок get_chat/get_history/render вместо двух; diffstat rss_generator отрицательный; pytest + golden зелёные (golden-дифф пустой).

Общие правила эпика

Ветка refactor/render-pipeline от f9550d8; один этап = один коммит; комментарии — English; точечные правки.

Часть эпика «Рефакторинг пайплайна рендера». Спека: `plans/2026-07-05-render-pipeline-refactor.md` (v5), раздел «Этап 3». Зависимость: этап 2 (#29). Line-refs действительны для f9550d8. ## Цель `generate_channel_rss` и `generate_channel_html` — близнецы на ~80% (branch:347–527 и 578–717): валидация, get_chat с одинаковой обработкой ошибок, история, вызов `_render_pipeline`, flush — скопированы построчно. Выделить общий препроцессинг; форматтеры становятся тонкими. Поведенческие различия путей (глубина истории, reply-enrichment) сохраняются как ЯВНЫЕ параметры. ## Пункты реестра §3, фиксируемые этим этапом - §3.9 (фикс HTTP): FloodWait при получении ИСТОРИИ пробрасывается → 429 (сейчас `except Exception` оборачивает его в ValueError → 400; маппинги в api_server.py: ValueError→400 — :1334–1337, FloodWait→429 — уже есть и работает для get_chat-пути). - §3.10 (минорное): тексты `detail` в 400-ответах унифицируются (исчезает суффикс «in HTML generation»). ## Конструкция ```python @dataclass class PreparedFeed: channel_username: str channel_title: str # used by RSS metadata only posts: list[dict] # rendered, filtered, sorted, SANITIZED class ChannelNotFound(Exception): def __init__(self, channel_identifier): # prepared id, for create_error_feed self.channel_identifier = channel_identifier # NO timed() abstraction: keep today's explicit logger.debug lines, prefixed # via log_prefix — an abstraction could not carry the paired rss_/html_ names # nor the extra context (message counts). async def _prepare_feed_posts(channel, client, *, limit, exclude_flags, exclude_text, merge_seconds, history_limit, enrich_replies: bool, log_prefix: str) -> PreparedFeed: # log_prefix: 'rss' | 'html' — preserves today's paired log-line names # (f"{log_prefix}_channel_info_timing", f"{log_prefix}_messages_retrieval_timing", ...) # 1) validate limit (1..200) # 2) channel_name_prepare + cached_get_chat: # UsernameInvalid/UsernameNotOccupied/no-username -> ChannelNotFound(prepared) # FloodWait -> re-raise; other errors -> ValueError chain (unified text, §3.10) # 3) cached_get_chat_history(limit=history_limit): # FloodWait -> re-raise BEFORE the ValueError wrap (§3.9) # NOTE: keep `from tg_cache import ...` INSIDE this function — feed tests # monkeypatch tg_cache and rely on late name resolution. # 4) if enrich_replies: messages = await _reply_enrichment(client, messages) # 5) try: to_thread(_render_pipeline, ...) finally: flush_pending_media_ids() ``` Форматтеры: - `generate_channel_rss`: `history_limit=limit*2, enrich_replies=False, log_prefix="rss"` (RSS over-fetches so merging still yields ~limit posts) → fg-метаданные + entries (`rss_date_range`-лог сохраняется) → `to_thread(fg.rss_str)`. - `generate_channel_html`: `history_limit=limit, enrich_replies=True, log_prefix="html"` (enrichment is HTML-only to keep RSS polling cheap — deliberate) → join с `<hr>`. - Оба: `except ChannelNotFound as e: return create_error_feed(str(e.channel_identifier), base_url)` — байт-эквивалентно текущему (prepared-значение используется и сегодня; int/str интерполируются одинаково). - Внешние catch-логи ОСТАЮТСЯ в форматтерах со своими именами (`generate_channel_rss: …` — branch:526, `html_generation_error: …` — :716): каждый форматтер сохраняет собственный внешний try/except. ## Тесты Golden БЕЗ изменений — главный критерий эквивалентности этапа. Новые: ChannelNotFound → error feed в обоих путях; FloodWait из get_chat → 429; FloodWait из истории → 429 (новый, §3.9); прочие ошибки истории → ValueError → 400. ## DoD Один блок get_chat/get_history/render вместо двух; diffstat rss_generator отрицательный; pytest + golden зелёные (golden-дифф пустой). ## Общие правила эпика Ветка `refactor/render-pipeline` от f9550d8; один этап = один коммит; комментарии — English; точечные правки.
Author
Collaborator

Известный пробел golden-оракула: reply-enrichment не покрыт (учесть при реализации этапа 3)

Обнаружено на этапе 0 (#27 / PR #35): _reply_enrichment (HTML-путь) делает ЖИВОЙ client.get_messages, минуя tg_cache. В golden-харнессе корпус реплеится через monkeypatch cached_get_chat_history, но client.get_messages там no-op'ит → enrichment фактически ничего не делает, в HTML-golden рендерятся только shallow-reply, записанные в корпусе. Фабриковать синтетику в golden осознанно НЕ стали (правильно — golden должен оставаться реальными данными).

Следствие для этого этапа: этап 3 переносит _reply_enrichment в _prepare_feed_posts (enrich_replies=True для HTML). Golden эту ветку НЕ проверяет — значит регрессию в enrichment он не поймает. Поэтому этап 3 ОБЯЗАН нести отдельный unit-тест на enrichment:

  • мок client.get_messages(chat_id, ids) возвращает сообщение с заполненным .reply_to_message;
  • проверить: (а) батчинг по chat_id (один вызов на chat_id, а не N), (б) reply_to_message проставляется в исходные сообщения по ключу (chat_id, message.id), (в) отрендеренный reply-блок содержит текст reply-таргета;
  • этот тест — не golden, а точечный, живёт вне корпуса.

Без него перенос enrichment в этап 3 окажется вообще непроверенным.

## Известный пробел golden-оракула: reply-enrichment не покрыт (учесть при реализации этапа 3) Обнаружено на этапе 0 (#27 / PR #35): `_reply_enrichment` (HTML-путь) делает ЖИВОЙ `client.get_messages`, минуя tg_cache. В golden-харнессе корпус реплеится через monkeypatch `cached_get_chat_history`, но `client.get_messages` там no-op'ит → enrichment фактически ничего не делает, в HTML-golden рендерятся только shallow-reply, записанные в корпусе. Фабриковать синтетику в golden осознанно НЕ стали (правильно — golden должен оставаться реальными данными). **Следствие для этого этапа:** этап 3 переносит `_reply_enrichment` в `_prepare_feed_posts` (`enrich_replies=True` для HTML). Golden эту ветку НЕ проверяет — значит регрессию в enrichment он не поймает. Поэтому этап 3 ОБЯЗАН нести отдельный unit-тест на enrichment: - мок `client.get_messages(chat_id, ids)` возвращает сообщение с заполненным `.reply_to_message`; - проверить: (а) батчинг по chat_id (один вызов на chat_id, а не N), (б) `reply_to_message` проставляется в исходные сообщения по ключу `(chat_id, message.id)`, (в) отрендеренный reply-блок содержит текст reply-таргета; - этот тест — не golden, а точечный, живёт вне корпуса. Без него перенос enrichment в этап 3 окажется вообще непроверенным.
Sign in to join this conversation.