Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

## 失败语义

Expand Down
48 changes: 30 additions & 18 deletions tests/test_publishers.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
DeliveryProvider,
StdoutPublisher,
TelegramPublisher,
_safe_html_boundary,
_split_message,
)
from weather_briefing.render import PlainTextRenderer
Expand Down Expand Up @@ -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"),
(
("<b>abcdefgh</b>", 5, ("<b>abcde</b>", "<b>fgh</b>")),
(
"<b><i>abcdef</i></b>",
3,
("<b><i>abc</i></b>", "<b><i>def</i></b>"),
),
("<b>ab&amp;cd</b>", 3, ("<b>ab&amp;</b>", "<b>cd</b>")),
("<b>ab&#38;cd</b>", 3, ("<b>ab&#38;</b>", "<b>cd</b>")),
("<b>abc&amp;d</b>", 3, ("<b>abc</b>", "<b>&amp;d</b>")),
("<b>abc</b><i>def</i>", 3, ("<b>abc</b>", "<i>def</i>")),
("<b>abc</b><i></i>", 3, ("<b>abc</b>",)),
(
"<b>first line\nsecond line</b>",
12,
("<b>first line</b>", "<b>\nsecond line</b>"),
),
),
)
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:
Expand Down Expand Up @@ -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 &amp more text", 16) == 10


def test_safe_html_boundary_avoids_splitting_inside_html_tag() -> None:
assert _safe_html_boundary("text <b>bold</b>", 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("<tag", 1) == 1
96 changes: 74 additions & 22 deletions weather_briefing/publishers.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import logging
from dataclasses import dataclass
from html.parser import HTMLParser
from typing import Protocol

import httpx
Expand Down Expand Up @@ -199,25 +200,76 @@ def _rendered_text_logging_enabled(diagnostics: RenderedTextDiagnostics | None)
def _split_message(body: str, limit: int) -> 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
Comment thread
coderabbitai[bot] marked this conversation as resolved.