diff --git a/plugins/daily_paper/src/reme_daily_paper/analyze.py b/plugins/daily_paper/src/reme_daily_paper/analyze.py index c1f84001..ae9c1b25 100644 --- a/plugins/daily_paper/src/reme_daily_paper/analyze.py +++ b/plugins/daily_paper/src/reme_daily_paper/analyze.py @@ -10,11 +10,10 @@ from .base import ( PAPER_COUNT, DailyPaperStep, iter_note_metadata, + markdown_output, normalize_chinese_title, replace_surrogates, reserve_unique_note_path, - strip_frontmatter, - structured_output, utc_now_iso, write_markdown, ) @@ -119,7 +118,7 @@ class DailyPaperAnalyzeStep(DailyPaperStep): for attempt in range(2): try: result = await self.agent_wrapper.reply(prompt, output_schema=DailyPaperMarkdownOutput) - output = structured_output(result, DailyPaperMarkdownOutput) + output = markdown_output(result) break except recoverable: if attempt: @@ -141,10 +140,7 @@ class DailyPaperAnalyzeStep(DailyPaperStep): ) as (title, note_path): used_titles.add(title) note_rel = note_path.relative_to(self.workspace_path).as_posix() - desc = replace_surrogates(output.desc.strip()) - body = replace_surrogates(strip_frontmatter(output.body)) - if not desc or not body: - raise ValueError(f"Agent returned an empty paper note for {paper.arxiv_id}") + desc, body = output.desc, output.body await write_markdown( note_path, body, diff --git a/plugins/daily_paper/src/reme_daily_paper/base.py b/plugins/daily_paper/src/reme_daily_paper/base.py index b58b8f9d..0808c30b 100644 --- a/plugins/daily_paper/src/reme_daily_paper/base.py +++ b/plugins/daily_paper/src/reme_daily_paper/base.py @@ -17,10 +17,12 @@ from pydantic import BaseModel from reme.steps import BaseStep from reme.steps.file_io import get_path_lock, validate_filename_component +from .schema import DailyPaperMarkdownOutput + # Number of papers selected, analyzed, and digested each run. Shared across steps. PAPER_COUNT = 3 _STATE_PREFIX = "daily_paper_" -_FRONTMATTER_PATTERN = re.compile(r"^---\s*\n.*?\n---\s*\n", re.DOTALL) +_FRONTMATTER_PATTERN = re.compile(r"^---\s*\n.*?\n---(?:\s*\n|$)", re.DOTALL) _MARKDOWN_HEADING_PATTERN = re.compile(r"^#+\s*") _UNSAFE_FILENAME_CHARS = re.compile(r'[<>:"/\\|?*\x00-\x1f]') _CHINESE_PATTERN = re.compile(r"[\u3400-\u4dbf\u4e00-\u9fff]") @@ -57,6 +59,16 @@ def _replace_surrogates_recursive(value: Any) -> Any: return value +def markdown_output(result: dict[str, Any]) -> DailyPaperMarkdownOutput: + """Clean and validate usable Markdown before callers persist or retry it.""" + output = structured_output(result, DailyPaperMarkdownOutput) + desc = replace_surrogates(output.desc.strip()) + body = replace_surrogates(strip_frontmatter(output.body)) + if not desc or not body: + raise ValueError("Agent returned an empty Markdown description or body") + return output.model_copy(update={"desc": desc, "body": body}) + + def normalize_chinese_title(raw: str, fallback: str) -> str: """Return one safe Chinese title that can also be used as the filename stem.""" title = replace_surrogates(str(raw or "").strip()) diff --git a/plugins/daily_paper/src/reme_daily_paper/digest.py b/plugins/daily_paper/src/reme_daily_paper/digest.py index 460eddce..d11e8927 100644 --- a/plugins/daily_paper/src/reme_daily_paper/digest.py +++ b/plugins/daily_paper/src/reme_daily_paper/digest.py @@ -11,10 +11,9 @@ from reme.steps.file_io import refresh_day_index from .base import ( PAPER_COUNT, DailyPaperStep, + markdown_output, normalize_chinese_title, reserve_unique_note_path, - strip_frontmatter, - structured_output, utc_now_iso, write_markdown, ) @@ -136,10 +135,8 @@ class DailyPaperDigestStep(DailyPaperStep): }, ) self.logger.info(f"[{self.name}] agent done notes={len(analyses)}") - output = structured_output(result, DailyPaperMarkdownOutput) - body = strip_frontmatter(output.body) - if not output.desc.strip() or not body: - raise ValueError("Agent returned an empty daily paper brief") + output = markdown_output(result) + body = output.body day = run_day.isoformat() title = normalize_chinese_title(output.title, f"每日论文简报-{day}") diff --git a/plugins/daily_paper/tests/test_daily_paper.py b/plugins/daily_paper/tests/test_daily_paper.py index 1fd81e22..f3bb9196 100644 --- a/plugins/daily_paper/tests/test_daily_paper.py +++ b/plugins/daily_paper/tests/test_daily_paper.py @@ -1213,3 +1213,38 @@ async def test_digest_failure_boundary_preserves_cancellation(tmp_path): step._generate_digest = AsyncMock(side_effect=asyncio.CancelledError) # pylint: disable=protected-access with pytest.raises(asyncio.CancelledError): await step(RuntimeContext()) + + +@pytest.mark.asyncio +@pytest.mark.parametrize( + "invalid", + [ + {"title": "论文", "desc": " ", "body": "content"}, + {"title": "论文", "desc": "summary", "body": "\n "}, + {"title": "论文", "desc": "summary", "body": "---\ntitle: generated\n---\n"}, + ], +) +@pytest.mark.parametrize("recover", [True, False]) +async def test_blank_note_retries_once_before_writing(tmp_path, monkeypatch, invalid, recover): + """Blank or frontmatter-only content uses the same bounded shorter-note recovery.""" + from types import SimpleNamespace + + valid = {"title": "论文", "desc": " summary ", "body": "---\ntitle: model\n---\nEvidence"} + agent = _QueuedAgentWrapper([invalid, valid if recover else invalid]) + step = DailyPaperAnalyzeStep(app_context=ApplicationContext(workspace_dir=str(tmp_path)), agent_wrapper=agent) + step.context = RuntimeContext(daily_paper_run_date="2026-07-21") + monkeypatch.setattr(step, "_extract_pdf_text_sync", lambda *_: ("evidence", 1, False)) + downloader = SimpleNamespace(download=AsyncMock()) + paper = _paper("2607.10001") + pick = PaperPick(arxiv_id=paper.arxiv_id, reasoning="test") + if recover: + result = await step._analyze_one(downloader, paper, pick, set()) # pylint: disable=protected-access + assert result.desc == "summary" and result.body == "Evidence" + assert (tmp_path / result.note_path).is_file() + else: + with pytest.raises(ValueError, match="empty Markdown"): + await step._analyze_one(downloader, paper, pick, set()) # pylint: disable=protected-access + assert not list(tmp_path.rglob("*.md")) + assert len(agent.calls) == 2 + assert step.prompt_format("analyze_short_retry") in agent.calls[1]["inputs"] + downloader.download.assert_awaited_once()