mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-11-02 07:13:50 +00:00
Some checks are pending
Pre-commit / run (ubuntu-latest) (push) Waiting to run
Tests ReMe / Unit Tests - py3.13 (push) Waiting to run
Tests ReMe / Unit Tests - py3.11 (push) Waiting to run
Tests ReMe / Unit Tests - py3.12 (push) Waiting to run
Windows Smoke / CLI smoke - py3.11 (push) Waiting to run
* feat: add ssh proxy * feat: add ssh proxy * feat: add ssh proxy * feat: add ssh proxy * feat: add prompt * feat: add agent wrapper * feat: add agent wrapper * feat: add agent wrapper * feat: add tushare skill * feat: add tushare skill * feat: add tushare skill * feat: add none stream * chore(deps): update dependency versions in pyproject.toml - Bump claude-agent-sdk from 0.2.123 to 0.2.126 - Upgrade pre-commit to version 4.6.1 or higher - Upgrade pytest to version 9.1.1 or higher * feat(agent_wrapper): add session compaction support and unify session commands - Introduce compact_session method to BaseAgentWrapper and implement it in AsAgentWrapper, CcAgentWrapper, and CodexAgentWrapper - Add session_command module with SessionCommandResult dataclass and handle_session_command function for /clear and /compact commands - Update __init__.py exports to include session_command handlers - Modify DingTalkWaitStep to handle session commands via handle_session_command function - Remove streaming mode from DingTalkWaitStep and simplify reply handling to final Markdown replies only - Add unit tests for session compaction methods and session command handling across wrappers and DingTalk integration - Clean up and remove obsolete streaming and card rendering code from DingTalk wait step - Adjust daily_cookbook.yaml to remove stream and card_update_interval config entries for DingTalk wait step * feat(auto_fin): add Auto Fin simulated portfolio cookbook workflow - Add comprehensive Auto Fin schema exports for multiple models and enums - Implement base class and helpers for Auto Fin analysis steps - Create file, state, and formatting utilities for Auto Fin with atomic file writes and locking - Define Auto Fin pipeline with four analysis agents: backtest, event, portfolio, and US correlation - Register Auto Fin package in cookbook workflows and schema initialization - Add detailed documentation in markdown describing the system design, workflow, and data contracts * feat(outbound_proxy): add application-scoped outbound HTTP proxy components - Introduce BaseOutboundProxy and OutboundProxyEndpoint as core contracts - Implement FixedHttpOutboundProxy for external HTTP proxy integration - Add SshHttpOutboundProxy providing SSH-backed local HTTP proxy tunnels - Register outbound proxy components in component registry and enumeration - Update components package to include outbound_proxy module - Add dependency on pproxy for SSH HTTP proxy bridging - Include comprehensive unit tests covering proxy lifecycle, validation, environment merging, error handling, readiness, and monitoring mechanisms * refactor(network): replace SSH proxy with explicit HTTP outbound proxy - Remove SSH proxy helper implementation and references in codebase - Add support for explicit HTTP proxy URL in arXiv and HuggingFace clients - Modify clients to use async context manager for consistent resource handling - Update daily paper steps to forward outbound proxy configuration explicitly - Change tests to cover new proxy usage model and remove SSH proxy mocks - Add outbound proxy component configuration in daily_cookbook.yaml - Ensure proxy URL usage disables environment trust in HTTP clients - Fix app context component enum access to be defensive against missing keys * feat(agent_wrapper): add managed proxy support for command environments - Introduce BaseOutboundProxy binding in BaseAgentWrapper for outbound proxy management - Add bash_environment and command_proxy_environment properties to apply proxy settings - Update WorkspaceBackend instantiation in AsAgentWrapper to use bash_environment - Inject managed proxy export commands into Claude Code Bash commands via hooks - Enhance CodexAgentWrapper to include managed proxy in shell environment policy - Modify daily_cookbook.yaml steps to specify outbound_proxy as default where needed - Add comprehensive unit tests verifying managed proxy injection and environment isolation - Ensure subprocess_environment remains unchanged while proxy is applied selectively to commands * refactor(memory): replace search job_tools with memory in daily cookbook config - Change workspace_dir default from .reme to reme_workspace - Replace search job_tools with memory across multiple components and jobs - Update descriptions to reflect long-term memory retrieval instead of search - Modify system prompts to instruct using memory for retrieving notes - Adjust unit tests to verify memory job_tools and job presence instead of search - Ensure consistency in configuration and tests for memory backend usage * refactor(config): rename memory to memory_search in daily cookbook config - Change all occurrences of "memory" to "memory_search" in job_tools and job definitions - Update related system prompts to reflect the new memory_search terminology - Modify unit tests to assert the presence of memory_search instead of memory - Ensure consistency across skills, job tools, and backend configurations in multiple components * feat(auto_fin): add deterministic quantitative research and ranking fusion - Introduce new schema models: EtfScore, RankingMetrics, ExtremeAnalysis, DimensionRanking, and FusionRanking to represent deterministic research outputs - Add ranking data to event, backtest, us_correlation, and portfolio analysis outputs - Implement ranking_section renderer to format Top20 scores and diagnostics in Markdown - Develop AutoFinQuantStep for deterministic ETF ranking using TuShare data, Polars, and a custom extremely randomized tree ensemble - Integrate quantitative rankings into backtest and portfolio analysis steps and reports - Extend auto_fin pipeline with new quant_enabled and quant_required config options - Enforce ranking constraints like unique codes, contiguous ranks, and normalized fusion weights - Update analysis YAMLs with rules limiting data freshness, universe, and ranking usage - Incorporate ranking outputs into all major markdown report bodies in Auto Fin pipeline - Add concurrency-limited asynchronous TuShare client to fetch required market data - Introduce cross-sectional rank correlation and NDCG metrics for ranking quality evaluation * feat(auto_fin): implement stage-wise notification and reporting for analysis pipeline - Refactor notification config in daily_cookbook.yaml to support dispatch steps - Update AutoFinNotificationStep to deduplicate notifications per run stage - Add _notify_stage method in pipeline to send notifications for each analysis stage - Implement persistence and notification for event, backtest, US correlation, and portfolio stages - Modify pipeline flow to persist reports and notify after each stage completion - Adjust metadata to track notifications and errors per stage - Update tests to verify stage-wise notification sending and deduplication - Remove older combined report persistence in favor of modular stage handling * feat(auto_fin): add outbound proxy support for Tushare API usage - Introduce BaseOutboundProxy reference in AutoFinPipelineStep and AutoFinQuantStep - Update TushareResearchClient and trade calendar fetch to accept and use proxy URL - Create _ProxiedTushareApi adapter to route Tushare requests via explicit HTTP proxy - Modify create_tushare_api utility to optionally return proxied API client - Add unit tests covering proxy forwarding and client behavior with managed proxies - Ensure proxy usage respects explicit proxy URL over environment fallback - Integrate outbound proxy into data fetching and quantitative research steps * feat(auto_fin): enforce checkpoint time validation and add state models - Introduce AnalysisState base class and specific states for event, backtest, and US correlation analyses - Replace analysis output types with corresponding state classes in run schemas - Add require_checkpoint_reached method to validate decision_at/data_cutoff against current time - Enforce checkpoint time checks before analysis steps in event, backtest, portfolio, and quant analyses - Refactor quant data loading to include adjustment factors and apply price adjustments without fallback - Update analysis YAML docs to require real-time checkpoint validation and forbid using future data - Improve portfolio run serialization by excluding redundant legacy fields and nested proposed actions - Add helper to extract readable sections from persisted checkpoint documents - Fix event analysis output validation to reject events and sources with future timestamps * feat(auto_fin): auto-select latest reached checkpoint if none specified - Extend checkpoint config to accept empty string for auto selection - Add static method to compute latest checkpoint reached by current time - Modify pipeline step to auto-select checkpoint based on trade calendar and time - Adjust force flag default depending on whether checkpoint is explicit or auto - Log details when checkpoint is auto-selected to improve observability - Add comprehensive tests for auto checkpoint selection logic and edge cases - Remove deprecated default and required constraints from force parameter in config * refactor(auto_fin): unify datetime comparison with compare_datetimes utility - Replace direct datetime comparisons with compare_datetimes function calls - Use cmp_to_key with compare_datetimes for sorting datetime tuples and lists - Update validation logic in backtest, event, analysis, and ledger modules for consistent datetime handling - Add unit tests to verify handling of naive and aware datetime comparisons in event and backtest validations - Ensure marked_at and interval_end timestamps are set and compared consistently using compare_datetimes - Improve correctness of ordering and conditional checks related to timestamps throughout auto_fin steps and ledger code * feat(auto_fin): add datetime comparison helper for mixed timezone data - Implement compare_datetimes function to handle naive and aware datetimes - Ensure naive datetime is interpreted in the known timezone of the counterpart - Facilitate comparisons between legacy and timezone-aware Auto Fin data - Add module docstring explaining purpose of the helpers * docs(auto_fin): enforce unique ETF representative per sub-theme in analysis rules - Update backtest.yaml to recommend or highlight only one ETF per sub-theme for ETF analyses - Modify event.yaml to map only one representative ETF per sub-theme, avoiding duplicate recommendations - Revise portfolio.yaml to restrict holdings/buys to a single ETF per sub-theme, preventing repeated buys of highly overlapping ETFs - Adjust us_correlation.yaml to retain only one representative A-share ETF per sub-theme for mapping or recommendation - Add test to verify presence of new sub-theme uniqueness guidance in step prompts * feat(auto_fin): separate draft model and include deterministic fusion ranking - Introduce _PortfolioProposalDraft pydantic model for agent-authored fields before ranking - Discard any "fusion_ranking" data from draft to prevent conflicts with canonical ranking - Modify AutoFinPortfolioStep to receive draft, enrich with fusion_ranking, and produce final output - Update tests to use _PortfolioProposalDraft and validate deterministic fusion ranking propagation - Add async test verifying fusion ranking is correctly set in portfolio output with no errors * refactor(auto_fin): rewrite and simplify Auto Fin schema and steps - Remove legacy Auto Fin analysis step modules and helpers - Replace complex ranking and portfolio models with simplified current-news models - Update schema to focus on news-case workflow with new domain models - Remove A-share decision checkpoints and backtest details from schema - Simplify recommendation and decision output structures - Clean up deprecated state and utility functions - Update Auto Fin steps initialization to new pipeline steps only - Improve uniqueness validation for themes and ETFs in research plan * feat(auto_fin): implement full local cache and analysis workflow for Auto Fin - Add AutoFinDataStep to prepare and cache daily TuShare data with lookback - Add AutoFinAnalysisStep to analyze cached data and generate Markdown report - Implement detailed time window, ETF filtering, and historical case validation - Introduce YAML prompts for planning and decision-making steps - Update .gitignore to include reme_workspace/ - Clean up config and import structure for auto_fin steps - Remove old pipeline.py and consolidate functionality into new modules - Use polars for efficient CSV reading and data processing - Ensure atomic writes and strict JSON serialization for cache files - Enforce rules on news timing, ETF universe, and historical case usage * fix(auto_fin): restrict news data source to '财联社' in analysis and cache - Update analysis templates to specify current news as from '财联社' only - Modify news fetching functions to filter by source '财联社' - Add validation method to check cached news source correctness - Update news caching logic to exclude non-'财联社' news - Enhance unit tests with multiple sources to ensure filtering works - Confirm news API calls include source filter parameter as '财联社' * refactor(auto_fin): convert I/O methods to asynchronous implementations - Change _news, _dataset, and _theme_data methods to async for improved concurrency - Move JSONL and CSV reading operations to asynchronous wrappers using asyncio.to_thread - Remove synchronous _read_jsonl and _read_csv functions, integrate them as static async class methods - Update cache validation methods to async, awaiting I/O operations accordingly - Adjust usage of dataset and news retrieval in analysis step to await asynchronous methods - Add async unit test to validate JSONL reading with unicode line separators - Preserve existing functionality while enabling non-blocking file and data access * fix(nx_file_graph): defer networkx import and improve dependency handling - Move networkx import inside NxFileGraph constructor for lazy loading - Raise ImportError with original exception context if networkx is missing - Remove module-level fallback assignment of nx to None - Expand test to block loading of multiple optional core dependencies eagerly - Change exception type in test from ModuleNotFoundError to AssertionError - Update test comments to reflect broader optional dependency checks * feat(embedding_store): add quota retry delay mechanism for embedding requests - Introduce quota_retry_delay parameter to configure wait time before retry on quota exhaustion - Implement detection of insufficient quota errors in LocalEmbeddingStore without external SDK - Add retry logic with custom delay when quota is insufficient during embedding requests - Update configuration to set max_retries and quota_retry_delay defaults for embedding store - Add unit tests covering quota exhaustion retry behavior with delay and opt-in control - Ensure existing retry behavior remains unchanged if quota_retry_delay is not set * feat(auto_fin): add detailed logging to analysis and data fetching steps - Add _preview static method for bounded diagnostic output in analysis.py - Log prompt start, completion, errors, and validation details in _reply method - Add info logs for major processing steps in execute method of analysis.py - Add debug and info logs for cache validation, data fetching, and pagination in data.py - Log conditions for skipping reports and cache plans in data.py execute method - Log download summaries and cache writes for news and ETF data - Improve error logging with exception details in cache validation functions - Ensure all logs include context such as record counts, paths, and parameters * refactor(auto_fin): overhaul Auto Fin workflow and schema contracts - Replace old Auto Fin schema models with comprehensive new data classes - Remove legacy Auto Fin analysis step in favor of modular agent-based steps - Introduce AutoFinAgentStep for validating structured agent replies - Simplify data cleaning and JSONL writing utilities for news cache - Remove synchronous and asynchronous dataset methods from analysis step - Redefine Auto Fin analysis configuration for 360-day news retention and multi-step pipeline - Remove embedded analysis prompt templates and replace with agent-driven logic - Update __init__.py exports to match new step implementations and remove deprecated classes - Improve error handling and validation in agent step reply processing - Clean up redundant imports and unused code in analysis and data preparation modules * feat(auto_fin): add detailed logging for analysis and data processing steps - Add timing logs to measure agent prompt processing duration in analysis.py - Log news cache hits and news write paths with record counts in data.py - Include detailed info logs for news download start and completion in data.py - Add start, progress, and completion logs with topic and event counts in history.py - Log start and completion of merge step including path and ETF count in merge.py - Add start and done logs with window and news counts in topic.py * feat(auto_fin): enhance schema and steps with detailed ETF and event modeling - Replace and add multiple AutoFin schema classes to support detailed ETF selection, historical research, market analysis, forecast models, and report output with validation - Implement Shanghai timezone normalization and strict validation in schema models - Remove deprecated AutoFin analysis agent step and consolidate reply handling in base step - Introduce AutoFinStep base class with shared helpers for prompt handling, data fetching, logging, and JSONL file operations - Add AutoFinDataStep to manage daily news data complete with schedule validation, caching, and source validation logic - Update cookbook configuration to customize auto_fin step parameters and simplify outbound proxy settings - Refactor imports and clean unused code for better maintainability * feat(auto_fin): introduce detailed historical event resolution and market similarity analysis - Add AutoFinHistoricalEventReference and AutoFinHistoricalSimilarity models for refined event referencing and similarity judgment - Implement validation to ensure non-empty critical fields and uniqueness of historical news IDs - Develop method to resolve Agent-selected historical event references from workspace files with strict path and existence checks - Enrich historical events with market entry and future returns data after resolution - Redesign market step to calculate similarity-weighted ETF forecasts based on matched historical event similarities - Enforce validation on matched historical events for uniqueness and proper weight summation - Simplify merge step output to final Markdown report without YAML frontmatter and redundant fields - Update user instructions for history search, market, and merge steps to reflect new data structures and responsibilities - Adjust test suite to cover new schema and step behavior changes, including enhanced validation and JSON output formats * feat(auto_fin): add new cron jobs and output analysis jsonl - Add new cron jobs auto_fin_1145_cron and auto_fin_1800_cron with auto_fin_steps - Change auto_fin_0930_cron schedule to run Monday to Sunday - Extend merge step to write analysis data to auto_fin_analysis.jsonl - Update unit tests to verify new cron jobs and their steps configuration * fix(auto_fin): improve atomic file write and refresh daily index - Change temporary file naming to include UUID for uniqueness and hidden prefix - Replace atomic write method from using Path.replace to os.replace with safe unlink - Add import and use os.replace for safer file replace operation - Refresh daily index after writing auto finance markdown and JSONL files - Import and call refresh_day_index in merge step to update file index asynchronously * docs(cookbook): add optional SSH proxy configuration in README files - Introduce optional SSH proxy setup in auto-fin and daily_paper cookbooks - Provide instructions to enable outbound proxy via `daily_cookbook.yaml` and environment variables - Add `REME_PROXY_IP` and `REME_PROXY_ACCOUNT` environment variables descriptions in multiple README files - Update English and Chinese README and README_ZH documents with proxy details - Maintain consistent formatting of environment variable tables across documents * fix(file_io): include schema_version in hidden metadata keys - Added "schema_version" to _INDEX_HIDDEN_METADATA_KEYS in _daily_index.py - Updated _render_notes_block to always include additional keys regardless of schema_version fix(deps): move pproxy dependency to later in pyproject.toml - Removed pproxy from early dependencies list - Added pproxy back near the end of dependency list for better ordering fix(outbound_proxy): require pproxy package for ssh_http proxy - Added importlib.util check for pproxy package presence - Raise RuntimeError if pproxy is not installed when using SSH HTTP outbound proxy - Improved error message suggests installing reme-ai with 'core' extra * docs(readme): update News section with new Cookbook workflows - Clarify introduction of optional Cookbooks with Daily Paper and Auto Fin workflows - Update English README to reflect both paper discovery and file-native ETF event research - Revise Chinese README to include financial news and historical market data research capability - Maintain announcement of paper acceptance at Findings of ACL 2026 * feat(auto_fin): add calculation results to final Markdown output - Implement _calculation_results to summarize forecast for each ETF analyzed - Include program-calculated results in the JSON input for the Markdown report - Update YAML template to incorporate calculation results and adjust recommendation rules - Refine recommendation logic to rely on event impact judgments combined with calculation outputs - Modify tests to verify presence of calculation results and updated report content and format * up prompt * fix(keyword_index): ignore non-indexable chunks during keyword sync - Add is_indexable method to base and BM25 keyword index classes to check text tokenizability - Update local file store to exclude non-indexable chunks from expected document IDs to prevent rebuild - Fix JSONL chunker to correctly handle Unicode line separator U+2028 inside JSON strings without splitting - Add test to ensure non-empty but non-indexable chunk does not trigger keyword index rebuild - Add test to verify U+2028 character does not cause incorrect JSONL record splitting
730 lines
27 KiB
Python
730 lines
27 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 AsyncMock, 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.outbound_proxy import FixedHttpOutboundProxy
|
||
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 import huggingface_papers as hf_utils
|
||
from reme.utils.huggingface_papers import paper_ids_from_html, paper_info_from_payload
|
||
from reme.enumeration import ComponentEnum
|
||
|
||
|
||
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"]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_hf_client_uses_explicit_outbound_proxy(monkeypatch):
|
||
"""The owned HTTP client uses only the explicitly supplied HTTP proxy."""
|
||
events: list[str] = []
|
||
client_kwargs: dict = {}
|
||
logger = MagicMock()
|
||
|
||
class FakeAsyncClient:
|
||
"""Capture construction and close ordering without network access."""
|
||
|
||
def __init__(self, **kwargs):
|
||
client_kwargs.update(kwargs)
|
||
|
||
async def aclose(self):
|
||
"""Record deterministic client cleanup."""
|
||
events.append("client-close")
|
||
|
||
monkeypatch.setattr(hf_utils.httpx, "AsyncClient", FakeAsyncClient)
|
||
|
||
client = hf_utils.HuggingFacePapersClient(
|
||
proxy_url="http://127.0.0.1:43124",
|
||
timeout=12.0,
|
||
logger=logger,
|
||
)
|
||
assert client.client is None
|
||
async with client:
|
||
assert client_kwargs["proxy"] == "http://127.0.0.1:43124"
|
||
assert client_kwargs["trust_env"] is False
|
||
|
||
assert events == ["client-close"]
|
||
info_messages = [call.args[0] for call in logger.info.call_args_list]
|
||
assert info_messages == ["[HuggingFacePapersClient] network mode=outbound_proxy"]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_hf_client_logs_retry_after_http_error(monkeypatch):
|
||
"""Transient HTTP failures report the attempt before retrying."""
|
||
attempts = 0
|
||
logger = MagicMock()
|
||
|
||
async def handler(request: httpx.Request) -> httpx.Response:
|
||
nonlocal attempts
|
||
attempts += 1
|
||
if attempts == 1:
|
||
raise httpx.ConnectTimeout("timed out", request=request)
|
||
return httpx.Response(200, json=[])
|
||
|
||
sleep = AsyncMock()
|
||
monkeypatch.setattr(hf_utils.asyncio, "sleep", sleep)
|
||
async with httpx.AsyncClient(
|
||
base_url="https://huggingface.co",
|
||
transport=httpx.MockTransport(handler),
|
||
) as raw_client:
|
||
async with hf_utils.HuggingFacePapersClient(
|
||
client=raw_client,
|
||
max_retries=2,
|
||
logger=logger,
|
||
) as client:
|
||
paper_ids = await client.fetch_daily_ids("2026-07-22")
|
||
assert raw_client.is_closed is False
|
||
|
||
assert paper_ids == set()
|
||
assert attempts == 2
|
||
sleep.assert_awaited_once_with(0.25)
|
||
warning = logger.warning.call_args.args[0]
|
||
assert "request retry path=/api/daily_papers attempt=1/2" in warning
|
||
assert "error=ConnectTimeout detail=timed out" in warning
|
||
|
||
|
||
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"
|
||
logger = MagicMock()
|
||
|
||
async with arxiv_utils.ArxivPdfClient(logger=logger) as client:
|
||
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"]
|
||
info_messages = [call.args[0] for call in logger.info.call_args_list]
|
||
assert any("download start arxiv_id=2607.10001" in message for message in info_messages)
|
||
assert any("download done arxiv_id=2607.10001" in message for message in info_messages)
|
||
assert "cache hit arxiv_id=2607.10001" in logger.debug.call_args.args[0]
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_arxiv_pdf_uses_explicit_outbound_proxy(tmp_path: Path, monkeypatch):
|
||
"""The owned arXiv client uses and closes its explicit HTTP proxy client."""
|
||
events: list[str] = []
|
||
client_kwargs: dict = {}
|
||
logger = MagicMock()
|
||
|
||
class FakeResponse:
|
||
"""Return one small, valid PDF stream."""
|
||
|
||
headers = {"content-length": "12"}
|
||
|
||
def raise_for_status(self):
|
||
"""Match the successful httpx response interface."""
|
||
|
||
async def aiter_bytes(self):
|
||
"""Yield the fake PDF body."""
|
||
yield b"%PDF-proxied"
|
||
|
||
class FakeStream:
|
||
"""Async response context returned by the HTTP client."""
|
||
|
||
async def __aenter__(self):
|
||
return FakeResponse()
|
||
|
||
async def __aexit__(self, exc_type, exc_value, traceback):
|
||
return None
|
||
|
||
class FakeAsyncClient:
|
||
"""Capture client construction and cleanup without network access."""
|
||
|
||
def __init__(self, **kwargs):
|
||
"""Capture HTTPX construction arguments."""
|
||
client_kwargs.update(kwargs)
|
||
|
||
async def aclose(self):
|
||
"""Record deterministic client cleanup."""
|
||
events.append("client-close")
|
||
|
||
def stream(self, method, url):
|
||
"""Return the fake streaming response context."""
|
||
assert (method, url) == ("GET", "https://arxiv.org/pdf/2607.10001")
|
||
return FakeStream()
|
||
|
||
monkeypatch.setattr(arxiv_utils.httpx, "AsyncClient", FakeAsyncClient)
|
||
target = tmp_path / "2607.10001.pdf"
|
||
|
||
async with arxiv_utils.ArxivPdfClient(
|
||
proxy_url="http://127.0.0.1:43124",
|
||
timeout=12.0,
|
||
logger=logger,
|
||
) as client:
|
||
assert await client.download("2607.10001", target) == target
|
||
|
||
assert target.read_bytes() == b"%PDF-proxied"
|
||
assert client_kwargs["proxy"] == "http://127.0.0.1:43124"
|
||
assert client_kwargs["trust_env"] is False
|
||
assert events == ["client-close"]
|
||
info_messages = [call.args[0] for call in logger.info.call_args_list]
|
||
assert info_messages[0] == "[ArxivPdfClient] network mode=outbound_proxy"
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_paper_clients_enforce_context_and_unambiguous_ownership(tmp_path: Path):
|
||
"""Requests require context entry and injected clients cannot also receive proxy_url."""
|
||
with pytest.raises(RuntimeError, match="async context manager"):
|
||
await hf_utils.HuggingFacePapersClient().fetch_daily_ids("2026-07-22")
|
||
with pytest.raises(RuntimeError, match="async context manager"):
|
||
await arxiv_utils.ArxivPdfClient().download("2607.10001", tmp_path / "paper.pdf")
|
||
|
||
async with httpx.AsyncClient() as raw_client:
|
||
async with arxiv_utils.ArxivPdfClient(client=raw_client):
|
||
pass
|
||
assert raw_client.is_closed is False
|
||
|
||
with pytest.raises(ValueError, match="client and proxy_url"):
|
||
hf_utils.HuggingFacePapersClient(
|
||
client=raw_client,
|
||
proxy_url="http://127.0.0.1:43124",
|
||
)
|
||
with pytest.raises(ValueError, match="client and proxy_url"):
|
||
arxiv_utils.ArxivPdfClient(
|
||
client=raw_client,
|
||
proxy_url="http://127.0.0.1:43124",
|
||
)
|
||
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_daily_paper_steps_forward_one_managed_proxy_endpoint(tmp_path: Path, monkeypatch):
|
||
"""Collection and one shared PDF client receive the same application proxy URL."""
|
||
paper = _paper("2607.10001", title="Managed proxy paper")
|
||
hf_kwargs: list[dict] = []
|
||
arxiv_kwargs: list[dict] = []
|
||
|
||
class FakeHfClient:
|
||
"""Return one eligible paper while recording construction."""
|
||
|
||
def __init__(self, **kwargs):
|
||
hf_kwargs.append(kwargs)
|
||
|
||
async def __aenter__(self):
|
||
return self
|
||
|
||
async def __aexit__(self, *_args):
|
||
return None
|
||
|
||
async def fetch_scope(self, _scope: str, _value: str):
|
||
"""Return one ranked paper."""
|
||
return [paper]
|
||
|
||
async def fetch_daily_ids(self, _day: str):
|
||
"""Return no yesterday exclusions."""
|
||
return set()
|
||
|
||
class FakeArxivClient:
|
||
"""Write a fake PDF while recording one shared downloader."""
|
||
|
||
def __init__(self, **kwargs):
|
||
arxiv_kwargs.append(kwargs)
|
||
|
||
async def __aenter__(self):
|
||
return self
|
||
|
||
async def __aexit__(self, *_args):
|
||
return None
|
||
|
||
async def download(self, _arxiv_id: str, target: Path):
|
||
"""Write one minimal PDF fixture."""
|
||
target.parent.mkdir(parents=True, exist_ok=True)
|
||
target.write_bytes(b"%PDF-fake")
|
||
return target
|
||
|
||
monkeypatch.setattr(collect, "HuggingFacePapersClient", FakeHfClient)
|
||
monkeypatch.setattr(analyze, "ArxivPdfClient", FakeArxivClient)
|
||
monkeypatch.setattr(
|
||
analyze.DailyPaperAnalyzeStep,
|
||
"_extract_pdf_text_sync",
|
||
lambda *_args: ("--- PAGE 1 ---\nPaper content", 1, False),
|
||
)
|
||
|
||
proxy = FixedHttpOutboundProxy(url="http://127.0.0.1:43124")
|
||
await proxy.start()
|
||
app_context = ApplicationContext(workspace_dir=str(tmp_path))
|
||
app_context.components = {ComponentEnum.OUTBOUND_PROXY: {"default": proxy}}
|
||
context = RuntimeContext(date="2026-07-21")
|
||
agent = _QueuedAgentWrapper(
|
||
[
|
||
{
|
||
"description": "Detailed note",
|
||
"body": "# Detailed reading\n\nEvidence [p. 1].",
|
||
},
|
||
],
|
||
)
|
||
|
||
try:
|
||
await DailyPaperCollectStep(app_context=app_context)(context)
|
||
context["daily_paper_selection"] = PaperSelection.model_validate(
|
||
{
|
||
"selection_reasoning": "Only candidate.",
|
||
"selected": [
|
||
{
|
||
"arxiv_id": paper.arxiv_id,
|
||
"rank": 1,
|
||
"reason": "Relevant",
|
||
"memory_relevance": "low",
|
||
},
|
||
],
|
||
"alternates": [],
|
||
},
|
||
)
|
||
context["daily_paper_selected_papers"] = [paper]
|
||
await DailyPaperAnalyzeStep(app_context=app_context, agent_wrapper=agent)(context)
|
||
finally:
|
||
await proxy.close()
|
||
|
||
assert hf_kwargs[0]["proxy_url"] == "http://127.0.0.1:43124"
|
||
assert len(arxiv_kwargs) == 1
|
||
assert arxiv_kwargs[0]["proxy_url"] == "http://127.0.0.1:43124"
|
||
|
||
|
||
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,
|
||
),
|
||
},
|
||
]
|