From 571410c53320b32ba94d7bcca945e4a5c83ab0a4 Mon Sep 17 00:00:00 2001 From: IceCodeNew <32576256+IceCodeNew@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:19:53 +0800 Subject: [PATCH 1/8] fix(bark): render compact plain-text sources --- tests/test_render.py | 36 +++++++++++++++++++---- weather_briefing/delivery/renderers.py | 40 ++++++++++++++++++++------ 2 files changed, 61 insertions(+), 15 deletions(-) diff --git a/tests/test_render.py b/tests/test_render.py index b5527c6f..a8ad7451 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -81,9 +81,33 @@ 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_trims_outer_whitespace() -> None: @@ -92,7 +116,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 +138,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/delivery/renderers.py b/weather_briefing/delivery/renderers.py index 7b955e57..fa56d139 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()) + 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] From b0056e72c3f1e161a6216d6efb49c3cf8e99e026 Mon Sep 17 00:00:00 2001 From: IceCodeNew <32576256+IceCodeNew@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:20:13 +0800 Subject: [PATCH 2/8] fix(prompt): require plain-text briefing fields --- tests/test_prompts.py | 2 ++ weather_briefing/data/system_prompt.txt | 2 ++ 2 files changed, 4 insertions(+) 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/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 必须是一句简洁、信息密集的标题,将当下最重要的天气概况浓缩其中;优先包含天气现象、 高低温或显著体感,以及需要立即准备的短时降水等变化,不要另写摘要段落。 不同来源对同一时段的天气现象有冲突时,不得拼接成无争议的单一结论;应明确说明差异, From f94e487b7b7b11c107ba184d59e9b6f2e969482c Mon Sep 17 00:00:00 2001 From: IceCodeNew <32576256+IceCodeNew@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:32:41 +0800 Subject: [PATCH 3/8] fix(bark): preserve unnamed source identities --- tests/test_render.py | 15 +++++++++++++++ weather_briefing/delivery/renderers.py | 2 +- 2 files changed, 16 insertions(+), 1 deletion(-) diff --git a/tests/test_render.py b/tests/test_render.py index a8ad7451..cf5218f7 100644 --- a/tests/test_render.py +++ b/tests/test_render.py @@ -110,6 +110,21 @@ def test_bark_text_renderer_merges_source_ids_with_the_same_display_name() -> No 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: context = SourceDocument("source", "Weather API ", "https://example.invalid/context", "Forecast") result = BriefingResult(" Daily", ("source",), (), output_language="en") diff --git a/weather_briefing/delivery/renderers.py b/weather_briefing/delivery/renderers.py index fa56d139..c197acfa 100644 --- a/weather_briefing/delivery/renderers.py +++ b/weather_briefing/delivery/renderers.py @@ -345,7 +345,7 @@ def _bark_numbered_source_references( 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()) + 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: From c3edd8d15f90c5dba20f523b0ddd0c24b6105484 Mon Sep 17 00:00:00 2001 From: IceCodeNew <32576256+IceCodeNew@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:32:52 +0800 Subject: [PATCH 4/8] fix(llm): reject Markdown briefing fields --- tests/test_llm.py | 61 ++++++++++++++++++++++++++++++++++ weather_briefing/llm/schema.py | 30 ++++++++++++++--- 2 files changed, 86 insertions(+), 5 deletions(-) diff --git a/tests/test_llm.py b/tests/test_llm.py index 87a7c601..edd40dae 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -210,6 +210,67 @@ def test_rejects_invalid_top_level_field(field: str, value: object) -> None: parse_result(payload, _now(), {"source"}) +@pytest.mark.parametrize( + "markdown_text", + ( + "# Forecast", + "- Rain", + "1. Rain", + "> Rain", + "---", + "```forecast```", + "[Forecast](https://example.invalid)", + "**Forecast**", + "*Forecast*", + "_Forecast_", + "~~Forecast~~", + "`Forecast`", + ), +) +def test_rejects_markdown_in_headline(markdown_text: str) -> None: + payload = _valid_payload() + payload["headline"] = markdown_text + + with pytest.raises(LLMError, match=r"schema validation failed at headline"): + parse_result(payload, _now(), {"source"}) + + +@pytest.mark.parametrize("plain_text", ("weather_forecast", "3 * 4", "Rain (80%)")) +def test_accepts_plain_text_with_non_markdown_punctuation(plain_text: str) -> None: + payload = _valid_payload() + payload["headline"] = plain_text + + result = parse_result(payload, _now(), {"source"}) + + assert result.headline == plain_text + + +@pytest.mark.parametrize( + ("section", "item"), + ( + ("conclusions", {"text": "- Rain", "source_ids": ["source"]}), + ("disaster_tracking", {"text": "# Storm", "source_ids": ["source"]}), + ("advice", {"topic": "clothing", "text": "**Wear layers**", "source_ids": ["source"]}), + ( + "active_warnings", + { + "id": "warning", + "title": "[Warning](https://example.invalid)", + "status": "active", + "detail": "Details", + "source_ids": ["source"], + }, + ), + ), +) +def test_rejects_markdown_in_user_facing_sections(section: str, item: object) -> None: + payload = _valid_payload() + payload[section] = [item] + + with pytest.raises(LLMError, match=rf"schema validation failed at {section}.0"): + parse_result(payload, _now(), {"source"}) + + @pytest.mark.parametrize("section", ("conclusions", "disaster_tracking")) @pytest.mark.parametrize( "item", diff --git a/weather_briefing/llm/schema.py b/weather_briefing/llm/schema.py index c46fc847..fc32508a 100644 --- a/weather_briefing/llm/schema.py +++ b/weather_briefing/llm/schema.py @@ -2,6 +2,7 @@ from __future__ import annotations +import re from collections.abc import Mapping from typing import Annotated, Any, Literal, TypeAlias @@ -9,6 +10,18 @@ from .base import LLMError, LLMRequestError +_MARKDOWN_PATTERNS = ( + re.compile(r"(?m)^\s{0,3}(?:#{1,6}|[-*+>]|\d+[.)])\s+"), + re.compile(r"(?m)^\s*(?:-{3,}|\*{3,}|_{3,})\s*$"), + re.compile(r"```|~~~"), + re.compile(r"\[[^\]\n]+\]\([^)\n]+\)"), + re.compile(r"(?P\*\*|__)(?=\S).+?(?<=\S)(?P=delimiter)"), + re.compile(r"(? str: if not value.strip(): @@ -16,7 +29,14 @@ def _non_empty(value: str) -> str: return value +def _plain_text(value: str) -> str: + if any(pattern.search(value) for pattern in _MARKDOWN_PATTERNS): + raise ValueError("must not contain Markdown syntax") + return value + + NonEmptyString: TypeAlias = Annotated[str, AfterValidator(_non_empty)] +PlainTextString: TypeAlias = Annotated[str, AfterValidator(_non_empty), AfterValidator(_plain_text)] CitedSourceIds: TypeAlias = Annotated[list[NonEmptyString], Field(min_length=1)] @@ -29,7 +49,7 @@ class _StrictLLMPayload(BaseModel): class SourcedTextPayload(_StrictLLMPayload): """Describe one source-cited statement in the model response.""" - text: NonEmptyString + text: PlainTextString source_ids: CitedSourceIds @@ -37,9 +57,9 @@ class WarningPayload(_StrictLLMPayload): """Describe one active warning in the model response.""" id: NonEmptyString - title: NonEmptyString - status: NonEmptyString - detail: NonEmptyString + title: PlainTextString + status: PlainTextString + detail: PlainTextString source_ids: CitedSourceIds @@ -52,7 +72,7 @@ class AdvicePayload(SourcedTextPayload): class LLMStructuredOutput(_StrictLLMPayload): """Define the complete, strict response contract requested from every LLM.""" - headline: NonEmptyString + headline: PlainTextString headline_source_ids: CitedSourceIds conclusions: list[SourcedTextPayload] active_warnings: list[WarningPayload] From dd5a225aeb7df196d450c0ae659b6fe0cfa8e362 Mon Sep 17 00:00:00 2001 From: IceCodeNew <32576256+IceCodeNew@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:45:29 +0800 Subject: [PATCH 5/8] refactor(llm): parse Markdown output syntax --- pyproject.toml | 1 + tests/test_llm.py | 4 ++++ uv.lock | 2 ++ weather_briefing/llm/schema.py | 25 ++++++++++++------------- 4 files changed, 19 insertions(+), 13 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index da7a3877..370b17ee 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,6 +16,7 @@ dependencies = [ "cryptography>=45,<50", "feedparser>=6.0.11,<7", "httpx[socks]>=0.27,<1", + "markdown-it-py>=4.2,<5", "pendulum>=3.1,<4", "pydantic>=2.12,<3", "PyJWT>=2.10,<3", diff --git a/tests/test_llm.py b/tests/test_llm.py index edd40dae..d67e0f7e 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -214,17 +214,21 @@ def test_rejects_invalid_top_level_field(field: str, value: object) -> None: "markdown_text", ( "# Forecast", + "Forecast\n===", "- Rain", "1. Rain", "> Rain", "---", "```forecast```", "[Forecast](https://example.invalid)", + "[Forecast]: https://example.invalid", + "", "**Forecast**", "*Forecast*", "_Forecast_", "~~Forecast~~", "`Forecast`", + "A | B\n--|--\n1 | 2", ), ) def test_rejects_markdown_in_headline(markdown_text: str) -> None: diff --git a/uv.lock b/uv.lock index c7eb106c..b0cc02e6 100644 --- a/uv.lock +++ b/uv.lock @@ -3337,6 +3337,7 @@ dependencies = [ { name = "cryptography" }, { name = "feedparser" }, { name = "httpx", extra = ["socks"] }, + { name = "markdown-it-py" }, { name = "pendulum" }, { name = "pydantic" }, { name = "pyjwt" }, @@ -3363,6 +3364,7 @@ requires-dist = [ { name = "cryptography", specifier = ">=45,<50" }, { name = "feedparser", specifier = ">=6.0.11,<7" }, { name = "httpx", extras = ["socks"], specifier = ">=0.27,<1" }, + { name = "markdown-it-py", specifier = ">=4.2,<5" }, { name = "pendulum", specifier = ">=3.1,<4" }, { name = "pydantic", specifier = ">=2.12,<3" }, { name = "pyjwt", specifier = ">=2.10,<3" }, diff --git a/weather_briefing/llm/schema.py b/weather_briefing/llm/schema.py index fc32508a..d90644aa 100644 --- a/weather_briefing/llm/schema.py +++ b/weather_briefing/llm/schema.py @@ -2,25 +2,17 @@ from __future__ import annotations -import re from collections.abc import Mapping from typing import Annotated, Any, Literal, TypeAlias +from markdown_it import MarkdownIt from pydantic import AfterValidator, BaseModel, ConfigDict, Field, ValidationError from .base import LLMError, LLMRequestError -_MARKDOWN_PATTERNS = ( - re.compile(r"(?m)^\s{0,3}(?:#{1,6}|[-*+>]|\d+[.)])\s+"), - re.compile(r"(?m)^\s*(?:-{3,}|\*{3,}|_{3,})\s*$"), - re.compile(r"```|~~~"), - re.compile(r"\[[^\]\n]+\]\([^)\n]+\)"), - re.compile(r"(?P\*\*|__)(?=\S).+?(?<=\S)(?P=delimiter)"), - re.compile(r"(? str: @@ -30,7 +22,14 @@ def _non_empty(value: str) -> str: def _plain_text(value: str) -> str: - if any(pattern.search(value) for pattern in _MARKDOWN_PATTERNS): + environment: dict[str, object] = {} + tokens = _MARKDOWN_PARSER.parse(value, environment) + has_markup = bool(environment.get("references")) or any( + token.type not in _PLAIN_BLOCK_TOKENS + or (token.children is not None and any(child.type not in _PLAIN_INLINE_TOKENS for child in token.children)) + for token in tokens + ) + if has_markup: raise ValueError("must not contain Markdown syntax") return value From 22f0d3a216a3ef37d0539cc9dd48455a5f87044f Mon Sep 17 00:00:00 2001 From: IceCodeNew <32576256+IceCodeNew@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:49:51 +0800 Subject: [PATCH 6/8] refactor(llm): keep formatting in renderers --- pyproject.toml | 1 - tests/test_llm.py | 65 ---------------------------------- uv.lock | 2 -- weather_briefing/llm/schema.py | 29 +++------------ 4 files changed, 5 insertions(+), 92 deletions(-) diff --git a/pyproject.toml b/pyproject.toml index 370b17ee..da7a3877 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -16,7 +16,6 @@ dependencies = [ "cryptography>=45,<50", "feedparser>=6.0.11,<7", "httpx[socks]>=0.27,<1", - "markdown-it-py>=4.2,<5", "pendulum>=3.1,<4", "pydantic>=2.12,<3", "PyJWT>=2.10,<3", diff --git a/tests/test_llm.py b/tests/test_llm.py index d67e0f7e..87a7c601 100644 --- a/tests/test_llm.py +++ b/tests/test_llm.py @@ -210,71 +210,6 @@ def test_rejects_invalid_top_level_field(field: str, value: object) -> None: parse_result(payload, _now(), {"source"}) -@pytest.mark.parametrize( - "markdown_text", - ( - "# Forecast", - "Forecast\n===", - "- Rain", - "1. Rain", - "> Rain", - "---", - "```forecast```", - "[Forecast](https://example.invalid)", - "[Forecast]: https://example.invalid", - "", - "**Forecast**", - "*Forecast*", - "_Forecast_", - "~~Forecast~~", - "`Forecast`", - "A | B\n--|--\n1 | 2", - ), -) -def test_rejects_markdown_in_headline(markdown_text: str) -> None: - payload = _valid_payload() - payload["headline"] = markdown_text - - with pytest.raises(LLMError, match=r"schema validation failed at headline"): - parse_result(payload, _now(), {"source"}) - - -@pytest.mark.parametrize("plain_text", ("weather_forecast", "3 * 4", "Rain (80%)")) -def test_accepts_plain_text_with_non_markdown_punctuation(plain_text: str) -> None: - payload = _valid_payload() - payload["headline"] = plain_text - - result = parse_result(payload, _now(), {"source"}) - - assert result.headline == plain_text - - -@pytest.mark.parametrize( - ("section", "item"), - ( - ("conclusions", {"text": "- Rain", "source_ids": ["source"]}), - ("disaster_tracking", {"text": "# Storm", "source_ids": ["source"]}), - ("advice", {"topic": "clothing", "text": "**Wear layers**", "source_ids": ["source"]}), - ( - "active_warnings", - { - "id": "warning", - "title": "[Warning](https://example.invalid)", - "status": "active", - "detail": "Details", - "source_ids": ["source"], - }, - ), - ), -) -def test_rejects_markdown_in_user_facing_sections(section: str, item: object) -> None: - payload = _valid_payload() - payload[section] = [item] - - with pytest.raises(LLMError, match=rf"schema validation failed at {section}.0"): - parse_result(payload, _now(), {"source"}) - - @pytest.mark.parametrize("section", ("conclusions", "disaster_tracking")) @pytest.mark.parametrize( "item", diff --git a/uv.lock b/uv.lock index b0cc02e6..c7eb106c 100644 --- a/uv.lock +++ b/uv.lock @@ -3337,7 +3337,6 @@ dependencies = [ { name = "cryptography" }, { name = "feedparser" }, { name = "httpx", extra = ["socks"] }, - { name = "markdown-it-py" }, { name = "pendulum" }, { name = "pydantic" }, { name = "pyjwt" }, @@ -3364,7 +3363,6 @@ requires-dist = [ { name = "cryptography", specifier = ">=45,<50" }, { name = "feedparser", specifier = ">=6.0.11,<7" }, { name = "httpx", extras = ["socks"], specifier = ">=0.27,<1" }, - { name = "markdown-it-py", specifier = ">=4.2,<5" }, { name = "pendulum", specifier = ">=3.1,<4" }, { name = "pydantic", specifier = ">=2.12,<3" }, { name = "pyjwt", specifier = ">=2.10,<3" }, diff --git a/weather_briefing/llm/schema.py b/weather_briefing/llm/schema.py index d90644aa..c46fc847 100644 --- a/weather_briefing/llm/schema.py +++ b/weather_briefing/llm/schema.py @@ -5,15 +5,10 @@ from collections.abc import Mapping from typing import Annotated, Any, Literal, TypeAlias -from markdown_it import MarkdownIt from pydantic import AfterValidator, BaseModel, ConfigDict, Field, ValidationError from .base import LLMError, LLMRequestError -_MARKDOWN_PARSER = MarkdownIt("commonmark").enable(("strikethrough", "table")) -_PLAIN_BLOCK_TOKENS = frozenset(("paragraph_open", "inline", "paragraph_close")) -_PLAIN_INLINE_TOKENS = frozenset(("text", "softbreak", "hardbreak")) - def _non_empty(value: str) -> str: if not value.strip(): @@ -21,21 +16,7 @@ def _non_empty(value: str) -> str: return value -def _plain_text(value: str) -> str: - environment: dict[str, object] = {} - tokens = _MARKDOWN_PARSER.parse(value, environment) - has_markup = bool(environment.get("references")) or any( - token.type not in _PLAIN_BLOCK_TOKENS - or (token.children is not None and any(child.type not in _PLAIN_INLINE_TOKENS for child in token.children)) - for token in tokens - ) - if has_markup: - raise ValueError("must not contain Markdown syntax") - return value - - NonEmptyString: TypeAlias = Annotated[str, AfterValidator(_non_empty)] -PlainTextString: TypeAlias = Annotated[str, AfterValidator(_non_empty), AfterValidator(_plain_text)] CitedSourceIds: TypeAlias = Annotated[list[NonEmptyString], Field(min_length=1)] @@ -48,7 +29,7 @@ class _StrictLLMPayload(BaseModel): class SourcedTextPayload(_StrictLLMPayload): """Describe one source-cited statement in the model response.""" - text: PlainTextString + text: NonEmptyString source_ids: CitedSourceIds @@ -56,9 +37,9 @@ class WarningPayload(_StrictLLMPayload): """Describe one active warning in the model response.""" id: NonEmptyString - title: PlainTextString - status: PlainTextString - detail: PlainTextString + title: NonEmptyString + status: NonEmptyString + detail: NonEmptyString source_ids: CitedSourceIds @@ -71,7 +52,7 @@ class AdvicePayload(SourcedTextPayload): class LLMStructuredOutput(_StrictLLMPayload): """Define the complete, strict response contract requested from every LLM.""" - headline: PlainTextString + headline: NonEmptyString headline_source_ids: CitedSourceIds conclusions: list[SourcedTextPayload] active_warnings: list[WarningPayload] From 7aa593a3bc526ea121adad94671f525ade2aca47 Mon Sep 17 00:00:00 2001 From: IceCodeNew <32576256+IceCodeNew@users.noreply.github.com> Date: Fri, 24 Jul 2026 13:56:56 +0800 Subject: [PATCH 7/8] fix(bark): trim notification split boundaries --- tests/test_bark_publisher.py | 6 +++++- weather_briefing/delivery/bark.py | 12 +++++++----- 2 files changed, 12 insertions(+), 6 deletions(-) diff --git a/tests/test_bark_publisher.py b/tests/test_bark_publisher.py index 17f9c5fa..d11b5ae6 100644 --- a/tests/test_bark_publisher.py +++ b/tests/test_bark_publisher.py @@ -258,7 +258,11 @@ def handler(request: httpx.Request) -> httpx.Response: def test_split_plain_message_prefers_line_boundary() -> None: - assert split_plain_message("first line\nsecond line", 12) == ("first line", "\nsecond line") + 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) + assert "\n".join(chunks) == "first line\nsecond line" def test_split_plain_message_uses_the_minimum_number_of_chunks() -> None: diff --git a/weather_briefing/delivery/bark.py b/weather_briefing/delivery/bark.py index c499fe13..6a796301 100644 --- a/weather_briefing/delivery/bark.py +++ b/weather_briefing/delivery/bark.py @@ -184,11 +184,13 @@ 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:] + 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:] chunks.append(remaining) return tuple(chunks) From 8dcce6fc3fc359f6819f9ff98f956bf0bef698f3 Mon Sep 17 00:00:00 2001 From: IceCodeNew <32576256+IceCodeNew@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:09:31 +0800 Subject: [PATCH 8/8] fix(bark): define message split semantics --- tests/test_bark_publisher.py | 15 +++++++++------ weather_briefing/delivery/bark.py | 5 +++-- 2 files changed, 12 insertions(+), 8 deletions(-) diff --git a/tests/test_bark_publisher.py b/tests/test_bark_publisher.py index d11b5ae6..9bc52734 100644 --- a/tests/test_bark_publisher.py +++ b/tests/test_bark_publisher.py @@ -257,20 +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: +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) - assert "\n".join(chunks) == "first line\nsecond line" -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/weather_briefing/delivery/bark.py b/weather_briefing/delivery/bark.py index 6a796301..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: @@ -191,7 +191,8 @@ def split_plain_message(body: str, limit: int) -> tuple[str, ...]: else: chunks.append(remaining[:limit]) remaining = remaining[limit:] - chunks.append(remaining) + if remaining: + chunks.append(remaining) return tuple(chunks)