mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-08-28 05:25:04 +00:00
Some checks failed
Pre-commit / run (ubuntu-latest) (push) Has been cancelled
Tests ReMe / Unit Tests - py3.11 (push) Has been cancelled
Tests ReMe / Unit Tests - py3.12 (push) Has been cancelled
Tests ReMe / Unit Tests - py3.13 (push) Has been cancelled
Windows Smoke / CLI smoke - py3.11 (push) Has been cancelled
* feat(daily-paper): add daily paper cookbook workflow with schema and tests - Introduce daily paper schema types (DailyBriefOutput, PaperInfo, PaperNoteOutput, etc.) - Create daily paper cookbook module with analyze, collect, digest, rank, and select steps - Add cookbook entry point and integrate into main steps module - Replace job config export with daily brief output in schema exports - Add comprehensive unit tests covering pipeline, filtering, and output generation - Update dependencies including openai-codex and pypdf packages - Configure standalone daily paper cron job with proper scheduling and routing * test(daily_paper): update tests to use Claude Code wrapper exclusively - Add test to verify web search is disallowed by default in Claude Code - Update imports to include DailyBriefOutput, PaperNoteOutput, and PaperSelection schemas - Change test name from standalone_config_has_backend_split to reflect Claude Code only usage - Remove default agent wrapper and configure all steps to use Claude Code wrapper - Rename select_wrapper to cc_wrapper for clarity and consistency - Remove duplicate Claude Code wrapper initialization - Update test assertions to verify output schema usage matches expected sequence - Remove unused as_llm component from standalone configuration test * refactor(agent-wrapper): simplify skill resolution logic across all wrappers - Replace duplicate skill resolution code with centralized _resolve_project_skills method - Add project_path property with configurable relative path resolution - Introduce proper validation for skill names and directory existence - Change Codex wrapper to use project_path instead of workspace_path for skills - Add SKILL.md requirement validation for project skills - Remove redundant skill processing logic from individual wrappers * feat(daily_paper): add daily paper workflow with PDF analysis and brief generation - Implement shared state management and file helpers for daily-paper steps - Add PDF download and text extraction capabilities with arXiv integration - Create paper collection step with Hugging Face weekly/monthly rankings - Build ranking system using reciprocal-rank fusion with memory keyword scoring - Add Claude Code integration for paper analysis and detailed note generation - Implement digest step to create final five-minute brief from detailed notes - Add configuration for standalone daily cookbook application with cron scheduling - Create typed schema for paper information, selection, and output formats - Add atomic file writing with temporary file safety mechanisms - Implement exclusion logic for previously recommended papers and daily filters * feat(daily_paper): add DingTalk notification integration and enhance logging - Integrate DingTalk markdown send step to notify groups about daily paper briefs - Add comprehensive logging throughout daily paper workflow including start/finish events - Update daily paper analysis prompt to include code repository context requirement - Configure DingTalk notification in daily_cookbook.yaml with app credentials - Add dingtalk-stream dependency for proactive message API integration - Enhance daily paper README with DingTalk notification section and updated flow chart - Implement detailed logging for each step including paper processing and agent calls - Add test coverage for DingTalk markdown sending functionality and configuration - Update pre-commit config to exclude skills directory from checks - Add .claude/skills to gitignore for local development environment * refactor(dingtalk): move dingtalk_stream import to local scope and improve code safety - Moved global dingtalk_stream import to local scope in send.py to avoid eager loading - Added dynamic import with error handling for optional dependency cases - Updated test suite to verify lazy loading behavior works correctly - Fixed markdown title generation by using safe variable naming in wait.py - Enhanced test coverage for arxiv PDF download caching functionality - Updated application context initialization with proper resource directory configuration - Modified paper metadata to include source PDF path reference in output files * refactor(daily_paper): remove manifest system and store selection metadata in digest files - Remove JSON manifest creation and storage functionality - Store selection data directly in digest file frontmatter instead of separate manifest files - Add load_saved_selection method to rebuild selection from digest and paper-note metadata - Update README documentation to reflect new cookbook workflow architecture - Modify test cases to verify selection metadata in digest files instead of manifest JSON - Remove unused json import from multiple daily paper modules - Integrate PaperSelection schema for proper data validation in stored metadata * docs(daily_paper): add bilingual cookbook guides
515 lines
20 KiB
Python
515 lines
20 KiB
Python
"""Focused tests for the daily-paper cookbook workflow."""
|
||
|
||
import datetime as dt
|
||
import importlib
|
||
import json
|
||
from pathlib import Path
|
||
import subprocess
|
||
import sys
|
||
from unittest.mock import MagicMock
|
||
|
||
import frontmatter
|
||
import httpx
|
||
import pytest
|
||
|
||
from reme.components import ApplicationContext
|
||
from reme.components.agent_wrapper.base_agent_wrapper import BaseAgentWrapper
|
||
from reme.components.runtime_context import RuntimeContext
|
||
from reme.config.config_parser import _load_config
|
||
from reme.schema import DailyBriefOutput, PaperInfo, PaperNoteOutput, PaperSelection
|
||
from reme.steps.cookbook.daily_paper import (
|
||
DailyPaperAnalyzeStep,
|
||
DailyPaperCollectStep,
|
||
DailyPaperDigestStep,
|
||
DailyPaperRankStep,
|
||
DailyPaperSelectStep,
|
||
)
|
||
from reme.steps.cookbook.daily_paper import analyze, collect
|
||
from reme.steps.cookbook.daily_paper.rank import build_candidate_pool, rrf_score
|
||
from reme.steps.cookbook.dingtalk import DingTalkMarkdownSendStep
|
||
from reme.steps.cookbook.dingtalk import send as dingtalk_send
|
||
from reme.utils import arxiv as arxiv_utils
|
||
from reme.utils.huggingface_papers import paper_ids_from_html, paper_info_from_payload
|
||
|
||
|
||
class _QueuedAgentWrapper(BaseAgentWrapper):
|
||
"""Return queued structured responses without contacting an LLM."""
|
||
|
||
def __init__(self, outputs: list[dict], **kwargs):
|
||
super().__init__(**kwargs)
|
||
self.outputs = list(outputs)
|
||
self.calls: list[dict] = []
|
||
|
||
async def reply(self, inputs, **kwargs) -> dict:
|
||
"""Record the request and pop the next structured fixture."""
|
||
self.calls.append({"inputs": inputs, "kwargs": kwargs})
|
||
return {"structured_output": self.outputs.pop(0), "result": "ok"}
|
||
|
||
|
||
def _paper(arxiv_id: str, *, title: str = "Paper", upvotes: int = 10) -> PaperInfo:
|
||
return PaperInfo(
|
||
arxiv_id=arxiv_id,
|
||
title=title,
|
||
summary=f"Summary for {title}",
|
||
authors=["A. Author"],
|
||
upvotes=upvotes,
|
||
)
|
||
|
||
|
||
def test_hf_payload_and_html_normalization():
|
||
"""HF list/detail shapes normalize and HTML rank order de-duplicates."""
|
||
payload = {
|
||
"paper": {
|
||
"id": "2607.16051",
|
||
"title": "Loop the Loopies!",
|
||
"summary": "Abstract",
|
||
"authors": [{"name": "Zitian Gao"}],
|
||
"upvotes": 53,
|
||
"githubRepo": "https://github.com/example/repo",
|
||
},
|
||
"organization": {"fullname": "IQuest"},
|
||
}
|
||
|
||
paper = paper_info_from_payload(payload)
|
||
|
||
assert paper.arxiv_id == "2607.16051"
|
||
assert paper.authors == ["Zitian Gao"]
|
||
assert paper.organization == "IQuest"
|
||
assert paper.github_repo == "https://github.com/example/repo"
|
||
assert paper_ids_from_html(
|
||
'<a href="/papers/2607.16051">one</a><a href="/papers/2607.16051">dup</a>'
|
||
'<a href="/papers/2607.10001">two</a>',
|
||
) == ["2607.16051", "2607.10001"]
|
||
|
||
|
||
def test_rrf_and_memory_candidate_reserve():
|
||
"""RRF is exact and the candidate pool preserves a memory-related slot."""
|
||
general = _paper("2607.10001", title="General model", upvotes=100)
|
||
memory = _paper("2607.10002", title="Long-term memory for agents", upvotes=1)
|
||
general.fused_score = rrf_score(1, None, rrf_k=60, weekly_weight=0.7)
|
||
memory.fused_score = rrf_score(100, None, rrf_k=60, weekly_weight=0.7)
|
||
|
||
candidates = build_candidate_pool([general, memory], limit=2, memory_reserve=1)
|
||
|
||
assert candidates == [general, memory]
|
||
assert general.fused_score == pytest.approx(1 / 61)
|
||
|
||
|
||
def test_history_exclusion_reads_prior_frontmatter_only(tmp_path: Path):
|
||
"""Only prior dated paper notes contribute historical exclusions."""
|
||
prior = tmp_path / "daily" / "2026-07-20"
|
||
current = tmp_path / "daily" / "2026-07-21"
|
||
prior.mkdir(parents=True)
|
||
current.mkdir(parents=True)
|
||
(prior / "paper-2607.10001.md").write_text(
|
||
frontmatter.dumps(frontmatter.Post("body", arxiv_id="2607.10001")),
|
||
encoding="utf-8",
|
||
)
|
||
(current / "paper-2607.10002.md").write_text(
|
||
frontmatter.dumps(frontmatter.Post("body", arxiv_id="2607.10002")),
|
||
encoding="utf-8",
|
||
)
|
||
|
||
found = DailyPaperCollectStep.load_historical_arxiv_ids(
|
||
tmp_path,
|
||
dt.date(2026, 7, 21),
|
||
30,
|
||
"daily",
|
||
)
|
||
|
||
assert found == {"2607.10001"}
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_arxiv_pdf_downloads_missing_cache_once(tmp_path: Path, monkeypatch):
|
||
"""A missing PDF is downloaded atomically and then reused on the next lookup."""
|
||
requests: list[httpx.Request] = []
|
||
|
||
async def handler(request: httpx.Request) -> httpx.Response:
|
||
requests.append(request)
|
||
return httpx.Response(200, content=b"%PDF-downloaded")
|
||
|
||
transport = httpx.MockTransport(handler)
|
||
async_client = httpx.AsyncClient
|
||
monkeypatch.setattr(
|
||
arxiv_utils.httpx,
|
||
"AsyncClient",
|
||
lambda **kwargs: async_client(transport=transport, **kwargs),
|
||
)
|
||
target = tmp_path / "resource" / "papers" / "2607.10001.pdf"
|
||
client = arxiv_utils.ArxivPdfClient()
|
||
|
||
assert await client.download("2607.10001", target) == target
|
||
assert await client.download("2607.10001", target) == target
|
||
|
||
assert target.read_bytes() == b"%PDF-downloaded"
|
||
assert [str(request.url) for request in requests] == ["https://arxiv.org/pdf/2607.10001"]
|
||
|
||
|
||
def test_standalone_config_uses_only_claude_code_and_eight_am_cron(monkeypatch):
|
||
"""The standalone config schedules 08:00 and routes all agent work to CC."""
|
||
for name in (
|
||
"DINGTALK_APP_KEY",
|
||
"DINGTALK_APP_SECRET",
|
||
"DINGTALK_ROBOT_CODE",
|
||
"DINGTALK_CONVERSATION_IDS",
|
||
):
|
||
monkeypatch.delenv(name, raising=False)
|
||
config = _load_config("daily_cookbook")
|
||
|
||
assert config.get("extends") is None
|
||
assert config["jobs"]["daily_paper_cron"]["cron"] == "0 8 * * *"
|
||
steps = config["jobs"]["daily_paper"]["steps"]
|
||
assert config["jobs"]["daily_paper_cron"]["steps"] == steps
|
||
agent_steps = [
|
||
step
|
||
for step in steps
|
||
if step["backend"]
|
||
in {
|
||
"daily_paper_select_step",
|
||
"daily_paper_analyze_step",
|
||
"daily_paper_digest_step",
|
||
}
|
||
]
|
||
assert {step.get("agent_wrapper") for step in agent_steps} == {"claude_code"}
|
||
assert steps[-1] == {
|
||
"backend": "dingtalk_markdown_send_step",
|
||
"input_mapping": {"daily_paper_digest_path": "markdown_path"},
|
||
"app_key": "",
|
||
"app_secret": "",
|
||
"robot_code": "",
|
||
"conversation_ids": "",
|
||
"title": "ReMe Daily Paper",
|
||
"timeout": 15,
|
||
}
|
||
assert set(config["components"]["agent_wrapper"]) == {"claude_code"}
|
||
assert "as_llm" not in config["components"]
|
||
assert config["components"]["agent_wrapper"]["claude_code"]["project_path"] == ".."
|
||
assert "skills" not in config["components"]["agent_wrapper"]["claude_code"]
|
||
|
||
|
||
def test_daily_paper_config_passes_dingtalk_environment(monkeypatch):
|
||
"""The notifier receives all proactive-message settings from the environment."""
|
||
values = {
|
||
"DINGTALK_APP_KEY": "app-key",
|
||
"DINGTALK_APP_SECRET": "app-secret",
|
||
"DINGTALK_ROBOT_CODE": "robot-code",
|
||
"DINGTALK_CONVERSATION_IDS": "group-one,group-two",
|
||
}
|
||
for name, value in values.items():
|
||
monkeypatch.setenv(name, value)
|
||
|
||
step = _load_config("daily_cookbook")["jobs"]["daily_paper"]["steps"][-1]
|
||
|
||
assert {key: step[key] for key in ("app_key", "app_secret", "robot_code", "conversation_ids")} == {
|
||
"app_key": "app-key",
|
||
"app_secret": "app-secret",
|
||
"robot_code": "robot-code",
|
||
"conversation_ids": "group-one,group-two",
|
||
}
|
||
|
||
|
||
def test_reme_import_does_not_require_optional_dingtalk_stream():
|
||
"""Importing ReMe must not eagerly load the core-only DingTalk dependency."""
|
||
script = """
|
||
import builtins
|
||
|
||
original_import = builtins.__import__
|
||
|
||
def guarded_import(name, *args, **kwargs):
|
||
if name == "dingtalk_stream":
|
||
raise ModuleNotFoundError("blocked optional dependency")
|
||
return original_import(name, *args, **kwargs)
|
||
|
||
builtins.__import__ = guarded_import
|
||
import reme
|
||
"""
|
||
|
||
result = subprocess.run(
|
||
[sys.executable, "-c", script],
|
||
cwd=Path(__file__).parents[2],
|
||
capture_output=True,
|
||
text=True,
|
||
check=False,
|
||
)
|
||
|
||
assert result.returncode == 0, result.stderr
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_pipeline_filters_strict_yesterday_and_writes_outputs(
|
||
tmp_path: Path,
|
||
monkeypatch,
|
||
):
|
||
"""The complete mocked pipeline filters yesterday/history and writes linked notes."""
|
||
papers = {
|
||
"2607.10001": _paper("2607.10001", title="Best monthly paper", upvotes=100),
|
||
"2607.10002": _paper("2607.10002", title="Yesterday paper", upvotes=90),
|
||
"2607.10003": _paper("2607.10003", title="Previously recommended", upvotes=80),
|
||
}
|
||
|
||
prior_dir = tmp_path / "daily" / "2026-07-19"
|
||
prior_dir.mkdir(parents=True)
|
||
(prior_dir / "paper-2607.10003.md").write_text(
|
||
frontmatter.dumps(frontmatter.Post("old", arxiv_id="2607.10003")),
|
||
encoding="utf-8",
|
||
)
|
||
|
||
class _FakeHfClient:
|
||
requested_daily: list[str] = []
|
||
|
||
def __init__(self, **_kwargs):
|
||
pass
|
||
|
||
async def __aenter__(self):
|
||
return self
|
||
|
||
async def __aexit__(self, *_args):
|
||
return None
|
||
|
||
async def fetch_scope(self, scope: str, value: str):
|
||
"""Return deterministic weekly/monthly fixtures."""
|
||
if scope == "month":
|
||
assert value == "2026-07"
|
||
return list(papers.values())
|
||
assert value == "2026-W30"
|
||
return [papers["2607.10001"], papers["2607.10002"]]
|
||
|
||
async def fetch_daily_ids(self, day: str):
|
||
"""Record and return the exact requested day."""
|
||
self.requested_daily.append(day)
|
||
return {"2607.10002"}
|
||
|
||
async def fake_download(_self, _arxiv_id: str, target: Path):
|
||
"""Create a minimal cached-PDF fixture."""
|
||
target.parent.mkdir(parents=True, exist_ok=True)
|
||
target.write_bytes(b"%PDF-fake")
|
||
return target
|
||
|
||
def fake_extract(_self, _path: Path, _max_pages: int, _max_chars: int):
|
||
"""Return deterministic extracted text."""
|
||
return "--- PAGE 1 ---\nPaper content", 1, False
|
||
|
||
monkeypatch.setattr(collect, "HuggingFacePapersClient", _FakeHfClient)
|
||
monkeypatch.setattr(analyze.ArxivPdfClient, "download", fake_download)
|
||
monkeypatch.setattr(analyze.DailyPaperAnalyzeStep, "_extract_pdf_text_sync", fake_extract)
|
||
|
||
cc_wrapper = _QueuedAgentWrapper(
|
||
[
|
||
{
|
||
"selection_reasoning": "Best remaining ranked paper.",
|
||
"selected": [
|
||
{
|
||
"arxiv_id": "2607.10001",
|
||
"rank": 1,
|
||
"reason": "Strong result",
|
||
"memory_relevance": "low",
|
||
},
|
||
],
|
||
"alternates": [],
|
||
},
|
||
{
|
||
"description": "Detailed note",
|
||
"body": "# Detailed reading\n\nEvidence [p. 1].",
|
||
},
|
||
{
|
||
"description": "Five-minute brief",
|
||
"body": "# 今日论文速读\n\n[[daily/2026-07-21/paper-2607.10001.md]]",
|
||
},
|
||
],
|
||
)
|
||
app_context = ApplicationContext(
|
||
workspace_dir=str(tmp_path),
|
||
resource_dir="external-assets",
|
||
timezone="Asia/Shanghai",
|
||
language="zh",
|
||
)
|
||
context = RuntimeContext(
|
||
date="2026-07-21",
|
||
top_k=1,
|
||
candidate_limit=2,
|
||
memory_reserve=0,
|
||
)
|
||
|
||
await DailyPaperCollectStep(app_context=app_context)(context)
|
||
await DailyPaperRankStep(app_context=app_context)(context)
|
||
await DailyPaperSelectStep(app_context=app_context, agent_wrapper=cc_wrapper)(context)
|
||
await DailyPaperAnalyzeStep(app_context=app_context, agent_wrapper=cc_wrapper)(context)
|
||
await DailyPaperDigestStep(app_context=app_context, agent_wrapper=cc_wrapper)(context)
|
||
|
||
assert _FakeHfClient.requested_daily == ["2026-07-20"]
|
||
assert context.response.metadata["selected_arxiv_ids"] == ["2607.10001"]
|
||
assert context.response.metadata["excluded_yesterday_count"] == 1
|
||
assert context.response.metadata["excluded_history_count"] == 1
|
||
note_path = tmp_path / "daily" / "2026-07-21" / "paper-2607.10001.md"
|
||
digest_path = tmp_path / "daily" / "2026-07-21" / "daily-paper-brief.md"
|
||
note = frontmatter.load(note_path)
|
||
assert note.metadata["arxiv_id"] == "2607.10001"
|
||
assert note.metadata["source_pdf"] == "[[external-assets/papers/2607.10001.pdf]]"
|
||
assert (tmp_path / "external-assets" / "papers" / "2607.10001.pdf").is_file()
|
||
assert "[[daily/2026-07-21/paper-2607.10001.md]]" in digest_path.read_text(
|
||
encoding="utf-8",
|
||
)
|
||
assert not (tmp_path / "metadata" / "daily_paper" / "2026-07-21.json").exists()
|
||
digest = frontmatter.load(digest_path)
|
||
assert digest.metadata["selection_reasoning"] == "Best remaining ranked paper."
|
||
assert digest.metadata["arxiv_ids"] == ["2607.10001"]
|
||
assert all(set(call["kwargs"]) == {"output_schema"} for call in cc_wrapper.calls)
|
||
assert [call["kwargs"]["output_schema"] for call in cc_wrapper.calls] == [
|
||
PaperSelection,
|
||
PaperNoteOutput,
|
||
DailyBriefOutput,
|
||
]
|
||
analysis_prompt = cc_wrapper.calls[1]["inputs"]
|
||
assert "长期记忆相关性初筛:low" in analysis_prompt
|
||
assert "必须先使用代码读取和搜索工具查看当前 ReMe 代码仓库" in analysis_prompt
|
||
assert "这应当是少数例外:一般情况下不要给建议" in analysis_prompt
|
||
|
||
rerun = RuntimeContext(date="2026-07-21")
|
||
await DailyPaperCollectStep(app_context=app_context)(rerun)
|
||
assert rerun.response.metadata["skipped"] is True
|
||
assert rerun.response.metadata["selection"] == {
|
||
"selection_reasoning": "Best remaining ranked paper.",
|
||
"selected": [
|
||
{
|
||
"arxiv_id": "2607.10001",
|
||
"rank": 1,
|
||
"reason": "Strong result",
|
||
"memory_relevance": "low",
|
||
},
|
||
],
|
||
"alternates": [],
|
||
}
|
||
assert rerun.get("daily_paper_digest_path") == "daily/2026-07-21/daily-paper-brief.md"
|
||
assert _FakeHfClient.requested_daily == ["2026-07-20"]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_dingtalk_markdown_sends_groups_serially_in_configured_order(tmp_path: Path, monkeypatch):
|
||
"""The notifier gets one app token and posts once per group in list order."""
|
||
digest_path = tmp_path / "daily" / "2026-07-21" / "daily-paper-brief.md"
|
||
digest_path.parent.mkdir(parents=True)
|
||
digest_path.write_text(
|
||
frontmatter.dumps(frontmatter.Post("# 今日论文\n\n测试内容", name="daily-paper-brief")),
|
||
encoding="utf-8",
|
||
)
|
||
token_calls = 0
|
||
seen_payloads: list[dict] = []
|
||
|
||
def get_access_token(client):
|
||
nonlocal token_calls
|
||
token_calls += 1
|
||
assert client.credential.client_id == "app-key"
|
||
assert client.credential.client_secret == "app-secret"
|
||
return "app-access-token"
|
||
|
||
async def handler(request: httpx.Request) -> httpx.Response:
|
||
assert request.url.path == "/v1.0/robot/groupMessages/send"
|
||
assert request.headers["x-acs-dingtalk-access-token"] == "app-access-token"
|
||
seen_payloads.append(json.loads(request.content))
|
||
return httpx.Response(200, json={"processQueryKey": f"query-{len(seen_payloads)}"})
|
||
|
||
transport = httpx.MockTransport(handler)
|
||
transport_kwargs: dict = {}
|
||
|
||
def ipv4_transport(**kwargs):
|
||
transport_kwargs.update(kwargs)
|
||
return transport
|
||
|
||
dingtalk_stream = importlib.import_module("dingtalk_stream")
|
||
monkeypatch.setattr(dingtalk_stream.DingTalkStreamClient, "get_access_token", get_access_token)
|
||
monkeypatch.setattr(dingtalk_send.httpx, "AsyncHTTPTransport", ipv4_transport)
|
||
app_context = ApplicationContext(workspace_dir=str(tmp_path))
|
||
context = RuntimeContext(markdown_path="daily/2026-07-21/daily-paper-brief.md")
|
||
|
||
step = DingTalkMarkdownSendStep(
|
||
app_context=app_context,
|
||
app_key="app-key",
|
||
app_secret="app-secret",
|
||
robot_code="robot-code",
|
||
conversation_ids=" group-one,group-two ",
|
||
title="ReMe Daily Paper",
|
||
)
|
||
step.logger = MagicMock()
|
||
response = await step(context)
|
||
|
||
assert token_calls == 1
|
||
assert transport_kwargs == {"local_address": "0.0.0.0"}
|
||
assert [payload["openConversationId"] for payload in seen_payloads] == ["group-one", "group-two"]
|
||
assert all(payload["robotCode"] == "robot-code" for payload in seen_payloads)
|
||
assert all(payload["msgKey"] == "sampleMarkdown" for payload in seen_payloads)
|
||
assert [json.loads(payload["msgParam"]) for payload in seen_payloads] == [
|
||
{"title": "ReMe Daily Paper", "text": "# 今日论文\n\n测试内容"},
|
||
] * 2
|
||
assert response.metadata["dingtalk_configured_count"] == 2
|
||
assert response.metadata["dingtalk_sent_count"] == 2
|
||
logs = "\n".join(call.args[0] for call in step.logger.info.call_args_list)
|
||
assert "sending DingTalk Markdown" in logs
|
||
assert "delivery complete sent=2 total=2" in logs
|
||
assert all(value not in logs for value in ("app-key", "app-secret", "robot-code", "group-one", "group-two"))
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_dingtalk_markdown_without_conversations_is_a_noop(tmp_path: Path):
|
||
"""An empty conversation list keeps daily-paper generation usable without DingTalk."""
|
||
context = RuntimeContext(markdown_path="missing.md")
|
||
|
||
response = await DingTalkMarkdownSendStep(app_context=ApplicationContext(workspace_dir=str(tmp_path)))(context)
|
||
|
||
assert response.success is True
|
||
assert response.metadata["dingtalk_configured_count"] == 0
|
||
assert response.metadata["dingtalk_sent_count"] == 0
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_existing_daily_paper_is_reused_and_sent_to_dingtalk(tmp_path: Path, monkeypatch):
|
||
"""An idempotent daily-paper run skips generation but still notifies DingTalk."""
|
||
digest_path = tmp_path / "daily" / "2026-07-22" / "daily-paper-brief.md"
|
||
digest_path.parent.mkdir(parents=True)
|
||
digest_path.write_text(
|
||
frontmatter.dumps(frontmatter.Post("# 已有日报\n\n复用正文", name="daily-paper-brief")),
|
||
encoding="utf-8",
|
||
)
|
||
seen_payloads: list[dict] = []
|
||
|
||
dingtalk_stream = importlib.import_module("dingtalk_stream")
|
||
monkeypatch.setattr(
|
||
dingtalk_stream.DingTalkStreamClient,
|
||
"get_access_token",
|
||
lambda _client: "app-access-token",
|
||
)
|
||
|
||
async def handler(request: httpx.Request) -> httpx.Response:
|
||
seen_payloads.append(json.loads(request.content))
|
||
return httpx.Response(200, json={"processQueryKey": "query-1"})
|
||
|
||
transport = httpx.MockTransport(handler)
|
||
|
||
monkeypatch.setattr(dingtalk_send.httpx, "AsyncHTTPTransport", lambda **_kwargs: transport)
|
||
app_context = ApplicationContext(workspace_dir=str(tmp_path))
|
||
context = RuntimeContext(date="2026-07-22")
|
||
|
||
await DailyPaperCollectStep(app_context=app_context)(context)
|
||
response = await DingTalkMarkdownSendStep(
|
||
app_context=app_context,
|
||
input_mapping={"daily_paper_digest_path": "markdown_path"},
|
||
app_key="app-key",
|
||
app_secret="app-secret",
|
||
robot_code="robot-code",
|
||
conversation_ids="existing-group",
|
||
title="ReMe Daily Paper",
|
||
)(context)
|
||
|
||
assert response.metadata["skipped"] is True
|
||
assert response.metadata["dingtalk_sent_count"] == 1
|
||
assert seen_payloads == [
|
||
{
|
||
"robotCode": "robot-code",
|
||
"openConversationId": "existing-group",
|
||
"msgKey": "sampleMarkdown",
|
||
"msgParam": json.dumps(
|
||
{"title": "ReMe Daily Paper", "text": "# 已有日报\n\n复用正文"},
|
||
ensure_ascii=False,
|
||
),
|
||
},
|
||
]
|