diff --git a/docs/design.md b/docs/design.md index 1e0ae002..5ed146ad 100644 --- a/docs/design.md +++ b/docs/design.md @@ -102,7 +102,7 @@ SQLite 没有原生日期时间类型,状态存储需要直接对 TEXT 做范 `DeliveryProvider` 把同一平台的模板 renderer 与消息 publisher 组合为投递边界。`MessageRenderer` 接收经过引用校验的 `BriefingResult`、文章和上下文,返回带正文及可见长度的 `RenderedMessage`;publisher 只负责传输该消息,不读取 LLM JSON,也不解析平台模板。Telegram provider 组合 Bot API HTML renderer 与 Telegram publisher,纯文本 stdout provider 用于本地测试。新增平台只需提供自己的组合,不修改核心编排。 -日常简报在模型契约中使用低于平台上限的可配置字符预算,Telegram publisher 再校验 renderer 提供的可见字符数不超过 4096;超限视为任务失败,不拆分为多条而破坏“单条简报”约束。权威预报正文属于独立的全文投递,不与日常简报合并。 +日常简报在模型契约中使用低于平台上限的可配置字符预算,Telegram publisher 再校验 renderer 提供的可见字符数不超过 4096;超限视为任务失败,不拆分为多条而破坏“单条简报”约束。权威预报正文属于独立的全文投递,不与日常简报合并;其分片按解析后的可见字符计数,并在边界处闭合和重新打开格式标签,保证每个 Bot API 请求都包含独立有效的 HTML。 ## 失败语义 diff --git a/tests/test_publishers.py b/tests/test_publishers.py index 157ca40d..c5b4e1e8 100644 --- a/tests/test_publishers.py +++ b/tests/test_publishers.py @@ -9,7 +9,6 @@ DeliveryProvider, StdoutPublisher, TelegramPublisher, - _safe_html_boundary, _split_message, ) from weather_briefing.render import PlainTextRenderer @@ -69,7 +68,36 @@ def test_delivery_provider_applies_platform_limit_without_leaking_it_into_config def test_split_message_prefers_line_boundary() -> None: - assert _split_message("first line\nsecond line", 12) == ("first line", "second line") + assert _split_message("first line\nsecond line", 12) == ("first line", "\nsecond line") + + +@pytest.mark.parametrize( + ("body", "limit", "expected"), + ( + ("abcdefgh", 5, ("abcde", "fgh")), + ( + "abcdef", + 3, + ("abc", "def"), + ), + ("ab&cd", 3, ("ab&", "cd")), + ("ab&cd", 3, ("ab&", "cd")), + ("abc&d", 3, ("abc", "&d")), + ("abcdef", 3, ("abc", "def")), + ("abc", 3, ("abc",)), + ( + "first line\nsecond line", + 12, + ("first line", "\nsecond line"), + ), + ), +) +def test_split_message_balances_html_tags( + body: str, + limit: int, + expected: tuple[str, ...], +) -> None: + assert _split_message(body, limit) == expected async def test_telegram_publisher_uses_runtime_values(caplog) -> None: @@ -190,19 +218,3 @@ async def test_stdout_publisher_outputs_message_body(capsys) -> None: await StdoutPublisher().publish(RenderedMessage("test body", 9)) assert capsys.readouterr().out.strip() == "test body" - - -def test_safe_html_boundary_avoids_splitting_inside_html_entity() -> None: - assert _safe_html_boundary("text with & more text", 16) == 10 - - -def test_safe_html_boundary_avoids_splitting_inside_html_tag() -> None: - assert _safe_html_boundary("text bold", 7) == 5 - - -def test_safe_html_boundary_returns_limit_when_no_boundary_issue() -> None: - assert _safe_html_boundary("plain text without html", 10) == 10 - - -def test_safe_html_boundary_returns_limit_when_boundary_is_zero() -> None: - assert _safe_html_boundary(" tuple[str, ...]: if len(body) <= limit: return (body,) - chunks: list[str] = [] - remaining = body - while remaining: - split_at = remaining.rfind("\n", 0, limit + 1) - if split_at <= 0: - split_at = _safe_html_boundary(remaining, limit) - chunks.append(remaining[:split_at].rstrip()) - remaining = remaining[split_at:].lstrip("\n") - return tuple(chunks) - - -def _safe_html_boundary(value: str, limit: int) -> int: - boundary = limit - last_entity_start = value.rfind("&", 0, boundary) - last_entity_end = value.rfind(";", 0, boundary) - if last_entity_start > last_entity_end: - boundary = last_entity_start - last_tag_start = value.rfind("<", 0, boundary) - last_tag_end = value.rfind(">", 0, boundary) - if last_tag_start > last_tag_end: - boundary = last_tag_start - return boundary or limit + chunker = _TelegramHTMLChunker(limit) + chunker.feed(body) + chunker.close() + return chunker.finish() + + +class _TelegramHTMLChunker(HTMLParser): + """Split Telegram HTML while making every chunk independently valid.""" + + def __init__(self, limit: int) -> None: + super().__init__(convert_charrefs=False) + self._limit = limit + self._chunks: list[str] = [] + self._parts: list[str] = [] + self._open_tags: list[tuple[str, str]] = [] + self._visible_length = 0 + + def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: + if self._visible_length == self._limit: + self._finish_chunk() + start_tag = self.get_starttag_text() + assert start_tag is not None + self._parts.append(start_tag) + self._open_tags.append((tag, start_tag)) + + def handle_endtag(self, tag: str) -> None: + self._parts.append(f"") + self._open_tags.pop() + + def handle_data(self, data: str) -> None: + while True: + available = self._limit - self._visible_length + if available == 0: + self._finish_chunk() + available = self._limit + if len(data) <= available: + self._parts.append(data) + self._visible_length += len(data) + return + split_at = data.rfind("\n", 0, available + 1) + if split_at > 0: + self._parts.append(data[:split_at]) + self._visible_length += split_at + data = data[split_at:] + else: + self._parts.append(data[:available]) + self._visible_length += available + data = data[available:] + self._finish_chunk() + + def handle_entityref(self, name: str) -> None: + self._append_entity(f"&{name};") + + def handle_charref(self, name: str) -> None: + self._append_entity(f"&#{name};") + + def finish(self) -> tuple[str, ...]: + self._finish_chunk() + return tuple(self._chunks) + + def _append_entity(self, value: str) -> None: + if self._visible_length == self._limit: + self._finish_chunk() + self._parts.append(value) + self._visible_length += 1 + + def _finish_chunk(self) -> None: + if self._visible_length == 0: + return + closing_tags = (f"" for tag, _ in reversed(self._open_tags)) + self._chunks.append("".join((*self._parts, *closing_tags))) + self._parts = [start_tag for _, start_tag in self._open_tags] + self._visible_length = 0