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"{tag}>")
+ 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"{tag}>" 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