diff --git a/tests/test_bark_publisher.py b/tests/test_bark_publisher.py index 17f9c5fa..9bc52734 100644 --- a/tests/test_bark_publisher.py +++ b/tests/test_bark_publisher.py @@ -257,16 +257,23 @@ def handler(request: httpx.Request) -> httpx.Response: assert "Private body" not in caplog.text -def test_split_plain_message_prefers_line_boundary() -> None: - assert split_plain_message("first line\nsecond line", 12) == ("first line", "\nsecond line") +def test_split_plain_message_consumes_line_boundary() -> None: + chunks = split_plain_message("first line\nsecond line", 12) + assert chunks == ("first line", "second line") + assert all(not chunk.startswith("\n") and not chunk.endswith("\n") for chunk in chunks) -def test_split_plain_message_uses_the_minimum_number_of_chunks() -> None: + +def test_split_plain_message_omits_empty_chunk_after_final_boundary() -> None: + chunks = split_plain_message("x" * 12 + "\n", 12) + + assert chunks == ("x" * 12,) + + +def test_split_plain_message_preserves_non_boundary_newlines() -> None: chunks = split_plain_message("x" * 100 + "\n" + "y" * 1199, 650) - assert len(chunks) == 2 - assert all(len(chunk) <= 650 for chunk in chunks) - assert "".join(chunks) == "x" * 100 + "\n" + "y" * 1199 + assert chunks == ("x" * 100 + "\n" + "y" * 549, "y" * 650) @pytest.mark.parametrize("limit", (0, -1)) diff --git a/tests/test_prompts.py b/tests/test_prompts.py index ec54b6d1..028250dd 100644 --- a/tests/test_prompts.py +++ b/tests/test_prompts.py @@ -63,6 +63,8 @@ def test_prompt_uses_a_soft_briefing_target_and_hard_output_limits() -> None: def test_prompt_requires_attribution_and_preserves_source_conflicts() -> None: assert "headline_source_ids 以及 conclusions" in SYSTEM_PROMPT + assert "只能包含纯文本" in SYSTEM_PROMPT + assert "不得使用 Markdown" in SYSTEM_PROMPT assert "不得拼接成无争议的单一结论" in SYSTEM_PROMPT assert "优先采用可识别的当地权威气象机构" in SYSTEM_PROMPT assert "input.required_advice_topics" in SYSTEM_PROMPT diff --git a/tests/test_render.py b/tests/test_render.py index b5527c6f..cf5218f7 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -81,9 +81,48 @@ def test_bark_text_renderer_uses_numbered_sources_without_urls() -> None: rendered = BarkTextRenderer().render_briefing(result, (article,), (context,)) - assert rendered.body == "Daily [1]\n- Rain [1][2]\nSources: [1] Feed; [2] Weather API" + assert rendered.body == "Daily [1]\nRain [1][2]\n[1] Feed\n[2] Weather API" assert article.url not in rendered.body assert context.url not in rendered.body + assert "- " not in rendered.body + + +def test_bark_text_renderer_merges_source_ids_with_the_same_display_name() -> None: + weather = SourceDocument("weather:open-meteo", "Open-Meteo", "https://example.invalid/weather", "Forecast") + air_quality = SourceDocument( + "air-quality:open-meteo", + " open-meteo ", + "https://example.invalid/air-quality", + "Air quality", + ) + result = BriefingResult( + "Daily", + ("weather:open-meteo",), + (Conclusion("Rain", ("weather:open-meteo", "air-quality:open-meteo")),), + advice=(Advice(AdviceTopic.MASK, "Limit exposure", ("air-quality:open-meteo",)),), + output_language="en", + ) + + rendered = BarkTextRenderer().render_briefing(result, (), (weather, air_quality)) + + assert rendered.body == "Daily [1]\nRain [1]\nAdvice\nLimit exposure [1]\n[1] Open-Meteo" + assert "[2]" not in rendered.body + assert "Sources:" not in rendered.body + + +def test_bark_text_renderer_uses_source_ids_for_distinct_blank_names() -> None: + weather = SourceDocument("weather:blank", " ", "https://example.invalid/weather", "Forecast") + air_quality = SourceDocument("air-quality:blank", "", "https://example.invalid/air-quality", "Air quality") + result = BriefingResult( + "Daily", + ("weather:blank",), + (Conclusion("Rain", ("weather:blank", "air-quality:blank")),), + output_language="en", + ) + + rendered = BarkTextRenderer().render_briefing(result, (), (weather, air_quality)) + + assert rendered.body == ("Daily [1]\nRain [1][2]\n[1] weather:blank\n[2] air-quality:blank") def test_bark_text_renderer_trims_outer_whitespace() -> None: @@ -92,7 +131,7 @@ def test_bark_text_renderer_trims_outer_whitespace() -> None: rendered = BarkTextRenderer().render_briefing(result, (), (context,)) - assert rendered.body == "Daily [1]\nSources: [1] Weather API" + assert rendered.body == "Daily [1]\n[1] Weather API" assert rendered.visible_length == len(rendered.body) @@ -114,12 +153,12 @@ def test_bark_text_renderer_compacts_warning_disaster_and_advice_sections() -> N assert rendered.body == ( "Rain today [1]\n" "Weather warnings\n" - "- Heavy rain (active): Avoid low areas [1]\n" + "Heavy rain (active): Avoid low areas [1]\n" "Natural disaster updates\n" - "- Storm approaching [1]\n" + "Storm approaching [1]\n" "Advice\n" - "- Exercise indoors [1]\n" - "Sources: [1] Weather API" + "Exercise indoors [1]\n" + "[1] Weather API" ) diff --git a/weather_briefing/data/system_prompt.txt b/weather_briefing/data/system_prompt.txt index b0246aec..9b95c1b8 100644 --- a/weather_briefing/data/system_prompt.txt +++ b/weather_briefing/data/system_prompt.txt @@ -16,6 +16,8 @@ recent_context_documents 中的 language 是来源正文的实际语言;来源 不得把翻译结果冒充来源原文或改变专名、预警编号和数值。 headline_source_ids 以及 conclusions、active_warnings、disaster_tracking 和 advice 中的每一项都必须包含至少一个 source_id。 +headline、conclusions[].text、active_warnings 中的 title、status、detail、disaster_tracking[].text +以及 advice[].text 只能包含纯文本,不得使用 Markdown 标题、列表、强调、代码或链接语法;章节标题和项目符号由发布端统一渲染。 headline 必须是一句简洁、信息密集的标题,将当下最重要的天气概况浓缩其中;优先包含天气现象、 高低温或显著体感,以及需要立即准备的短时降水等变化,不要另写摘要段落。 不同来源对同一时段的天气现象有冲突时,不得拼接成无争议的单一结论;应明确说明差异, diff --git a/weather_briefing/delivery/bark.py b/weather_briefing/delivery/bark.py index c499fe13..cc7bdaac 100644 --- a/weather_briefing/delivery/bark.py +++ b/weather_briefing/delivery/bark.py @@ -174,7 +174,7 @@ def bark_error_reason(response: httpx.Response) -> tuple[str, bool]: def split_plain_message(body: str, limit: int) -> tuple[str, ...]: - """Split plain text at line boundaries when possible.""" + """Split into display-ready chunks, consuming newlines used as boundaries.""" if limit <= 0: raise ValueError("Message split limit must be positive") if not body or len(body) <= limit: @@ -184,12 +184,15 @@ def split_plain_message(body: str, limit: int) -> tuple[str, ...]: while len(remaining) > limit: remaining_chunk_count = math.ceil(len(remaining) / limit) earliest_split = len(remaining) - (remaining_chunk_count - 1) * limit - split_at = remaining.rfind("\n", earliest_split, limit + 1) - if split_at < earliest_split: - split_at = limit - chunks.append(remaining[:split_at]) - remaining = remaining[split_at:] - chunks.append(remaining) + newline_at = remaining.rfind("\n", earliest_split, limit + 1) + if newline_at >= earliest_split: + chunks.append(remaining[:newline_at]) + remaining = remaining[newline_at + 1 :] + else: + chunks.append(remaining[:limit]) + remaining = remaining[limit:] + if remaining: + chunks.append(remaining) return tuple(chunks) diff --git a/weather_briefing/delivery/renderers.py b/weather_briefing/delivery/renderers.py index 7b955e57..c197acfa 100644 --- a/weather_briefing/delivery/renderers.py +++ b/weather_briefing/delivery/renderers.py @@ -218,7 +218,7 @@ def render_briefing( source_references.update( {document.id: self._source_reference(document.name, document.url) for document in context} ) - numbered_references, source_footer = _numbered_source_references(result, source_references, labels) + numbered_references, source_footer = _bark_numbered_source_references(result, source_references) lines = [ f"{result.headline} " f"{_plain_attribution(result.headline_source_ids, numbered_references, labels, numbered=True)}" @@ -227,7 +227,7 @@ def render_briefing( if result.active_warnings: lines.append(labels["warnings"]) lines.extend( - f"- {warning.title}{labels['status_open']}{warning.status}{labels['status_close']}" + f"{warning.title}{labels['status_open']}{warning.status}{labels['status_close']}" f"{labels['detail_separator']}{warning.detail} " f"{_plain_attribution(warning.source_ids, numbered_references, labels, numbered=True)}" for warning in result.active_warnings @@ -295,8 +295,7 @@ def _compact_plain_items( return [] lines = [title] if title is not None else [] lines.extend( - f"- {item.text} {_plain_attribution(item.source_ids, source_references, labels, numbered=True)}" - for item in items + f"{item.text} {_plain_attribution(item.source_ids, source_references, labels, numbered=True)}" for item in items ) return lines @@ -329,11 +328,7 @@ def _numbered_source_references( source_references: dict[str, str], labels: Mapping[str, str], ) -> tuple[dict[str, str], str]: - ordered_source_ids = list(result.headline_source_ids) - for items in (result.conclusions, result.active_warnings, result.disaster_tracking, result.advice): - for item in items: - ordered_source_ids.extend(item.source_ids) - ordered_source_ids = list(dict.fromkeys(ordered_source_ids)) + ordered_source_ids = _ordered_source_ids(result) numbered_references = {source_id: f"[{index}]" for index, source_id in enumerate(ordered_source_ids, start=1)} source_list = labels["plain_source_separator"].join( f"{numbered_references[source_id]} {source_references[source_id]}" for source_id in ordered_source_ids @@ -342,6 +337,33 @@ def _numbered_source_references( return numbered_references, footer +def _bark_numbered_source_references( + result: BriefingResult, + source_references: dict[str, str], +) -> tuple[dict[str, str], str]: + numbered_references: dict[str, str] = {} + numbers_by_name: dict[str, str] = {} + source_lines: list[str] = [] + for source_id in _ordered_source_ids(result): + source_name = " ".join(source_references[source_id].split()) or source_id + normalized_name = source_name.casefold() + number = numbers_by_name.get(normalized_name) + if number is None: + number = f"[{len(numbers_by_name) + 1}]" + numbers_by_name[normalized_name] = number + source_lines.append(f"{number} {source_name}") + numbered_references[source_id] = number + return numbered_references, "\n".join(source_lines) + + +def _ordered_source_ids(result: BriefingResult) -> list[str]: + ordered_source_ids = list(result.headline_source_ids) + for items in (result.conclusions, result.active_warnings, result.disaster_tracking, result.advice): + for item in items: + ordered_source_ids.extend(item.source_ids) + return list(dict.fromkeys(ordered_source_ids)) + + def _briefing_labels(language: str) -> Mapping[str, str]: selected = _BRIEFING_LANGUAGE_SUPPORT.match(language) return _BRIEFING_LABELS[selected]