refactor: rebuild auto-fin and daily-paper cookbooks on structured-output agents (#432)
Some checks failed
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
Pre-commit / run (ubuntu-latest) (push) Has been cancelled
Tests ReMe / Unit Tests - py3.11 (push) Has been cancelled

* refactor: rebuild auto-fin and daily-paper cookbooks on structured-output agents

Rework the auto-fin and daily-paper cookbooks to run on structured-output
LLM agents instead of Claude Code agent wrappers, replace the SSH proxy with
data-source mirrors, and rewrite the affected unit tests.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

* refactor(auto_fin): unify JSON output serialization and writing

- Extracted _write_output static method to serialize and write Pydantic models as compact JSON
- Replaced inline JSON dump and write calls with _write_output usage across auto_fin steps
- Added _report_path and _current_report for managing intra-day reports in AutoFinMergeStep
- Updated auto_fin merge step to write output via new _write_output method
- Enhanced news reading with caching in AutoFinHistoryStep
- Refined returns calculation to handle events before close on non-trading days correctly

feat(daily_paper): improve note path resolution and metadata handling

- Introduced iter_note_metadata generator for safe Markdown frontmatter iteration
- Added resolve_unique_note_path to avoid note filename conflicts on disk and in used titles
- Updated analyze, collect, digest, and select steps to use centralized constants and helpers
- Used utc_now_iso for consistent timestamping in metadata
- Replaced direct frontmatter loads with iter_note_metadata in collect and analyze steps
- Replaced hardcoded paper selection count with PAPER_COUNT constant in all relevant places
- Added _MAX_SELECT_ATTEMPTS constant in select step for attempt management
- Improved error messages for filename validation in daily paper title normalization

feat(auto_fin): add multi-run cron schedules for intraday refinement

- Defined three auto_fin cron jobs at 09:30, 11:30, and 18:00 Shanghai time for gradual report updates
- Each intraday run adds evidence cumulatively instead of replacing prior output wholly
- Updated daily_cookbook.yaml to register new cron schedules and remove legacy 12:00 cron

refactor(auto_fin_data): clean ETF code handling and page limits

- Replaced hardcoded DEFAULT_ETF_CODES with required non-empty config value "etf_codes"
- Added constants for major news and fund page limits to control pagination
- Improved ETF name extraction logic to handle missing fields consistently

fix(auto_fin_merge): fix report retrieval and merging logic

- Added support for getting current intra-day report in addition to previous day's report
- Modified merge template to include prior and current report sections for better context
- Adjusted report path handling to consistently use Path objects

test(auto_fin): add coverage for returns calculation and report retrieval

- Added test for returns when event occurs before close on non-trading day, checking next session entry
- Added test for previous and current report retrieval feeding merge context with disk files
- Extended test asserts for auto_fin cron schedule changes in config

style(daily_paper): reorder and cleanup imports

- Reorganized imports in _common.py for clarity and added missing collections.abc.Iterator import
- Cleaned up commented and unused imports across daily_paper steps

* feat: add configurable upstream mirror proxy

* style: format auto-fin data step

* fix: align cookbook mirrors and contracts

---------

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
jinliyl 2026-08-07 23:53:14 +08:00 committed by GitHub
parent e05b201da9
commit d5e0d2837b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
40 changed files with 2615 additions and 4071 deletions

View file

@ -2,323 +2,214 @@
[中文](README_ZH.md)
Auto Fin is a local-first, file-native ETF event-research workflow. It identifies market events in CLS news, selects
related liquid ETFs, studies similar historical events and subsequent returns, and produces a Chinese research report.
Auto Fin is a local-first, file-native ETF event-research workflow. It collects CLS news and market data through
Tushare, identifies current news related to a configured ETF list, retrieves comparable events from local ReMe memory,
calculates observed post-event returns, and writes a Chinese research report.
> Auto Fin provides event research and holding-period references only. It is not investment advice, does not
> connect to a broker, and does not place or simulate trades.
## Capabilities
- Download CLS news through Tushare and maintain up to 360 days of traceable local news records.
- Rank ETF candidates by previous-trading-day turnover, then select representative ETFs related to current events.
- Search ReMe memory and local news files for similar historical events, with strict source-path and news-ID checks.
- Calculate adjusted D1D10 historical returns in deterministic code instead of asking an Agent to invent numbers.
- Let an Agent judge event similarity, then calculate weights, expected returns, and a reference holding period in code.
- Save readable Markdown and structured JSON/JSONL artifacts, refresh the daily index, and optionally deliver the report
to DingTalk.
> Auto Fin is for event research and holding-period reference only. It is not investment advice, does not connect to a
> broker, and does not place or simulate trades.
The workflow is assembled by
[`daily_cookbook.yaml`](../../reme/config/daily_cookbook.yaml). Its public schemas are in
[`reme/schema/auto_fin.py`](../../reme/schema/auto_fin.py), and its steps are in
[`reme/schema/auto_fin.py`](../../reme/schema/auto_fin.py), and its four steps are in
[`reme/steps/cookbook/auto_fin/`](../../reme/steps/cookbook/auto_fin/).
## Quick start
Auto Fin requires Python 3.11 or newer, the `core` dependencies, a Tushare token, and credentials for the configured
Claude Code-compatible endpoint.
From the repository root:
Auto Fin requires Python 3.11 or newer, the `core` dependencies, a Tushare token, and an available AgentScope LLM.
```bash
python -m pip install -e ".[core]"
export TUSHARE_TOKEN="your-tushare-token"
export CLAUDE_CODE_API_KEY="your-api-key"
export LLM_API_KEY="your-api-key"
reme start config=daily_cookbook job=auto_fin
```
The built-in configuration uses `qwen3.7-max` through DashScope's Anthropic-compatible endpoint. Override these
variables to use another compatible model or provider:
The built-in LLM component defaults to `qwen3.7-plus`. `LLM_BASE_URL` has no built-in value, so set it when your
provider requires a custom OpenAI-compatible endpoint. The model and endpoint can be overridden with
`LLM_MODEL_NAME` and `LLM_BASE_URL`.
The default workspace is `reme_workspace/` beneath the process working directory. Override it with
`DAILY_PAPER_WORKSPACE_DIR`; Auto Fin and Daily Paper share this setting.
Dates and times use `Asia/Shanghai`. An explicit `date` must be today's date:
```bash
export CLAUDE_CODE_MODEL_NAME="your-model"
export CLAUDE_CODE_BASE_URL="https://your-anthropic-compatible-endpoint"
reme start config=daily_cookbook job=auto_fin date=2026-08-07
```
The default workspace is `reme_workspace/`. This standalone cookbook shares its workspace setting with the daily-paper
workflow:
Auto Fin checks the SSE trading calendar first and skips the whole workflow on a closed market day.
```bash
export DAILY_PAPER_WORKSPACE_DIR="/absolute/path/to/reme-workspace"
```
To deliver the final Markdown report to DingTalk, set:
```bash
export DINGTALK_APP_KEY="your-app-key"
export DINGTALK_APP_SECRET="your-app-secret"
export DINGTALK_ROBOT_CODE="your-robot-code"
export DINGTALK_CONVERSATION_IDS="conversation-id-1,conversation-id-2"
```
DingTalk delivery is skipped when the required values are empty.
Dates and times use `Asia/Shanghai`. The optional `date` must be the current date:
```bash
reme start config=daily_cookbook job=auto_fin date=2026-07-25
```
To refresh every configured news day instead of reusing valid historical files:
```bash
reme start config=daily_cookbook job=auto_fin force=true
```
This may issue many Tushare requests. A normal run reuses valid historical news files and always refreshes today's file.
### Optional SSH proxy
The outbound proxy is disabled by default. To enable it, uncomment `components.outbound_proxy.default` in
`daily_cookbook.yaml`, configure non-interactive SSH authentication, and set:
```bash
export REME_PROXY_IP="your-ssh-proxy-host"
export REME_PROXY_ACCOUNT="your-ssh-account"
```
## How it works
```mermaid
flowchart LR
A[Resolve run date and cutoff] --> B[Maintain CLS news files]
B --> C[Resolve previous A-share trading day]
C --> D[Build current event window]
D --> E[Filter liquid ETF candidates]
E --> F[Agent selects related ETFs]
F --> G{For each ETF}
G --> H[Agent searches historical events]
H --> I[Code resolves original news]
I --> J[Code calculates adjusted D1-D10 returns]
J --> K[Agent judges similarity]
K --> L[Code calculates weighted forecast]
L --> G
G --> M[Agent writes the combined report]
M --> N[Write artifacts and refresh daily index]
N --> O[Optional DingTalk delivery]
```
The top-level job contains four Auto Fin steps:
| Step | Responsibility | Agent |
|-------------------------|-------------------------------------------------------------|-------|
| `auto_fin_data_step` | Maintain news files and resolve the previous trading day | No |
| `auto_fin_topic_step` | Build inputs and select related ETFs and current events | Yes |
| `auto_fin_history_step` | Orchestrate historical research and market analysis per ETF | Yes |
| `auto_fin_merge_step` | Validate results and produce the final Markdown report | Yes |
For each selected ETF, `auto_fin_history_step` dispatches:
- `auto_fin_history_search_step`, which asks the Agent for historical news references and then resolves the original
records and calculates their returns in code.
- `auto_fin_market_step`, which asks the Agent only for similarity judgments and then calculates weights and forecasts
in code.
Agents handle semantic judgments; deterministic code handles source validation and financial calculations.
## Data and time boundaries
### News history
`auto_fin_data_step` reads CLS news from Tushare's `major_news` endpoint:
- The default lookback is 360 calendar days, including the run date.
- A valid historical file is reused unless `force=true`.
- Today's file is refreshed through the current `decision_at` on every run.
- Large responses are fetched through recursively split time windows.
- Records are ordered and deduplicated before being written with a stable `news_id`.
The current event window is:
## Pipeline
```text
(previous A-share trading day at 15:00, decision_at]
Tushare trade calendar
├─ closed day ──► skip
Collect CLS news + configured ETF history
Update the ReMe index
Select ETF/news relationships with an agent
Search local memory for comparable historical news
Select same/opposite events with an agent + calculate D1/D2/D3/D5 returns in code
Generate report with an agent ──► refresh day index ──► DingTalk (optional)
```
Each run rebuilds this complete window; midday and evening runs do not use only the increment since the previous run.
| Step | Responsibility | Agent |
|---|---|---|
| `auto_fin_data_step` | Check the trading day, maintain news, and cache configured ETF market history | No |
| `auto_fin_topic_step` | Select direct relationships between today's news and configured ETFs | Yes |
| `auto_fin_history_step` | Retrieve comparable news, validate selections, and calculate observed returns | Yes |
| `auto_fin_merge_step` | Combine prepared evidence and the previous report into the final Markdown | Yes |
### ETF candidates
All three model-facing steps use structured Pydantic output. Agents make semantic judgments; code owns identifier
validation, source resolution, market calculations, and file writes.
The candidate universe combines:
## Data and selection boundaries
- `etf_basic` for currently listed ETFs and their tracked indexes.
- `fund_daily` for turnover on the previous A-share trading day.
### News
Code sorts candidates by turnover, removes duplicates by ETF name and index identity, and provides at most 150
candidates to the Topic Agent. The Agent may return at most 20 ETFs and must copy every ETF code, name, and news ID from
the generated candidate files.
`auto_fin_data_step` calls Tushare `major_news` with `src="财联社"`. The default lookback is 60 calendar days including
today. Existing files for earlier days are reused, while today's file is always overwritten with news from 00:00
through the current decision time. Large responses are recursively split when a request returns at least 400 rows.
Turnover is used only to narrow the research universe; it is not a trading signal.
Each item is stored in `daily/YYYY-MM-DD/auto_fin_news.md` with a stable ID made from its publication timestamp and a
short content hash. The current-event set used by the Topic step is today's complete file, not an increment since an
earlier run.
## Historical research and forecasting
### Configured ETFs
### Source resolution
The built-in configuration currently enables:
The History Agent searches by event type, entities, transmission mechanism, and expected direction. It first uses
`memory_search` and may then scan:
- `518880.SH`
- `159530.SZ`
- `512760.SH`
```text
daily/YYYY-MM-DD/auto_fin_news_data.jsonl
```
Other examples remain commented out in `daily_cookbook.yaml`. For each enabled code, the Data step resolves its name
through `etf_basic`, then pages backward through `fund_daily` and `fund_adj` and rewrites its complete local JSONL
history. A missing ETF name fails the run.
Its output contains only a reason, `news_id`, and workspace-relative `source_path`. Code rejects:
The Topic agent receives only the configured ETF code/name pairs and today's locally stored news. It may retain up to
`current_news_limit_per_etf` valid, unique news references per ETF (10 by default). Unknown ETF codes, unknown news IDs,
empty reasons, duplicates, and ETFs with no accepted event are removed by code.
- Current-window news presented as historical evidence.
- Absolute paths, `..` traversal, or paths outside the workspace.
- Sources not named `auto_fin_news_data.jsonl`.
- Missing files or IDs that do not resolve exactly once.
- Records without a usable publication time, title, or body.
## Historical comparison and returns
Historical Markdown may guide retrieval, but the original news JSONL is the source of truth.
For every accepted current ETF/news pair, `auto_fin_history_step` calls the configured `memory_search` job over the
60-day news window, ending yesterday. `historical_search_limit` controls the maximum search results requested per
current event. Only search hits whose path is named `auto_fin_news.md` contribute candidate IDs; the step rereads the
source Markdown and resolves those IDs before calling the History agent.
### Adjusted returns
The History agent may select at most five candidates by default and labels each relationship `same` or `opposite`.
Code discards unknown or duplicate IDs and empty reasons, then calculates adjusted cumulative returns for D1, D2, D3,
and D5:
For every resolved historical event, code reads `fund_daily` and `fund_adj` and calculates up to ten future closes:
- For an event before 15:00 on a trading day, the adjusted same-day close is the entry; D1 is the next trading close.
- For an event at or after 15:00, the adjusted next-trading-day open is the entry; D1 is that day's close.
- If an entry or horizon cannot be calculated from valid positive prices and adjustment factors, that value is `null`.
- Before 09:30 on a trading day: enter at that day's open.
- From 09:30 until before 15:00: enter at that day's close.
- At or after 15:00, or on a non-trading day: enter at the next trading day's open.
- A daily close later than the current `decision_at` is excluded.
The final agent receives the fixed ETF list, all current and historical evidence, `same`/`opposite` directions, computed
returns, and the most recent earlier `auto_fin.md`. It decides whether the evidence supports a recommendation or an
explicit wait-and-see conclusion; the code does not calculate a score, expected return, or mandatory holding period.
```text
adjusted_entry = raw_entry × entry_adjustment_factor
adjusted_close = raw_close × close_adjustment_factor
cumulative_return = adjusted_close / adjusted_entry - 1
```
Missing prices, factors, trading days, or horizons become explicit limitations. They are never filled with Agent-made
values.
### Similarity and forecast
The Market Agent returns semantic similarity in `[-1, 1]`:
- Positive values mean a similar mechanism and direction.
- Negative values mean a comparable mechanism but opposite direction.
- Zero means no useful relationship.
Code clamps out-of-range values, ignores zero-similarity events, normalizes weights from absolute similarity, and
reverses the historical return direction for negative matches. Each D1D10 horizon is calculated from the samples
available at that horizon. The suggested holding period is the positive-return horizon with the highest expected return,
or empty when none is positive.
The result also records limited samples, missing horizons, conflicting return directions, and other data limitations. It
is a comparison with a small historical sample, not evidence of statistical significance.
## Output layout
## Outputs
```text
reme_workspace/
├── daily/
│ ├── YYYY-MM-DD.md
│ └── YYYY-MM-DD/
│ ├── auto_fin_news_data.jsonl
│ ├── auto_fin_analysis.jsonl
│ ├── auto_fin_news.md
│ └── auto_fin.md
└── resource/
├── fin/
│ ├── etfs.json
│ ├── 518880.SH.jsonl
│ └── <other-configured-ETF>.jsonl
└── YYYY-MM-DD/
├── filtered_news.jsonl
├── filtered_etf.jsonl
├── auto_fin_topic_output.jsonl
├── auto_fin_history_<index>_<ETF-code>_output.json
├── auto_fin_market_<index>_<ETF-code>_output.json
├── auto_fin_history_output.jsonl
├── auto_fin_topic_output.json
├── auto_fin_history_001_output.json
├── ...
├── auto_fin_analysis.jsonl
└── auto_fin_merge_output.json
```
Important artifacts:
The daily news and report are user-owned Markdown. `resource/fin/` contains the market cache used for deterministic
return calculations. Date-scoped JSON/JSONL files preserve structured agent replies and prepared analyses. Writes use
same-directory temporary files and atomic replacement; the day index is refreshed after the report is written.
- `auto_fin_news_data.jsonl` is the user-owned source used to resolve historical news.
- `filtered_news.jsonl` and `filtered_etf.jsonl` are bounded inputs for the Topic Agent.
- Per-ETF history files contain resolved source news and code-calculated return paths.
- Per-ETF market files contain code-calculated matches, weights, and D1D10 forecasts.
- `auto_fin_analysis.jsonl` contains the final structured analysis for every selected ETF.
- `auto_fin.md` is the readable report and DingTalk payload.
- `daily/YYYY-MM-DD.md` is refreshed after report generation so the report is discoverable from the daily index.
## Parameters and defaults
News and reports remain ordinary user-owned files. Resource artifacts and search indexes can be rebuilt.
Public job parameters:
## Configuration
| Parameter | Default | Purpose |
|---|---:|---|
| `date` | `""` | Empty uses today in `Asia/Shanghai`; a value must be strict `YYYY-MM-DD` and equal today |
| `historical_search_limit` | `10` | Maximum `memory_search` results requested for each current event; minimum 1 |
### Job parameters
Relevant job settings in `daily_cookbook.yaml`:
| Parameter | Default | Meaning |
|-----------|-------------:|---------------------------------------------------------|
| `date` | Current date | Strict `YYYY-MM-DD`; only the current date is supported |
| `force` | `false` | Refresh all configured news days |
| Setting | Default | Purpose |
|---|---:|---|
| `etf_codes` | three enabled codes above | Fixed ETF research universe |
| `news_lookback_days` | `60` | Local news and historical-search window |
| `current_news_limit_per_etf` | `10` | Maximum accepted current events per ETF |
| `historical_news_limit` | `5` | Maximum comparable events retained per current event |
### Environment variables
There is no public `force` parameter. Earlier news files are reused, today's news and all configured ETF market files
are refreshed, and same-day report/resource paths are overwritten on each successful run.
| Variable | Required | Meaning |
|-----------------------------|----------|---------------------------------------------------------|
| `TUSHARE_TOKEN` | Yes | News, calendar, ETF daily data, and adjustment factors |
| `CLAUDE_CODE_API_KEY` | Yes | Auto Fin Agent credentials |
| `CLAUDE_CODE_MODEL_NAME` | No | Defaults to `qwen3.7-max` |
| `CLAUDE_CODE_BASE_URL` | No | Anthropic-compatible endpoint |
| `AUTO_FIN_AGENT_BACKEND` | No | Defaults to `claude_code` |
| `AUTO_FIN_PROJECT_PATH` | No | Agent project path; defaults to `..` |
| `REME_PROXY_IP` | No | SSH proxy host; used only when `ssh_http` is enabled |
| `REME_PROXY_ACCOUNT` | No | SSH proxy account; used only when `ssh_http` is enabled |
| `DAILY_PAPER_WORKSPACE_DIR` | No | Standalone cookbook workspace |
| `DINGTALK_*` | No | DingTalk application, robot, and conversation settings |
## Environment and scheduling
Unit tests can inject `tushare_provider` through the runtime context and do not require real credentials.
| Variable | Required | Purpose |
|---|---|---|
| `TUSHARE_TOKEN` | Yes | Trading calendar, CLS news, ETF metadata, prices, and adjustment factors |
| `LLM_API_KEY` | Provider-dependent | Shared AgentScope LLM credentials; config defaults to an empty value |
| `LLM_MODEL_NAME` | No | Defaults to `qwen3.7-plus` |
| `LLM_BASE_URL` | Provider-dependent | OpenAI-compatible endpoint; no built-in default |
| `TUSHARE_MIRROR_URL` | No | Replaces the Tushare SDK HTTP URL after trimming a trailing slash |
| `DAILY_PAPER_WORKSPACE_DIR` | No | Shared standalone cookbook workspace |
| `DINGTALK_*` | No | Optional DingTalk application, robot, and group settings |
### Scheduled jobs
The optional mirror can be configured, for example, as:
`daily_cookbook.yaml` defines:
```bash
export TUSHARE_MIRROR_URL="http://112.124.63.173:4000/tushare"
```
| Job | Cron | Asia/Shanghai |
|----------------------|---------------|----------------|
| `auto_fin_0930_cron` | `30 9 * * *` | Daily at 09:30 |
| `auto_fin_1145_cron` | `45 11 * * *` | Daily at 11:45 |
| `auto_fin_1800_cron` | `0 18 * * *` | Daily at 18:00 |
`auto_fin_0930_cron`, `auto_fin_1130_cron`, and `auto_fin_1800_cron` run every day at 09:30, 11:30, and 18:00 in
`Asia/Shanghai`. The crons fire on weekends and holidays, but the Data step then skips the remaining workflow when
Tushare reports that the date is not an SSE trading day. Same-day reruns refine the existing report.
These cron expressions do not exclude weekends or market holidays. The workflow resolves the previous A-share trading
day but does not currently skip a run merely because the run date is not a trading day.
To send a completed report, configure `DINGTALK_APP_KEY`, `DINGTALK_APP_SECRET`, `DINGTALK_ROBOT_CODE`, and the
comma-separated `DINGTALK_CONVERSATION_IDS`. With no conversation IDs, delivery is a no-op.
## Agent and security boundaries
## Agent and failure boundaries
The Auto Fin wrapper loads the `tushare-data` skill, exposes the `memory_search` job tool, and defaults to
`bypassPermissions`. Prompts constrain each Agent's role, while code revalidates schemas, ETF identities, source paths,
news references, and calculated values.
Auto Fin and Daily Paper share the tool-free `default` AgentScope wrapper. Built-in and configured job tools are not
exposed to their model calls. Auto Fin itself invokes `memory_search` in deterministic step code; this is not an agent
tool call. The separate interactive `dingtalk_wait` step has its own `bash` and ReMe job-tool allowlist.
The standalone cookbook does not configure an embedding store by default, so `memory_search` normally uses BM25 recall.
Vector and BM25 fusion becomes available only when an embedding store is configured.
The standalone config has no embedding store enabled by default, so `memory_search` uses the available BM25 path;
vector/BM25 fusion requires enabling the commented embedding components.
`bypassPermissions` is not an operating-system sandbox. Review the configured project path, workspace, credentials, and
network boundary before deployment.
Invalid dates, missing credentials or services, invalid structured model output, unknown configured ETFs, missing
market files, and failed memory search stop the job. A market holiday is a successful skip. The workflow has no global
same-date execution lock or cross-file transaction, and repeated successful runs can resend DingTalk notifications.
## Reruns and limitations
## Tests
- Valid historical news files are reused; today's news is always refreshed.
- Outputs use stable per-day paths, so a later same-day run replaces the previous report and resource outputs.
- Auto Fin intentionally has no “report exists, skip” shortcut because its scheduled runs analyze updated news.
- Every successful run attempts DingTalk delivery when configured; notification deduplication is not implemented.
- Missing historical market horizons degrade one sample and are recorded as limitations.
- Invalid dates, missing required services, invalid Agent schemas, unknown ETFs or news IDs, unsafe paths, and
inconsistent cross-step ETF identities fail the job.
The current implementation does not include stocks, US-market correlation, portfolio accounting, BUY/SELL/HOLD actions,
T+1 execution rules, fees, slippage, broker integration, or real/simulated order execution.
## Development
Install development dependencies and run the focused suite:
Focused unit tests mock model and market-data boundaries:
```bash
python -m pip install -e ".[dev,core]"
PYTHONPATH=. pytest tests/unit/test_auto_fin.py -v
pytest tests/unit/test_auto_fin.py -v
```
The unit suite mocks model and market-data boundaries. Tests requiring real Tushare, model, or DingTalk credentials
should be run separately and only with explicit authorization.
Tests requiring real Tushare, LLM, or DingTalk credentials should be run separately and only with explicit
authorization.

View file

@ -2,303 +2,200 @@
[English](README.md)
Auto Fin 是一个本地优先、文件原生的 ETF 事件研究工作流。它从财联社新闻中识别市场事件,选择相关且流动性较好的
ETF,研究相似历史事件及其后续收益,并生成中文研究报告。
Auto Fin 是一个 local-first、file-native 的 ETF 事件研究工作流。它通过 Tushare 获取财联社新闻和行情数据,从固定
ETF 列表中识别与当日新闻相关的标的,利用 ReMe 本地记忆检索可比历史事件,计算事件后的实际收益,并生成中文研究报告。
> Auto Fin 只提供事件研究和持有时间参考,不构成投资建议。当前实现不连接券商、不提交委托,也不执行模拟交易。
> Auto Fin 只提供事件研究和持有时间参考,不构成投资建议,不连接券商,也不会执行或模拟交易。
## 能力
- 通过 Tushare 获取财联社新闻,并维护最多 360 天可追溯的本地新闻记录。
- 按上一交易日成交额筛选 ETF 候选,再选择与当前事件直接相关的代表性 ETF。
- 通过 ReMe 记忆检索和本地新闻文件查找相似历史事件,并严格校验来源路径和新闻 ID。
- 由确定性代码计算复权后的 D1D10 历史收益,不让 Agent 编造行情数值。
- 由 Agent 判断事件相似度,再由代码计算权重、预期收益和参考持有时间。
- 保存可读 Markdown 和结构化 JSON/JSONL刷新每日索引并支持可选钉钉投递。
工作流由 [`daily_cookbook.yaml`](../../reme/config/daily_cookbook.yaml) 装配,公共 schema 位于
[`reme/schema/auto_fin.py`](../../reme/schema/auto_fin.py),各步骤位于
工作流由 [`daily_cookbook.yaml`](../../reme/config/daily_cookbook.yaml) 装配;公开 schema 位于
[`reme/schema/auto_fin.py`](../../reme/schema/auto_fin.py),四个 Step 位于
[`reme/steps/cookbook/auto_fin/`](../../reme/steps/cookbook/auto_fin/)。
## 快速开始
Auto Fin 要求 Python 3.11 或更高版本、`core` 依赖、Tushare token以及所配置 Claude Code 兼容 endpoint 的凭据。
在仓库根目录运行:
要求 Python 3.11 或更高版本、`core` 依赖、Tushare token 和可用的 AgentScope LLM。
```bash
python -m pip install -e ".[core]"
export TUSHARE_TOKEN="your-tushare-token"
export CLAUDE_CODE_API_KEY="your-api-key"
export LLM_API_KEY="your-api-key"
reme start config=daily_cookbook job=auto_fin
```
内置配置默认通过 DashScope 的 Anthropic 兼容 endpoint 使用 `qwen3.7-max`。如需更换兼容模型或服务商:
内置 LLM 组件默认使用 `qwen3.7-plus``LLM_BASE_URL` 没有内置默认值;如果服务商要求自定义 OpenAI 兼容
endpoint需要显式设置。可通过 `LLM_MODEL_NAME``LLM_BASE_URL` 覆盖模型与 endpoint。
默认 workspace 是进程启动目录下的 `reme_workspace/`。可通过 `DAILY_PAPER_WORKSPACE_DIR` 覆盖Auto Fin 与
Daily Paper 共用该设置。
日期和时间使用 `Asia/Shanghai`。显式传入的 `date` 必须是当天:
```bash
export CLAUDE_CODE_MODEL_NAME="your-model"
export CLAUDE_CODE_BASE_URL="https://your-anthropic-compatible-endpoint"
reme start config=daily_cookbook job=auto_fin date=2026-08-07
```
默认 workspace 是 `reme_workspace/`。该 standalone cookbook 与每日论文工作流共用 workspace 配置:
Auto Fin 首先检查上交所交易日历;休市日会跳过整个工作流。
```bash
export DAILY_PAPER_WORKSPACE_DIR="/absolute/path/to/reme-workspace"
```
如需把最终 Markdown 报告发送到钉钉:
```bash
export DINGTALK_APP_KEY="your-app-key"
export DINGTALK_APP_SECRET="your-app-secret"
export DINGTALK_ROBOT_CODE="your-robot-code"
export DINGTALK_CONVERSATION_IDS="conversation-id-1,conversation-id-2"
```
相关配置为空时会跳过钉钉投递。
日期和时间均使用 `Asia/Shanghai`。可选的 `date` 必须是当天:
```bash
reme start config=daily_cookbook job=auto_fin date=2026-07-25
```
如需刷新全部新闻日期,而不是复用有效历史文件:
```bash
reme start config=daily_cookbook job=auto_fin force=true
```
这可能产生大量 Tushare 请求。普通运行会复用有效历史新闻,并始终刷新当天文件。
### 可选 SSH 代理
出站代理默认关闭。如需启用,请取消 `daily_cookbook.yaml`
`components.outbound_proxy.default` 的注释,配置免交互 SSH 认证,并设置:
```bash
export REME_PROXY_IP="your-ssh-proxy-host"
export REME_PROXY_ACCOUNT="your-ssh-account"
```
## 工作原理
```mermaid
flowchart LR
A[确定运行日和截止时间] --> B[维护财联社新闻文件]
B --> C[确定上一 A 股交易日]
C --> D[构建当前事件窗口]
D --> E[筛选高流动性 ETF 候选]
E --> F[Agent 选择相关 ETF]
F --> G{逐只 ETF}
G --> H[Agent 搜索历史事件]
H --> I[代码回查原始新闻]
I --> J[代码计算复权 D1-D10 收益]
J --> K[Agent 判断相似度]
K --> L[代码计算加权预测]
L --> G
G --> M[Agent 编写合并报告]
M --> N[写入产物并刷新每日索引]
N --> O[可选钉钉投递]
```
顶层 Job 包含四个 Auto Fin Step
| Step | 职责 | Agent |
|-------------------------|-----------------------------------|-------|
| `auto_fin_data_step` | 维护新闻文件并确定上一交易日 | 否 |
| `auto_fin_topic_step` | 构建输入并选择相关 ETF 和当前事件 | 是 |
| `auto_fin_history_step` | 逐只 ETF 编排历史研究和行情分析 | 是 |
| `auto_fin_merge_step` | 校验结果并生成最终 Markdown 报告 | 是 |
对于每只已选 ETF`auto_fin_history_step` 会派发:
- `auto_fin_history_search_step`Agent 返回历史新闻引用,然后由代码解析原始记录并计算收益。
- `auto_fin_market_step`Agent 只判断相似度,然后由代码计算权重和预测。
Agent 负责语义判断,确定性代码负责来源校验和金融数值计算。
## 数据和时间边界
### 新闻历史
`auto_fin_data_step` 使用 Tushare `major_news` 接口读取财联社新闻:
- 默认回看包含运行日在内的 360 个自然日。
- 有效历史文件会复用,除非设置 `force=true`
- 每次运行都会把当天文件刷新到当前 `decision_at`
- 数据量接近接口上限时会递归拆分时间窗口。
- 写入前会排序和去重,并生成稳定 `news_id`
当前事件窗口为:
## 工作流
```text
(上一 A 股交易日 15:00, decision_at]
Tushare 交易日历
├─ 休市 ──► 跳过
采集财联社新闻 + 固定 ETF 行情历史
更新 ReMe 索引
Agent 筛选 ETF/当日新闻关系
从本地记忆检索可比历史新闻
Agent 选择 same/opposite 事件 + 代码计算 D1/D2/D3/D5 收益
Agent 生成报告 ──► 刷新当日索引 ──► 钉钉(可选)
```
每次运行都会重建完整窗口;午间和晚间运行不是只读取上次运行后的增量。
| Step | 职责 | Agent |
|---|---|---|
| `auto_fin_data_step` | 检查交易日、维护新闻并缓存固定 ETF 的完整行情历史 | 否 |
| `auto_fin_topic_step` | 筛选当日新闻与固定 ETF 的直接关系 | 是 |
| `auto_fin_history_step` | 检索可比新闻、校验选择并计算实际收益 | 是 |
| `auto_fin_merge_step` | 汇总证据和上一份报告,生成最终 Markdown | 是 |
### ETF 候选
三个模型 Step 都使用 Pydantic 结构化输出。Agent 负责语义判断;标识校验、来源解析、行情计算和文件写入由代码负责。
候选池组合使用:
## 数据与筛选边界
- `etf_basic`:当前上市 ETF 及其跟踪指数。
- `fund_daily`:上一 A 股交易日成交额。
### 新闻
代码按成交额排序,并按 ETF 名称和指数标识去重,最多向 Topic Agent 提供 150 个候选。Agent 最多返回 20 只 ETF且 ETF 代码、名称和新闻
ID 都必须逐字来自候选文件。
`auto_fin_data_step` 调用 Tushare `major_news`,并固定传入 `src="财联社"`。默认回看 60 个自然日(包含当天)。
更早日期已有的文件会复用;当天文件始终覆盖为 00:00 至当前决策时刻的新闻。单次请求返回至少 400 条时,时间区间会递归拆分。
成交额仅用于缩小研究范围,不是交易信号。
每条新闻写入 `daily/YYYY-MM-DD/auto_fin_news.md`,其稳定 ID 由发布时间和短内容哈希组成。Topic Step 使用当天
完整文件,不是从上一次运行到本次运行之间的增量。
## 历史研究和预测
### 固定 ETF
### 来源回查
内置配置当前启用:
History Agent 按事件类型、关键实体、传导机制和预期方向搜索。它优先使用 `memory_search`,必要时扫描:
- `518880.SH`
- `159530.SZ`
- `512760.SH`
```text
daily/YYYY-MM-DD/auto_fin_news_data.jsonl
```
`daily_cookbook.yaml` 中还保留了其他被注释的示例。Data Step 通过 `etf_basic` 解析每个启用代码的名称,然后对
`fund_daily``fund_adj` 向前分页,并覆盖写入完整本地 JSONL 行情历史。任一 ETF 无法解析名称都会终止运行。
Agent 只返回理由、`news_id` 和 workspace 相对 `source_path`。代码会拒绝:
Topic Agent 只接收固定 ETF 的 code/name 和当天本地新闻。每只 ETF 默认最多保留
`current_news_limit_per_etf=10` 条有效且唯一的新闻引用。未知 ETF、未知 news ID、空理由和重复项会被代码移除
没有有效事件的 ETF 不进入后续步骤。
- 把当前事件窗口内的新闻当作历史证据;
- 绝对路径、`..` 路径穿越或 workspace 外路径;
- 文件名不是 `auto_fin_news_data.jsonl` 的来源;
- 不存在的文件或不能唯一解析的 ID
- 缺少有效发布时间、标题或正文的记录。
## 历史比较与收益
历史 Markdown 只能作为检索线索,原始新闻 JSONL 才是事实来源。
对每个有效的 ETF/当日新闻组合,`auto_fin_history_step` 会在 60 日新闻窗口内调用配置中的 `memory_search`
结束日期为昨天。`historical_search_limit` 控制每个当前事件最多请求多少条检索结果。只有路径名为
`auto_fin_news.md` 的命中才会贡献候选 IDStep 会重新读取源 Markdown 并解析 ID再调用 History Agent。
### 复权收益
History Agent 默认最多选择五条候选,并将关系标记为 `same``opposite`。代码会移除未知或重复 ID 以及空理由,
随后计算 D1、D2、D3、D5 的复权累计收益:
对每条已回查的历史事件,代码读取 `fund_daily``fund_adj`,计算最多十个未来收盘点:
- 交易日 15:00 前发生的事件以当日复权收盘价为入场价D1 是下一交易日收盘价;
- 15:00 或之后发生的事件以下一交易日复权开盘价为入场价D1 是该日收盘价;
- 如果无法从有效正价格和复权因子计算入场价或某个期限,该值为 `null`
- 交易日 09:30 前发生:以当日开盘价为 entry。
- 09:30 至 15:00 前发生:以当日收盘价为 entry。
- 15:00 或之后、以及非交易日发生:以下一交易日开盘价为 entry。
- 晚于当前 `decision_at` 的日线收盘数据不会参与计算。
最终 Agent 接收固定 ETF 列表、所有当前/历史证据、`same`/`opposite` 方向、代码计算的收益,以及此前最近一份
`auto_fin.md`。它自行判断证据是否支持推荐或应明确观望;代码不会计算评分、期望收益,也不强制给出持有期限。
```text
adjusted_entry = raw_entry × entry_adjustment_factor
adjusted_close = raw_close × close_adjustment_factor
cumulative_return = adjusted_close / adjusted_entry - 1
```
缺少价格、复权因子、交易日或 horizon 时会记录明确限制,不会用 Agent 生成的数值补齐。
### 相似度和预测
Market Agent 返回 `[-1, 1]` 范围内的语义相似度:
- 正值表示机制和方向相似。
- 负值表示机制可比但方向相反。
- `0` 表示没有有效关系。
代码会截断越界值、忽略零相似度事件,并按相似度绝对值归一化权重。负相似度样本会反转历史收益方向。 每个 D1D10 horizon 只使用该
horizon 有数据的样本。参考持有时间取正预期收益中最高的 horizon 没有正值时留空。
结果还会记录样本不足、horizon 缺失、收益方向冲突等限制。它只是有限历史样本比较,不代表统计显著性。
## 输出布局
## 产物
```text
reme_workspace/
├── daily/
│ ├── YYYY-MM-DD.md
│ └── YYYY-MM-DD/
│ ├── auto_fin_news_data.jsonl
│ ├── auto_fin_analysis.jsonl
│ ├── auto_fin_news.md
│ └── auto_fin.md
└── resource/
├── fin/
│ ├── etfs.json
│ ├── 518880.SH.jsonl
│ └── <其他固定 ETF>.jsonl
└── YYYY-MM-DD/
├── filtered_news.jsonl
├── filtered_etf.jsonl
├── auto_fin_topic_output.jsonl
├── auto_fin_history_<序号>_<ETF代码>_output.json
├── auto_fin_market_<序号>_<ETF代码>_output.json
├── auto_fin_history_output.jsonl
├── auto_fin_topic_output.json
├── auto_fin_history_001_output.json
├── ...
├── auto_fin_analysis.jsonl
└── auto_fin_merge_output.json
```
主要产物:
每日新闻和报告是用户拥有的 Markdown。`resource/fin/` 是确定性收益计算所用的行情缓存;日期目录下的 JSON/JSONL
保留结构化 Agent 回复和整理后的分析。写入通过同目录临时文件原子替换;报告写完后会刷新当日索引。
- `auto_fin_news_data.jsonl`:用户拥有的历史新闻回查来源。
- `filtered_news.jsonl``filtered_etf.jsonl`:边界明确的 Topic Agent 输入。
- 各 ETF history 文件:已回查的原始新闻和代码计算的收益路径。
- 各 ETF market 文件:代码计算的匹配、权重和 D1D10 预测。
- `auto_fin_analysis.jsonl`:全部已选 ETF 的最终结构化分析。
- `auto_fin.md`:可读报告和钉钉投递内容。
- `daily/YYYY-MM-DD.md`:报告生成后会刷新,确保每日索引能够发现 Auto Fin 报告。
## 参数与默认值
新闻和报告都是用户拥有的普通文件resource 中间产物和搜索索引均可重建。
公开 Job 参数:
## 配置
| 参数 | 默认值 | 作用 |
|---|---:|---|
| `date` | `""` | 空值使用 `Asia/Shanghai` 当天;非空值必须是严格 `YYYY-MM-DD` 且等于当天 |
| `historical_search_limit` | `10` | 每个当前事件请求的 `memory_search` 结果上限;最小值为 1 |
### Job 参数
`daily_cookbook.yaml` 中相关的 Job 级配置:
| 参数 | 默认值 | 说明 |
|---------|--------:|-----------------------------------|
| `date` | 当天 | 严格 `YYYY-MM-DD`;当前只支持当天 |
| `force` | `false` | 是否刷新全部已配置新闻日期 |
| 配置 | 默认值 | 作用 |
|---|---:|---|
| `etf_codes` | 上述三个启用代码 | 固定 ETF 研究范围 |
| `news_lookback_days` | `60` | 本地新闻及历史检索窗口 |
| `current_news_limit_per_etf` | `10` | 每只 ETF 最多保留的当前事件数 |
| `historical_news_limit` | `5` | 每个当前事件最多保留的可比历史事件数 |
### 环境变量
当前没有公开 `force` 参数。更早的新闻文件会复用;当天新闻和所有固定 ETF 行情文件会刷新;同一天再次成功运行会覆盖
当天报告和 resource 产物。
| 变量 | 必需 | 说明 |
|-----------------------------|------|-------------------------------------|
| `TUSHARE_TOKEN` | 是 | 新闻、交易日历、ETF 日线和复权因子 |
| `CLAUDE_CODE_API_KEY` | 是 | Auto Fin Agent 凭据 |
| `CLAUDE_CODE_MODEL_NAME` | 否 | 默认 `qwen3.7-max` |
| `CLAUDE_CODE_BASE_URL` | 否 | Anthropic 兼容 endpoint |
| `AUTO_FIN_AGENT_BACKEND` | 否 | 默认 `claude_code` |
| `AUTO_FIN_PROJECT_PATH` | 否 | Agent project path默认 `..` |
| `REME_PROXY_IP` | 否 | 仅启用 `ssh_http` 时使用的 SSH 主机 |
| `REME_PROXY_ACCOUNT` | 否 | 仅启用 `ssh_http` 时使用的 SSH 账户 |
| `DAILY_PAPER_WORKSPACE_DIR` | 否 | standalone cookbook workspace |
| `DINGTALK_*` | 否 | 钉钉应用、机器人和会话设置 |
## 环境变量与定时任务
单元测试可通过 RuntimeContext 注入 `tushare_provider`,不需要真实凭据。
| 变量 | 必需 | 作用 |
|---|---|---|
| `TUSHARE_TOKEN` | 是 | 交易日历、财联社新闻、ETF 元数据、价格与复权因子 |
| `LLM_API_KEY` | 取决于服务商 | 共享 AgentScope LLM 凭据;配置默认值为空 |
| `LLM_MODEL_NAME` | 否 | 默认 `qwen3.7-plus` |
| `LLM_BASE_URL` | 取决于服务商 | OpenAI 兼容 endpoint无内置默认值 |
| `TUSHARE_MIRROR_URL` | 否 | 去掉末尾 `/` 后替换 Tushare SDK HTTP URL |
| `DAILY_PAPER_WORKSPACE_DIR` | 否 | standalone cookbook 的共享 workspace |
| `DINGTALK_*` | 否 | 可选的钉钉应用、机器人和群设置 |
### 定时任务
镜像可按需配置,例如:
`daily_cookbook.yaml` 定义:
```bash
export TUSHARE_MIRROR_URL="http://112.124.63.173:4000/tushare"
```
| Job | Cron | Asia/Shanghai |
|----------------------|---------------|---------------|
| `auto_fin_0930_cron` | `30 9 * * *` | 每天 09:30 |
| `auto_fin_1145_cron` | `45 11 * * *` | 每天 11:45 |
| `auto_fin_1800_cron` | `0 18 * * *` | 每天 18:00 |
`auto_fin_0930_cron``auto_fin_1130_cron``auto_fin_1800_cron``Asia/Shanghai` 时区每天 09:30、11:30 和
18:00 触发。Cron 在周末和节假日仍会启动,但如果 Tushare 返回当天不是上交所交易日Data Step 会跳过后续工作流;
同一天的后续运行会在已有报告基础上继续完善。
这些 cron 表达式不会排除周末或休市日。工作流会确定上一 A 股交易日,但当前不会仅因为运行日不是交易日而跳过。
要发送完成的报告,需要配置 `DINGTALK_APP_KEY``DINGTALK_APP_SECRET``DINGTALK_ROBOT_CODE` 和逗号分隔的
`DINGTALK_CONVERSATION_IDS`。没有会话 ID 时发送步骤无副作用。
## Agent 和安全边界
## Agent 与失败边界
Auto Fin wrapper 会加载 `tushare-data` skill、暴露 `memory_search` Job 工具,并默认使用
`bypassPermissions`。Prompt 会约束各 Agent 的职责,代码则再次校验 schema、ETF 身份、来源路径、 新闻引用和计算值。
Auto Fin 和 Daily Paper 共用无工具的 `default` AgentScope wrapper其模型调用不会暴露内置工具或配置型 Job
工具。Auto Fin 由确定性的 Step 代码主动调用 `memory_search`,这不是 Agent 工具调用。独立的交互式
`dingtalk_wait` Step 才有自己的 `bash` 和 ReMe Job tool allowlist。
standalone cookbook 默认没有配置 embedding store因此 `memory_search` 通常使用 BM25 召回; 配置 embedding store 后才能使用向量与
BM25 融合。
standalone 配置默认未启用 embedding store因此 `memory_search` 使用可用的 BM25 路径;只有启用被注释的
embedding 组件后才有向量/BM25 融合。
`bypassPermissions` 不是操作系统沙箱。部署前应检查 project path、workspace、凭据和网络边界。
非法日期、缺少凭据或服务、模型结构化输出无效、固定 ETF 未知、行情文件缺失、记忆检索失败都会终止 Job休市日是
成功跳过。工作流没有同日期全局执行锁或跨文件事务;重复成功运行也可能重复发送钉钉通知。
## 重跑和限制
## 测试
- 有效历史新闻会复用,当天新闻始终刷新。
- 输出使用稳定的每日路径,因此同一天后一次运行会替换前一次报告和 resource 输出。
- Auto Fin 不使用“报告已存在则跳过”,因为定时运行需要分析更新后的新闻。
- 配置钉钉后,每次成功运行都会尝试投递;当前没有通知去重。
- 历史样本缺少部分 horizon 时会降级该样本并记录限制。
- 非法日期、缺少必要服务、Agent schema 错误、未知 ETF/新闻、危险路径或跨步骤 ETF 身份不一致会使 Job 失败。
当前没有实现个股、美股关联、组合账本、BUY/SELL/HOLD、T+1 执行、手续费、滑点、券商连接, 也不会执行真实或模拟委托。
## 开发
安装开发依赖并运行聚焦测试:
聚焦单元测试会 mock 模型和行情数据边界:
```bash
python -m pip install -e ".[dev,core]"
PYTHONPATH=. pytest tests/unit/test_auto_fin.py -v
pytest tests/unit/test_auto_fin.py -v
```
单元测试会 mock 模型和行情数据边界。需要真实 Tushare、模型或钉钉凭据的测试应单独运行,且需要显式授权。
需要真实 Tushare、LLM 或钉钉凭据的测试应单独运行,且需要显式授权。

View file

@ -2,334 +2,118 @@
[中文](README_ZH.md)
Daily Paper is a local-first, file-native workflow for turning research rankings into a daily reading package.
## Capabilities
- Collect papers from the Hugging Face weekly and monthly rankings while excluding yesterday's papers and recent
recommendations.
- Rank and select candidates, then use Claude Code to produce detailed Chinese notes and a five-minute Chinese brief.
- Keep PDFs, notes, and memories as ordinary user-owned files; indexes and caches remain rebuildable.
- Support daily scheduling, optional DingTalk delivery, conversation memory, auto-dream consolidation, and BM25 recall
for the background DingTalk agent.
The workflow is assembled by [`daily_cookbook.yaml`](../../reme/config/daily_cookbook.yaml). Its schemas live in
[`reme/schema/daily_paper.py`](../../reme/schema/daily_paper.py), and its steps live in
[`reme/steps/cookbook/daily_paper/`](../../reme/steps/cookbook/daily_paper/).
Daily Paper selects three papers from the Hugging Face Papers weekly and monthly rankings, downloads their arXiv PDFs,
and produces detailed Chinese reading notes plus a roughly five-minute Chinese brief. The implementation lives in
[`reme/steps/cookbook/daily_paper/`](../../reme/steps/cookbook/daily_paper/) and is assembled by
[`daily_cookbook.yaml`](../../reme/config/daily_cookbook.yaml).
## Quick start
Daily Paper requires Python 3.11 or later, the `core` dependencies, network access to Hugging Face and arXiv, and
credentials for the configured Claude Code endpoint. Auto-memory and auto-dream additionally require the AgentScope LLM
credentials.
From the repository root:
The workflow requires Python 3.11 or later, the `core` dependencies, an available AgentScope LLM, and network access to
Hugging Face Papers and arXiv.
```bash
python -m pip install -e ".[core]"
export CLAUDE_CODE_API_KEY="your-api-key"
export LLM_API_KEY="your-api-key"
reme start config=daily_cookbook job=daily_paper
```
The built-in configuration uses `qwen3.7-max` through DashScope's Anthropic-compatible endpoint. Override
`CLAUDE_CODE_MODEL_NAME` and `CLAUDE_CODE_BASE_URL` when using another compatible model or provider.
The built-in LLM component defaults to:
This is enough to generate paper notes and the daily brief. To use auto-memory and auto-dream, also configure:
- model: `qwen3.7-plus`
- endpoint: no built-in `LLM_BASE_URL`; set the OpenAI-compatible endpoint required by your provider
- environment variables: `LLM_API_KEY`, `LLM_MODEL_NAME`, and `LLM_BASE_URL`
```bash
export LLM_API_KEY="your-api-key"
```
Auto Fin and Daily Paper share this single `default` LLM and the single tool-free `default` AgentScope wrapper.
Only the interactive `dingtalk_wait` step overrides the wrapper per call with AgentScope `bash` and an explicit ReMe
job allowlist; Daily Paper calls remain tool-free.
By default, outputs are written under `reme_workspace/` in the directory where ReMe starts.
The default workspace is `reme_workspace/` beneath the process working directory. Override it with
`DAILY_PAPER_WORKSPACE_DIR`.
### Optional SSH proxy
The outbound proxy is disabled by default. To enable it, uncomment `components.outbound_proxy.default` in
`daily_cookbook.yaml`, configure non-interactive SSH authentication, and set:
```bash
export REME_PROXY_IP="your-ssh-proxy-host"
export REME_PROXY_ACCOUNT="your-ssh-account"
```
## What it creates
A successful run writes ordinary PDFs and Markdown files beneath `workspace_dir`:
## Pipeline
```text
reme_workspace/
├── daily/
│ ├── YYYY-MM-DD.md
│ └── YYYY-MM-DD/
│ ├── daily-paper-brief.md
│ ├── paper-<arxiv-id>.md
│ └── ...
├── resource/
│ └── papers/
│ ├── <arxiv-id>.pdf
│ └── ...
├── digest/
│ ├── personal/
│ ├── project/
│ ├── resource/
│ └── wiki/
├── metadata/
│ └── ... derived catalogs, indexes, and caches
└── mem_session/
├── agentscope/
└── claude_config/
Hugging Face weekly/monthly rankings
Collect ──► Rank ──► Select 3 ──► Analyze PDFs ──► Digest ──► DingTalk (optional)
│ │
├─ PDFs ├─ daily brief
└─ paper notes └─ day index
```
- `paper-<arxiv-id>.md` is a detailed Chinese reading note with YAML frontmatter linking back to the source PDF and
paper pages.
- `daily-paper-brief.md` is a roughly five-minute Chinese digest with wikilinks to every selected paper note.
- `daily/YYYY-MM-DD.md` is a derived day index rebuilt from the Markdown files for that date.
- `resource/papers/` holds reusable source PDFs.
- `digest/` contains durable auto-dream output; files there remain ordinary user-owned Markdown.
- `metadata/` and search caches are derived state. `reindex` rebuilds the file store, BM25 index, and graph from source
files.
### 1. Collect
The paper notes are the source of truth for recommendation history: their frontmatter contains the `arxiv_id` values
used for future deduplication. The day index is derived and can be rebuilt. The workflow does not currently write a
separate run manifest.
`daily_paper_collect_step` concurrently fetches:
## How the workflow works
- the Hugging Face weekly ranking for the run date's ISO week;
- the monthly ranking for the run date's calendar month; and
- Hugging Face Daily Papers for exactly the previous calendar day.
```mermaid
flowchart LR
HF[Hugging Face<br/>weekly + monthly] --> C[1. Collect]
Y[Yesterday's papers] --> C
H[Recent local notes] --> C
C --> R[2. Rank]
R --> S[3. Select]
S --> A[4. Analyze PDFs]
A --> D[5. Build brief]
D --> N[6. Notify DingTalk]
A --> P[PDFs + paper notes]
D --> B[Brief + day index]
```
The weekly and monthly results are merged by arXiv ID while preserving both ranks. The step then excludes papers found
in yesterday's list or in the `arxiv_id` frontmatter of `daily/<date>/*.md` within the previous `history_days`.
### 1. Collect and deduplicate
If a Markdown file with `kind: daily-paper-brief` already exists and `force=false`, generation is skipped; the saved
brief can still proceed to DingTalk delivery. The job fails when no eligible papers remain.
The Collect step fetches the weekly ranking for the run date's ISO week, the monthly ranking for its calendar month, and
the Hugging Face Daily Papers IDs for exactly the previous calendar day. It merges weekly and monthly metadata by arXiv
ID and preserves each list's display rank.
### 2. Rank
It then scans `daily/<prior-date>/paper-*.md` over the configured history window and excludes IDs found in note
frontmatter. The job fails clearly if no eligible papers remain.
### 2. Rank candidates
The Rank step uses reciprocal-rank fusion:
`daily_paper_rank_step` uses reciprocal-rank fusion:
```text
score = 1 / (rrf_k + monthly_rank)
+ weekly_weight / (rrf_k + weekly_rank)
```
A missing rank contributes zero. Candidates are ordered by fused score, upvotes, and arXiv ID. The bounded candidate
pool also reserves several positions for papers whose titles or summaries match memory-related terms such as agent
memory, memory retrieval, continual learning, context compression, knowledge graphs, and RAG. This reserve is a simple
keyword heuristic, not a semantic classifier.
A missing rank contributes zero. Papers are ordered by fused score, upvotes, and arXiv ID. The pool is capped at
`candidate_limit`, and Rank applies no topic preference.
### 3. Select papers
### 3. Select
Claude Code receives the bounded candidate pool and returns a structured `PaperSelection`. The implementation requires
exactly `top_k` unique in-pool IDs with consecutive ranks. Invalid output is returned to the agent once as validation
feedback; a second invalid response fails the job.
`daily_paper_select_step` sends candidate metadata to a tool-free AgentScope agent and requires exactly three items:
### 4. Download and analyze PDFs
Selected papers are processed sequentially. For each paper, the workflow:
1. validates the modern arXiv ID format;
2. downloads and validates the PDF, or reuses an existing file with a valid `%PDF-` header;
3. extracts text with `pypdf`, adding page markers and applying page and character limits;
4. asks Claude Code for a structured detailed reading; and
5. writes normalized frontmatter plus the generated Markdown body.
The current extractor requires a usable PDF text layer. Scanned or image-only PDFs fail because there is no OCR
fallback. If extraction exceeds a configured limit, the note records that the input was truncated.
### 5. Build the brief and index
Claude Code reads every detailed note and produces the daily brief. The code verifies that each source-note wikilink is
present and appends any missing links before writing the file. It then rebuilds `daily/YYYY-MM-DD.md` from that day's
Markdown frontmatter.
### 6. Optionally notify DingTalk
The final step sends the brief body, without YAML frontmatter, to each configured DingTalk group in order. With no
conversation IDs it is a no-op. If one group fails, the step still attempts the remaining groups and reports the
combined failure afterward.
## Memory and search
The standalone configuration separates agent wrappers by responsibility:
- `daily_paper` selects papers, analyzes them, and builds the brief. It keeps Claude Code's normal local tools and
disables `WebSearch`, but currently has no memory-retrieval job configured.
- `dingtalk_wait` runs the background DingTalk agent and exposes `memory_search` as a callable tool.
- `memory` runs auto-memory and the LLM-backed auto-dream steps through AgentScope. Its built-in shell and file tools
are disabled; memory changes go through the narrower ReMe jobs such as `daily_write`, `read`, `edit`, and `write`.
The built-in `memory_search` job uses BM25 over Markdown under `daily/` and `digest/`. ReMe's search step can fuse
vector results, but this cookbook does not configure an embedding store by default, so vector retrieval is not run.
`node_search` is a narrower digest recall tool used internally by auto-dream.
`index_update_loop` indexes existing memory files when the service starts and watches those directories for later
changes. Run `reindex` when recovering the derived file store or forcing a complete index rebuild. Source Markdown and
PDFs are not deleted by `reindex`.
`auto_memory` writes or updates one daily note from caller-supplied conversation messages and a stable `session_id`.
`auto_dream` scans recent daily notes, integrates durable units under `digest/`, and writes interest topics. Both are
on-demand jobs in this cookbook; no auto-dream cron is configured. The DingTalk agent can recall through
`memory_search`, but it does not automatically call `auto_memory` after a conversation.
## Dates, reruns, and idempotency
- `date` must be an exact `YYYY-MM-DD` value. When omitted, the job uses today in the application timezone, which is
`Asia/Shanghai` in the built-in configuration.
- “Yesterday” means `date - 1 day`, not the previous 24 hours.
- `history_days` considers prior dated note directories only; the current run date is never part of its history scan.
- If `daily/<date>/daily-paper-brief.md` already exists and `force=false`, collection, ranking, model calls, PDF work,
and digest generation are skipped. The existing brief remains available to the DingTalk notification step.
- `force=true` regenerates the notes and brief. Existing valid PDFs are still reused.
Each PDF, detailed note, and final brief uses a temporary file followed by replacement so callers do not see a partially
written file. The complete multi-file workflow is not transactional, and there is no global lock for two concurrent runs
of the same date.
## Running the cookbook
The main jobs in the standalone configuration are:
| Job | Behavior |
|---------------------|-----------------------------------------------------------------|
| `daily_paper` | On-demand generation through the CLI or HTTP service |
| `daily_paper_cron` | The same pipeline every day at 08:00 in `Asia/Shanghai` |
| `dingtalk_wait` | A supervised background DingTalk agent with `memory_search` |
| `auto_memory` | Write or update a daily note from conversation messages |
| `auto_dream` | Consolidate recent daily notes into digest memory and interests |
| `memory_search` | BM25 recall over daily and digest Markdown |
| `reindex` | Rebuild derived search state from existing memory files |
| `index_update_loop` | Initialize and continuously update search state in service mode |
Supporting jobs such as `node_search`, `daily_list`, `daily_write`, `read`, `write`, `edit`, and frontmatter updates
provide the constrained tools used by the memory agent.
### One-time runs
The quick-start command generates today's brief. To generate a specific date with selected overrides:
```bash
reme start \
config=daily_cookbook \
job=daily_paper \
date=2026-07-21 \
top_k=3 \
history_days=30
```json
{"papers": [{"arxiv_id": "2601.01234", "reasoning": "A specific, verifiable reason"}]}
```
Regenerate a date whose brief already exists:
All IDs must be unique and belong to the candidate pool, and every reason must be non-empty. A validation failure is
returned to the agent for one retry. Only a non-empty `topics` value injects a personalized subject preference into
the selection prompt; it does not change the fixed count of three papers.
```bash
reme start config=daily_cookbook job=daily_paper date=2026-07-21 force=true
```
### 4. Analyze
Add `service.show_metadata=true` to a one-time command when the response metadata is useful for diagnostics.
`daily_paper_analyze_step` processes the selected papers in order:
### Long-running service and cron
1. validates a modern `YYYY.NNNN` or `YYYY.NNNNN` arXiv ID;
2. downloads the PDF to `resource/papers/<arxiv-id>.pdf`;
3. reuses an existing target whose header is `%PDF-`;
4. extracts paginated text with `pypdf`, bounded by `max_pdf_pages` and `max_pdf_chars`;
5. sends metadata, selection reasoning, and PDF text to a tool-free agent; and
6. writes a Chinese note to `daily/<date>/<Chinese-title>.md`.
Start the standalone HTTP service and its scheduled/background jobs:
Downloads use a temporary file and atomically replace the target only after validating the PDF header. They are also
bounded by `max_pdf_bytes`. There is no OCR fallback, so scanned or textless PDFs fail. When extraction is truncated,
the note records `pdf_text_truncated: true` in its frontmatter.
```bash
reme start config=daily_cookbook
```
### 5. Digest
It listens on `127.0.0.1:8001` by default, so it can run beside the default ReMe service. Call the on-demand job from
another terminal with either the ReMe client or HTTP:
`daily_paper_digest_step` builds the Chinese brief directly from the three in-memory analyses; it does not reread or
search other material. The agent returns `title`, `desc`, and `body`. The code then:
```bash
reme daily_paper host=127.0.0.1 port=8001
```
- strips model-generated YAML frontmatter if present;
- normalizes the Chinese title for use as a filename;
- deterministically appends wikilinks to all three source notes;
- writes `daily/<date>/<Chinese-brief-title>.md`; and
- rebuilds the `daily/<date>.md` day index.
```bash
curl -s http://127.0.0.1:8001/daily_paper \
-H 'Content-Type: application/json' \
-d '{"date":"2026-07-21","top_k":3,"force":false}'
```
Final response metadata includes the date, week/month scopes, selected arXiv IDs, selection reasons, note/PDF/brief
paths, source counts, and exclusion counts.
Recall memory, record a conversation, consolidate it, or explicitly rebuild the search index:
### 6. DingTalk
```bash
reme memory_search host=127.0.0.1 port=8001 query="agent memory" limit=5
reme auto_memory host=127.0.0.1 port=8001 \
session_id=example-session \
messages='[{"name":"user","role":"user","content":"I prefer concise paper summaries."}]'
reme auto_dream host=127.0.0.1 port=8001 date=2026-07-21
reme reindex host=127.0.0.1 port=8001
```
Service and schedule settings can be overridden at startup:
```bash
reme start \
config=daily_cookbook \
service.host=0.0.0.0 \
service.port=8101 \
jobs.daily_paper_cron.cron="30 7 * * *"
```
## Configuration
The most useful job settings are:
| Setting | Default | Purpose |
|-------------------|-------------:|--------------------------------------------------------------|
| `candidate_limit` | `20` | Maximum number of papers sent to selection |
| `memory_reserve` | `5` | Candidate positions reserved by the memory-keyword heuristic |
| `top_k` | `3` | Number of papers selected and analyzed |
| `rrf_k` | `60` | Reciprocal-rank fusion constant |
| `weekly_weight` | `0.7` | Weight of the weekly ranking in fusion |
| `history_days` | `30` | Prior recommendation window excluded by arXiv ID |
| `hf_timeout` | `30` seconds | Hugging Face request timeout |
| `hf_max_retries` | `3` | Maximum Hugging Face request attempts |
| `pdf_timeout` | `90` seconds | arXiv download timeout |
| `max_pdf_bytes` | `52428800` | Maximum PDF size (50 MiB) |
| `max_pdf_pages` | `80` | Maximum pages extracted for analysis |
| `max_pdf_chars` | `240000` | Maximum extracted characters sent for one paper |
The public job parameters are `date`, `force`, `top_k`, `weekly_weight`, and `history_days`. Explicit invocation values
take precedence over the job defaults.
The standalone application also accepts these environment variables:
| Variable | Purpose |
|-----------------------------------------|------------------------------------------------|
| `DAILY_PAPER_WORKSPACE_DIR` | Overrides the default `reme_workspace` |
| `DAILY_PAPER_PROJECT_PATH` | Repository/project path visible to Claude Code |
| `REME_PROXY_IP` | Optional SSH proxy host |
| `REME_PROXY_ACCOUNT` | Optional SSH proxy account |
| `DAILY_PAPER_HOST` / `DAILY_PAPER_PORT` | HTTP bind address |
| `CLAUDE_CODE_API_KEY` | API key for the Claude Code endpoint |
| `CLAUDE_CODE_MODEL_NAME` | Claude Code model; default `qwen3.7-max` |
| `CLAUDE_CODE_BASE_URL` | Claude Code Anthropic-compatible endpoint |
| `LLM_API_KEY` | API key for the AgentScope memory model |
| `LLM_MODEL_NAME` | Memory model; default `qwen3.7-max` |
| `LLM_BASE_URL` | Memory model's Anthropic-compatible endpoint |
`DAILY_PAPER_PROJECT_PATH` defaults to `..` relative to the workspace. With the default `reme_workspace`, starting from
the repository root resolves it back to the repository. If the workspace lives elsewhere, set both paths explicitly.
ReMe loads an uncommitted `.env` file found from the current directory upward, so the same values may be placed there
instead of exported in the shell.
## DingTalk configuration
DingTalk is optional. Configure it only when brief delivery or the background DingTalk agent is needed:
The final `dingtalk_markdown_send_step` is optional. With no conversation IDs it is a no-op. When configured, it strips
frontmatter and sends the brief body to each group in order:
```dotenv
DINGTALK_APP_KEY=your-app-key
@ -338,39 +122,119 @@ DINGTALK_ROBOT_CODE=your-robot-code
DINGTALK_CONVERSATION_IDS=cid-group-one,cid-group-two
```
`DINGTALK_CONVERSATION_IDS` is required only for proactive brief delivery. The background `dingtalk_wait` job uses the
first three credentials but not the conversation list.
A failed recipient does not prevent later attempts; the step reports a combined failure after trying every group.
## Failure recovery and boundaries
## Outputs
| Situation | Behavior |
|--------------------------------------|----------------------------------------------------------------|
| Temporary Hugging Face failure | Retries with exponential delay up to `hf_max_retries` attempts |
| No eligible papers | Fails before ranking |
| Invalid `top_k` or selection output | Fails after validation; selection output gets one retry |
| Oversized, invalid, or textless PDF | Stops during analysis |
| PDF exceeds page or character limits | Continues with truncated text and records the truncation |
| One paper analysis fails | Stops the job; earlier PDFs and notes remain on disk |
| Brief misses a source-note link | Appends the missing wikilink before writing |
| Auto-dream partial integration | Successful units remain; failed paths are not checkpointed |
```text
reme_workspace/
├── daily/
│ ├── YYYY-MM-DD.md
│ └── YYYY-MM-DD/
│ ├── <Chinese-paper-title>.md # three, kind: daily-paper-analysis
│ └── <Chinese-brief-title>.md # one, kind: daily-paper-brief
└── resource/
└── papers/
└── <arxiv-id>.pdf
```
To recover, inspect the date's notes and PDFs, fix the network, credential, model, or PDF issue, then rerun the same
date with `force=true`. Valid cached PDFs will be reused.
Filenames come from the agent's Chinese titles. The implementation removes unsafe path characters and resolves title
collisions. Markdown and PDF outputs are written through same-directory temporary files and atomic replacement.
The built-in Claude Code components run with `permission_mode: bypassPermissions` and disable `WebSearch`.
`dingtalk_wait` can call the local `memory_search` job; `daily_paper` currently has no job tools. The analysis and brief
prompts constrain what the agent should read, but these steps do not set a strict per-call tool allowlist or an
operating-system sandbox. The AgentScope memory wrapper disables its built-in shell and filesystem tools, but runs its
ReMe job tools in bypass permission mode. Run the cookbook only with a trusted project and workspace, and tighten the
agent configuration before shared or production use.
## Parameters and defaults
Public job parameters:
| Parameter | Default | Purpose |
|---|---:|---|
| `date` | `""` | Run date; empty uses today in the app timezone, otherwise requires `YYYY-MM-DD` |
| `force` | `false` | Regenerate even when the day's brief exists |
| `topics` | `""` | Topics to prioritize during selection |
| `weekly_weight` | `0.7` | Weekly contribution to RRF |
| `history_days` | `30` | Prior recommendation exclusion window |
Step-level settings on the `daily_paper` job:
| Setting | Default | Purpose |
|---|---:|---|
| `candidate_limit` | `20` | Maximum candidates sent to Select |
| `rrf_k` | `60` | RRF constant |
| `hf_timeout` | `600` seconds | Timeout for one Hugging Face request |
| `hf_max_retries` | `3` | Maximum Hugging Face attempts |
| `pdf_timeout` | `600` seconds | arXiv PDF download timeout |
| `max_pdf_bytes` | `52428800` | PDF limit, 50 MiB |
| `max_pdf_pages` | `20` | Maximum extracted pages |
| `max_pdf_chars` | `300000` | Maximum extracted PDF characters sent to the agent |
## Mirrors
The data clients use httpx's default environment handling, so `HTTP_PROXY`, `HTTPS_PROXY`, and `NO_PROXY` take effect
when present. Mirror environment variables independently replace each service's base URL:
```dotenv
# Defaults to https://huggingface.co when unset
HF_MIRROR_URL=https://hf-mirror.com
# Defaults to https://arxiv.org when unset
ARXIV_MIRROR_URL=https://export.arxiv.org
# Path-prefixed relay URLs are also supported
# HF_MIRROR_URL=http://relay-host:18080/hf
# ARXIV_MIRROR_URL=http://relay-host:18080/arxiv
```
`HF_MIRROR_URL` must implement the `/papers/...`, `/api/daily_papers`, and `/api/papers/...` routes used by the current
client. `ARXIV_MIRROR_URL` must implement `/pdf/<arxiv-id>`. A path prefix in either base URL is preserved, and a
trailing slash is optional. When a variable is unset, the official service is used directly; there is no fallback chain.
## Running the workflow
Generate a brief for a specific date:
```bash
reme start \
config=daily_cookbook \
job=daily_paper \
date=2026-08-06 \
topics="Agent memory" \
history_days=30
```
Force a rerun; valid local PDFs are still reused:
```bash
reme start config=daily_cookbook job=daily_paper date=2026-08-06 force=true
```
Start the HTTP service and scheduled jobs:
```bash
reme start config=daily_cookbook
```
The built-in service listens on `127.0.0.1:8001`. `daily_paper_cron` runs every day at 08:00 in the
`Asia/Shanghai` timezone. Override the bind address with `DAILY_PAPER_HOST`, `DAILY_PAPER_PORT`, or startup arguments.
```bash
curl -s http://127.0.0.1:8001/daily_paper \
-H 'Content-Type: application/json' \
-d '{"date":"2026-08-06","force":false,"topics":"Agent memory"}'
```
## Failures and reruns
- Hugging Face failures use exponential backoff up to `hf_max_retries` attempts.
- Fewer than three candidates, invalid agent selection, invalid/oversized/textless PDFs, or empty agent output stop the
job.
- Papers are analyzed sequentially; PDFs and notes completed before a failure remain on disk.
- `force=true` regenerates notes and the brief while reusing valid PDFs.
- The multi-file workflow is not transactional and has no global per-date execution lock.
## Tests
The focused unit suite mocks Hugging Face, arXiv, Claude Code, and DingTalk boundaries:
The focused unit tests mock Hugging Face, arXiv, AgentScope, and DingTalk boundaries and do not call real services:
```bash
python -m pip install -e ".[dev,core]"
pytest tests/unit/test_daily_paper.py -v
```
Real runs access external services and may incur model costs; they should not be used as ordinary unit tests.

View file

@ -2,312 +2,108 @@
[English](README.md)
每日论文是一个本地优先、文件原生的研究资讯工作流,用于把研究榜单转化为每日阅读材料。
## 能力
- 从 Hugging Face 周榜和月榜采集论文,并排除昨日论文和近期已经推荐过的论文。
- 对候选论文进行排序和精选,再使用 Claude Code 生成中文详细论文笔记和约五分钟可读完的中文简报。
- 将 PDF、笔记和记忆保存为由用户拥有的普通文件索引和缓存均可重建。
- 支持每日定时运行、可选钉钉投递、对话记忆、auto-dream 整理,以及供后台钉钉 Agent 使用的 BM25 检索。
工作流由 [`daily_cookbook.yaml`](../../reme/config/daily_cookbook.yaml) 装配,公共 schema 位于
[`reme/schema/daily_paper.py`](../../reme/schema/daily_paper.py),各步骤位于
[`reme/steps/cookbook/daily_paper/`](../../reme/steps/cookbook/daily_paper/)。
每日论文工作流从 Hugging Face Papers 的周榜和月榜中筛选三篇论文,下载 arXiv PDF生成中文论文解读和一篇约五分钟可读完的中文简报。当前实现位于
[`reme/steps/cookbook/daily_paper/`](../../reme/steps/cookbook/daily_paper/),由
[`daily_cookbook.yaml`](../../reme/config/daily_cookbook.yaml) 装配。
## 快速开始
每日论文要求 Python 3.11 或更高版本、`core` 依赖、可访问 Hugging Face 和 arXiv 的网络,以及所配置 Claude Code endpoint
的凭据。auto-memory 和 auto-dream 还需要 AgentScope LLM 凭据。
在仓库根目录运行:
要求 Python 3.11 或更高版本、`core` 依赖、可用的 AgentScope LLM以及能访问 Hugging Face Papers 和 arXiv 的网络。
```bash
python -m pip install -e ".[core]"
export CLAUDE_CODE_API_KEY="your-api-key"
export LLM_API_KEY="your-api-key"
reme start config=daily_cookbook job=daily_paper
```
内置配置默认通过 DashScope 的 Anthropic 兼容 endpoint 使用 `qwen3.7-max`。如需使用其他兼容模型或服务商, 请覆盖
`CLAUDE_CODE_MODEL_NAME``CLAUDE_CODE_BASE_URL`
内置 LLM 组件默认配置为:
以上配置足以生成论文笔记和每日简报。要使用 auto-memory 和 auto-dream还需配置
- 模型:`qwen3.7-plus`
- endpoint无内置 `LLM_BASE_URL`;请设置服务商要求的 OpenAI 兼容 endpoint
- 环境变量:`LLM_API_KEY``LLM_MODEL_NAME``LLM_BASE_URL`
```bash
export LLM_API_KEY="your-api-key"
```
Auto Fin 和 Daily Paper 共用这一个 `default` LLM以及唯一的无工具 `default` AgentScope wrapper。
只有交互式 `dingtalk_wait` Step 会在调用时覆盖默认值,启用 AgentScope `bash` 和明确的 ReMe Job allowlistDaily
Paper 的模型调用仍然无工具。
默认情况下,产物写入 ReMe 启动目录下的 `reme_workspace/`
默认 workspace 是启动目录下的 `reme_workspace/`,可通过 `DAILY_PAPER_WORKSPACE_DIR` 覆盖
### 可选 SSH 代理
出站代理默认关闭。如需启用,请取消 `daily_cookbook.yaml`
`components.outbound_proxy.default` 的注释,配置免交互 SSH 认证,并设置:
```bash
export REME_PROXY_IP="your-ssh-proxy-host"
export REME_PROXY_ACCOUNT="your-ssh-account"
```
## 文件产物
一次成功运行会在 `workspace_dir` 下写入普通 PDF 和 Markdown 文件:
## 工作流
```text
reme_workspace/
├── daily/
│ ├── YYYY-MM-DD.md
│ └── YYYY-MM-DD/
│ ├── daily-paper-brief.md
│ ├── paper-<arxiv-id>.md
│ └── ...
├── resource/
│ └── papers/
│ ├── <arxiv-id>.pdf
│ └── ...
├── digest/
│ ├── personal/
│ ├── project/
│ ├── resource/
│ └── wiki/
├── metadata/
│ └── ... 派生 catalog、索引和缓存
└── mem_session/
├── agentscope/
└── claude_config/
Hugging Face 周榜/月榜
Collect ──► Rank ──► Select 3 篇 ──► Analyze PDF ──► Digest ──► DingTalk可选
│ │
├─ PDF ├─ 每日简报
└─ 论文解读 └─ 当日索引
```
- `paper-<arxiv-id>.md` 是中文详细论文解读,其 YAML frontmatter 会链接原始 PDF 和论文页面。
- `daily-paper-brief.md` 是约五分钟可读完的中文简报,并包含每篇入选论文笔记的 wikilink。
- `daily/YYYY-MM-DD.md` 是从当日 Markdown 文件重建的派生日索引。
- `resource/papers/` 保存可复用的原始 PDF。
- `digest/` 保存持久的 auto-dream 产物,其中仍然是由用户拥有的普通 Markdown 文件。
- `metadata/` 和搜索缓存属于派生状态;`reindex` 会根据源文件重建 file store、BM25 索引和图。
### 1. Collect
论文笔记是推荐历史的事实来源:后续排重会读取其 frontmatter 中的 `arxiv_id`。日索引属于可重建的派生文件。 当前工作流不会另外写入运行
manifest。
`daily_paper_collect_step` 根据运行日期并发读取:
## 工作流程
- 该日期所在 ISO week 的 Hugging Face 周榜;
- 该日期所在自然月的 Hugging Face 月榜;
- 严格前一个自然日的 Hugging Face Daily Papers。
```mermaid
flowchart LR
HF[Hugging Face<br/>周榜 + 月榜] --> C[1. Collect]
Y[昨日论文] --> C
H[近期本地笔记] --> C
C --> R[2. Rank]
R --> S[3. Select]
S --> A[4. Analyze PDFs]
A --> D[5. Build brief]
D --> N[6. Notify DingTalk]
A --> P[PDF + 论文笔记]
D --> B[简报 + 日索引]
```
周榜和月榜按 arXiv ID 合并,并保留各自排名。随后排除:
### 1. 采集与排重
- 昨日 Daily Papers 中的论文;
- `history_days` 窗口内,已出现在 `daily/<date>/*.md` frontmatter `arxiv_id` 中的论文。
Collect 会获取运行日所在 ISO week 的周榜、所在自然月的月榜,以及严格前一个自然日的 Hugging Face Daily Papers ID。周榜和月榜元数据按
arXiv ID 合并,同时保留两个榜单各自的展示排名。
如果当天已经存在 `kind: daily-paper-brief` 的 Markdown 且 `force=false`整个生成流程会跳过已有简报仍可进入钉钉发送步骤。没有剩余候选论文时Job 直接失败。
随后,它会在配置的历史窗口内扫描 `daily/<prior-date>/paper-*.md`,排除笔记 frontmatter 中已有的 ID。如果排重后 没有任何可选论文Job
会明确失败。
### 2. Rank
### 2. 候选排序
Rank 使用 reciprocal-rank fusionRRF
`daily_paper_rank_step` 使用 reciprocal-rank fusion
```text
score = 1 / (rrf_k + monthly_rank)
+ weekly_weight / (rrf_k + weekly_rank)
```
论文缺少某个榜单排名时该项贡献为零。候选按融合分、upvotes 和 arXiv ID 排序。有界候选池还会为标题或摘要命中 Agent
memory、memory retrieval、continual learning、context compression、knowledge graph、RAG 等记忆相关关键词的
论文保留若干位置。这个保留策略只是关键词启发式,不是语义分类器。
缺失的榜单排名贡献为零。论文按融合分、upvotes、arXiv ID 排序,候选池最多保留 `candidate_limit` 篇。Rank 阶段不应用任何主题倾向。
### 3. 精选论文
### 3. Select
Claude Code 接收有界候选池,并返回结构化的 `PaperSelection`。实现要求恰好选择 `top_k` 个候选池内的唯一 ID 且 rank
必须连续。输出不合法时,校验错误会反馈给 Agent 并重试一次;第二次仍不合法则 Job 失败。
`daily_paper_select_step` 将候选元数据交给无工具的 AgentScope Agent并要求返回恰好三项
### 4. 下载并解读 PDF
入选论文按顺序逐篇处理。每篇论文都会经过:
1. 校验当前支持的新版 arXiv ID 格式;
2. 下载并校验 PDF或复用文件头为 `%PDF-` 的已有文件;
3. 使用 `pypdf` 提取文本、插入页码标记,并应用页数和字符数限制;
4. 请求 Claude Code 返回结构化的详细解读;
5. 写入规范化 frontmatter 和生成的 Markdown 正文。
当前提取器依赖可用的 PDF 文本层。扫描版或纯图片 PDF 会失败,因为没有 OCR fallback。提取内容超过配置限制时 笔记会记录输入已被截断。
### 5. 生成简报与索引
Claude Code 会读取全部详细笔记并生成当日简报。代码会检查每篇源笔记的 wikilink如有遗漏会在写入前自动补齐。 随后,工作流根据当日
Markdown frontmatter 重建 `daily/YYYY-MM-DD.md`
### 6. 可选的钉钉通知
最后一步会去掉 YAML frontmatter把简报正文按顺序发送到每个已配置的钉钉群。未配置群会话 ID 时,该步骤无副作用
跳过。某个群发送失败不会阻止继续尝试其他群,所有发送完成后再汇总报告失败。
## 记忆与检索
独立配置按职责分离 agent wrapper
- `daily_paper` 执行论文精选、解读和简报生成。它保留 Claude Code 的常规本地工具并禁用 `WebSearch`,但当前没有 配置记忆检索
Job。
- `dingtalk_wait` 运行后台钉钉 Agent并把 `memory_search` 作为可调用工具。
- `memory` 通过 AgentScope 执行 auto-memory 和 auto-dream 中依赖 LLM 的步骤。它禁用内置 shell 和文件工具; 记忆变更只能经过
`daily_write``read``edit``write` 等更窄的 ReMe job。
内置配置的 `memory_search` 使用 BM25 检索 `daily/``digest/` 下的 Markdown。ReMe 的搜索步骤支持融合向量结果, 但本
cookbook 默认没有配置 embedding store因此不会执行向量检索。`node_search` 是 auto-dream 内部使用的 digest 节点检索工具。
`index_update_loop` 会在服务启动时索引已有记忆文件,并持续监听这些目录的后续变化。修复派生 file store 或需要强制
完整重建索引时,可运行 `reindex``reindex` 不会删除源 Markdown 或 PDF。
`auto_memory` 根据调用方传入的对话消息和稳定 `session_id` 写入或更新一篇 daily note。`auto_dream` 扫描近期 daily
notes把持久记忆单元整合到 `digest/`,并生成兴趣主题。这两个 job 在本 cookbook 中都是按需执行;当前没有配置 auto-dream
cron。钉钉 Agent 可以通过 `memory_search` 召回记忆,但对话结束后不会自动调用 `auto_memory`
## 日期、重跑与幂等
- `date` 必须严格符合 `YYYY-MM-DD`。省略时使用应用配置时区中的今天;内置配置为 `Asia/Shanghai`
- “昨日”表示 `date - 1 day`,不是模糊的最近 24 小时。
- `history_days` 只扫描此前的日期目录,不会把本次运行日纳入历史窗口。
- 如果 `daily/<date>/daily-paper-brief.md` 已存在且 `force=false`采集、排序、模型调用、PDF 处理和简报生成都会
跳过;已有简报仍会交给钉钉通知步骤。
- `force=true` 会重新生成笔记和简报,但仍会复用已有且有效的 PDF。
每个 PDF、详细笔记和最终简报都会先写临时文件再替换避免读取方看到半写状态。整个多文件工作流不是事务 同一日期的并发运行也没有全局锁。
## 运行方式
独立配置中的主要 Job 如下:
| Job | 行为 |
|---------------------|---------------------------------------------------------------|
| `daily_paper` | 通过 CLI 或 HTTP 服务按需生成 |
| `daily_paper_cron` | 每天 08:00`Asia/Shanghai`)执行相同 pipeline |
| `dingtalk_wait` | 由 supervisor 管理、具有 `memory_search` 能力的后台钉钉 Agent |
| `auto_memory` | 根据对话消息写入或更新 daily note |
| `auto_dream` | 把近期 daily notes 整理为 digest 记忆和兴趣主题 |
| `memory_search` | 对 daily 和 digest Markdown 执行 BM25 检索 |
| `reindex` | 根据已有记忆文件重建派生检索状态 |
| `index_update_loop` | 在服务模式下初始化并持续更新检索状态 |
`node_search``daily_list``daily_write``read``write``edit` 和 frontmatter 更新等辅助 job 构成 memory agent 使用的受约束工具。
### 一次性运行
快速开始中的命令会生成今天的简报。要生成指定日期并覆盖部分参数:
```bash
reme start \
config=daily_cookbook \
job=daily_paper \
date=2026-07-21 \
top_k=3 \
history_days=30
```json
{"papers": [{"arxiv_id": "2601.01234", "reasoning": "具体且可核验的选择理由"}]}
```
重新生成已有简报的日期:
三个 ID 必须唯一且都属于候选池,理由不能为空。校验失败后,错误信息会反馈给 Agent 并重试一次。只有非空 `topics` 会向精选提示注入个性化主题,且不会改变固定的三篇数量。
```bash
reme start config=daily_cookbook job=daily_paper date=2026-07-21 force=true
```
### 4. Analyze
需要查看响应 metadata 进行诊断时,可在一次性命令中加入 `service.show_metadata=true`
`daily_paper_analyze_step` 按精选顺序逐篇处理:
### 常驻服务与 cron
1. 校验新版 arXiv ID 格式 `YYYY.NNNN``YYYY.NNNNN`
2. 下载 PDF 到 `resource/papers/<arxiv-id>.pdf`
3. 如果目标文件已存在且以 `%PDF-` 开头,直接复用;
4. 用 `pypdf` 提取分页文本,受 `max_pdf_pages``max_pdf_chars` 限制;
5. 将论文元数据、选择理由和 PDF 文本交给无工具 Agent
6. 将中文解读写入 `daily/<date>/<中文标题>.md`
启动独立 HTTP 服务以及定时、后台 Job
下载采用临时文件并在校验 PDF 文件头后原子替换,同时限制 `max_pdf_bytes`。当前没有 OCR扫描版或无文本层 PDF 会失败。提取被截断时,笔记 frontmatter 中的 `pdf_text_truncated` 会记录为 `true`
```bash
reme start config=daily_cookbook
```
### 5. Digest
服务默认监听 `127.0.0.1:8001`,因此可以和默认 ReMe 服务并行运行。在另一个终端中通过 ReMe client 或 HTTP 调用按需任务
`daily_paper_digest_step` 直接使用内存中的三篇解读生成中文简报,不会重新读取或搜索其他资料。输出必须包含 `title``desc``body`。代码会:
```bash
reme daily_paper host=127.0.0.1 port=8001
```
- 去掉模型可能生成的 YAML frontmatter
- 规范化中文标题并用作文件名;
- 确定性追加三篇源笔记的 wikilink
- 写入 `daily/<date>/<中文简报标题>.md`
- 重建 `daily/<date>.md` 当日索引。
```bash
curl -s http://127.0.0.1:8001/daily_paper \
-H 'Content-Type: application/json' \
-d '{"date":"2026-07-21","top_k":3,"force":false}'
```
最终响应 metadata 包含日期、周/月范围、入选 arXiv ID、选择理由、笔记/PDF/简报路径、源榜单数量和排重数量。
检索记忆、记录对话、整理记忆,或显式重建检索索引:
### 6. DingTalk
```bash
reme memory_search host=127.0.0.1 port=8001 query="agent memory" limit=5
reme auto_memory host=127.0.0.1 port=8001 \
session_id=example-session \
messages='[{"name":"user","role":"user","content":"I prefer concise paper summaries."}]'
reme auto_dream host=127.0.0.1 port=8001 date=2026-07-21
reme reindex host=127.0.0.1 port=8001
```
监听地址和调度时间可以在启动时覆盖:
```bash
reme start \
config=daily_cookbook \
service.host=0.0.0.0 \
service.port=8101 \
jobs.daily_paper_cron.cron="30 7 * * *"
```
## 配置
最常用的 Job 配置如下:
| 配置项 | 默认值 | 用途 |
|-------------------|-----------:|------------------------------------|
| `candidate_limit` | `20` | 送入精选阶段的最大论文数 |
| `memory_reserve` | `5` | 记忆关键词启发式保留的候选位置数 |
| `top_k` | `3` | 最终精选和解读的论文数 |
| `rrf_k` | `60` | RRF 常数 |
| `weekly_weight` | `0.7` | 周榜在融合排序中的权重 |
| `history_days` | `30` | 按 arXiv ID 排除近期推荐的时间窗口 |
| `hf_timeout` | `30` 秒 | Hugging Face 请求 timeout |
| `hf_max_retries` | `3` | Hugging Face 请求最多尝试次数 |
| `pdf_timeout` | `90` 秒 | arXiv 下载 timeout |
| `max_pdf_bytes` | `52428800` | PDF 大小上限50 MiB |
| `max_pdf_pages` | `80` | 最多提取的 PDF 页数 |
| `max_pdf_chars` | `240000` | 单篇论文送入模型的最大提取字符数 |
公开 Job 参数为 `date``force``top_k``weekly_weight``history_days`。调用时显式传入的值优先于 Job 默认值。
独立应用还支持以下环境变量:
| 环境变量 | 用途 |
|-----------------------------------------|----------------------------------------|
| `DAILY_PAPER_WORKSPACE_DIR` | 覆盖默认 `reme_workspace` |
| `DAILY_PAPER_PROJECT_PATH` | Claude Code 可见的仓库或项目路径 |
| `REME_PROXY_IP` | 可选 SSH 代理主机 |
| `REME_PROXY_ACCOUNT` | 可选 SSH 代理账户 |
| `DAILY_PAPER_HOST` / `DAILY_PAPER_PORT` | HTTP 监听地址 |
| `CLAUDE_CODE_API_KEY` | Claude Code endpoint 的 API key |
| `CLAUDE_CODE_MODEL_NAME` | Claude Code 模型;默认 `qwen3.7-max` |
| `CLAUDE_CODE_BASE_URL` | Claude Code 的 Anthropic 兼容 endpoint |
| `LLM_API_KEY` | AgentScope memory 模型的 API key |
| `LLM_MODEL_NAME` | memory 模型;默认 `qwen3.7-max` |
| `LLM_BASE_URL` | memory 模型的 Anthropic 兼容 endpoint |
`DAILY_PAPER_PROJECT_PATH` 默认是相对于 workspace 的 `..`。使用默认 `reme_workspace` 并从仓库根目录启动时, 它会解析回仓库根目录。如果
workspace 位于其他位置,请显式设置这两个路径。
ReMe 会从当前目录向上查找未提交的 `.env`,因此也可以把相同变量放在其中,而不是在 shell 中导出。
## 钉钉配置
钉钉是可选能力。仅在需要投递简报或运行后台钉钉 Agent 时配置:
最后的 `dingtalk_markdown_send_step` 是可选步骤。未设置群会话 ID 时无副作用跳过;配置后会去掉 frontmatter并把简报正文依次发送给所有群
```dotenv
DINGTALK_APP_KEY=your-app-key
@ -316,36 +112,113 @@ DINGTALK_ROBOT_CODE=your-robot-code
DINGTALK_CONVERSATION_IDS=cid-group-one,cid-group-two
```
只有主动投递简报需要 `DINGTALK_CONVERSATION_IDS`。后台 `dingtalk_wait` Job 使用前三项凭据,不使用群会话列表
任一群发送失败不会阻止继续尝试后续群,全部尝试结束后统一报告失败
## 故障恢复与边界
## 产物
| 场景 | 当前行为 |
|----------------------------|----------------------------------------------|
| Hugging Face 暂时失败 | 按指数间隔重试,最多尝试 `hf_max_retries` 次 |
| 没有 eligible 论文 | 在排序前失败 |
| `top_k` 或精选结果不合法 | 校验后失败;精选结果可重试一次 |
| PDF 太大、无效或没有文本层 | 在解读阶段停止 |
| PDF 超过页数或字符数限制 | 使用截断文本继续,并记录截断状态 |
| 某篇论文解读失败 | Job 停止;此前写入的 PDF 和笔记保留 |
| 简报遗漏源笔记链接 | 写入前自动补齐 wikilink |
| auto-dream 部分整合失败 | 成功单元保留,失败路径不会被 checkpoint |
```text
reme_workspace/
├── daily/
│ ├── YYYY-MM-DD.md
│ └── YYYY-MM-DD/
│ ├── <中文论文标题>.md # 三篇kind: daily-paper-analysis
│ └── <中文简报标题>.md # 一篇kind: daily-paper-brief
└── resource/
└── papers/
└── <arxiv-id>.pdf
```
恢复时,先检查该日期已有的笔记和 PDF修复网络、凭据、模型或 PDF 问题,再使用相同日期和 `force=true` 重跑。 有效的缓存 PDF
会被复用。
文件名来自 Agent 返回的中文标题。代码会清理路径不安全字符并处理同名文件。Markdown 和 PDF 都通过同目录临时文件写入后原子替换。
内置 Claude Code 组件使用 `permission_mode: bypassPermissions`,并禁用 `WebSearch``dingtalk_wait` 可以调用本地
`memory_search``daily_paper` 当前没有配置 Job 工具。Analyze 和 Brief prompt 会限制 Agent 应读取的内容,但这些
步骤没有设置严格的逐次调用工具 allowlist也不是操作系统级沙箱。AgentScope memory wrapper 禁用了内置 shell 和 文件系统工具,但其
ReMe job tools 运行于 bypass permission mode。请只在可信的项目和 workspace 中运行;用于共享 或生产环境前,应进一步收紧配置。
## 参数与默认值
可在调用时传入的 Job 参数:
| 参数 | 默认值 | 作用 |
|---|---:|---|
| `date` | `""` | 运行日期;空值使用应用时区当天,非空值必须为 `YYYY-MM-DD` |
| `force` | `false` | 已有当日简报时仍重新生成 |
| `topics` | `""` | 精选论文时优先考虑的主题 |
| `weekly_weight` | `0.7` | RRF 中周榜权重 |
| `history_days` | `30` | 历史推荐排重窗口 |
`daily_paper` Job 的步骤级配置:
| 配置 | 默认值 | 作用 |
|---|---:|---|
| `candidate_limit` | `20` | 送入 Select 的最大候选数 |
| `rrf_k` | `60` | RRF 常数 |
| `hf_timeout` | `600` 秒 | Hugging Face 单次请求超时 |
| `hf_max_retries` | `3` | Hugging Face 最大尝试次数 |
| `pdf_timeout` | `600` 秒 | arXiv PDF 下载超时 |
| `max_pdf_bytes` | `52428800` | PDF 上限50 MiB |
| `max_pdf_pages` | `20` | 最多提取页数 |
| `max_pdf_chars` | `300000` | 最多送入 Agent 的 PDF 字符数 |
## 镜像站
数据客户端使用 httpx 默认的环境处理,因此存在 `HTTP_PROXY``HTTPS_PROXY``NO_PROXY` 时会自动生效。镜像环境变量独立替换对应数据源的 base URL
```dotenv
# 未设置时使用 https://huggingface.co
HF_MIRROR_URL=https://hf-mirror.com
# 未设置时使用 https://arxiv.org
ARXIV_MIRROR_URL=https://export.arxiv.org
# 也支持带路径前缀的中转地址
# HF_MIRROR_URL=http://relay-host:18080/hf
# ARXIV_MIRROR_URL=http://relay-host:18080/arxiv
```
`HF_MIRROR_URL` 必须提供当前代码使用的 `/papers/...``/api/daily_papers``/api/papers/...` 路径。`ARXIV_MIRROR_URL` 必须支持 `/pdf/<arxiv-id>`。两种 base URL 都会保留路径前缀,末尾 `/` 可有可无;不配置就直接访问官方站点,不会执行备用地址回退。
## 运行方式
生成指定日期的简报:
```bash
reme start \
config=daily_cookbook \
job=daily_paper \
date=2026-08-06 \
topics="Agent memory" \
history_days=30
```
强制重跑;有效的本地 PDF 仍会复用:
```bash
reme start config=daily_cookbook job=daily_paper date=2026-08-06 force=true
```
启动 HTTP 服务和定时任务:
```bash
reme start config=daily_cookbook
```
内置服务监听 `127.0.0.1:8001``daily_paper_cron``Asia/Shanghai` 时区每天 08:00 运行。可通过 `DAILY_PAPER_HOST``DAILY_PAPER_PORT` 或启动参数覆盖监听地址和端口。
```bash
curl -s http://127.0.0.1:8001/daily_paper \
-H 'Content-Type: application/json' \
-d '{"date":"2026-08-06","force":false,"topics":"Agent memory"}'
```
## 失败与重跑
- Hugging Face 请求失败会指数退避重试,最多尝试 `hf_max_retries` 次。
- 候选少于三篇、Agent 精选不合法、PDF 无效/过大/无文本或 Agent 输出为空都会终止 Job。
- 三篇论文按顺序处理;中途失败时,之前已完成的 PDF 和笔记会保留。
- `force=true` 会重新生成笔记和简报,但会复用有效 PDF。
- 多文件流程不是事务,也没有同一日期的全局运行锁。
## 测试
聚焦的单元测试会 mock Hugging Face、arXiv、Claude Code 和钉钉边界:
单元测试会 mock Hugging Face、arXiv、AgentScope 和 DingTalk 边界,不访问真实服务
```bash
python -m pip install -e ".[dev,core]"
pytest tests/unit/test_daily_paper.py -v
```
真实运行会访问外部服务并可能产生模型费用,不应把它当作普通单元测试执行。

View file

@ -3,6 +3,7 @@ EMBEDDING_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
LLM_API_KEY=sk-xxx
LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
# Optional SSH jump host for Hugging Face daily-paper downloads.
# REME_PROXY_IP=proxy.example.com
# REME_PROXY_ACCOUNT=your-ssh-account
# Optional data-source mirrors. Unset variables use the official services.
# HF_MIRROR_URL=https://hf-mirror.com
# ARXIV_MIRROR_URL=https://export.arxiv.org
# TUSHARE_MIRROR_URL=http://112.124.63.173:4000/tushare

View file

@ -57,18 +57,12 @@ jobs:
default: 7
steps:
- backend: dream_extract_step
as_llm: memory
agent_wrapper: memory
file_catalog: dream
topic_session_id: interests
scan_days: 2
max_units: 5
- backend: dream_integrate_step
as_llm: memory
agent_wrapper: memory
- backend: dream_topics_step
as_llm: memory
agent_wrapper: memory
topic_count: 3
topic_diversity_days: 7
- backend: dream_finish_step
@ -99,7 +93,6 @@ jobs:
required: [messages]
steps:
- backend: auto_memory_step
agent_wrapper: memory
reindex:
backend: base
@ -325,7 +318,25 @@ jobs:
auto_fin:
backend: base
description: "Maintain configured daily news history, analyze current topics and historical ETF reactions, then deliver the report."
description: "Analyze configured ETFs from current news and comparable historical events."
etf_codes: &auto_fin_etf_codes
- 518880.SH
# - 159516.SZ
# - 512800.SH
# - 512890.SH
# - 159992.SZ
- 159530.SZ
# - 159869.SZ
# - 512690.SH
# - 159755.SZ
# - 159611.SZ
# - 159652.SZ
- 512760.SH
# - 159732.SZ
news_lookback_days: 60
current_news_limit_per_etf: 10
historical_news_limit: 5
historical_search_limit: 10
parameters:
type: object
properties:
@ -333,22 +344,26 @@ jobs:
type: string
description: "Current date in YYYY-MM-DD; empty means today in Asia/Shanghai."
default: ""
force:
type: boolean
description: "Refresh all configured news days; today's news is always refreshed."
default: false
now:
type: string
description: "Optional simulated current time in ISO 8601 format; empty means the real current time."
default: ""
historical_search_limit:
type: integer
description: "Maximum historical-news candidates retrieved for each current event."
default: 10
minimum: 1
steps: &auto_fin_steps
- backend: auto_fin_data_step
outbound_proxy: default
lookback_days: 360
progress_interval: 30
etf_codes: *auto_fin_etf_codes
news_lookback_days: 60
- backend: update_index_step
- backend: auto_fin_topic_step
agent_wrapper: auto_fin
current_news_limit_per_etf: 10
- backend: auto_fin_history_step
agent_wrapper: auto_fin
dispatch_steps: [auto_fin_history_search_step, auto_fin_market_step]
historical_news_limit: 5
historical_search_limit: 10
- backend: auto_fin_merge_step
agent_wrapper: auto_fin
- backend: dingtalk_markdown_send_step
input_mapping:
auto_fin_digest_path: markdown_path
@ -359,14 +374,17 @@ jobs:
title: ReMe Auto Fin
timeout: 15
# Three intraday runs (Asia/Shanghai). Non-trading days are skipped inside
# auto_fin_data_step, so a daily trigger is fine. Each rerun refines the same
# day's report on top of the earlier run rather than replacing it wholesale.
auto_fin_0930_cron:
backend: cron
cron: "30 9 * * *"
steps: *auto_fin_steps
auto_fin_1145_cron:
auto_fin_1130_cron:
backend: cron
cron: "45 11 * * *"
cron: "30 11 * * *"
steps: *auto_fin_steps
auto_fin_1800_cron:
@ -378,17 +396,15 @@ jobs:
backend: base
description: "Build detailed readings and a five-minute brief from Hugging Face weekly/monthly papers."
candidate_limit: &candidate_limit 20
memory_reserve: &memory_reserve 5
top_k: &top_k 3
rrf_k: &rrf_k 60
weekly_weight: &weekly_weight 0.7
history_days: &history_days 30
hf_timeout: &hf_timeout 30
hf_timeout: &hf_timeout 600
hf_max_retries: &hf_max_retries 3
pdf_timeout: &pdf_timeout 90
pdf_timeout: &pdf_timeout 600
max_pdf_bytes: &max_pdf_bytes 52428800
max_pdf_pages: &max_pdf_pages 80
max_pdf_chars: &max_pdf_chars 240000
max_pdf_pages: &max_pdf_pages 20
max_pdf_chars: &max_pdf_chars 300000
parameters:
type: object
properties:
@ -400,10 +416,10 @@ jobs:
type: boolean
description: "Regenerate even when that day's final brief already exists."
default: false
top_k:
type: integer
description: "Number of papers selected by Claude Code."
default: 3
topics:
type: string
description: "Optional topics to prioritize when selecting papers."
default: ""
weekly_weight:
type: number
description: "Weekly contribution in reciprocal-rank fusion."
@ -414,15 +430,10 @@ jobs:
default: 30
steps: &daily_paper_steps
- backend: daily_paper_collect_step
outbound_proxy: default
- backend: daily_paper_rank_step
- backend: daily_paper_select_step
agent_wrapper: daily_paper
- backend: daily_paper_analyze_step
agent_wrapper: daily_paper
outbound_proxy: default
- backend: daily_paper_digest_step
agent_wrapper: daily_paper
- backend: dingtalk_markdown_send_step
input_mapping:
daily_paper_digest_path: markdown_path
@ -437,8 +448,6 @@ jobs:
backend: cron
cron: "0 8 * * *"
candidate_limit: *candidate_limit
memory_reserve: *memory_reserve
top_k: *top_k
rrf_k: *rrf_k
weekly_weight: *weekly_weight
history_days: *history_days
@ -456,87 +465,45 @@ jobs:
close_timeout: 10
steps:
- backend: dingtalk_wait_step
agent_wrapper: dingtalk_wait
app_key: ${DINGTALK_APP_KEY:-}
app_secret: ${DINGTALK_APP_SECRET:-}
robot_code: ${DINGTALK_ROBOT_CODE:-}
worker_count: 4
builtin_tools: [bash]
job_tools:
- memory_search
- read
- write
- edit
- daily_list
- daily_write
- frontmatter_read
- frontmatter_update
components:
outbound_proxy: {}
# default:
# backend: ssh_http
# host: ${REME_PROXY_IP:-}
# account: ${REME_PROXY_ACCOUNT:-}
# connect_timeout: 10
# monitor_interval: 1
# restart_initial_delay: 1
# restart_max_delay: 30
tokenizer:
default:
backend: regex
as_llm:
memory:
backend: anthropic
model: ${LLM_MODEL_NAME:-qwen3.7-max}
default:
backend: openai
model: ${LLM_MODEL_NAME:-qwen3.7-plus}
stream: true
context_size: 200000
max_retries: 3
credential:
api_key: ${LLM_API_KEY:-}
base_url: ${LLM_BASE_URL:-https://dashscope.aliyuncs.com/apps/anthropic}
base_url: ${LLM_BASE_URL:-}
parameters:
max_tokens: 65536
thinking_enable: false
agent_wrapper:
auto_fin:
backend: ${AUTO_FIN_AGENT_BACKEND:-claude_code}
project_path: ${AUTO_FIN_PROJECT_PATH:-..}
model: ${CLAUDE_CODE_MODEL_NAME:-qwen3.7-max}
api_key: ${CLAUDE_CODE_API_KEY:-}
base_url: ${CLAUDE_CODE_BASE_URL:-https://dashscope.aliyuncs.com/apps/anthropic}
skills: [tushare-data]
job_tools: [memory_search]
permission_mode: bypassPermissions
daily_paper:
backend: claude_code
project_path: ${DAILY_PAPER_PROJECT_PATH:-..}
model: ${CLAUDE_CODE_MODEL_NAME:-qwen3.7-max}
api_key: ${CLAUDE_CODE_API_KEY:-}
base_url: ${CLAUDE_CODE_BASE_URL:-https://dashscope.aliyuncs.com/apps/anthropic}
permission_mode: bypassPermissions
dingtalk_wait:
backend: claude_code
project_path: ${DAILY_PAPER_PROJECT_PATH:-..}
model: ${CLAUDE_CODE_MODEL_NAME:-qwen3.7-max}
api_key: ${CLAUDE_CODE_API_KEY:-}
base_url: ${CLAUDE_CODE_BASE_URL:-https://dashscope.aliyuncs.com/apps/anthropic}
skills: [tushare-data]
job_tools: [memory_search]
system_prompt:
type: preset
preset: claude_code
append: >-
Daily-paper Markdown is stored under the ReMe workspace. Detailed notes, including historical notes, are at
daily/YYYY-MM-DD/paper-<arxiv-id>.md; daily briefs are at daily/YYYY-MM-DD/daily-paper-brief.md. Use
memory_search to retrieve relevant long-term notes across dates.
permission_mode: bypassPermissions
memory:
default:
backend: agentscope
as_llm: memory
as_llm: default
builtin_tools: false
permission_mode: bypass
react_config:
max_iters: 30
context_config:
trigger_ratio: 0.8
reserve_ratio: 0.1
tool_result_limit: 50000
model_config:
max_retries: 1
# as_embedding:
# default:

View file

@ -2,27 +2,24 @@
from .application_config import ApplicationConfig, ComponentConfig, JobConfig
from .auto_fin import (
AutoFinEtfEventReference,
AutoFinEtfHistoryDetail,
AutoFinEtfHistoricalEvents,
AutoFinEtfHistoricalResearch,
AutoFinCurrentEvent,
AutoFinEtfAnalysis,
AutoFinEtfSelection,
AutoFinEtfsOutput,
AutoFinDailyEntry,
AutoFinForecastReturnPoint,
AutoFinFutureReturnPoint,
AutoFinEventReference,
AutoFinHistoricalEvent,
AutoFinHistoricalEventReference,
AutoFinHistoricalDirectionReference,
AutoFinHistoricalMatch,
AutoFinMarketSelection,
AutoFinMarketSample,
AutoFinHistoricalOutput,
AutoFinHistoricalReference,
AutoFinReportOutput,
AutoFinSelectedEvent,
AutoFinSelectedEtfAnalysis,
AutoFinWeightedForecast,
AutoFinReturns,
)
from .daily_paper import (
AnalyzedPaper,
DailyPaperMarkdownOutput,
PaperInfo,
PaperPick,
PaperPickList,
)
from .daily_paper import DailyBriefOutput, PaperInfo, PaperNoteOutput, PaperSelection, SelectedPaper
from .dream import (
DreamExtractOutput,
DreamState,
@ -46,27 +43,19 @@ from .traverse_graph import TraverseGraph, TraverseGraphEdge, TraverseGraphNode
__all__ = [
"ApplicationConfig",
"AutoFinEtfEventReference",
"AutoFinEtfHistoryDetail",
"AutoFinEtfHistoricalEvents",
"AutoFinEtfHistoricalResearch",
"AutoFinCurrentEvent",
"AutoFinEtfAnalysis",
"AutoFinEtfSelection",
"AutoFinEtfsOutput",
"AutoFinDailyEntry",
"AutoFinForecastReturnPoint",
"AutoFinFutureReturnPoint",
"AutoFinEventReference",
"AutoFinHistoricalEvent",
"AutoFinHistoricalEventReference",
"AutoFinHistoricalDirectionReference",
"AutoFinHistoricalMatch",
"AutoFinMarketSelection",
"AutoFinMarketSample",
"AutoFinHistoricalOutput",
"AutoFinHistoricalReference",
"AutoFinReportOutput",
"AutoFinSelectedEvent",
"AutoFinSelectedEtfAnalysis",
"AutoFinWeightedForecast",
"AutoFinReturns",
"ComponentConfig",
"DailyBriefOutput",
"AnalyzedPaper",
"DailyPaperMarkdownOutput",
"DreamExtractOutput",
"DreamState",
"DreamTopic",
@ -82,12 +71,11 @@ __all__ = [
"IntegrateOutcome",
"JobConfig",
"PaperInfo",
"PaperNoteOutput",
"PaperSelection",
"PaperPick",
"PaperPickList",
"ProactiveResult",
"Request",
"Response",
"SelectedPaper",
"StreamChunk",
"TokenUsage",
"TopicSelectionOutput",

View file

@ -2,298 +2,102 @@
from __future__ import annotations
from datetime import date, datetime
from math import isclose
from typing import Annotated, Literal
from zoneinfo import ZoneInfo
from datetime import datetime
from typing import Literal
from pydantic import BaseModel, BeforeValidator, ConfigDict, Field, model_validator
_SHANGHAI = ZoneInfo("Asia/Shanghai")
def _shanghai_local_time(value):
"""Normalize aware input to naive Shanghai wall-clock time."""
if not isinstance(value, (str, datetime)):
return value
parsed = value if isinstance(value, datetime) else datetime.fromisoformat(value)
if parsed.tzinfo is not None and parsed.utcoffset() is not None:
parsed = parsed.astimezone(_SHANGHAI).replace(tzinfo=None)
return parsed
ShanghaiDateTime = Annotated[datetime, BeforeValidator(_shanghai_local_time)]
from pydantic import BaseModel, ConfigDict, Field
class AutoFinModel(BaseModel):
"""Strict base for program-owned Auto Fin data."""
"""Strict program-owned Auto Fin data."""
model_config = ConfigDict(extra="forbid")
class AutoFinAgentModel(AutoFinModel):
"""Tolerant base for raw Agent output."""
"""Agent output tolerant of harmless extra fields."""
model_config = ConfigDict(extra="ignore")
class AutoFinEtfEventReference(AutoFinAgentModel):
"""One selected news item and why it is relevant to an ETF."""
class AutoFinEventReference(AutoFinAgentModel):
"""One current news item related to an ETF."""
reason: str
news_id: str
class AutoFinSelectedEvent(AutoFinModel):
"""A selected current event with its source news reference."""
event_time: ShanghaiDateTime
event_content: str
reason: str
news_id: str
event_title: str = ""
class AutoFinEtfSelection(AutoFinAgentModel):
"""One ETF selection returned by the Topic Agent."""
"""One ETF selected from the configured codes."""
etf_code: str
etf_name: str = ""
events: list[AutoFinEtfEventReference] = Field(default_factory=list)
events: list[AutoFinEventReference] = Field(default_factory=list)
class AutoFinEtfsOutput(AutoFinAgentModel):
"""ETF selections returned by the Topic Agent before normalization."""
"""Selections returned by the first Agent."""
etfs: list[AutoFinEtfSelection] = Field(default_factory=list)
class AutoFinHistoricalEventReference(AutoFinAgentModel):
"""One historical news item selected by the search Agent."""
class AutoFinHistoricalReference(AutoFinAgentModel):
"""One historical event selected by the second Agent."""
reason: str
news_id: str
source_path: str = ""
reason: str
direction: Literal["same", "opposite"]
class AutoFinEtfHistoricalEvents(AutoFinAgentModel):
"""Historical news references returned by the search Agent."""
class AutoFinHistoricalOutput(AutoFinAgentModel):
"""Historical matches for one current news item."""
etf_code: str = ""
etf_name: str = ""
historical_events: list[AutoFinHistoricalEventReference] = Field(default_factory=list)
historical_events: list[AutoFinHistoricalReference] = Field(default_factory=list)
class AutoFinDailyEntry(AutoFinModel):
"""First daily open or close that can be traded after an event."""
class AutoFinReturns(AutoFinModel):
"""Adjusted cumulative ETF returns after one historical event."""
entry_time: ShanghaiDateTime
trade_date: date
price_type: Literal["open", "close"]
raw_price: float = Field(gt=0)
adj_factor: float = Field(gt=0)
@model_validator(mode="after")
def valid_entry_timestamp(self) -> "AutoFinDailyEntry":
"""Require a Shanghai-local timestamp matching the daily price."""
if self.entry_time.date() != self.trade_date:
raise ValueError("entry time and trade date must match")
expected_clock = (9, 30) if self.price_type == "open" else (15, 0)
if (
(self.entry_time.hour, self.entry_time.minute) != expected_clock
or self.entry_time.second
or self.entry_time.microsecond
):
raise ValueError(f"{self.price_type} entry time must use the official daily price timestamp")
return self
class AutoFinFutureReturnPoint(AutoFinModel):
"""Cumulative adjusted return at one future valid close."""
horizon: int = Field(ge=1, le=10)
trade_date: date
raw_close: float = Field(gt=0)
adj_factor: float = Field(gt=0)
cumulative_return: float
class AutoFinMarketSample(AutoFinModel):
"""Daily adjusted ETF returns following one historical event."""
event_time: ShanghaiDateTime
entry: AutoFinDailyEntry | None = None
future_returns: list[AutoFinFutureReturnPoint] = Field(default_factory=list, max_length=10)
reaction_summary: str
@model_validator(mode="after")
def valid_daily_return_path(self) -> "AutoFinMarketSample":
"""Reject look-ahead entries and inconsistent adjusted returns."""
if self.entry is None:
if self.future_returns:
raise ValueError("future returns require an entry")
return self
if self.entry.entry_time <= self.event_time:
raise ValueError("entry must be strictly after the event")
expected_horizons = list(range(1, len(self.future_returns) + 1))
if [point.horizon for point in self.future_returns] != expected_horizons:
raise ValueError("future return horizons must be contiguous and start at 1")
trade_dates = [point.trade_date for point in self.future_returns]
if trade_dates != sorted(set(trade_dates)):
raise ValueError("future return trade dates must be unique and ascending")
if trade_dates:
first_trade_date = trade_dates[0]
if self.entry.price_type == "open" and first_trade_date < self.entry.trade_date:
raise ValueError("an open entry cannot use an earlier close")
if self.entry.price_type == "close" and first_trade_date <= self.entry.trade_date:
raise ValueError("a close entry requires a later close")
adjusted_entry = self.entry.raw_price * self.entry.adj_factor
for point in self.future_returns:
expected_return = point.raw_close * point.adj_factor / adjusted_entry - 1
if not isclose(point.cumulative_return, expected_return, rel_tol=1e-6, abs_tol=1e-6):
raise ValueError(f"incorrect adjusted return at horizon {point.horizon}")
return self
d1: float | None = None
d2: float | None = None
d3: float | None = None
d5: float | None = None
class AutoFinHistoricalEvent(AutoFinModel):
"""One resolved historical news item and its calculated ETF return path."""
"""A resolved historical event and its observed ETF performance."""
reason: str
news_id: str
source_path: str
event_time: ShanghaiDateTime
event_title: str
event_content: str
market_entry: AutoFinDailyEntry | None = None
future_returns: list[AutoFinFutureReturnPoint] = Field(default_factory=list, max_length=10)
@model_validator(mode="after")
def valid_historical_event(self) -> "AutoFinHistoricalEvent":
"""Require source identity and validate the embedded market reaction."""
for field in ("reason", "news_id", "source_path", "event_title", "event_content"):
value = getattr(self, field).strip()
if not value:
raise ValueError(f"historical event {field} must not be empty")
setattr(self, field, value)
AutoFinMarketSample(
event_time=self.event_time,
entry=self.market_entry,
future_returns=self.future_returns,
reaction_summary="",
)
return self
class AutoFinEtfHistoricalResearch(AutoFinModel):
"""Resolved historical events with embedded calculated ETF return paths."""
etf_code: str
etf_name: str
historical_events: list[AutoFinHistoricalEvent] = Field(default_factory=list)
limitations: list[str] = Field(default_factory=list)
@model_validator(mode="after")
def unique_historical_news(self) -> "AutoFinEtfHistoricalResearch":
"""Reject duplicate resolved source records."""
news_ids = [event.news_id for event in self.historical_events]
if len(news_ids) != len(set(news_ids)):
raise ValueError("historical event news IDs must be unique")
return self
class AutoFinHistoricalDirectionReference(AutoFinAgentModel):
"""One direction-classified historical event returned by the Market Agent."""
event_time: datetime
title: str
content: str
reason: str
news_id: str
class AutoFinMarketSelection(AutoFinAgentModel):
"""Same- and opposite-direction historical events returned by the Market Agent."""
same_direction_events: list[AutoFinHistoricalDirectionReference] = Field(default_factory=list)
opposite_direction_events: list[AutoFinHistoricalDirectionReference] = Field(default_factory=list)
class AutoFinHistoricalMatch(AutoFinModel):
"""One direction-classified historical event used by the equal-weight forecast."""
reason: str
news_id: str
event_time: ShanghaiDateTime
direction: Literal["same", "opposite"]
weight: float = Field(ge=0.0, le=1.0)
returns: AutoFinReturns
class AutoFinForecastReturnPoint(AutoFinModel):
"""Weighted expected cumulative return for one holding horizon."""
class AutoFinCurrentEvent(AutoFinModel):
"""One current event with comparable historical evidence."""
horizon: int = Field(ge=1, le=10)
expected_return: float | None = None
news_id: str
event_time: datetime
title: str
content: str
reason: str
historical_events: list[AutoFinHistoricalEvent] = Field(default_factory=list)
class AutoFinWeightedForecast(AutoFinModel):
"""Program-calculated forecast derived from similar historical events."""
returns: list[AutoFinForecastReturnPoint] = Field(min_length=10, max_length=10)
suggested_holding_days: int | None = Field(default=None, ge=1, le=10)
@model_validator(mode="after")
def complete_horizons(self) -> "AutoFinWeightedForecast":
"""Require one ordered forecast point for every D1-D10 horizon."""
if [point.horizon for point in self.returns] != list(range(1, 11)):
raise ValueError("forecast horizons must be ordered D1-D10")
return self
class AutoFinSelectedEtfAnalysis(AutoFinModel):
"""Program-calculated weighted forecast for one selected ETF."""
class AutoFinEtfAnalysis(AutoFinModel):
"""All evidence prepared for the final Agent for one ETF."""
etf_code: str
etf_name: str
matched_historical_events: list[AutoFinHistoricalMatch] = Field(default_factory=list)
forecast: AutoFinWeightedForecast
limitations: list[str] = Field(default_factory=list)
@model_validator(mode="after")
def valid_historical_weights(self) -> "AutoFinSelectedEtfAnalysis":
"""Reject duplicate matches and invalid normalized weights."""
news_ids = [event.news_id for event in self.matched_historical_events]
if len(news_ids) != len(set(news_ids)):
raise ValueError("matched historical events must be unique")
if news_ids and not isclose(
sum(event.weight for event in self.matched_historical_events),
1.0,
rel_tol=1e-6,
abs_tol=1e-6,
):
raise ValueError("matched historical event weights must sum to 1")
return self
class AutoFinEtfHistoryDetail(AutoFinModel):
"""Complete historical research and market result for one selected ETF."""
etf: AutoFinEtfSelection
current_events: list[AutoFinSelectedEvent] = Field(min_length=1)
historical_research: AutoFinEtfHistoricalResearch
market_analysis: AutoFinSelectedEtfAnalysis
@model_validator(mode="after")
def consistent_etf_and_events(self) -> "AutoFinEtfHistoryDetail":
"""Reject stale or cross-ETF outputs from dispatched steps."""
identity = (self.etf.etf_code, self.etf.etf_name)
if (self.historical_research.etf_code, self.historical_research.etf_name) != identity:
raise ValueError("historical research ETF must match the selected ETF")
if (self.market_analysis.etf_code, self.market_analysis.etf_name) != identity:
raise ValueError("market analysis ETF must match the selected ETF")
return self
events: list[AutoFinCurrentEvent] = Field(default_factory=list)
class AutoFinReportOutput(AutoFinAgentModel):
"""Final Markdown title and body for all selected ETFs."""
"""Final Markdown returned by the third Agent."""
title: str = ""
description: str = ""
body: str = ""

View file

@ -1,7 +1,5 @@
"""Typed contracts for the daily-paper cookbook workflow."""
from typing import Literal
from pydantic import BaseModel, Field
@ -40,32 +38,31 @@ class PaperInfo(BaseModel):
return f"https://arxiv.org/pdf/{self.arxiv_id}"
class SelectedPaper(BaseModel):
"""One agent-selected paper."""
class PaperPick(BaseModel):
"""One minimal paper selection returned by the agent."""
arxiv_id: str
rank: int
reason: str
memory_relevance: Literal["high", "medium", "low"]
reasoning: str
class PaperSelection(BaseModel):
"""Structured paper selection result."""
class PaperPickList(BaseModel):
"""The ordered papers selected for detailed analysis."""
selection_reasoning: str
selected: list[SelectedPaper]
alternates: list[str] = Field(default_factory=list)
papers: list[PaperPick]
class PaperNoteOutput(BaseModel):
"""Structured Claude Code output for one detailed paper note."""
class DailyPaperMarkdownOutput(BaseModel):
"""One Chinese Markdown document returned by a tool-free agent."""
description: str
title: str
desc: str
body: str
class DailyBriefOutput(BaseModel):
"""Structured Claude Code output for the final five-minute brief."""
class AnalyzedPaper(DailyPaperMarkdownOutput):
"""A persisted paper analysis passed directly to the digest step."""
description: str
body: str
arxiv_id: str
reasoning: str
note_path: str
pdf_path: str

View file

@ -1,17 +1,13 @@
"""Auto Fin news research workflow."""
from .data import AutoFinDataStep
from .history_search import AutoFinHistorySearchStep
from .history import AutoFinHistoryStep
from .market import AutoFinMarketStep
from .merge import AutoFinMergeStep
from .topic import AutoFinTopicStep
__all__ = [
"AutoFinDataStep",
"AutoFinHistorySearchStep",
"AutoFinHistoryStep",
"AutoFinMarketStep",
"AutoFinMergeStep",
"AutoFinTopicStep",
]

View file

@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio
import hashlib
from html.parser import HTMLParser
import json
import math
import os
@ -16,16 +17,44 @@ from zoneinfo import ZoneInfo
from pydantic import BaseModel
from ....components.outbound_proxy import BaseOutboundProxy
from ....enumeration import ComponentEnum
from ....utils.tushare import create_tushare_api
from ...base_step import BaseStep, Ref
from ...base_step import BaseStep
AGENT_INPUT_LOG_LIMIT = 2000
AGENT_OUTPUT_LOG_LIMIT = 4000
SHANGHAI_TIMEZONE = ZoneInfo("Asia/Shanghai")
class _TextExtractor(HTMLParser):
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.parts: list[str] = []
self.hidden = 0
def handle_starttag(self, tag: str, _attrs) -> None:
if tag in {"script", "style"}:
self.hidden += 1
elif tag in {"br", "div", "li", "p"}:
self.parts.append(" ")
def handle_endtag(self, tag: str) -> None:
if tag in {"script", "style"} and self.hidden:
self.hidden -= 1
elif tag in {"div", "li", "p"}:
self.parts.append(" ")
def handle_data(self, data: str) -> None:
if not self.hidden:
self.parts.append(data)
def _plain_text(value: str) -> str:
parser = _TextExtractor()
parser.feed(value)
parser.close()
return " ".join("".join(parser.parts).split())
def _news_hash(row: dict[str, Any]) -> str:
src = str(row.get("src") or "")
content = str(row.get("content") or "")
@ -81,8 +110,6 @@ def _records(value: Any) -> list[dict[str, Any]]:
class AutoFinStep(BaseStep):
"""Shared Auto Fin helpers."""
outbound_proxy: BaseOutboundProxy | None = Ref(BaseOutboundProxy, ComponentEnum.OUTBOUND_PROXY, optional=True)
def _value(self, key: str, default: Any = None) -> Any:
assert self.context is not None
return self.context.get(key, self.kwargs.get(key, default))
@ -98,10 +125,6 @@ class AutoFinStep(BaseStep):
prompt_name: str,
resource_name: str,
model: type[BaseModel],
*,
output_suffix: str = ".json",
jsonl_field: str | None = None,
tool_context_id: str | None = None,
**values: str,
) -> tuple[BaseModel, Path]:
"""Send a complete prompt directly and persist its structured reply."""
@ -109,15 +132,9 @@ class AutoFinStep(BaseStep):
raise RuntimeError("Auto Fin analysis requires an agent_wrapper")
if Path(resource_name).name != resource_name:
raise ValueError(f"Invalid Auto Fin resource name: {resource_name}")
if output_suffix not in {".json", ".jsonl"}:
raise ValueError(f"Invalid Auto Fin output suffix: {output_suffix}")
prompt = self.prompt_format(prompt_name, **values)
output_path = (
self.workspace_path
/ "resource"
/ str(self._required("auto_fin_date"))
/ f"{resource_name}_output{output_suffix}"
self.workspace_path / "resource" / str(self._required("auto_fin_date")) / f"{resource_name}_output.json"
)
started_at = perf_counter()
serialized_input = json.dumps(prompt, ensure_ascii=False)
@ -126,25 +143,14 @@ class AutoFinStep(BaseStep):
f"[{self.name}] agent input prompt={prompt_name} schema={model.__name__} "
f"query_chars={len(prompt)} truncated={str(input_truncated).lower()} query={input_preview}",
)
agent_kwargs: dict[str, Any] = {"output_schema": model}
if tool_context_id:
agent_kwargs["tool_context_id"] = tool_context_id
result = await self.agent_wrapper.reply(prompt, **agent_kwargs)
result = await self.agent_wrapper.reply(prompt, output_schema=model)
if not isinstance(result, dict):
raise TypeError("Auto Fin Agent reply must be a dictionary")
value = result.get("structured_output")
if value is None:
raise ValueError(f"Auto Fin Agent returned no structured output: {self._preview(result)}")
output = value if isinstance(value, model) else model.model_validate(value)
payload = output.model_dump(mode="json")
serialized_output = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
if jsonl_field is None:
_write(output_path, f"{serialized_output}\n")
else:
records = payload.get(jsonl_field)
if not isinstance(records, list) or not all(isinstance(record, dict) for record in records):
raise ValueError(f"Auto Fin JSONL field must contain objects: {jsonl_field}")
_write_jsonl(output_path, records)
serialized_output = self._write_output(output_path, output)
output_preview, output_truncated = self._text_preview(serialized_output, AGENT_OUTPUT_LOG_LIMIT)
self.logger.info(
f"[{self.name}] agent output prompt={prompt_name} schema={model.__name__} "
@ -153,6 +159,13 @@ class AutoFinStep(BaseStep):
)
return output, output_path
@staticmethod
def _write_output(path: Path, model: BaseModel) -> str:
"""Persist a model as compact JSON and return the serialized text."""
serialized = json.dumps(model.model_dump(mode="json"), ensure_ascii=False, separators=(",", ":"))
_write(path, f"{serialized}\n")
return serialized
@staticmethod
def _text_preview(text: str, limit: int) -> tuple[str, bool]:
limit = max(0, limit)
@ -162,11 +175,7 @@ class AutoFinStep(BaseStep):
@staticmethod
def _preview(value: Any, limit: int = 1000) -> str:
text = json.dumps(value, ensure_ascii=False, default=str)
return text if len(text) <= limit else f"{text[:limit]}...<truncated>"
@property
def _proxy_url(self) -> str | None:
return self.outbound_proxy.http_url if self.outbound_proxy is not None else None
return AutoFinStep._text_preview(text, limit)[0]
async def _fetch(self, endpoint: str, **kwargs) -> list[dict[str, Any]]:
provider = self._value("tushare_provider")
@ -176,8 +185,7 @@ class AutoFinStep(BaseStep):
provider_name = "injected" if provider is not None else "sdk"
started_at = perf_counter()
self.logger.debug(
f"[{self.name}] tushare fetch start endpoint={endpoint} provider={provider_name} "
f"proxy={bool(self._proxy_url)} {details}",
f"[{self.name}] tushare fetch start endpoint={endpoint} provider={provider_name} {details}",
)
try:
if provider is not None:
@ -187,7 +195,7 @@ class AutoFinStep(BaseStep):
token = os.getenv("TUSHARE_TOKEN", "").strip()
if not token:
raise RuntimeError("TUSHARE_TOKEN is required for Auto Fin")
api = create_tushare_api(token, proxy_url=self._proxy_url)
api = create_tushare_api(token)
rows = _records(await asyncio.to_thread(getattr(api, endpoint), **kwargs))
except Exception:
self.logger.exception(
@ -201,10 +209,6 @@ class AutoFinStep(BaseStep):
)
return rows
def _news_path(self, day: date) -> Path:
daily_dir = str(self.config_value("daily_dir"))
return self.workspace_path / daily_dir / day.isoformat() / "auto_fin_news_data.jsonl"
@staticmethod
def _days(start: date, end: date) -> list[date]:
return [start + timedelta(days=offset) for offset in range((end - start).days + 1)]

View file

@ -1,24 +1,29 @@
"""Download the news required by Auto Fin."""
"""Prepare local news and ETF market data for Auto Fin."""
from __future__ import annotations
import asyncio
import json
from datetime import date, datetime, time, timedelta
from pathlib import Path
from typing import Any
from ....components import R
from ._base import SHANGHAI_TIMEZONE, AutoFinStep, _news_id, _write_jsonl
from ._base import SHANGHAI_TIMEZONE, AutoFinStep, _news_id, _plain_text, _write, _write_jsonl
NEWS_FILENAME = "auto_fin_news.md"
MAJOR_NEWS_PAGE_LIMIT = 400 # major_news caps a single response; split the window when hit.
FUND_PAGE_LIMIT = 2000 # fund_daily / fund_adj cap a single response; page backwards past it.
@R.register("auto_fin_data_step")
class AutoFinDataStep(AutoFinStep):
"""Fill missing daily news files and always refresh today's news."""
"""Skip closed markets, then overwrite today's news and all configured ETF data."""
def _schedule(self) -> tuple[date, datetime]:
now_value = self._value("now")
now = datetime.fromisoformat(str(now_value)) if now_value is not None else datetime.now(SHANGHAI_TIMEZONE)
if now.tzinfo is not None and now.utcoffset() is not None:
value = str(self._value("now", "")).strip()
now = datetime.fromisoformat(value) if value else datetime.now(SHANGHAI_TIMEZONE)
if now.tzinfo is not None:
now = now.astimezone(SHANGHAI_TIMEZONE).replace(tzinfo=None)
requested = str(self._value("date", "")).strip()
run_date = date.fromisoformat(requested) if requested else now.date()
@ -26,43 +31,20 @@ class AutoFinDataStep(AutoFinStep):
raise ValueError("Auto Fin only supports the current date")
return run_date, now
async def _previous_trade_date(self, run_date: date) -> date:
supplied = self._value("trade_dates")
if supplied is not None:
dates = [date.fromisoformat(str(value)) for value in supplied]
else:
start = run_date - timedelta(days=30)
rows = await self._fetch(
"trade_cal",
exchange="SSE",
start_date=start.strftime("%Y%m%d"),
end_date=run_date.strftime("%Y%m%d"),
fields="cal_date,is_open",
)
dates = [
datetime.strptime(str(row["cal_date"]), "%Y%m%d").date()
for row in rows
if int(row.get("is_open", 0)) == 1
]
previous = [day for day in dates if day < run_date]
if not previous:
raise ValueError("Auto Fin requires a previous A-share trade date")
return max(previous)
async def _is_trade_day(self, day: date) -> bool:
rows = await self._fetch(
"trade_cal",
exchange="SSE",
start_date=day.strftime("%Y%m%d"),
end_date=day.strftime("%Y%m%d"),
fields="cal_date,is_open",
)
return any(
str(row.get("cal_date")) == day.strftime("%Y%m%d") and int(row.get("is_open", 0)) == 1 for row in rows
)
async def _valid_news(self, path: Path) -> bool:
if not path.is_file():
return False
try:
rows = await self._read_jsonl(path)
except (OSError, ValueError) as exc:
self.logger.warning(
f"[{self.name}] invalid news cache path={path} error={type(exc).__name__}: {exc}",
)
return False
valid = all(str(row.get("src") or "") == "财联社" for row in rows)
if not valid:
self.logger.warning(f"[{self.name}] invalid news cache source path={path}")
return valid
def _news_path(self, day: date) -> Path:
return self.workspace_path / str(self.config_value("daily_dir")) / day.isoformat() / NEWS_FILENAME
async def _fetch_news(self, start: datetime, end: datetime) -> list[dict[str, Any]]:
rows = await self._fetch(
@ -72,83 +54,181 @@ class AutoFinDataStep(AutoFinStep):
end_date=end.strftime("%Y-%m-%d %H:%M:%S"),
fields="title,pub_time,src,content",
)
if len(rows) < 400 or end - start <= timedelta(minutes=1):
if len(rows) < MAJOR_NEWS_PAGE_LIMIT or end - start <= timedelta(minutes=1):
return rows
midpoint = start + (end - start) / 2
self.logger.debug(
f"[{self.name}] news fetch split start={start.isoformat()} end={end.isoformat()} "
f"midpoint={midpoint.isoformat()} records={len(rows)}",
)
left, right = await asyncio.gather(self._fetch_news(start, midpoint), self._fetch_news(midpoint, end))
self.logger.debug(
f"[{self.name}] news fetch split done start={start.isoformat()} end={end.isoformat()} "
f"records={len(left) + len(right)}",
)
middle = start + (end - start) / 2
left, right = await asyncio.gather(self._fetch_news(start, middle), self._fetch_news(middle, end))
return left + right
async def _cache_news(self, day: date, decision_at: datetime, refresh: bool) -> bool:
path = self._news_path(day)
if not refresh and await self._valid_news(path):
self.logger.debug(f"[{self.name}] news cache hit date={day.isoformat()} path={path}")
return False
async def _write_news(self, day: date, decision_at: datetime) -> str:
start = datetime.combine(day, time.min)
end = decision_at if day == decision_at.date() else start + timedelta(days=1)
candidates = []
records: dict[str, dict[str, str]] = {}
for row in await self._fetch_news(start, end):
published_at = self._published_at(row)
in_range = (
published_at is not None
and start <= published_at
and (published_at <= end if day == decision_at.date() else published_at < end)
if published_at is None or str(row.get("src") or "") != "财联社":
continue
if not start <= published_at <= end or (day != decision_at.date() and published_at == end):
continue
news_id = _news_id(row, published_at)
records.setdefault(
news_id,
{
"news_id": news_id,
"event_time": published_at.isoformat(),
"title": _plain_text(str(row.get("title") or "")),
"content": _plain_text(str(row.get("content") or "")),
},
)
if in_range and str(row.get("src") or "") == "财联社":
candidates.append((published_at, _news_id(row, published_at), row))
news = {}
for _published_at, news_id, row in sorted(candidates, key=lambda item: item[:2]):
news.setdefault(news_id, {**row, "news_id": news_id})
_write_jsonl(path, list(news.values()))
self.logger.debug(f"[{self.name}] news written date={day.isoformat()} records={len(news)} path={path}")
return True
ordered = sorted(records.values(), key=lambda row: (row["event_time"], row["news_id"]))
path = self._news_path(day)
change = "modified" if path.exists() else "added"
_write(path, self._render_news(day, ordered))
return change
@staticmethod
def _render_news(day: date, rows: list[dict[str, str]]) -> str:
blocks = [f"# 财联社新闻 {day.isoformat()}\n"]
for row in rows:
blocks.append(
"\n".join(
[
f"## {row['title'] or '无标题'}",
"",
f"- news_id: `{row['news_id']}`",
f"- 时间: {row['event_time']}",
"- 来源: 财联社",
"",
row["content"] or row["title"],
"",
],
),
)
return "\n".join(blocks).rstrip() + "\n"
@staticmethod
def read_news(path: Path) -> list[dict[str, str]]:
"""Parse an Auto Fin news Markdown file written by `_render_news`."""
text = path.read_text(encoding="utf-8")
rows = []
for block in text.split("\n## ")[1:]:
lines = block.splitlines()
if len(lines) < 5:
continue
news_line = next((line for line in lines if line.startswith("- news_id: `")), "")
time_line = next((line for line in lines if line.startswith("- 时间: ")), "")
news_id = news_line.removeprefix("- news_id: `").removesuffix("`").strip()
event_time = time_line.removeprefix("- 时间: ").strip()
content_start = next(
(index + 1 for index, line in enumerate(lines) if line == "- 来源: 财联社"),
len(lines),
)
content = "\n".join(lines[content_start:]).strip()
if news_id and event_time:
rows.append(
{
"news_id": news_id,
"event_time": event_time,
"title": lines[0].strip(),
"content": content,
},
)
return rows
async def _fetch_all(self, endpoint: str, code: str, end: date) -> list[dict[str, Any]]:
"""Page backwards because TuShare fund endpoints cap one response."""
rows_by_date: dict[str, dict[str, Any]] = {}
end_date = end
while True:
page = await self._fetch(
endpoint,
ts_code=code,
start_date="19900101",
end_date=end_date.strftime("%Y%m%d"),
)
for row in page:
if trade_date := str(row.get("trade_date") or ""):
rows_by_date[trade_date] = row
if len(page) < FUND_PAGE_LIMIT:
break
dates = [
datetime.strptime(str(row["trade_date"]), "%Y%m%d").date() for row in page if row.get("trade_date")
]
if not dates or min(dates) <= date(1990, 1, 1):
break
next_end = min(dates) - timedelta(days=1)
if next_end >= end_date:
raise RuntimeError(f"TuShare pagination did not advance for {endpoint} {code}")
end_date = next_end
return [rows_by_date[key] for key in sorted(rows_by_date)]
@staticmethod
def _etf_name(row: dict[str, Any]) -> str:
return str(row.get("csname") or row.get("extname") or row.get("cname") or "").strip()
async def _cache_etfs(self, codes: list[str], run_date: date) -> dict[str, str]:
basics = await self._fetch(
"etf_basic",
list_status="L",
fields="ts_code,csname,extname,cname,list_status",
)
names = {str(row.get("ts_code") or "").strip().upper(): self._etf_name(row) for row in basics}
missing = [code for code in codes if not names.get(code)]
if missing:
raise ValueError(f"TuShare returned no ETF name for: {', '.join(missing)}")
fin_dir = self.workspace_path / str(self.config_value("resource_dir")) / "fin"
mapping = [{"etf_code": code, "etf_name": names[code]} for code in codes]
_write(
fin_dir / "etfs.json",
json.dumps(mapping, ensure_ascii=False, indent=2) + "\n",
)
for code in codes:
daily, factors = await asyncio.gather(
self._fetch_all("fund_daily", code, run_date),
self._fetch_all("fund_adj", code, run_date),
)
factor_by_date = {str(row.get("trade_date")): row.get("adj_factor") for row in factors}
merged = [{**row, "adj_factor": factor_by_date.get(str(row.get("trade_date")))} for row in daily]
_write_jsonl(fin_dir / f"{code}.jsonl", merged)
return {code: names[code] for code in codes}
async def execute(self):
assert self.context is not None
run_date, decision_at = self._schedule()
news_days = int(self._value("lookback_days"))
progress_interval = int(self._value("progress_interval"))
if news_days < 1:
raise ValueError("lookback_days must be at least 1")
if progress_interval < 1:
raise ValueError("progress_interval must be at least 1")
start = run_date - timedelta(days=news_days - 1)
force = bool(self._value("force", False))
self.logger.info(
f"[{self.name}] start date={run_date.isoformat()} range={start.isoformat()}..{run_date.isoformat()} "
f"days={news_days} force={force} decision_at={decision_at.isoformat()}",
)
previous_trade_date = await self._previous_trade_date(run_date)
self.logger.info(
f"[{self.name}] trade date resolved date={run_date.isoformat()} "
f"previous_trade_date={previous_trade_date.isoformat()}",
)
downloaded = 0
for processed, day in enumerate(self._days(start, run_date), start=1):
downloaded += int(await self._cache_news(day, decision_at, force or day == run_date))
if processed % progress_interval == 0 and processed < news_days:
self.logger.info(
f"[{self.name}] progress processed={processed}/{news_days} downloaded={downloaded} "
f"cached={processed - downloaded} last_date={day.isoformat()}",
)
if not await self._is_trade_day(run_date):
reason = f"{run_date.isoformat()} 不是交易日Auto Fin 已跳过。"
self.context["auto_fin_skipped"] = True
self.context["auto_fin_skip_reason"] = reason
self.context.response.answer = reason
self.context.response.metadata.update({"date": run_date.isoformat(), "skipped": True})
return self.context.response
lookback = int(self._value("news_lookback_days", 60))
if lookback < 1:
raise ValueError("news_lookback_days must be positive")
news_start = run_date - timedelta(days=lookback - 1)
changes = []
for day in self._days(news_start, run_date):
path = self._news_path(day)
if day != run_date and path.is_file():
continue
change = await self._write_news(day, decision_at)
changes.append({"change": change, "path": str(path)})
codes = self._value("etf_codes")
if not codes:
raise ValueError("auto_fin_data_step requires a non-empty etf_codes")
codes = [str(code).strip().upper() for code in codes]
names = await self._cache_etfs(codes, run_date)
self.context.update(
{
"changes": changes,
"auto_fin_date": run_date.isoformat(),
"auto_fin_decision_at": decision_at.isoformat(),
"auto_fin_news_start": start.isoformat(),
"auto_fin_previous_trade_date": previous_trade_date.isoformat(),
"auto_fin_news_start": news_start.isoformat(),
"auto_fin_etf_names": names,
},
)
self.context.response.metadata.update({"date": run_date.isoformat(), "news_downloaded": downloaded})
self.logger.info(
f"[{self.name}] done downloaded={downloaded} cached={news_days - downloaded} "
f"previous_trade_date={previous_trade_date.isoformat()}",
)
self.context.response.metadata.update({"date": run_date.isoformat(), "news_downloaded": len(changes)})
return self.context.response

View file

@ -1,93 +1,200 @@
"""Orchestrate historical research and market analysis for selected ETFs."""
"""Prepare historical news and observed ETF returns."""
from __future__ import annotations
from ....components import R
from ....schema import (
AutoFinEtfHistoricalResearch,
AutoFinEtfHistoryDetail,
AutoFinEtfSelection,
AutoFinSelectedEtfAnalysis,
AutoFinSelectedEvent,
)
from ._base import AutoFinStep, _write_jsonl
import json
import re
from datetime import date, datetime, time, timedelta
from pathlib import Path
from typing import Any
DEFAULT_DISPATCH_STEPS = ["auto_fin_history_search_step", "auto_fin_market_step"]
from ....components import R
from ....schema import AutoFinEtfAnalysis, AutoFinHistoricalOutput
from ._base import AutoFinStep, _write_jsonl
from .data import AutoFinDataStep, NEWS_FILENAME
NEWS_ID_RE = re.compile(r"\b\d{14}_[0-9a-fA-F]{4}\b")
HORIZONS = (1, 2, 3, 5)
@R.register("auto_fin_history_step")
class AutoFinHistoryStep(AutoFinStep):
"""Dispatch historical and market steps for each selected ETF."""
"""Search with ReMe, then let a tool-free Agent select comparable events."""
def __init__(self, *args, dispatch_steps=None, **kwargs):
super().__init__(
*args,
dispatch_steps=DEFAULT_DISPATCH_STEPS if dispatch_steps is None else dispatch_steps,
**kwargs,
def _market_rows(self, code: str) -> list[dict[str, Any]]:
path = self.workspace_path / str(self.config_value("resource_dir")) / "fin" / f"{code}.jsonl"
rows = self._read_jsonl_sync(path)
return sorted(rows, key=lambda row: str(row.get("trade_date") or ""))
@staticmethod
def _read_news(
path: Path,
cache: dict[str, list[dict[str, str]]],
) -> list[dict[str, str]]:
"""Parse a news file once per run; the same file recurs across events/ETFs."""
key = str(path)
if key not in cache:
cache[key] = AutoFinDataStep.read_news(path)
return cache[key]
@staticmethod
def _number(value: Any) -> float | None:
try:
number = float(value)
except (TypeError, ValueError):
return None
return number if number > 0 else None
@classmethod
def _returns(cls, event_time: datetime, rows: list[dict[str, Any]]) -> dict[str, float | None]:
usable = []
for row in rows:
try:
trade_day = datetime.strptime(str(row.get("trade_date")), "%Y%m%d").date()
except ValueError:
continue
if (close := cls._number(row.get("close"))) is None or (
factor := cls._number(row.get("adj_factor"))
) is None:
continue
usable.append((trade_day, row, close, factor))
event_day = event_time.date()
before_close = event_time.time() < time(15)
entry_index = entry_price = None
entered_at_close = False
for index, (trade_day, row, close, factor) in enumerate(usable):
# React before the close only when the event day is itself a trading day.
if before_close and trade_day == event_day:
entry_index, entry_price, entered_at_close = index, close * factor, True
break
# Otherwise (post-close, or any time on a non-trading day) enter at the
# open of the first trading day after the event.
if trade_day > event_day:
if (opened := cls._number(row.get("open"))) is not None:
entry_index, entry_price = index, opened * factor
break
result = {f"d{horizon}": None for horizon in HORIZONS}
if entry_index is None or entry_price is None:
return result
first_close = entry_index + 1 if entered_at_close else entry_index
for horizon in HORIZONS:
target = first_close + horizon - 1
if target < len(usable):
_, _, close, factor = usable[target]
result[f"d{horizon}"] = close * factor / entry_price - 1
return result
async def _candidates(
self,
event: dict[str, str],
start: date,
end: date,
news_cache: dict[str, list[dict[str, str]]],
) -> list[dict[str, str]]:
query = " ".join(filter(None, [event.get("title"), event.get("content"), event.get("reason")]))[:2000]
response = await self.run_job(
"memory_search",
query=query,
limit=int(self._value("historical_search_limit", 10)),
start_date=start.isoformat(),
end_date=end.isoformat(),
strict_date_filter=True,
)
if not response.success:
raise RuntimeError(f"Auto Fin memory search failed: {response.answer}")
ids_by_path: dict[str, set[str]] = {}
for result in response.metadata.get("results", []):
path = str(result.get("path") or "")
if Path(path).name == NEWS_FILENAME:
ids_by_path.setdefault(path, set()).update(NEWS_ID_RE.findall(str(result.get("text") or "")))
candidates = []
for relative, news_ids in ids_by_path.items():
path = self.workspace_path / relative
for row in self._read_news(path, news_cache):
if row["news_id"] in news_ids and row["news_id"] != event["news_id"]:
candidates.append(row)
unique = {row["news_id"]: row for row in candidates}
return sorted(
unique.values(),
key=lambda row: (row["event_time"], row["news_id"]),
reverse=True,
)
@staticmethod
def _normalize(output: AutoFinHistoricalOutput, candidates: list[dict[str, str]], limit: int):
by_id = {row["news_id"]: row for row in candidates}
selected = []
seen = set()
for item in output.historical_events:
news_id, reason = item.news_id.strip(), item.reason.strip()
if news_id in by_id and news_id not in seen and reason:
selected.append(item.model_copy(update={"news_id": news_id, "reason": reason}))
seen.add(news_id)
selected.sort(key=lambda item: by_id[item.news_id]["event_time"], reverse=True)
return selected[:limit]
async def execute(self):
assert self.context is not None
etfs = [AutoFinEtfSelection.model_validate(item) for item in self._required("auto_fin_etfs")]
history_details = []
news_rows = await self._read_jsonl(self.workspace_path / str(self._required("auto_fin_filtered_news")))
news_by_id = {str(row["news_id"]): row for row in news_rows}
current_keys = (
"auto_fin_current_history",
"auto_fin_current_history_resource",
"auto_fin_current_analysis",
)
self.logger.info(f"[{self.name}] start etfs={len(etfs)}")
for index, item in enumerate(etfs, 1):
label = f"{item.etf_code}({item.etf_name})"
self.logger.info(
f"[{self.name}] etf start index={index}/{len(etfs)} etf={label!r} events={len(item.events)}",
)
events = [
AutoFinSelectedEvent(
reason=event.reason,
news_id=event.news_id,
event_time=news_by_id[event.news_id]["event_time"],
event_title=str(news_by_id[event.news_id].get("title") or ""),
event_content=str(
news_by_id[event.news_id].get("content") or news_by_id[event.news_id].get("title") or "",
),
if self.context.get("auto_fin_skipped"):
return self.context.response
news_by_id = {row["news_id"]: row for row in self._required("auto_fin_news")}
run_date = date.fromisoformat(str(self._required("auto_fin_date")))
search_start = date.fromisoformat(str(self._required("auto_fin_news_start")))
limit = int(self._value("historical_news_limit", 5))
analyses = []
call_index = 0
news_cache: dict[str, list[dict[str, str]]] = {}
for etf in self._required("auto_fin_etfs"):
market_rows = self._market_rows(etf["etf_code"])
events = []
for reference in etf["events"]:
current = {
**news_by_id[reference["news_id"]],
"reason": reference["reason"],
}
candidates = await self._candidates(
current,
search_start,
run_date - timedelta(days=1),
news_cache,
)
for event in item.events
]
for key in current_keys:
if key in self.context:
del self.context[key]
await self.dispatch_steps(
self.dispatch_step_specs,
agent_wrapper=self.agent_wrapper,
auto_fin_current_index=index,
auto_fin_current_etf=item.model_dump(mode="json"),
auto_fin_current_events=[event.model_dump(mode="json") for event in events],
call_index += 1
output, _ = await self._reply(
"history_user",
f"auto_fin_history_{call_index:03d}",
AutoFinHistoricalOutput,
etf=json.dumps(
{"etf_code": etf["etf_code"], "etf_name": etf["etf_name"]},
ensure_ascii=False,
),
current_event=json.dumps(current, ensure_ascii=False),
candidates=json.dumps(candidates, ensure_ascii=False),
)
historical = []
candidates_by_id = {row["news_id"]: row for row in candidates}
for match in self._normalize(output, candidates, limit):
row = candidates_by_id[match.news_id]
historical.append(
{
**row,
"reason": match.reason,
"direction": match.direction,
"returns": self._returns(datetime.fromisoformat(row["event_time"]), market_rows),
},
)
events.append({**current, "historical_events": historical})
analyses.append(
AutoFinEtfAnalysis.model_validate(
{
"etf_code": etf["etf_code"],
"etf_name": etf["etf_name"],
"events": events,
},
).model_dump(mode="json"),
)
history = AutoFinEtfHistoricalResearch.model_validate(self._required("auto_fin_current_history"))
analysis = AutoFinSelectedEtfAnalysis.model_validate(self._required("auto_fin_current_analysis"))
detail = AutoFinEtfHistoryDetail(
etf=item,
current_events=events,
historical_research=history,
market_analysis=analysis,
)
history_details.append(detail.model_dump(mode="json"))
self.logger.info(
f"[{self.name}] etf done index={index}/{len(etfs)} etf={label!r}",
)
for key in current_keys:
if key in self.context:
del self.context[key]
history_path = (
self.workspace_path / "resource" / str(self._required("auto_fin_date")) / "auto_fin_history_output.jsonl"
)
_write_jsonl(history_path, history_details)
self.context["auto_fin_history_details"] = history_details
self.context["auto_fin_history_resource"] = str(history_path)
self.context.response.metadata["analysis_count"] = len(history_details)
self.logger.info(
f"[{self.name}] done analyses={len(history_details)} history_resource={history_path}",
)
path = self.workspace_path / str(self.config_value("resource_dir")) / str(run_date) / "auto_fin_analysis.jsonl"
_write_jsonl(path, analyses)
self.context["auto_fin_analyses"] = analyses
self.context.response.metadata["analysis_count"] = len(analyses)
return self.context.response

View file

@ -0,0 +1,14 @@
history_user: |
你是历史事件比较 Agent。上下文已完整提供不得搜索、调用工具或补充外部信息。
ETF{etf}
当前新闻:{current_event}
ReMe 已检索出的历史候选新闻:{candidates}
从候选新闻中选择与当前新闻在事件类型、关键实体或价格传导机制上真正可比的历史事件。
direction 表示历史事件对该 ETF 的影响方向与当前事件相比:
- same两次事件对 ETF 的影响方向相同;
- opposite影响方向相反。例如当前降息利好黄金、历史加息利空黄金应为 opposite。
只复制候选中完整的 news_id没有看到完整 news_id 就不要输出。不要根据事后 ETF 涨跌判断方向。
按结构化输出契约返回 historical_events每项包含完整 news_id、reason 和 direction。

View file

@ -1,339 +0,0 @@
"""Find historical events relevant to one selected ETF."""
from __future__ import annotations
import asyncio
import json
from datetime import date, datetime, time
from pathlib import Path
from typing import Any
from uuid import uuid4
from ....components import R
from ....schema import (
AutoFinEtfHistoricalEvents,
AutoFinEtfHistoricalResearch,
AutoFinEtfSelection,
AutoFinHistoricalEvent,
AutoFinHistoricalEventReference,
AutoFinMarketSample,
AutoFinSelectedEvent,
)
from ...index._dedup import _ToolContextDedupMixin
from ._base import AutoFinStep, _write
from .topic import _plain_text
@R.register("auto_fin_history_search_step")
class AutoFinHistorySearchStep(AutoFinStep):
"""Find historical events and calculate their adjusted ETF returns."""
@staticmethod
def _trade_date(value: Any) -> date | None:
text = str(value or "").replace("-", "")
try:
return datetime.strptime(text, "%Y%m%d").date()
except ValueError:
return None
@staticmethod
def _positive_float(value: Any) -> float | None:
try:
number = float(value)
except (TypeError, ValueError):
return None
return number if number > 0 else None
def _historical_source_candidates(self, source_path_value: str, news_id: str) -> list[Path]:
"""Return safe declared and date-derived source candidates within the workspace."""
workspace = self.workspace_path.resolve()
relative_path = Path(source_path_value)
candidates = []
if not relative_path.is_absolute() and ".." not in relative_path.parts:
source_path = (workspace / relative_path).resolve()
try:
source_path.relative_to(workspace)
except ValueError:
pass
else:
if source_path.name == "auto_fin_news_data.jsonl":
candidates.append(source_path)
news_date = news_id.partition("_")[0][:8]
try:
parsed_date = datetime.strptime(news_date, "%Y%m%d").date()
except ValueError:
parsed_date = None
if parsed_date is not None:
inferred_path = workspace / "daily" / parsed_date.isoformat() / "auto_fin_news_data.jsonl"
if inferred_path not in candidates:
candidates.append(inferred_path)
if not candidates:
raise ValueError(f"Historical source cannot be inferred safely: {source_path_value}")
return candidates
async def _resolve_historical_event(
self,
reference: AutoFinHistoricalEventReference,
current_news_ids: set[str],
window_start: datetime,
rows_by_path: dict[Path, list[dict[str, Any]]],
) -> AutoFinHistoricalEvent:
"""Resolve one Agent-selected identity from user-owned source files."""
workspace = self.workspace_path.resolve()
if reference.news_id in current_news_ids:
raise ValueError(f"History Agent returned a current news item: {reference.news_id}")
candidates = self._historical_source_candidates(reference.source_path, reference.news_id)
matches: list[tuple[Path, dict[str, Any]]] = []
for source_path in candidates:
if not source_path.is_file():
continue
rows = rows_by_path.get(source_path)
if rows is None:
rows = await self._read_jsonl(source_path)
rows_by_path[source_path] = rows
matches.extend((source_path, row) for row in rows if str(row.get("news_id") or "") == reference.news_id)
if len(matches) != 1:
raise ValueError(
f"Historical news_id must resolve exactly once from {reference.source_path} "
f"or its ID-derived daily file: {reference.news_id}",
)
source_path, row = matches[0]
event_time = self._published_at(row)
if event_time is None:
raise ValueError(f"Historical news has no valid publication time: {reference.news_id}")
if event_time >= window_start:
raise ValueError(f"History Agent returned an event inside the current news window: {reference.news_id}")
event_title = str(row.get("title") or "").strip()
event_content = _plain_text(str(row.get("content") or event_title))
if not event_title or not event_content:
raise ValueError(f"Historical news has no usable title or content: {reference.news_id}")
return AutoFinHistoricalEvent(
reason=reference.reason,
news_id=reference.news_id,
source_path=source_path.relative_to(workspace).as_posix(),
event_time=event_time,
event_title=event_title,
event_content=event_content,
)
async def _resolve_historical_events(
self,
references: list[AutoFinHistoricalEventReference],
current_news_ids: set[str],
window_start: datetime,
) -> tuple[list[AutoFinHistoricalEvent], list[str]]:
"""Resolve valid references and report invalid ones without stopping the ETF."""
rows_by_path: dict[Path, list[dict[str, Any]]] = {}
events_by_news_id: dict[str, AutoFinHistoricalEvent] = {}
limitations = []
for reference in references:
reference = reference.model_copy(
update={
"reason": reference.reason.strip(),
"news_id": reference.news_id.strip(),
"source_path": reference.source_path.strip(),
},
)
if not reference.reason or not reference.news_id:
limitation = "跳过缺少 reason 或 news_id 的历史新闻"
self.logger.warning(f"[{self.name}] {limitation}")
limitations.append(limitation)
continue
try:
event = await self._resolve_historical_event(
reference,
current_news_ids,
window_start,
rows_by_path,
)
except (OSError, ValueError) as exc:
limitation = f"跳过无法解析的历史新闻 {reference.news_id}: {exc}"
self.logger.warning(f"[{self.name}] {limitation}")
limitations.append(limitation)
continue
events_by_news_id.setdefault(reference.news_id, event)
resolved = sorted(events_by_news_id.values(), key=lambda event: (event.event_time, event.news_id))
return resolved, limitations
async def _calculate_samples(
self,
etf_code: str,
events: list[AutoFinHistoricalEvent],
decision_at: datetime,
) -> tuple[list[AutoFinMarketSample], list[str]]:
if not events:
return [], []
start_date = min(event.event_time.date() for event in events).strftime("%Y%m%d")
end_date = decision_at.date().strftime("%Y%m%d")
daily, factors = await asyncio.gather(
self._fetch("fund_daily", ts_code=etf_code, start_date=start_date, end_date=end_date),
self._fetch("fund_adj", ts_code=etf_code, start_date=start_date, end_date=end_date),
)
factors_by_date = {
trade_date: self._positive_float(row.get("adj_factor"))
for row in factors
if (trade_date := self._trade_date(row.get("trade_date"))) is not None
}
daily_by_date = {
trade_date: {
"trade_date": trade_date,
"open": self._positive_float(row.get("open")),
"close": self._positive_float(row.get("close")),
"adj_factor": factors_by_date.get(trade_date),
}
for row in daily
if (trade_date := self._trade_date(row.get("trade_date"))) is not None
and datetime.combine(trade_date, time(15, 0)) <= decision_at
}
rows = [daily_by_date[trade_date] for trade_date in sorted(daily_by_date)]
row_indexes = {row["trade_date"]: index for index, row in enumerate(rows)}
samples = []
limitations = []
for event in events:
event_time = event.event_time
event_date = event_time.date()
row_index = row_indexes.get(event_date)
price_type = None
if row_index is not None and event_time.time() < time(9, 30):
price_type = "open"
elif row_index is not None and event_time.time() < time(15, 0):
price_type = "close"
else:
row_index = next(
(index for index, row in enumerate(rows) if row["trade_date"] > event_date),
None,
)
price_type = "open" if row_index is not None else None
if row_index is None or price_type is None:
limitations.append(f"{event_time.isoformat()} 之后没有已完成的 ETF 日线")
samples.append(
AutoFinMarketSample(
event_time=event.event_time,
reaction_summary="事件之后没有可用的已完成日线。",
),
)
continue
entry_row = rows[row_index]
raw_price = entry_row[price_type]
adj_factor = entry_row["adj_factor"]
entry_clock = time(9, 30) if price_type == "open" else time(15, 0)
entry_time = datetime.combine(entry_row["trade_date"], entry_clock)
if raw_price is None or adj_factor is None:
limitations.append(f"{entry_row['trade_date']} 缺少有效的 {price_type} 或 ETF 复权因子")
samples.append(
AutoFinMarketSample(
event_time=event.event_time,
reaction_summary="买入点缺少有效价格或复权因子。",
),
)
continue
future_returns = []
first_close_index = row_index if price_type == "open" else row_index + 1
adjusted_entry = raw_price * adj_factor
for future_row in rows[first_close_index : first_close_index + 10]:
raw_close = future_row["close"]
close_factor = future_row["adj_factor"]
if raw_close is None or close_factor is None:
limitations.append(f"{future_row['trade_date']} 缺少有效 close 或 ETF 复权因子")
break
future_returns.append(
{
"horizon": len(future_returns) + 1,
"trade_date": future_row["trade_date"],
"raw_close": raw_close,
"adj_factor": close_factor,
"cumulative_return": raw_close * close_factor / adjusted_entry - 1,
},
)
if len(future_returns) < 10:
limitations.append(f"{event_time.isoformat()} 只有 {len(future_returns)} 个已完成的未来收盘点")
samples.append(
AutoFinMarketSample.model_validate(
{
"event_time": event.event_time,
"entry": {
"entry_time": entry_time,
"trade_date": entry_row["trade_date"],
"price_type": price_type,
"raw_price": raw_price,
"adj_factor": adj_factor,
},
"future_returns": future_returns,
"reaction_summary": f"按复权日线计算了 {len(future_returns)} 个未来收盘点。",
},
),
)
return samples, list(dict.fromkeys(limitations))
async def execute(self):
assert self.context is not None
item = AutoFinEtfSelection.model_validate(self._required("auto_fin_current_etf"))
events = [AutoFinSelectedEvent.model_validate(event) for event in self._required("auto_fin_current_events")]
index = int(self._required("auto_fin_current_index"))
window_start = datetime.fromisoformat(str(self._required("auto_fin_window_start")))
decision_at = datetime.fromisoformat(str(self._required("auto_fin_decision_at")))
search_events = [event.model_dump(mode="json", exclude={"news_id"}) for event in events]
label = f"{item.etf_code}({item.etf_name})"
tool_context_id = f"auto_fin_history_{index:02d}_{item.etf_code}_{uuid4().hex}"
try:
history, history_path = await self._reply(
"history_search_user",
f"auto_fin_history_{index:02d}_{item.etf_code}",
AutoFinEtfHistoricalEvents,
tool_context_id=tool_context_id,
etf_code=item.etf_code,
etf_name=item.etf_name,
events=str(search_events),
window_start=window_start.isoformat(),
workspace_root=str(self.workspace_path),
)
finally:
if self.app_context is not None:
contexts = self.app_context.metadata.get(_ToolContextDedupMixin.TOOL_CONTEXTS_KEY)
if isinstance(contexts, dict):
contexts.pop(tool_context_id, None)
if not contexts:
self.app_context.metadata.pop(_ToolContextDedupMixin.TOOL_CONTEXTS_KEY, None)
resolved_events, resolution_limitations = await self._resolve_historical_events(
history.historical_events,
{event.news_id for event in events},
window_start,
)
samples, market_limitations = await self._calculate_samples(
item.etf_code,
resolved_events,
decision_at,
)
enriched_events = [
event.model_copy(
update={
"market_entry": sample.entry,
"future_returns": sample.future_returns,
},
)
for event, sample in zip(resolved_events, samples, strict=True)
]
enriched_history = AutoFinEtfHistoricalResearch(
etf_code=item.etf_code,
etf_name=item.etf_name,
historical_events=enriched_events,
limitations=list(dict.fromkeys([*resolution_limitations, *market_limitations])),
)
_write(
history_path,
json.dumps(enriched_history.model_dump(mode="json"), ensure_ascii=False, indent=2) + "\n",
)
self.context["auto_fin_current_history"] = enriched_history.model_dump(mode="json")
self.context["auto_fin_current_history_resource"] = str(history_path)
self.logger.info(
f"[{self.name}] ready etf={label!r} events={len(enriched_events)} "
f"limitations={len(enriched_history.limitations)}",
)
return self.context.response

View file

@ -1,25 +0,0 @@
history_search_user: |
为 {etf_code}{etf_name})寻找与当前事件相似的历史新闻。不查询行情、不计算收益或预测。
当前事件:{events}
历史截止时间:{window_start}
ReMe workspace{workspace_root}
工作要求:
1. 使用 memory_search 从事件类型、关键实体、传导机制和影响方向等角度搜索截止时间之前的
相似新闻;结果不足时调整角度继续搜索。必要时扫描 workspace 过去 360 天的
`daily/YYYY-MM-DD/auto_fin_news_data.jsonl`。
2. 选择机制可比的历史事件,说明相似原因,并返回原始新闻的 news_id 和 source_path。
当前事件不属于历史事件。程序会回查、过滤、去重、排序并补充行情。
3. 返回以下 JSON没有合适新闻时 historical_events 为空:
```json
{{
"historical_events": [
{{
"reason": "事件类型、关键实体、传导机制和影响方向相似",
"news_id": "20260601100000_a3f8",
"source_path": "daily/2026-06-01/auto_fin_news_data.jsonl"
}}
]
}}
```

View file

@ -1,164 +0,0 @@
"""Forecast one selected ETF from calculated historical samples."""
from __future__ import annotations
import json
from ....components import R
from ....schema import (
AutoFinEtfHistoricalResearch,
AutoFinEtfSelection,
AutoFinMarketSelection,
AutoFinSelectedEtfAnalysis,
AutoFinSelectedEvent,
)
from ._base import AutoFinStep, _write
@R.register("auto_fin_market_step")
class AutoFinMarketStep(AutoFinStep):
"""Classify historical event directions and calculate one ETF forecast."""
@staticmethod
def _normalize_selection(
selection: AutoFinMarketSelection,
history: AutoFinEtfHistoricalResearch,
) -> tuple[AutoFinMarketSelection, bool]:
"""Filter unknown, blank, and duplicate direction references."""
known_news_ids = {event.news_id for event in history.historical_events}
seen_news_ids: set[str] = set()
def valid_events(events):
normalized = []
for event in events:
reason = event.reason.strip()
news_id = event.news_id.strip()
if not reason or news_id not in known_news_ids or news_id in seen_news_ids:
continue
normalized.append({"reason": reason, "news_id": news_id})
seen_news_ids.add(news_id)
return normalized
normalized_selection = AutoFinMarketSelection.model_validate(
{
"same_direction_events": valid_events(selection.same_direction_events),
"opposite_direction_events": valid_events(selection.opposite_direction_events),
},
)
changed = normalized_selection.model_dump(mode="json") != selection.model_dump(mode="json")
return normalized_selection, changed
@staticmethod
def _calculate_analysis(
item: AutoFinEtfSelection,
history: AutoFinEtfHistoricalResearch,
selection: AutoFinMarketSelection,
) -> AutoFinSelectedEtfAnalysis:
"""Build all deterministic market fields from Agent-selected news IDs."""
history_by_news_id = {event.news_id: event for event in history.historical_events}
selected = [
*((match, "same", 1.0) for match in selection.same_direction_events),
*((match, "opposite", -1.0) for match in selection.opposite_direction_events),
]
weight = 1.0 / len(selected) if selected else 0.0
matches = [
{
"reason": match.reason,
"news_id": match.news_id,
"event_time": history_by_news_id[match.news_id].event_time,
"direction": direction,
"weight": weight,
}
for match, direction, _ in selected
]
returns = []
has_missing_horizon = False
has_direction_conflict = False
for horizon in range(1, 11):
available = []
for match, _, direction_coefficient in selected:
event = history_by_news_id[match.news_id]
point = next((point for point in event.future_returns if point.horizon == horizon), None)
if point is not None:
available.append(direction_coefficient * point.cumulative_return)
if not available:
has_missing_horizon = True
expected_return = None
else:
expected_return = sum(available) / len(available)
has_direction_conflict |= any(value > 0 for value in available) and any(
value < 0 for value in available
)
returns.append({"horizon": horizon, "expected_return": expected_return})
positive_returns = [point for point in returns if (point["expected_return"] or 0) > 0]
suggested_holding_days = (
max(positive_returns, key=lambda point: (point["expected_return"], -point["horizon"]))["horizon"]
if positive_returns
else None
)
limitations = list(history.limitations)
if not selected:
limitations.append("没有匹配的历史事件")
elif len(selected) < 2:
limitations.append("相似历史样本少于 2 个")
if has_missing_horizon:
limitations.append("部分持有期缺少可用历史收益")
if has_direction_conflict:
limitations.append("相似历史样本的收益方向存在分歧")
if selected and suggested_holding_days is None:
limitations.append("加权预期收益没有正值")
return AutoFinSelectedEtfAnalysis.model_validate(
{
"etf_code": item.etf_code,
"etf_name": item.etf_name,
"matched_historical_events": matches,
"forecast": {
"returns": returns,
"suggested_holding_days": suggested_holding_days,
},
"limitations": list(dict.fromkeys(limitations)),
},
)
async def execute(self):
assert self.context is not None
item = AutoFinEtfSelection.model_validate(self._required("auto_fin_current_etf"))
events = [AutoFinSelectedEvent.model_validate(event) for event in self._required("auto_fin_current_events")]
history = AutoFinEtfHistoricalResearch.model_validate(self._required("auto_fin_current_history"))
index = int(self._required("auto_fin_current_index"))
event_lines = "\n".join(
f"- [{event.event_time.isoformat()}] {event.event_title or event.reason}: {event.event_content}"
for event in events
)
resource_name = f"auto_fin_market_{index:02d}_{item.etf_code}"
if history.historical_events:
selection, selection_path = await self._reply(
"market_user",
resource_name,
AutoFinMarketSelection,
etf_code=item.etf_code,
etf_name=item.etf_name,
events=event_lines,
history_path=str(self._required("auto_fin_current_history_resource")),
decision_at=str(self._required("auto_fin_decision_at")),
)
else:
selection = AutoFinMarketSelection()
selection_path = (
self.workspace_path / "resource" / str(self._required("auto_fin_date")) / f"{resource_name}_output.json"
)
self.logger.warning(f"[{self.name}] skip direction Agent for {item.etf_code}: no valid history")
selection, normalized = self._normalize_selection(selection, history)
if normalized:
self.logger.info(f"[{self.name}] normalized historical direction selections")
analysis = self._calculate_analysis(item, history, selection)
_write(
selection_path,
json.dumps(analysis.model_dump(mode="json"), ensure_ascii=False, indent=2) + "\n",
)
self.context["auto_fin_current_analysis"] = analysis.model_dump(mode="json")
return self.context.response

View file

@ -1,34 +0,0 @@
market_user: |
筛选与当前事件机制可比的历史事件,并判断其影响方向与当前事件相同还是相反。
不计算收益或生成预测。
ETF{etf_code}{etf_name}
分析截止时间:{decision_at}
当前事件:
{events}
历史事件文件:{history_path}
工作要求:
1. 根据历史事件的时间、标题、正文和 reason综合事件类型、关键实体、传导机制和影响方向
- 机制可比且影响方向相同,放入 same_direction_events
- 机制可比但影响方向相反,放入 opposite_direction_events
- 机制不可比则不选择。
不要依据 market_entry 或 future_returns 选择事件,避免使用事后行情。
2. 返回 news_id 和判断理由。程序会过滤、去重并完成计算。
3. 返回以下 JSON
```json
{{
"same_direction_events": [
{{
"reason": "事件类型、关键实体、传导机制和影响方向相似",
"news_id": "20260601100000_a3f8"
}}
],
"opposite_direction_events": [
{{
"reason": "传导机制相似,但影响方向相反",
"news_id": "20260501100000_b4c9"
}}
]
}}
```

View file

@ -1,97 +1,93 @@
"""Merge all selected ETF analyses into the final report."""
"""Generate and save the final Auto Fin recommendation."""
from __future__ import annotations
import json
import re
from datetime import date
from pathlib import Path
from types import SimpleNamespace
from ....components import R
from ....schema import AutoFinEtfHistoryDetail, AutoFinReportOutput
from ....schema import AutoFinReportOutput
from ...file_io import refresh_day_index
from ._base import AutoFinStep, _write, _write_jsonl
from ._base import AutoFinStep, _write
@R.register("auto_fin_merge_step")
class AutoFinMergeStep(AutoFinStep):
"""Ask a fresh Agent for the final Markdown and persist it directly."""
"""Call the final tool-free Agent with all evidence already prepared."""
def _report_path(self, run_date: date) -> Path:
return self.workspace_path / str(self.config_value("daily_dir")) / str(run_date) / "auto_fin.md"
def _previous_report(self, run_date: date) -> str:
"""Return the most recent report from a *prior* day (yesterday's, typically)."""
daily = self.workspace_path / str(self.config_value("daily_dir"))
candidates = []
for path in daily.glob("*/auto_fin.md"):
try:
day = date.fromisoformat(path.parent.name)
except ValueError:
continue
if day < run_date:
candidates.append((day, path))
return max(candidates)[1].read_text(encoding="utf-8") if candidates else "无历史推荐。"
def _current_report(self, run_date: date) -> str:
"""Return today's existing report so intra-day reruns refine it, not replace it."""
path = self._report_path(run_date)
if path.is_file():
return path.read_text(encoding="utf-8")
return "今日暂无更早时段的推荐,本次为当日首次生成。"
@staticmethod
def _normalize_report(output: AutoFinReportOutput) -> AutoFinReportOutput:
"""Normalize cosmetic report fields and provide safe empty fallbacks."""
def _normalize(output: AutoFinReportOutput) -> AutoFinReportOutput:
title = re.sub(r"^#+\s*", "", output.title.strip()) or "Auto Fin ETF 结论"
description = output.description.strip() or "基于当前事件与相似历史表现的 ETF 观察。"
body = output.body.strip() or "## 结论\n\n暂无可用结论。"
first_line, separator, remainder = body.partition("\n")
if first_line.lstrip().startswith("# "):
body = remainder.lstrip() if separator else "## 结论\n\n暂无可用结论。"
return AutoFinReportOutput(title=title, body=body)
@staticmethod
def _calculation_results(history_details: list[AutoFinEtfHistoryDetail]) -> list[dict]:
"""Return the program-calculated forecast for every analyzed ETF."""
results = []
for item in history_details:
holding_days = item.market_analysis.forecast.suggested_holding_days
results.append(
{
"etf_code": item.etf.etf_code,
"etf_name": item.etf.etf_name,
"suggested_holding_days": holding_days,
"returns": [point.model_dump(mode="json") for point in item.market_analysis.forecast.returns],
},
)
return results
if body.startswith("# "):
body = body.partition("\n")[2].lstrip() or "## 结论\n\n暂无可用结论。"
return AutoFinReportOutput(title=title, description=description, body=body)
async def execute(self):
assert self.context is not None
etfs = list(self._required("auto_fin_etfs"))
history_details = [
AutoFinEtfHistoryDetail.model_validate(item) for item in self._required("auto_fin_history_details")
]
selected = [item.etf.model_dump(mode="json") for item in history_details]
if selected != etfs:
raise ValueError("Auto Fin merge history details must match the selected ETFs")
analyses = [item.market_analysis.model_dump(mode="json") for item in history_details]
calculation_results = self._calculation_results(history_details)
self.logger.info(
f"[{self.name}] start etfs={len(etfs)}",
)
if self.context.get("auto_fin_skipped"):
self.context.response.answer = str(self.context.get("auto_fin_skip_reason") or "Auto Fin 已跳过。")
return self.context.response
run_date = date.fromisoformat(str(self._required("auto_fin_date")))
output, output_path = await self._reply(
"merge_user",
"auto_fin_merge",
AutoFinReportOutput,
decision_at=str(self._required("auto_fin_decision_at")),
window_start=str(self._required("auto_fin_window_start")),
etfs_path=str(self._required("auto_fin_etfs_resource")),
history_path=str(self._required("auto_fin_history_resource")),
calculation_results=json.dumps(calculation_results, ensure_ascii=False),
etfs=json.dumps(
[
{"etf_code": code, "etf_name": name}
for code, name in dict(self._required("auto_fin_etf_names")).items()
],
ensure_ascii=False,
),
analyses=json.dumps(self._required("auto_fin_analyses"), ensure_ascii=False),
previous_report=self._previous_report(run_date),
current_report=self._current_report(run_date),
)
normalized_output = self._normalize_report(output)
if normalized_output != output:
_write(
output_path,
json.dumps(normalized_output.model_dump(mode="json"), ensure_ascii=False, separators=(",", ":")) + "\n",
)
output = normalized_output
markdown = f"# {output.title}\n\n{output.body}\n\n"
markdown += "> 仅为事件研究和持有时间参考,不构成投资建议,不会执行交易。\n"
day_dir = self.workspace_path / str(self.config_value("daily_dir")) / str(self._required("auto_fin_date"))
report_path = day_dir / "auto_fin.md"
_write_jsonl(day_dir / "auto_fin_analysis.jsonl", analyses)
_write(report_path, markdown)
relative = report_path.relative_to(self.workspace_path).as_posix()
output = self._normalize(output)
self._write_output(output_path, output)
markdown = (
f"# {output.title}\n\n> {output.description}\n\n{output.body}\n\n"
"> 仅为事件研究和持有时间参考,不构成投资建议,不会执行交易。\n"
)
report = self._report_path(run_date)
_write(report, markdown)
await refresh_day_index(
SimpleNamespace(workspace_path=self.workspace_path),
str(self._required("auto_fin_date")),
str(run_date),
str(self.config_value("daily_dir")),
)
relative = report.relative_to(self.workspace_path).as_posix()
self.context["markdown_path"] = relative
self.context["auto_fin_digest_path"] = relative
self.context.response.answer = output.body
self.context.response.metadata.update(
{"markdown_path": relative, "digest_path": relative, "etf_count": len(history_details)},
)
self.logger.info(
f"[{self.name}] done path={relative} etfs={self.context.response.metadata['etf_count']}",
)
self.context.response.metadata.update({"markdown_path": relative, "digest_path": relative})
return self.context.response

View file

@ -1,23 +1,16 @@
merge_user: |
把已经完成的结构化分析写成中文 Markdown 结论,不重新搜索新闻、下载行情或修改数值
你是最终 ETF 建议 Agent。上下文已完整提供不得搜索、调用工具、修改历史收益或虚构数据
分析截止时间:{decision_at}
新闻窗口:({window_start}, {decision_at}]
ETF 与当前事件:{etfs_path}
历史事件、行情样本及分析:{history_path}
程序计算结果:{calculation_results}
分析时间:{decision_at}
固定 ETF code/name{etfs}
当前新闻、相似历史新闻、same/opposite 方向及复权 D1/D2/D3/D5 表现:{analyses}
最近一份历史推荐(往日):
{previous_report}
今天早些时段的推荐(如有,请在其基础上结合截至当前的最新证据继续修订,而非推倒重来;
保留仍然成立的判断,只更新已变化或新出现的部分):
{current_report}
工作要求:
1. 根据当前事件内容和传导关系判断影响方向。只推荐同时满足以下条件的 ETF
- 经你判断,当前事件对该 ETF 所代表的资产、行业或主题影响明确为正向;
- 程序给出了最佳持有天数,且该天数对应的 expected_return 大于 0。
不使用计算结果反推事件方向;不满足条件时给出观望结论。
2. 推荐结论包含 ETF code、name、最佳持有天数和对应预估收益负向或无正收益的情况可以合并
简述。
3. 返回以下 JSON
```json
{{
"title": "Auto Fin ETF 结论",
"body": "## 结论\n\n推荐 518880.SH黄金ETF参考持有 3 个交易日,当前加权预估收益 +1.2%......。\n\n负向提示相关能源 ETF 事件影响偏负,不推荐。"
}}
```
综合事件传导方向、历史样本数量、不同期限表现、样本一致性、往日推荐以及今天早些的推荐,自行判断
是否推荐 ETF 及参考持有期限。direction=opposite 的历史表现应反向理解。没有历史样本、样本冲突或
证据不足时可以明确观望,不要求机械打分,也不要为了凑数推荐。
按结构化输出契约返回 title、description 和完整中文 Markdown body。

View file

@ -1,270 +1,67 @@
"""Build the current Auto Fin topic timelines."""
"""Select configured ETFs that are directly related to today's news."""
from __future__ import annotations
import math
import re
from datetime import date, datetime, time
from html.parser import HTMLParser
import json
from typing import Any
from ....components import R
from ....schema import AutoFinEtfsOutput
from ._base import AutoFinStep, _news_id, _write_jsonl
NEWS_TITLE_MAX_CHARS = 200
NEWS_CONTENT_MAX_CHARS = 1200
NEWS_TOTAL_CONTENT_MAX_CHARS = 60_000
ETF_CANDIDATE_LIMIT = 150
ETF_OUTPUT_LIMIT = 20
class _NewsTextExtractor(HTMLParser):
"""Extract visible text without retaining markup, links, or image URLs."""
def __init__(self) -> None:
super().__init__(convert_charrefs=True)
self.parts: list[str] = []
self.ignored_depth = 0
def handle_starttag(self, tag: str, _attrs) -> None:
if tag in {"script", "style"}:
self.ignored_depth += 1
elif tag in {"br", "div", "h1", "h2", "h3", "h4", "li", "p"}:
self.parts.append(" ")
def handle_endtag(self, tag: str) -> None:
if tag in {"script", "style"} and self.ignored_depth:
self.ignored_depth -= 1
elif tag in {"div", "h1", "h2", "h3", "h4", "li", "p"}:
self.parts.append(" ")
def handle_data(self, data: str) -> None:
if not self.ignored_depth:
self.parts.append(data)
def _plain_text(value: str) -> str:
parser = _NewsTextExtractor()
parser.feed(value)
parser.close()
return " ".join("".join(parser.parts).split())
def _normalized_key(value: Any) -> str:
"""Normalize display labels used only for deterministic deduplication."""
return re.sub(r"\s+", "", str(value or "")).casefold()
from ._base import AutoFinStep
@R.register("auto_fin_topic_step")
class AutoFinTopicStep(AutoFinStep):
"""Match current news to a small set of liquid, representative ETFs."""
async def _current_news(self, start: datetime, end: datetime) -> list[dict]:
news = {}
title_limit = max(0, int(self._value("news_title_max_chars", NEWS_TITLE_MAX_CHARS)))
content_limit = max(0, int(self._value("news_content_max_chars", NEWS_CONTENT_MAX_CHARS)))
total_content_limit = max(
0,
int(self._value("news_total_content_max_chars", NEWS_TOTAL_CONTENT_MAX_CHARS)),
)
first_day = date.fromisoformat(str(self._required("auto_fin_news_start")))
last_day = date.fromisoformat(str(self._required("auto_fin_date")))
for day in self._days(first_day, last_day):
for row in await self._read_jsonl(self._news_path(day)):
published_at = self._published_at(row)
if published_at is None or not start < published_at <= end:
continue
news_id = str(row.get("news_id") or _news_id(row, published_at))
news.setdefault(
news_id,
{
"news_id": news_id,
"event_time": published_at.isoformat(),
"title": str(row.get("title") or "").strip()[:title_limit],
"content": _plain_text(str(row.get("content") or "")),
},
)
rows = sorted(news.values(), key=lambda row: (row["event_time"], row["news_id"]))
per_news_limit = min(content_limit, total_content_limit // len(rows)) if rows else 0
for row in rows:
row["content"] = row["content"][:per_news_limit]
return rows
async def _filtered_etfs(self, trade_date: date) -> list[dict[str, str]]:
basics = await self._fetch(
"etf_basic",
list_status="L",
fields="ts_code,csname,extname,cname,index_code,index_name,list_status",
)
daily = await self._fetch(
"fund_daily",
trade_date=trade_date.strftime("%Y%m%d"),
fields="ts_code,trade_date,amount",
)
basic_by_code: dict[str, dict[str, Any]] = {}
for row in basics:
code = str(row.get("ts_code") or row.get("code") or "").strip().upper()
name = str(
row.get("csname") or row.get("name") or row.get("extname") or row.get("cname") or "",
).strip()
if code and name and str(row.get("list_status") or "L").upper() == "L":
basic_by_code.setdefault(code, {**row, "code": code, "name": name})
amount_by_code: dict[str, float] = {}
for row in daily:
code = str(row.get("ts_code") or row.get("code") or "").strip().upper()
try:
amount = float(row.get("amount"))
except (TypeError, ValueError):
continue
if code in basic_by_code and math.isfinite(amount) and amount >= 0:
amount_by_code[code] = max(amount, amount_by_code.get(code, -math.inf))
ranked = sorted(
({**basic_by_code[code], "amount": amount} for code, amount in amount_by_code.items()),
key=lambda row: (-row["amount"], row["code"]),
)
selected: list[dict[str, str]] = []
seen_names: set[str] = set()
seen_indexes: set[str] = set()
limit = max(0, int(self._value("etf_candidate_limit", ETF_CANDIDATE_LIMIT)))
if not limit:
return selected
for row in ranked:
name_key = _normalized_key(row["name"])
index_keys = {
key
for key in (
_normalized_key(row.get("index_code")),
_normalized_key(row.get("index_name")),
)
if key
}
if name_key in seen_names or index_keys & seen_indexes:
continue
selected.append({"code": row["code"], "name": row["name"]})
seen_names.add(name_key)
seen_indexes.update(index_keys)
if len(selected) >= limit:
break
return selected
"""Call the first tool-free Agent with complete current-news context."""
@staticmethod
def _repair_news_ids(
def _normalize(
output: AutoFinEtfsOutput,
news: list[dict[str, Any]],
) -> tuple[AutoFinEtfsOutput, dict[str, str]]:
"""Repair a mistyped timestamp only when the content hash is unambiguous."""
news_ids = {str(row["news_id"]) for row in news}
ids_by_suffix: dict[str, list[str]] = {}
for news_id in news_ids:
_, separator, suffix = news_id.rpartition("_")
if separator and suffix:
ids_by_suffix.setdefault(suffix, []).append(news_id)
data = output.model_dump(mode="json")
repairs: dict[str, str] = {}
for item in data["etfs"]:
for event in item["events"]:
event["reason"] = event["reason"].strip()
news_id = event["news_id"].strip()
event["news_id"] = news_id
if news_id in news_ids:
continue
_, separator, suffix = news_id.rpartition("_")
candidates = ids_by_suffix.get(suffix, []) if separator else []
if len(candidates) == 1:
event["news_id"] = candidates[0]
repairs[news_id] = candidates[0]
return AutoFinEtfsOutput.model_validate(data), repairs
@staticmethod
def _normalize_selection(
output: AutoFinEtfsOutput,
news: list[dict[str, Any]],
etfs: list[dict[str, str]],
) -> tuple[AutoFinEtfsOutput, bool]:
"""Canonicalize, filter, deduplicate, sort, and limit Agent selections."""
news_order = {str(row["news_id"]): index for index, row in enumerate(news)}
news_ids = set(news_order)
candidates = {str(row["code"]).strip().upper(): str(row["name"]).strip() for row in etfs}
news: list[dict[str, str]],
names: dict[str, str],
limit: int,
):
news_ids = {row["news_id"] for row in news}
selected: dict[str, dict[str, Any]] = {}
for item in output.etfs:
code = item.etf_code.strip().upper()
name = candidates.get(code)
if name is None:
if code not in names:
continue
normalized = selected.setdefault(
code,
{"etf_code": code, "etf_name": name, "events": [], "seen_news_ids": set()},
)
target = selected.setdefault(code, {"etf_code": code, "etf_name": names[code], "events": []})
seen = {event["news_id"] for event in target["events"]}
for event in item.events:
if not event.reason or event.news_id not in news_ids or event.news_id in normalized["seen_news_ids"]:
continue
normalized["events"].append(event.model_dump(mode="json"))
normalized["seen_news_ids"].add(event.news_id)
normalized_items = []
for item in selected.values():
events = sorted(item["events"], key=lambda event: news_order[event["news_id"]])
if events:
normalized_items.append(
{
"etf_code": item["etf_code"],
"etf_name": item["etf_name"],
"events": events,
},
)
if len(normalized_items) >= ETF_OUTPUT_LIMIT:
break
normalized_output = AutoFinEtfsOutput.model_validate({"etfs": normalized_items})
changed = normalized_output.model_dump(mode="json") != output.model_dump(mode="json")
return normalized_output, changed
news_id, reason = event.news_id.strip(), event.reason.strip()
if news_id in news_ids and news_id not in seen and reason and len(target["events"]) < limit:
target["events"].append({"news_id": news_id, "reason": reason})
seen.add(news_id)
return AutoFinEtfsOutput.model_validate({"etfs": [item for item in selected.values() if item["events"]]})
async def execute(self):
assert self.context is not None
decision_at = datetime.fromisoformat(str(self._required("auto_fin_decision_at")))
previous = date.fromisoformat(str(self._required("auto_fin_previous_trade_date")))
window_start = datetime.combine(previous, time(15))
news = await self._current_news(window_start, decision_at)
self.logger.info(
f"[{self.name}] start window=({window_start.isoformat()},{decision_at.isoformat()}] news={len(news)} "
f"content_chars={sum(len(row['content']) for row in news)}",
)
resource_dir = self.workspace_path / "resource" / decision_at.date().isoformat()
news_path = resource_dir / "filtered_news.jsonl"
etf_path = resource_dir / "filtered_etf.jsonl"
etfs = await self._filtered_etfs(previous)
_write_jsonl(news_path, news)
_write_jsonl(etf_path, etfs)
if self.context.get("auto_fin_skipped"):
return self.context.response
from .data import AutoFinDataStep # Avoid a module import cycle.
day = str(self._required("auto_fin_date"))
news_path = self.workspace_path / str(self.config_value("daily_dir")) / day / "auto_fin_news.md"
news = AutoFinDataStep.read_news(news_path)
names = dict(self._required("auto_fin_etf_names"))
output, output_path = await self._reply(
"topic_user",
"auto_fin_topic",
AutoFinEtfsOutput,
output_suffix=".jsonl",
jsonl_field="etfs",
window_start=window_start.isoformat(),
decision_at=decision_at.isoformat(),
filtered_news_path=str(news_path),
filtered_etf_path=str(etf_path),
news=json.dumps(news, ensure_ascii=False),
etfs=json.dumps(
[{"etf_code": code, "etf_name": name} for code, name in names.items()],
ensure_ascii=False,
),
)
output, repairs = self._repair_news_ids(output, news)
output, normalized = self._normalize_selection(output, news, etfs)
if repairs:
self.logger.warning(f"[{self.name}] repaired mistyped news IDs: {repairs}")
if normalized:
self.logger.info(f"[{self.name}] normalized ETF selections")
if repairs or normalized:
_write_jsonl(output_path, output.model_dump(mode="json")["etfs"])
self.context["auto_fin_window_start"] = window_start.isoformat()
self.context["auto_fin_etfs"] = output.model_dump(mode="json")["etfs"]
self.context["auto_fin_etfs_resource"] = str(output_path)
self.context["auto_fin_filtered_news"] = str(news_path)
self.context.response.metadata.update({"news_count": len(news), "etf_count": len(output.etfs)})
self.logger.info(
f"[{self.name}] done etfs={len(output.etfs)} "
f"events={sum(len(item.events) for item in output.etfs)} news_path={news_path} etf_path={etf_path}",
normalized = self._normalize(output, news, names, int(self._value("current_news_limit_per_etf", 10)))
if normalized != output:
self._write_output(output_path, normalized)
self.context["auto_fin_news"] = news
self.context["auto_fin_etfs"] = normalized.model_dump(mode="json")["etfs"]
self.context.response.metadata.update(
{"news_count": len(news), "etf_count": len(normalized.etfs)},
)
return self.context.response

View file

@ -1,32 +1,10 @@
topic_user: |
你只负责筛选与当前新闻直接相关的代表性 ETF并说明每条相关新闻与 ETF 的关系。
不搜索或补充新闻、不下载数据、不计算收益、不提供预测或投资建议。
你是 ETF 新闻关联分析 Agent。上下文已完整提供不得搜索、调用工具或补充外部信息。
输入文件:
- 新闻(时间窗口为 ({window_start}, {decision_at}]{filtered_news_path}
- 候选 ETF{filtered_etf_path}
固定 ETF{etfs}
今日 00:00 至当前时间的财联社新闻:{news}
输出要求:
1. 从候选 ETF 中选择与新闻有明确传导关系的代表性 ETF不要为了凑数纳入弱相关 ETF。
2. 为每只 ETF 返回相关新闻的 news_id并用 reason 简洁说明直接传导关系。
3. 返回以下 JSON
```json
{{
"etfs": [
{{
"etf_code": "518880.SH",
"etf_name": "华安易富黄金ETF",
"events": [
{{
"reason": "避险需求直接影响黄金价格",
"news_id": "20260724070000_4d19"
}},
{{
"reason": "美元变化影响黄金计价",
"news_id": "20260724072145_6fa5"
}}
]
}}
]
}}
```
只保留与 ETF 所代表资产、行业或主题真实且直接相关的新闻。弱相关、仅提及公司名称、无法说明
明确传导关系的新闻不要输出;没有相关新闻的 ETF 不要输出。etf_code、etf_name 和 news_id 必须逐字
复制输入,不得猜测或缩写。
按结构化输出契约返回 ETF 列表,以及每只 ETF 对应的 news_id 和 reason。

View file

@ -1,7 +1,9 @@
"""Shared state and file helpers for daily-paper steps."""
import datetime as dt
import os
import re
from collections.abc import Iterator
from pathlib import Path
from typing import Any, TypeVar
from uuid import uuid4
@ -10,13 +12,16 @@ import aiofiles
import frontmatter
from pydantic import BaseModel
from ....components.outbound_proxy import BaseOutboundProxy
from ....enumeration import ComponentEnum
from ...base_step import BaseStep, Ref
from ...file_io import get_path_lock
from ...base_step import BaseStep
from ...file_io import get_path_lock, validate_filename_component
# 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)
_MARKDOWN_HEADING_PATTERN = re.compile(r"^#+\s*")
_UNSAFE_FILENAME_CHARS = re.compile(r'[<>:"/\\|?*\x00-\x1f]')
_CHINESE_PATTERN = re.compile(r"[\u3400-\u4dbf\u4e00-\u9fff]")
_OutputT = TypeVar("_OutputT", bound=BaseModel)
@ -31,6 +36,56 @@ def strip_frontmatter(body: str) -> str:
return _FRONTMATTER_PATTERN.sub("", body.strip(), count=1).strip()
def normalize_chinese_title(raw: str, fallback: str) -> str:
"""Return one safe Chinese title that can also be used as the filename stem."""
title = _MARKDOWN_HEADING_PATTERN.sub("", str(raw or "").strip())
if title.lower().endswith(".md"):
title = title[:-3]
title = _UNSAFE_FILENAME_CHARS.sub("-", title)
title = re.sub(r"\s+", " ", title).strip(" .-")
if not title or not _CHINESE_PATTERN.search(title):
title = fallback
title = _UNSAFE_FILENAME_CHARS.sub("-", title).strip(" .-")
if error := validate_filename_component(title, kind="title"):
raise ValueError(f"Unable to produce a safe daily-paper title from {raw!r}: {error}")
return title
def utc_now_iso() -> str:
"""Return the current UTC time as an ISO-8601 string for note metadata."""
return dt.datetime.now(dt.timezone.utc).isoformat()
def iter_note_metadata(day_dir: Path) -> Iterator[tuple[Path, dict[str, Any]]]:
"""Yield ``(path, frontmatter metadata)`` for each readable Markdown note in a day."""
if not day_dir.is_dir():
return
for path in sorted(day_dir.glob("*.md")):
try:
yield path, frontmatter.load(path).metadata
except (OSError, UnicodeError, ValueError):
continue
def resolve_unique_note_path(
day_dir: Path,
title: str,
*,
taken: set[str],
taken_suffix: str,
disk_suffix: str,
existing: Path | None,
) -> tuple[str, Path]:
"""Disambiguate a note title against already-used titles and on-disk collisions."""
if title in taken:
title = f"{title}{taken_suffix}"
path = day_dir / f"{title}.md"
if path.exists() and path != existing:
title = f"{title}{disk_suffix}"
path = day_dir / f"{title}.md"
return title, path
async def write_atomic(path: Path, content: str | bytes) -> None:
"""Write through a sibling temporary file under the repository path lock."""
path.parent.mkdir(parents=True, exist_ok=True)
@ -56,12 +111,6 @@ async def write_markdown(path: Path, body: str, metadata: dict[str, Any]) -> Non
class DailyPaperStep(BaseStep):
"""Shared helpers for steps in one daily-paper RuntimeContext."""
outbound_proxy: BaseOutboundProxy | None = Ref(
BaseOutboundProxy,
ComponentEnum.OUTBOUND_PROXY,
optional=True,
)
def _skip(self) -> bool:
assert self.context is not None
return bool(self.context.get(f"{_STATE_PREFIX}skip", False))

View file

@ -1,26 +1,41 @@
"""Download and analyze selected daily-paper PDFs."""
import asyncio
import datetime as dt
import json
from pathlib import Path
from ....components import R
from ....schema import PaperInfo, PaperNoteOutput, PaperSelection, SelectedPaper
from ....schema import AnalyzedPaper, DailyPaperMarkdownOutput, PaperInfo, PaperPick
from ....utils.arxiv import ArxivPdfClient
from ._common import DailyPaperStep, strip_frontmatter, structured_output, write_markdown
from ._common import (
PAPER_COUNT,
DailyPaperStep,
iter_note_metadata,
normalize_chinese_title,
resolve_unique_note_path,
strip_frontmatter,
structured_output,
utc_now_iso,
write_markdown,
)
@R.register("daily_paper_analyze_step")
class DailyPaperAnalyzeStep(DailyPaperStep):
"""Download each selected PDF and use Claude Code for detailed reading."""
"""Download and analyze the three papers selected for the daily brief."""
@staticmethod
def _extract_pdf_text_sync(path: Path, max_pages: int, max_chars: int) -> tuple[str, int, bool]:
def _extract_pdf_text_sync(
path: Path,
max_pages: int,
max_chars: int,
) -> tuple[str, int, bool]:
try:
from pypdf import PdfReader
except ImportError as exc: # pragma: no cover - dependency error has an explicit message
raise RuntimeError("pypdf is required for the daily-paper workflow") from exc
raise RuntimeError(
"pypdf is required for the daily-paper workflow",
) from exc
reader = PdfReader(str(path))
chunks: list[str] = []
@ -41,33 +56,43 @@ class DailyPaperAnalyzeStep(DailyPaperStep):
raise ValueError(f"No extractable text found in PDF: {path.name}")
return content, len(reader.pages), truncated
@staticmethod
def _find_existing_note(day_dir: Path, arxiv_id: str) -> Path | None:
"""Find a prior generated note independently of its title filename."""
for path, metadata in iter_note_metadata(day_dir):
if metadata.get("arxiv_id") == arxiv_id and (
metadata.get("kind") == "daily-paper-analysis" or path.name == f"paper-{arxiv_id}.md"
):
return path
return None
async def _analyze_one(
self,
downloader: ArxivPdfClient,
paper: PaperInfo,
selected: SelectedPaper,
) -> tuple[str, str]:
selected: PaperPick,
used_titles: set[str],
) -> AnalyzedPaper:
if self.agent_wrapper is None:
raise RuntimeError("Claude Code agent_wrapper is required for paper analysis")
raise RuntimeError("An agent_wrapper is required for paper analysis")
day = self._run_day()
daily_dir, resource_dir = (
str(self.config_value("daily_dir")).strip("/"),
str(self.config_value("resource_dir")).strip("/"),
)
pdf_rel, note_rel = (
f"{resource_dir}/papers/{paper.arxiv_id}.pdf",
f"{daily_dir}/{day}/paper-{paper.arxiv_id}.md",
)
pdf_path, note_path = self.workspace_path / pdf_rel, self.workspace_path / note_rel
pdf_rel = f"{resource_dir}/papers/{paper.arxiv_id}.pdf"
pdf_path = self.workspace_path / pdf_rel
self.logger.info(f"[{self.name}] paper start arxiv_id={paper.arxiv_id}")
await downloader.download(paper.arxiv_id, pdf_path)
self.logger.info(f"[{self.name}] pdf ready arxiv_id={paper.arxiv_id} path={pdf_rel}")
self.logger.info(
f"[{self.name}] pdf ready arxiv_id={paper.arxiv_id} path={pdf_rel}",
)
pdf_text, page_count, truncated = await asyncio.to_thread(
self._extract_pdf_text_sync,
pdf_path,
int(self._value("max_pdf_pages", 80)),
int(self._value("max_pdf_chars", 240_000)),
int(self._value("max_pdf_pages", 20)),
int(self._value("max_pdf_chars", 300_000)),
)
self.logger.info(
f"[{self.name}] pdf extracted arxiv_id={paper.arxiv_id} pages={page_count} "
@ -78,27 +103,42 @@ class DailyPaperAnalyzeStep(DailyPaperStep):
self.prompt_format(
"analyze_user",
paper_info=json.dumps(paper.model_dump(), ensure_ascii=False, indent=2),
selection_reason=selected.reason,
memory_relevance=selected.memory_relevance,
selection_reason=selected.reasoning,
page_count=page_count,
truncated=str(truncated).lower(),
pdf_text=pdf_text,
),
output_schema=PaperNoteOutput,
output_schema=DailyPaperMarkdownOutput,
)
self.logger.info(f"[{self.name}] agent done arxiv_id={paper.arxiv_id}")
output = structured_output(result, PaperNoteOutput)
output = structured_output(result, DailyPaperMarkdownOutput)
title = normalize_chinese_title(output.title, f"论文解读-{paper.arxiv_id}")
day_dir = self.workspace_path / daily_dir / day
existing_note = self._find_existing_note(day_dir, paper.arxiv_id)
suffix = f"{paper.arxiv_id}"
title, note_path = resolve_unique_note_path(
day_dir,
title,
taken=used_titles,
taken_suffix=suffix,
disk_suffix=suffix,
existing=existing_note,
)
used_titles.add(title)
note_rel = note_path.relative_to(self.workspace_path).as_posix()
body = strip_frontmatter(output.body)
if not output.description.strip() or not body:
raise ValueError(f"Claude Code returned an empty paper note for {paper.arxiv_id}")
if not output.desc.strip() or not body:
raise ValueError(f"Agent returned an empty paper note for {paper.arxiv_id}")
await write_markdown(
note_path,
body,
{
"name": f"paper-{paper.arxiv_id}",
"description": output.description.strip(),
"name": title,
"title": title,
"description": output.desc.strip(),
"kind": "daily-paper-analysis",
"arxiv_id": paper.arxiv_id,
"title": paper.title,
"source_title": paper.title,
"authors": paper.authors,
"hf_url": paper.hf_url,
"arxiv_url": paper.arxiv_url,
@ -108,40 +148,55 @@ class DailyPaperAnalyzeStep(DailyPaperStep):
"monthly_rank": paper.monthly_rank,
"weekly_rank": paper.weekly_rank,
"fused_score": round(paper.fused_score, 8),
"selection_reason": selected.reason,
"memory_relevance": selected.memory_relevance,
"generated_at": dt.datetime.now(dt.timezone.utc).isoformat(),
"selection_reasoning": selected.reasoning,
"generated_at": utc_now_iso(),
"pdf_pages": page_count,
"pdf_text_truncated": truncated,
},
)
self.logger.info(f"[{self.name}] paper done arxiv_id={paper.arxiv_id} note_path={note_rel}")
return note_rel, pdf_rel
if existing_note is not None and existing_note != note_path:
existing_note.unlink()
self.logger.info(
f"[{self.name}] paper done arxiv_id={paper.arxiv_id} note_path={note_rel}",
)
return AnalyzedPaper(
arxiv_id=paper.arxiv_id,
reasoning=selected.reasoning,
title=title,
desc=output.desc.strip(),
body=body,
note_path=note_rel,
pdf_path=pdf_rel,
)
async def execute(self):
assert self.context is not None
if self._skip():
self.logger.info(f"[{self.name}] skip existing digest")
return self.context.response
selection: PaperSelection | None = self._state("selection")
papers: list[PaperInfo] = self._state("selected_papers") or []
if selection is None or len(selection.selected) != len(papers):
selected: list[PaperPick] = self._state("selected") or []
candidates: list[PaperInfo] = self._state("candidates") or []
candidate_map = {paper.arxiv_id: paper for paper in candidates}
if len(selected) != PAPER_COUNT or any(item.arxiv_id not in candidate_map for item in selected):
raise RuntimeError("Paper selection state is missing before analysis")
self.logger.info(f"[{self.name}] start papers={len(papers)}")
self.logger.info(f"[{self.name}] start papers={len(selected)}")
note_paths, pdf_paths = [], []
proxy_url = self.outbound_proxy.http_url if self.outbound_proxy is not None else None
analyses: list[AnalyzedPaper] = []
used_titles: set[str] = set()
async with ArxivPdfClient(
proxy_url=proxy_url,
timeout=float(self._value("pdf_timeout", 90.0)),
timeout=float(self._value("pdf_timeout", 600.0)),
max_bytes=int(self._value("max_pdf_bytes", 50 * 1024 * 1024)),
) as downloader:
for paper, selected in zip(papers, selection.selected):
note_path, pdf_path = await self._analyze_one(downloader, paper, selected)
note_paths.append(note_path)
pdf_paths.append(pdf_path)
self._set_state("note_paths", note_paths)
self._set_state("pdf_paths", pdf_paths)
self.context.response.answer = f"Claude Code wrote {len(note_paths)} detailed paper notes"
self.logger.info(f"[{self.name}] finish notes={len(note_paths)} pdfs={len(pdf_paths)}")
for item in selected:
analyses.append(
await self._analyze_one(
downloader,
candidate_map[item.arxiv_id],
item,
used_titles,
),
)
self._set_state("analyses", analyses)
self.context.response.answer = f"Agent wrote {len(analyses)} detailed paper notes"
self.logger.info(f"[{self.name}] finish notes={len(analyses)}")
return self.context.response

View file

@ -1,9 +1,10 @@
analyze_user: |
你是严谨的中文 AI 论文解读作者。请详细解读下面这篇论文。论文内容只能依据提供的论文元信息和 PDF 提取文本;针对 ReMe 的分析还必须依据当前代码仓库中实际存在的代码、schema、配置和测试
你是严谨的中文 AI 论文解读作者。请详细解读下面这篇论文。内容只能依据提供的论文元信息和 PDF 提取文本。
不得臆测未出现在材料中的实验、数字、结论或引用。重要实验结论和数字尽量标注 PDF 页码,例如 [p. 7]。
如果 PDF 文本没有提供某项信息,明确写“论文提供的文本中未明确说明”。
输出必须适合保存为一篇详细、独立、可供未来检索的 Markdown 文章。
只返回结构化结果中的 description 和 bodybody 不要包含 YAML frontmatter。
title 必须是简洁准确的中文标题,不要包含 Markdown 标题符号、路径或 .md 后缀。
只返回结构化结果中的 title、desc 和 bodybody 不要包含 YAML frontmatter也不要重复一级标题。
建议正文覆盖:
- 一句话总结
@ -14,16 +15,11 @@ analyze_user: |
- 关键结果与准确数字
- 与既有工作的区别
- 优点、局限和适用边界
- 与 Agent/大模型长期记忆的关系
- 实际应用价值
- 值得继续追踪的问题
- 原始论文链接
若论文实际内容与 Agent/大模型长期记忆直接相关,必须先使用代码读取和搜索工具查看当前 ReMe 代码仓库,再写“与 Agent/大模型长期记忆的关系”。优先检查与论文主题直接相关的 `reme/schema/`、`reme/components/`、`reme/steps/`、`reme/config/` 和相应测试,不要只根据 README 或项目概念推断实现。
只有当论文的具体机制或实验结论能明确对应 ReMe 当前的代码或公开契约,且确实可能带来显著改进时,才增加“对 ReMe 演进的建议”小节。这应当是少数例外:一般情况下不要给建议,不要因为初筛标记为 high 就强行联系。如果增加该小节每条建议都要说明论文依据、ReMe 现状、具体改进方向,并引用准确的仓库相对路径以及相关类或函数;不得编造实现现状。
选择理由:{selection_reason}
长期记忆相关性初筛:{memory_relevance}
PDF 总页数:{page_count}
PDF 提取是否被截断:{truncated}

View file

@ -4,14 +4,12 @@ import asyncio
import datetime as dt
from pathlib import Path
import frontmatter
from ....components import R
from ....schema import PaperInfo, PaperSelection
from ....schema import PaperInfo
from ....utils.arxiv import ARXIV_ID_PATTERN
from ....utils.huggingface_papers import HuggingFacePapersClient
from ...evolve import now
from ._common import DailyPaperStep
from ._common import DailyPaperStep, iter_note_metadata
@R.register("daily_paper_collect_step")
@ -44,7 +42,10 @@ class DailyPaperCollectStep(DailyPaperStep):
"""Read previously recommended paper ids from prior daily note frontmatter."""
if history_days <= 0:
return set()
earliest, root = run_date - dt.timedelta(days=history_days), workspace / daily_dir
earliest, root = (
run_date - dt.timedelta(days=history_days),
workspace / daily_dir,
)
if not root.is_dir():
return set()
@ -58,56 +59,19 @@ class DailyPaperCollectStep(DailyPaperStep):
continue
if not earliest <= note_date < run_date:
continue
for note_path in day_dir.glob("paper-*.md"):
try:
metadata = frontmatter.load(note_path).metadata
except (OSError, UnicodeError, ValueError):
continue
for _, metadata in iter_note_metadata(day_dir):
arxiv_id = str(metadata.get("arxiv_id") or "").strip()
if ARXIV_ID_PATTERN.fullmatch(arxiv_id):
found.add(arxiv_id)
return found
@staticmethod
def load_saved_selection(digest_path: Path) -> dict | None:
"""Rebuild the saved selection from the digest and paper-note frontmatter."""
try:
digest_metadata = frontmatter.load(digest_path).metadata
except (OSError, UnicodeError, ValueError):
return None
arxiv_ids = digest_metadata.get("arxiv_ids")
if not isinstance(arxiv_ids, list) or not arxiv_ids:
return None
selected = []
for rank, value in enumerate(arxiv_ids, start=1):
arxiv_id = str(value or "").strip()
if not ARXIV_ID_PATTERN.fullmatch(arxiv_id):
return None
try:
note_metadata = frontmatter.load(digest_path.parent / f"paper-{arxiv_id}.md").metadata
except (OSError, UnicodeError, ValueError):
return None
selected.append(
{
"arxiv_id": arxiv_id,
"rank": rank,
"reason": str(note_metadata.get("selection_reason") or "").strip(),
"memory_relevance": note_metadata.get("memory_relevance"),
},
)
try:
selection = PaperSelection.model_validate(
{
"selection_reasoning": str(digest_metadata.get("selection_reasoning") or "").strip(),
"selected": selected,
"alternates": digest_metadata.get("alternate_arxiv_ids") or [],
},
)
except (TypeError, ValueError):
return None
return selection.model_dump()
def find_saved_digest(day_dir: Path) -> Path | None:
"""Find a generated brief by frontmatter instead of by its model-generated title."""
for path, metadata in iter_note_metadata(day_dir):
if metadata.get("kind") == "daily-paper-brief" or path.name == "daily-paper-brief.md":
return path
return None
@staticmethod
def _merge_paper(existing: PaperInfo | None, incoming: PaperInfo) -> PaperInfo:
@ -130,30 +94,30 @@ class DailyPaperCollectStep(DailyPaperStep):
self._set_state("run_date", day)
daily_dir = str(self.config_value("daily_dir")).strip("/")
digest_rel = f"{daily_dir}/{day}/daily-paper-brief.md"
force = bool(self._value("force", False))
self.logger.info(f"[{self.name}] start date={day} force={force}")
digest_path = self.workspace_path / digest_rel
if digest_path.is_file() and not force:
self._set_state("skip", True)
self._set_state("digest_path", digest_rel)
self.context.response.success = True
self.context.response.answer = f"Skipped: daily paper brief already exists at {digest_rel}"
self.context.response.metadata.update({"date": day, "digest_path": digest_rel, "skipped": True})
if selection := self.load_saved_selection(digest_path):
self.context.response.metadata["selection"] = selection
self.logger.info(f"[{self.name}] skip existing digest path={digest_rel}")
return self.context.response
digest_path = self.find_saved_digest(self.workspace_path / daily_dir / day)
if digest_path is not None:
digest_rel = digest_path.relative_to(self.workspace_path).as_posix()
self._set_state("existing_digest_path", digest_rel)
if not force:
self._set_state("skip", True)
self._set_state("digest_path", digest_rel)
self.context.response.success = True
self.context.response.answer = f"Skipped: daily paper brief already exists at {digest_rel}"
self.context.response.metadata.update(
{"date": day, "digest_path": digest_rel, "skipped": True},
)
self.logger.info(f"[{self.name}] skip existing digest path={digest_rel}")
return self.context.response
week, month = self._paper_scope_values(run_date)
yesterday = (run_date - dt.timedelta(days=1)).isoformat()
self.logger.info(
f"[{self.name}] fetch start week={week} month={month} yesterday={yesterday}",
)
proxy_url = self.outbound_proxy.http_url if self.outbound_proxy is not None else None
async with HuggingFacePapersClient(
proxy_url=proxy_url,
timeout=float(self._value("hf_timeout", 30.0)),
timeout=float(self._value("hf_timeout", 600.0)),
max_retries=int(self._value("hf_max_retries", 3)),
) as client:
weekly, monthly, yesterday_ids = await asyncio.gather(
@ -167,10 +131,16 @@ class DailyPaperCollectStep(DailyPaperStep):
merged: dict[str, PaperInfo] = {}
for rank, paper in enumerate(monthly, start=1):
merged[paper.arxiv_id] = self._merge_paper(merged.get(paper.arxiv_id), paper)
merged[paper.arxiv_id] = self._merge_paper(
merged.get(paper.arxiv_id),
paper,
)
merged[paper.arxiv_id].monthly_rank = rank
for rank, paper in enumerate(weekly, start=1):
merged[paper.arxiv_id] = self._merge_paper(merged.get(paper.arxiv_id), paper)
merged[paper.arxiv_id] = self._merge_paper(
merged.get(paper.arxiv_id),
paper,
)
merged[paper.arxiv_id].weekly_rank = rank
historical_ids = self.load_historical_arxiv_ids(
@ -179,13 +149,16 @@ class DailyPaperCollectStep(DailyPaperStep):
int(self._value("history_days", 30)),
daily_dir,
)
eligible = {key: paper for key, paper in merged.items() if key not in yesterday_ids | historical_ids}
excluded_ids = yesterday_ids | historical_ids
eligible = {key: paper for key, paper in merged.items() if key not in excluded_ids}
self.logger.info(
f"[{self.name}] filter done merged={len(merged)} excluded_yesterday={len(yesterday_ids)} "
f"excluded_history={len(historical_ids)} eligible={len(eligible)}",
)
if not eligible:
raise RuntimeError("No eligible papers remain after yesterday and history exclusions")
raise RuntimeError(
"No eligible papers remain after yesterday and history exclusions",
)
for key, value in {
"info": eligible,
@ -194,7 +167,11 @@ class DailyPaperCollectStep(DailyPaperStep):
"yesterday": yesterday,
"excluded_yesterday": sorted(yesterday_ids),
"excluded_history": sorted(historical_ids),
"source_counts": {"weekly": len(weekly), "monthly": len(monthly), "merged": len(merged)},
"source_counts": {
"weekly": len(weekly),
"monthly": len(monthly),
"merged": len(merged),
},
}.items():
self._set_state(key, value)
self.context.response.success = True

View file

@ -1,18 +1,26 @@
"""Build the final daily-paper brief from detailed notes."""
import datetime as dt
import json
from types import SimpleNamespace
from ....components import R
from ....schema import DailyBriefOutput, PaperSelection
from ....schema import AnalyzedPaper, DailyPaperMarkdownOutput
from ...file_io import refresh_day_index
from ._common import DailyPaperStep, strip_frontmatter, structured_output, write_markdown
from ._common import (
PAPER_COUNT,
DailyPaperStep,
normalize_chinese_title,
resolve_unique_note_path,
strip_frontmatter,
structured_output,
utc_now_iso,
write_markdown,
)
@R.register("daily_paper_digest_step")
class DailyPaperDigestStep(DailyPaperStep):
"""Use Claude Code to read the detailed notes and create the final brief."""
"""Use an agent to read the detailed notes and create the final brief."""
async def execute(self):
assert self.context is not None
@ -20,56 +28,73 @@ class DailyPaperDigestStep(DailyPaperStep):
self.logger.info(f"[{self.name}] skip existing digest")
return self.context.response
if self.agent_wrapper is None:
raise RuntimeError("Claude Code agent_wrapper is required for the daily brief")
note_paths: list[str] = self._state("note_paths") or []
selection: PaperSelection | None = self._state("selection")
if selection is None or not note_paths:
raise RuntimeError("Detailed paper notes are missing before digest generation")
self.logger.info(f"[{self.name}] start notes={len(note_paths)}")
raise RuntimeError("An agent_wrapper is required for the daily brief")
analyses: list[AnalyzedPaper] = self._state("analyses") or []
if len(analyses) != PAPER_COUNT:
raise RuntimeError(
"Detailed paper notes are missing before digest generation",
)
self.logger.info(f"[{self.name}] start notes={len(analyses)}")
absolute_paths = [str((self.workspace_path / path).resolve()) for path in note_paths]
wikilinks = [f"[[{path}]]" for path in note_paths]
self.logger.info(f"[{self.name}] agent start notes={len(note_paths)}")
documents = [{"title": item.title, "desc": item.desc, "body": item.body} for item in analyses]
wikilinks = [f"[[{item.note_path}]]" for item in analyses]
self.logger.info(f"[{self.name}] agent start notes={len(analyses)}")
result = await self.agent_wrapper.reply(
self.prompt_format(
"digest_user",
top_k=len(note_paths),
note_paths=json.dumps(absolute_paths, ensure_ascii=False, indent=2),
wikilinks=json.dumps(wikilinks, ensure_ascii=False, indent=2),
documents=json.dumps(documents, ensure_ascii=False, indent=2),
),
output_schema=DailyBriefOutput,
output_schema=DailyPaperMarkdownOutput,
)
self.logger.info(f"[{self.name}] agent done notes={len(note_paths)}")
output = structured_output(result, DailyBriefOutput)
self.logger.info(f"[{self.name}] agent done notes={len(analyses)}")
output = structured_output(result, DailyPaperMarkdownOutput)
body = strip_frontmatter(output.body)
if not output.description.strip() or not body:
raise ValueError("Claude Code returned an empty daily paper brief")
missing_links = [link for link in wikilinks if link not in body]
if missing_links:
body += "\n\n## 详细文章\n\n" + "\n".join(f"- {link}" for link in missing_links)
if not output.desc.strip() or not body:
raise ValueError("Agent returned an empty daily paper brief")
body += "\n\n## 详细论文\n\n" + "\n".join(f"- {link}" for link in wikilinks)
day = self._run_day()
daily_dir = str(self.config_value("daily_dir")).strip("/")
digest_rel = f"{daily_dir}/{day}/daily-paper-brief.md"
selected_ids = [item.arxiv_id for item in selection.selected]
title = normalize_chinese_title(output.title, f"每日论文简报-{day}")
existing_rel = str(self._state("existing_digest_path") or "").strip()
existing_path = self.workspace_path / existing_rel if existing_rel else None
title, digest_path = resolve_unique_note_path(
self.workspace_path / daily_dir / day,
title,
taken={item.title for item in analyses},
taken_suffix="(每日简报)",
disk_suffix=f"{day}",
existing=existing_path,
)
digest_rel = digest_path.relative_to(self.workspace_path).as_posix()
selected_ids = [item.arxiv_id for item in analyses]
await write_markdown(
self.workspace_path / digest_rel,
digest_path,
body,
{
"name": "daily-paper-brief",
"description": output.description.strip(),
"name": title,
"title": title,
"description": output.desc.strip(),
"kind": "daily-paper-brief",
"date": day,
"arxiv_ids": selected_ids,
"selection_reasoning": selection.selection_reasoning,
"alternate_arxiv_ids": selection.alternates,
"selection_reasoning": [item.reasoning for item in analyses],
"source_notes": wikilinks,
"generated_at": dt.datetime.now(dt.timezone.utc).isoformat(),
"generated_at": utc_now_iso(),
},
)
if existing_path is not None and existing_path != digest_path:
existing_path.unlink()
self._set_state("digest_path", digest_rel)
self.logger.info(f"[{self.name}] digest written path={digest_rel}")
self.logger.info(f"[{self.name}] refresh index start date={day} daily_dir={daily_dir}")
await refresh_day_index(SimpleNamespace(workspace_path=self.workspace_path), day, daily_dir)
self.logger.info(
f"[{self.name}] refresh index start date={day} daily_dir={daily_dir}",
)
await refresh_day_index(
SimpleNamespace(workspace_path=self.workspace_path),
day,
daily_dir,
)
self.logger.info(f"[{self.name}] refresh index done date={day}")
self.context.response.success = True
@ -79,13 +104,15 @@ class DailyPaperDigestStep(DailyPaperStep):
"date": day,
"week": self._state("week"),
"month": self._state("month"),
"selection_reasoning": selection.selection_reasoning,
"selection_reasoning": [item.reasoning for item in analyses],
"selected_arxiv_ids": selected_ids,
"note_paths": note_paths,
"pdf_paths": self._state("pdf_paths"),
"note_paths": [item.note_path for item in analyses],
"pdf_paths": [item.pdf_path for item in analyses],
"digest_path": digest_rel,
"source_counts": self._state("source_counts"),
"excluded_yesterday_count": len(self._state("excluded_yesterday") or []),
"excluded_yesterday_count": len(
self._state("excluded_yesterday") or [],
),
"excluded_history_count": len(self._state("excluded_history") or []),
},
)

View file

@ -1,19 +1,14 @@
digest_user: |
你是中文 AI 研究资讯主编。请依次调用 Read 阅读下面 {top_k} 个绝对路径,忠实阅读指定的详细论文 Markdown生成一篇普通读者五分钟可以读懂的每日论文速读。
不得只根据文件名或标题写作,必须调用 Read 读取每一个指定文件。不要读取或修改其他文件
保留技术准确性,同时解释三篇论文为什么值得关注、它们之间有什么联系,以及哪些内容与大模型长期记忆有关
你是中文 AI 研究资讯主编。请忠实综合下面三篇详细论文解读,生成一篇普通读者五分钟可以读懂的每日论文速读。
内容只能依据输入文档,不得补充文档中没有提供的事实
保留技术准确性,同时解释三篇论文为什么值得关注,以及它们之间有什么联系
# 必须读取的 Markdown 路径
# 三篇论文解读
{note_paths}
# 必须原样出现在正文中的 wikilink
{wikilinks}
{documents}
输出要求:
- 只返回结构化结果中的 description 和 bodybody 不要包含 YAML frontmatter。
- title 必须是简洁准确的中文标题,不要包含 Markdown 标题符号、路径或 .md 后缀。
- 只返回结构化结果中的 title、desc 和 bodybody 不要包含 YAML frontmatter也不要重复一级标题。
- 总阅读时长约五分钟。
- 包含“今日一句话”“每篇一分钟读懂”“三篇论文之间的联系”“今天最值得关注什么”等部分。
- 每篇介绍必须附上对应的完整 wikilink不能缩写或改写链接。
- 对长期记忆相关论文,明确解释它对 Agent 记忆系统设计的启发。

View file

@ -4,26 +4,6 @@ from ....components import R
from ....schema import PaperInfo
from ._common import DailyPaperStep
_MEMORY_KEYWORDS = (
"long-term memory",
"long term memory",
"lifelong memory",
"agent memory",
"episodic memory",
"memory consolidation",
"memory retrieval",
"self-evolving memory",
"continual learning",
"context compression",
"personalization",
"knowledge graph",
"retrieval augmented",
"rag",
"长期记忆",
"记忆整合",
"记忆检索",
)
def rrf_score(
monthly_rank: int | None,
@ -40,30 +20,12 @@ def rrf_score(
return monthly_score + weekly_score
def memory_keyword_score(paper: PaperInfo) -> int:
"""Return a lightweight recall score used to reserve memory-related candidates."""
text = f"{paper.title}\n{paper.summary}".lower()
return sum(keyword in text for keyword in _MEMORY_KEYWORDS)
def build_candidate_pool(papers: list[PaperInfo], *, limit: int = 20, memory_reserve: int = 5) -> list[PaperInfo]:
"""Keep strong general papers while reserving room for memory-related work."""
def build_candidate_pool(papers: list[PaperInfo], *, limit: int = 20) -> list[PaperInfo]:
"""Return the highest-ranked papers without applying a topic preference."""
if limit <= 0:
raise ValueError("candidate_limit must be positive")
ranked = sorted(papers, key=lambda item: (-item.fused_score, -item.upvotes, item.arxiv_id))
reserve = min(max(memory_reserve, 0), limit)
selected = ranked[: max(0, limit - reserve)]
selected_ids = {paper.arxiv_id for paper in selected}
memory_candidates = [
paper for paper in ranked if paper.arxiv_id not in selected_ids and memory_keyword_score(paper)
]
memory_candidates.sort(
key=lambda item: (-memory_keyword_score(item), -item.fused_score, -item.upvotes, item.arxiv_id),
)
selected.extend(memory_candidates[:reserve])
selected_ids = {paper.arxiv_id for paper in selected}
selected.extend(paper for paper in ranked if paper.arxiv_id not in selected_ids and len(selected) < limit)
return selected[:limit]
return ranked[:limit]
@R.register("daily_paper_rank_step")
@ -78,10 +40,9 @@ class DailyPaperRankStep(DailyPaperStep):
papers_by_id: dict[str, PaperInfo] = self._state("info") or {}
rrf_k, weekly_weight = int(self._value("rrf_k", 60)), float(self._value("weekly_weight", 0.7))
candidate_limit = int(self._value("candidate_limit", 20))
memory_reserve = int(self._value("memory_reserve", 5))
self.logger.info(
f"[{self.name}] start papers={len(papers_by_id)} rrf_k={rrf_k} weekly_weight={weekly_weight} "
f"candidate_limit={candidate_limit} memory_reserve={memory_reserve}",
f"candidate_limit={candidate_limit}",
)
for paper in papers_by_id.values():
paper.fused_score = rrf_score(
@ -93,7 +54,6 @@ class DailyPaperRankStep(DailyPaperStep):
candidates = build_candidate_pool(
list(papers_by_id.values()),
limit=candidate_limit,
memory_reserve=memory_reserve,
)
if not candidates:
raise RuntimeError("RRF produced no paper candidates")

View file

@ -1,32 +1,40 @@
"""Select final daily papers with Claude Code."""
"""Select final daily papers with an agent."""
import json
from ....components import R
from ....schema import PaperInfo, PaperSelection
from ._common import DailyPaperStep, structured_output
from .rank import memory_keyword_score
from ....schema import PaperInfo, PaperPick, PaperPickList
from ._common import PAPER_COUNT, DailyPaperStep, structured_output
_MAX_SELECT_ATTEMPTS = 2
@R.register("daily_paper_select_step")
class DailyPaperSelectStep(DailyPaperStep):
"""Use Claude Code to select the final papers."""
"""Use an agent to select the final papers."""
@staticmethod
def _validate_selection(selection: PaperSelection, candidates: list[PaperInfo], top_k: int) -> PaperSelection:
def _validate_selection(
output: PaperPickList,
candidates: list[PaperInfo],
) -> list[PaperPick]:
"""Validate exactly three unique, in-pool selections."""
candidate_ids = {paper.arxiv_id for paper in candidates}
ordered = sorted(selection.selected, key=lambda item: item.rank)
selected_ids = [item.arxiv_id for item in ordered]
if len(ordered) != top_k:
raise ValueError(f"Agent selected {len(ordered)} papers; expected {top_k}")
if len(set(selected_ids)) != top_k or any(key not in candidate_ids for key in selected_ids):
raise ValueError("Agent selection contains duplicate or out-of-pool ids")
if [item.rank for item in ordered] != list(range(1, top_k + 1)):
raise ValueError("Agent selection ranks must be consecutive starting at 1")
alternates = [
key for key in dict.fromkeys(selection.alternates) if key in candidate_ids and key not in selected_ids
if len(output.papers) != PAPER_COUNT:
raise ValueError(
f"Agent selected {len(output.papers)} papers; expected {PAPER_COUNT}",
)
selected = [
PaperPick(arxiv_id=item.arxiv_id.strip(), reasoning=item.reasoning.strip()) for item in output.papers
]
return selection.model_copy(update={"selected": ordered, "alternates": alternates})
selected_ids = [item.arxiv_id for item in selected]
if len(set(selected_ids)) != PAPER_COUNT:
raise ValueError("Agent selection contains duplicate arxiv_ids")
if any(arxiv_id not in candidate_ids for arxiv_id in selected_ids):
raise ValueError("Agent returned an arxiv_id outside the candidate pool")
if any(not item.reasoning for item in selected):
raise ValueError("Agent selection reasoning cannot be empty")
return selected
async def execute(self):
assert self.context is not None
@ -34,60 +42,73 @@ class DailyPaperSelectStep(DailyPaperStep):
self.logger.info(f"[{self.name}] skip existing digest")
return self.context.response
if self.agent_wrapper is None:
raise RuntimeError("Claude Code agent_wrapper is required for paper selection")
raise RuntimeError("An agent_wrapper is required for paper selection")
candidates: list[PaperInfo] = self._state("candidates") or []
top_k = int(self._value("top_k", 3))
if top_k <= 0 or top_k > len(candidates):
raise ValueError(f"top_k must be between 1 and {len(candidates)}")
self.logger.info(f"[{self.name}] start candidates={len(candidates)} top_k={top_k}")
topics = str(self._value("topics", "") or "").strip()
selection_preference = (
f"用户明确感兴趣的主题:{topics}\n仅将这些 topics 作为主题偏好。"
if topics
else (
"用户未提供明确的 topic 倾向。优先选择 fused_score 更高的论文;"
"只有在研究价值、新颖性、影响或可读性明显更强时才偏离分数排序,"
"并在 reasoning 中具体说明相对高分候选的优势。"
)
)
if len(candidates) < PAPER_COUNT:
raise ValueError(
f"At least {PAPER_COUNT} paper candidates are required for selection",
)
self.logger.info(f"[{self.name}] start candidates={len(candidates)}")
candidate_payload = [
{
"arxiv_id": paper.arxiv_id,
"title": paper.title,
"summary": paper.summary,
"authors": paper.authors,
"organization": paper.organization,
"upvotes": paper.upvotes,
"monthly_rank": paper.monthly_rank,
"weekly_rank": paper.weekly_rank,
"fused_score": round(paper.fused_score, 8),
"github_repo": paper.github_repo,
"github_stars": paper.github_stars,
"memory_keyword_score": memory_keyword_score(paper),
}
for paper in candidates
]
feedback, selection = "", None
for attempt in range(1, 3):
self.logger.info(f"[{self.name}] agent start attempt={attempt}/2 candidates={len(candidates)}")
feedback, selected = "", None
for attempt in range(1, _MAX_SELECT_ATTEMPTS + 1):
self.logger.info(
f"[{self.name}] agent start attempt={attempt}/{_MAX_SELECT_ATTEMPTS} candidates={len(candidates)}",
)
result = await self.agent_wrapper.reply(
self.prompt_format(
"select_user",
top_k=top_k,
candidates=json.dumps(candidate_payload, ensure_ascii=False, indent=2),
candidates=json.dumps(
candidate_payload,
ensure_ascii=False,
indent=2,
),
retry_feedback=feedback or "(none)",
selection_preference=selection_preference,
),
output_schema=PaperSelection,
output_schema=PaperPickList,
)
try:
selection = self._validate_selection(structured_output(result, PaperSelection), candidates, top_k)
self.logger.info(f"[{self.name}] agent done attempt={attempt}/2 valid=True")
selected = self._validate_selection(
structured_output(result, PaperPickList),
candidates,
)
self.logger.info(
f"[{self.name}] agent done attempt={attempt}/{_MAX_SELECT_ATTEMPTS} valid=True",
)
break
except (ValueError, TypeError) as exc:
feedback = str(exc)
self.logger.warning(
f"[{self.name}] agent done attempt={attempt}/2 valid=False error={feedback!r}",
f"[{self.name}] agent done attempt={attempt}/{_MAX_SELECT_ATTEMPTS} valid=False error={feedback!r}",
)
if selection is None:
raise RuntimeError(f"Claude Code paper selection failed validation: {feedback}")
if selected is None:
raise RuntimeError(f"Agent paper selection failed validation: {feedback}")
candidate_map = {paper.arxiv_id: paper for paper in candidates}
selected_papers = [candidate_map[item.arxiv_id] for item in selection.selected]
self._set_state("selection", selection)
self._set_state("selected_papers", selected_papers)
self.context.response.answer = f"Selected {top_k} papers with Claude Code"
self.logger.info(
f"[{self.name}] finish selected={','.join(item.arxiv_id for item in selection.selected)}",
)
self._set_state("selected", selected)
selected_ids = [item.arxiv_id for item in selected]
self.context.response.answer = f"Selected {PAPER_COUNT} papers with an agent"
self.logger.info(f"[{self.name}] finish selected={','.join(selected_ids)}")
return self.context.response

View file

@ -1,18 +1,11 @@
select_user: |
你是 AI 研究论文编辑。请从以下候选中选择恰好 {top_k} 篇最值得深入阅读的论文,并另外给出至多 3 个候补 ID。
选择应兼顾研究价值、技术新颖性、潜在影响和可读性。融合分与榜单排名是重要依据,但不是唯一依据。
与大模型 Agent 长期记忆、记忆检索、记忆整合、持续学习、个性化和上下文管理直接相关的高质量论文应优先考虑。
只能选择输入候选集合中的 arXiv ID不得编造论文或事实。输出简洁、可核验的选择理由。
从候选中选择恰好 3 篇最值得深入阅读的 AI 论文。兼顾研究价值、新颖性、影响和可读性。
{selection_preference}
要求:
1. selected 的 rank 必须从 1 到 {top_k} 连续排列。
2. arxiv_id 必须逐字复制候选数据中的值,不能重复。
3. reason 说明具体选择依据,不要只复述标题。
4. memory_relevance 评价论文与大模型/Agent 长期记忆的相关程度。
5. selection_reasoning 是面向读者的简短决策摘要,不要输出隐含的逐步思维过程。
每个 arxiv_id 必须逐字来自候选且不能重复reasoning 为对应论文写一句具体、可核验的选择理由。
不要输出逐步思维过程。只返回 {{"papers":[{{"arxiv_id":"...","reasoning":"..."}}]}} 结构,
papers 必须恰好包含 3 项。
上一次校验反馈:{retry_feedback}
# 候选论文
{candidates}

View file

@ -39,6 +39,8 @@ class DingTalkWaitStep(BaseStep):
app_secret: str = "",
robot_code: str = "",
worker_count: int = 4,
builtin_tools: list[str] | str | bool = False,
job_tools: list[str] | None = None,
**kwargs,
):
super().__init__(**kwargs)
@ -46,6 +48,8 @@ class DingTalkWaitStep(BaseStep):
self.app_secret = app_secret
self.robot_code = robot_code
self.worker_count = max(1, worker_count)
self.builtin_tools = builtin_tools
self.job_tools = list(job_tools or [])
async def execute(self):
assert self.context is not None
@ -142,7 +146,12 @@ class DingTalkWaitStep(BaseStep):
"""Wait for the final Agent response and send one DingTalk Markdown reply."""
started_at = time.monotonic()
try:
result = await self.agent_wrapper.reply(text, **kwargs)
result = await self.agent_wrapper.reply(
text,
**kwargs,
builtin_tools=self.builtin_tools,
job_tools=self.job_tools,
)
if not isinstance(result, dict):
raise TypeError("Agent reply must be a dictionary")
if session_id := result.get("session_id"):

View file

@ -12,6 +12,7 @@ import httpx
from .logger_utils import get_logger
ARXIV_ID_PATTERN = re.compile(r"^\d{4}\.\d{4,5}$")
ARXIV_BASE_URL = "https://arxiv.org"
class ArxivPdfClient:
@ -20,15 +21,12 @@ class ArxivPdfClient:
def __init__(
self,
*,
proxy_url: str | None = None,
client: httpx.AsyncClient | None = None,
timeout: float = 90.0,
timeout: float = 600.0,
max_bytes: int = 50 * 1024 * 1024,
logger: Any | None = None,
) -> None:
if client is not None and proxy_url is not None:
raise ValueError("client and proxy_url cannot be provided together")
self.proxy_url = proxy_url
self.base_url = os.getenv("ARXIV_MIRROR_URL", "").strip().rstrip("/") or ARXIV_BASE_URL
self.client = client
self._owns_client = client is None
self.timeout, self.max_bytes = timeout, max_bytes
@ -37,14 +35,11 @@ class ArxivPdfClient:
async def __aenter__(self) -> "ArxivPdfClient":
if self.client is None:
self.client = httpx.AsyncClient(
proxy=self.proxy_url,
trust_env=False,
timeout=self.timeout,
follow_redirects=True,
headers={"User-Agent": "ReMe arXiv client"},
)
mode = "outbound_proxy" if self.proxy_url else "direct"
self.logger.info(f"[ArxivPdfClient] network mode={mode}")
self.logger.info(f"[ArxivPdfClient] base_url={self.base_url}")
else:
self.logger.debug("[ArxivPdfClient] network mode=injected_client")
return self
@ -79,7 +74,7 @@ class ArxivPdfClient:
f"[ArxivPdfClient] download start arxiv_id={arxiv_id} path={target} timeout={self.timeout:g}s",
)
try:
async with client.stream("GET", f"https://arxiv.org/pdf/{arxiv_id}") as response:
async with client.stream("GET", f"{self.base_url}/pdf/{arxiv_id}") as response:
response.raise_for_status()
content_length = int(response.headers.get("content-length") or 0)
if content_length and content_length > self.max_bytes:

View file

@ -1,6 +1,7 @@
"""Client and response normalization for Hugging Face Papers."""
import asyncio
import os
import re
from collections.abc import Iterable
from typing import Any
@ -67,17 +68,14 @@ class HuggingFacePapersClient:
def __init__(
self,
*,
proxy_url: str | None = None,
client: httpx.AsyncClient | None = None,
timeout: float = 30.0,
timeout: float = 600.0,
max_retries: int = 3,
detail_concurrency: int = 5,
logger: Any | None = None,
) -> None:
if client is not None and proxy_url is not None:
raise ValueError("client and proxy_url cannot be provided together")
self.logger = logger or get_logger()
self.proxy_url = proxy_url
self.base_url = os.getenv("HF_MIRROR_URL", "").strip().rstrip("/") or HF_BASE_URL
self._owns_client = client is None
self._timeout = timeout
self.client = client
@ -87,15 +85,12 @@ class HuggingFacePapersClient:
async def __aenter__(self) -> "HuggingFacePapersClient":
if self.client is None:
self.client = httpx.AsyncClient(
base_url=HF_BASE_URL,
proxy=self.proxy_url,
trust_env=False,
base_url=self.base_url,
timeout=self._timeout,
follow_redirects=True,
headers={"User-Agent": "ReMe daily-paper cookbook"},
)
mode = "outbound_proxy" if self.proxy_url else "direct"
self.logger.info(f"[HuggingFacePapersClient] network mode={mode}")
self.logger.info(f"[HuggingFacePapersClient] base_url={self.base_url}")
else:
self.logger.debug("[HuggingFacePapersClient] network mode=injected_client")
return self
@ -118,7 +113,10 @@ class HuggingFacePapersClient:
f"[HuggingFacePapersClient] request start path={path} params={params} "
f"attempt={attempt + 1}/{self.max_retries}",
)
response = await self._require_client().get(path, params=params)
# Keep an optional path prefix in HF_MIRROR_URL. httpx treats a
# leading slash as host-relative and would otherwise discard a
# prefix such as ``/hf`` from the configured base URL.
response = await self._require_client().get(path.lstrip("/"), params=params)
response.raise_for_status()
self.logger.debug(
f"[HuggingFacePapersClient] request done path={path} status={response.status_code} "

View file

@ -1,65 +1,17 @@
"""Scoped TuShare client construction with optional explicit proxy routing."""
"""TuShare client construction with an optional mirror endpoint."""
import json
from functools import partial
from typing import Any
import os
class _ProxiedTushareApi:
"""TuShare DataApi-compatible adapter using one explicit HTTP proxy."""
def __init__(self, api: Any, token: str, proxy_url: str) -> None:
http_url = getattr(api, "_DataApi__http_url", None)
if not isinstance(http_url, str) or not http_url:
raise RuntimeError(
"Unsupported tushare SDK: DataApi HTTP endpoint is unavailable",
)
self._http_url = http_url.rstrip("/")
self._timeout = getattr(api, "_DataApi__timeout", 30)
self._token = token
self._proxy_url = proxy_url
def query(self, api_name: str, fields: str = "", **kwargs):
"""Query one TuShare endpoint through the configured proxy."""
import pandas as pd
import requests
params = dict(kwargs)
params.setdefault("ts_type_name", self._http_url)
request = {
"api_name": api_name,
"token": self._token,
"params": params,
"fields": fields,
}
with requests.Session() as session:
session.trust_env = False
response = session.post(
f"{self._http_url}/{api_name}",
json=request,
timeout=self._timeout,
proxies={"http": self._proxy_url, "https": self._proxy_url},
)
if not response:
return pd.DataFrame()
result = json.loads(response.text)
if result["code"] != 0:
raise RuntimeError(result["msg"])
data = result["data"]
return pd.DataFrame(data["items"], columns=data["fields"])
def __getattr__(self, name: str):
return partial(self.query, name)
def create_tushare_api(token: str, *, proxy_url: str | None = None):
"""Create a TuShare DataApi, optionally pinned to one explicit proxy."""
def create_tushare_api(token: str):
"""Create a TuShare DataApi, using TUSHARE_MIRROR_URL when configured."""
try:
import tushare as ts
except ImportError as exc: # pragma: no cover - optional core dependency.
raise RuntimeError("tushare is required for market-data research") from exc
api = ts.pro_api(token)
if proxy_url is None:
return api
return _ProxiedTushareApi(api, token, proxy_url)
api._DataApi__timeout = 600 # pylint: disable=protected-access
if mirror_url := os.getenv("TUSHARE_MIRROR_URL", "").strip():
api._DataApi__http_url = mirror_url.rstrip("/") # pylint: disable=protected-access
return api

344
scripts/upstream_mirror_proxy.py Executable file
View file

@ -0,0 +1,344 @@
#!/usr/bin/env python3
"""Expose fixed upstream HTTP(S) services through path-prefixed mirror URLs.
The proxy is intentionally not a general-purpose forward proxy: callers can
only reach upstreams configured by the operator. It uses only the Python
standard library so it can run on a small relay host without installing extra
packages.
Examples:
python3 scripts/upstream_mirror_proxy.py
python3 scripts/upstream_mirror_proxy.py --bind 0.0.0.0 --allow 192.0.2.0/24
python3 scripts/upstream_mirror_proxy.py --route pypi=https://pypi.org
With the default routes, configure clients with base URLs such as:
HF_MIRROR_URL=http://relay-host:18080/hf
ARXIV_MIRROR_URL=http://relay-host:18080/arxiv
"""
from __future__ import annotations
import argparse
import ipaddress
import logging
import signal
import ssl
import sys
import threading
import time
import urllib.error
import urllib.request
from collections.abc import Iterable
from dataclasses import dataclass
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from urllib.parse import urlsplit
DEFAULT_ROUTES = {
"hf": "https://huggingface.co",
"arxiv": "https://arxiv.org",
}
DEFAULT_BIND = "127.0.0.1"
DEFAULT_PORT = 18080
DEFAULT_TIMEOUT = 600.0
DEFAULT_MAX_CONCURRENCY = 16
BUFFER_SIZE = 64 * 1024
REQUEST_HEADERS = {
"accept",
"accept-encoding",
"accept-language",
"cache-control",
"if-match",
"if-modified-since",
"if-none-match",
"if-range",
"if-unmodified-since",
"range",
"user-agent",
}
RESPONSE_HEADERS_TO_SKIP = {
"connection",
"keep-alive",
"proxy-authenticate",
"proxy-authorization",
"te",
"trailer",
"transfer-encoding",
"upgrade",
}
@dataclass(frozen=True)
class ProxyConfig:
"""Immutable configuration shared by request handler threads."""
routes: dict[str, str]
allowed_clients: tuple[ipaddress.IPv4Network | ipaddress.IPv6Network, ...]
timeout: float
slots: threading.BoundedSemaphore
def parse_route(value: str) -> tuple[str, str]:
"""Parse and validate one NAME=URL route."""
if "=" not in value:
raise argparse.ArgumentTypeError("route must have the form NAME=URL")
name, upstream = value.split("=", maxsplit=1)
name, upstream = name.strip().strip("/"), upstream.strip().rstrip("/")
if not name or any(char not in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-" for char in name):
raise argparse.ArgumentTypeError("route name may contain only letters, numbers, '_' and '-'")
parsed = urlsplit(upstream)
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
raise argparse.ArgumentTypeError("route upstream must be an absolute HTTP(S) URL")
if parsed.username or parsed.password or parsed.query or parsed.fragment:
raise argparse.ArgumentTypeError("route upstream must not contain credentials, a query, or a fragment")
return name, upstream
def parse_network(value: str) -> ipaddress.IPv4Network | ipaddress.IPv6Network:
"""Parse one allowed address or CIDR."""
try:
return ipaddress.ip_network(value, strict=False)
except ValueError as exc:
raise argparse.ArgumentTypeError(str(exc)) from exc
def client_is_allowed(
address: str,
networks: Iterable[ipaddress.IPv4Network | ipaddress.IPv6Network],
) -> bool:
"""Return whether an address belongs to any configured network."""
try:
client = ipaddress.ip_address(address)
except ValueError:
return False
return any(client in network for network in networks)
class MirrorProxyHandler(BaseHTTPRequestHandler):
"""Proxy GET and HEAD requests to a fixed, path-selected upstream."""
protocol_version = "HTTP/1.0"
server_version = "ReMeMirrorProxy/1.0"
@property
def config(self) -> ProxyConfig:
"""Return the server-wide immutable proxy configuration."""
return self.server.proxy_config # type: ignore[attr-defined,no-any-return]
def do_GET(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
"""Proxy a GET request."""
self._handle_request(send_body=True)
def do_HEAD(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
"""Proxy a HEAD request."""
self._handle_request(send_body=False)
def do_POST(self) -> None: # noqa: N802 - BaseHTTPRequestHandler API
"""Reject unsupported methods."""
self.send_error(HTTPStatus.METHOD_NOT_ALLOWED, "Only GET and HEAD are supported")
do_PUT = do_POST
do_PATCH = do_POST
do_DELETE = do_POST
do_CONNECT = do_POST
def _handle_request(self, *, send_body: bool) -> None:
started = time.monotonic()
if not client_is_allowed(self.client_address[0], self.config.allowed_clients):
logging.warning("rejected client address=%s path=%s", self.client_address[0], self.path)
self.send_error(HTTPStatus.FORBIDDEN, "Client address is not allowed")
return
parsed = urlsplit(self.path)
if parsed.path == "/healthz":
self._send_text(HTTPStatus.OK, "ok\n", send_body=send_body)
return
if parsed.path == "/routes":
body = "".join(f"/{name} -> {upstream}\n" for name, upstream in sorted(self.config.routes.items()))
self._send_text(HTTPStatus.OK, body, send_body=send_body)
return
route_name, separator, suffix = parsed.path.lstrip("/").partition("/")
upstream = self.config.routes.get(route_name)
if not separator or upstream is None:
self.send_error(HTTPStatus.NOT_FOUND, "Unknown mirror route")
return
target = f"{upstream}/{suffix.lstrip('/')}"
if parsed.query:
target = f"{target}?{parsed.query}"
if not self.config.slots.acquire(blocking=False):
self.send_error(HTTPStatus.SERVICE_UNAVAILABLE, "Proxy concurrency limit reached")
return
try:
self._proxy(target, send_body=send_body, started=started)
finally:
self.config.slots.release()
def _proxy(self, target: str, *, send_body: bool, started: float) -> None:
headers = {name: value for name, value in self.headers.items() if name.lower() in REQUEST_HEADERS}
headers.setdefault("User-Agent", "ReMe mirror proxy")
request = urllib.request.Request(target, headers=headers, method=self.command)
response = None
try:
# HTTPError is also a readable response and is handled by the same
# cleanup block below, so a single with statement is not suitable.
# pylint: disable=consider-using-with
response = urllib.request.urlopen( # noqa: S310 - targets are operator-configured
request,
timeout=self.config.timeout,
context=ssl.create_default_context(),
)
except urllib.error.HTTPError as exc:
response = exc
except (urllib.error.URLError, TimeoutError, OSError) as exc:
logging.error("upstream request failed target=%s error=%s", target, exc)
self.send_error(
HTTPStatus.BAD_GATEWAY,
f"Upstream request failed: {exc.reason if isinstance(exc, urllib.error.URLError) else exc}",
)
return
try:
self.send_response(response.status)
for name, value in response.headers.items():
if name.lower() not in RESPONSE_HEADERS_TO_SKIP:
self.send_header(name, value)
self.send_header("Connection", "close")
self.end_headers()
transferred = 0
if send_body:
while chunk := response.read(BUFFER_SIZE):
self.wfile.write(chunk)
transferred += len(chunk)
elapsed = time.monotonic() - started
logging.info(
"%s %s -> %s status=%s bytes=%s elapsed=%.3fs",
self.client_address[0],
self.path,
target,
response.status,
transferred,
elapsed,
)
except (BrokenPipeError, ConnectionResetError):
logging.warning("client disconnected path=%s target=%s", self.path, target)
finally:
response.close()
def _send_text(self, status: HTTPStatus, body: str, *, send_body: bool) -> None:
encoded = body.encode()
self.send_response(status)
self.send_header("Content-Type", "text/plain; charset=utf-8")
self.send_header("Content-Length", str(len(encoded)))
self.send_header("Connection", "close")
self.end_headers()
if send_body:
self.wfile.write(encoded)
def log_message(self, format_string: str, *args: object) -> None:
"""Send the built-in access message to debug logging."""
logging.debug("%s - %s", self.client_address[0], format_string % args)
class MirrorProxyServer(ThreadingHTTPServer):
"""Threading server carrying immutable proxy configuration."""
daemon_threads = True
allow_reuse_address = True
def __init__(self, address: tuple[str, int], config: ProxyConfig):
self.proxy_config = config
super().__init__(address, MirrorProxyHandler)
def build_parser() -> argparse.ArgumentParser:
"""Build the command-line parser."""
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--bind", default=DEFAULT_BIND, help=f"listen address (default: {DEFAULT_BIND})")
parser.add_argument(
"--port",
type=int,
default=DEFAULT_PORT,
help=f"listen port (default: {DEFAULT_PORT})",
)
parser.add_argument(
"--route",
action="append",
default=[],
type=parse_route,
metavar="NAME=URL",
help="add or replace a route; may be repeated",
)
parser.add_argument(
"--allow",
action="append",
default=[],
type=parse_network,
metavar="IP_OR_CIDR",
help="allow a client address/network in addition to loopback; may be repeated",
)
parser.add_argument(
"--timeout",
type=float,
default=DEFAULT_TIMEOUT,
help="upstream timeout in seconds",
)
parser.add_argument(
"--max-concurrency",
type=int,
default=DEFAULT_MAX_CONCURRENCY,
help=f"maximum simultaneous upstream requests (default: {DEFAULT_MAX_CONCURRENCY})",
)
parser.add_argument("--verbose", action="store_true", help="enable debug logging")
return parser
def main() -> int:
"""Run the mirror proxy until SIGINT or SIGTERM."""
args = build_parser().parse_args()
if not 1 <= args.port <= 65535:
raise SystemExit("--port must be between 1 and 65535")
if args.timeout <= 0:
raise SystemExit("--timeout must be positive")
if args.max_concurrency < 1:
raise SystemExit("--max-concurrency must be at least 1")
routes = dict(DEFAULT_ROUTES)
routes.update(args.route)
loopback_networks = [parse_network("127.0.0.0/8"), parse_network("::1/128")]
config = ProxyConfig(
routes=routes,
allowed_clients=tuple(loopback_networks + args.allow),
timeout=args.timeout,
slots=threading.BoundedSemaphore(args.max_concurrency),
)
logging.basicConfig(
level=logging.DEBUG if args.verbose else logging.INFO,
format="%(asctime)s | %(levelname)s | %(message)s",
)
server = MirrorProxyServer((args.bind, args.port), config)
for signum in (signal.SIGINT, signal.SIGTERM):
signal.signal(
signum,
lambda _signum, _frame: threading.Thread(target=server.shutdown).start(),
)
logging.info("listening on http://%s:%s routes=%s", args.bind, args.port, sorted(routes))
logging.info("allowed clients=%s", [str(network) for network in config.allowed_clients])
try:
server.serve_forever()
finally:
server.server_close()
logging.info("stopped")
return 0
if __name__ == "__main__":
sys.exit(main())

File diff suppressed because it is too large Load diff

View file

@ -14,10 +14,9 @@ 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.schema import AnalyzedPaper, DailyPaperMarkdownOutput, PaperInfo, PaperPick, PaperPickList
from reme.steps.cookbook.daily_paper import (
DailyPaperAnalyzeStep,
DailyPaperCollectStep,
@ -32,7 +31,6 @@ 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):
@ -86,8 +84,8 @@ def test_hf_payload_and_html_normalization():
@pytest.mark.asyncio
async def test_hf_client_uses_explicit_outbound_proxy(monkeypatch):
"""The owned HTTP client uses only the explicitly supplied HTTP proxy."""
async def test_hf_client_uses_configured_mirror(monkeypatch):
"""The owned HTTP client uses HF_MIRROR_URL as its source."""
events: list[str] = []
client_kwargs: dict = {}
logger = MagicMock()
@ -103,20 +101,47 @@ async def test_hf_client_uses_explicit_outbound_proxy(monkeypatch):
events.append("client-close")
monkeypatch.setattr(hf_utils.httpx, "AsyncClient", FakeAsyncClient)
monkeypatch.setenv("HF_MIRROR_URL", "https://hf-mirror.com/")
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 client_kwargs["base_url"] == "https://hf-mirror.com"
assert "trust_env" not in client_kwargs
assert "proxy" not in client_kwargs
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"]
assert info_messages == ["[HuggingFacePapersClient] base_url=https://hf-mirror.com"]
@pytest.mark.asyncio
async def test_hf_client_preserves_mirror_path_prefix(monkeypatch):
"""Relative requests retain a path-prefixed HF_MIRROR_URL."""
requests: list[httpx.Request] = []
async def handler(request: httpx.Request) -> httpx.Response:
requests.append(request)
return httpx.Response(200, json=[])
transport = httpx.MockTransport(handler)
async_client = httpx.AsyncClient
monkeypatch.setattr(
hf_utils.httpx,
"AsyncClient",
lambda **kwargs: async_client(transport=transport, **kwargs),
)
monkeypatch.setenv("HF_MIRROR_URL", "http://relay.example:18080/hf/")
async with hf_utils.HuggingFacePapersClient() as client:
assert await client.fetch_daily_ids("2026-07-22") == set()
assert [str(request.url) for request in requests] == [
"http://relay.example:18080/hf/api/daily_papers?date=2026-07-22&limit=100",
]
@pytest.mark.asyncio
@ -154,17 +179,17 @@ async def test_hf_client_logs_retry_after_http_error(monkeypatch):
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)
def test_rrf_candidate_pool_has_no_topic_preference():
"""The candidate pool follows RRF without reserving slots for a topic."""
higher = _paper("2607.10001", title="General model", upvotes=100)
lower = _paper("2607.10002", title="Long-term memory for agents", upvotes=1)
higher.fused_score = rrf_score(1, None, rrf_k=60, weekly_weight=0.7)
lower.fused_score = rrf_score(100, None, rrf_k=60, weekly_weight=0.7)
candidates = build_candidate_pool([general, memory], limit=2, memory_reserve=1)
candidates = build_candidate_pool([lower, higher], limit=1)
assert candidates == [general, memory]
assert general.fused_score == pytest.approx(1 / 61)
assert candidates == [higher]
assert higher.fused_score == pytest.approx(1 / 61)
def test_history_exclusion_reads_prior_frontmatter_only(tmp_path: Path):
@ -216,7 +241,9 @@ async def test_arxiv_pdf_downloads_missing_cache_once(tmp_path: Path, monkeypatc
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"]
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)
@ -224,8 +251,8 @@ async def test_arxiv_pdf_downloads_missing_cache_once(tmp_path: Path, monkeypatc
@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."""
async def test_arxiv_pdf_uses_configured_mirror(tmp_path: Path, monkeypatch):
"""The owned arXiv client downloads from ARXIV_MIRROR_URL."""
events: list[str] = []
client_kwargs: dict = {}
logger = MagicMock()
@ -264,56 +291,51 @@ async def test_arxiv_pdf_uses_explicit_outbound_proxy(tmp_path: Path, monkeypatc
def stream(self, method, url):
"""Return the fake streaming response context."""
assert (method, url) == ("GET", "https://arxiv.org/pdf/2607.10001")
assert (method, url) == ("GET", "https://export.arxiv.org/pdf/2607.10001")
return FakeStream()
monkeypatch.setattr(arxiv_utils.httpx, "AsyncClient", FakeAsyncClient)
monkeypatch.setenv("ARXIV_MIRROR_URL", "https://export.arxiv.org/")
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 "trust_env" not in client_kwargs
assert "proxy" not in client_kwargs
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"
assert info_messages[0] == "[ArxivPdfClient] base_url=https://export.arxiv.org"
@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."""
"""Requests require context entry and injected clients remain caller-owned."""
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")
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")
async def test_daily_paper_steps_construct_source_clients_without_proxy(
tmp_path: Path,
monkeypatch,
):
"""Collection and analysis construct direct source clients."""
papers = [_paper(f"2607.1000{index}", title=f"Managed proxy paper {index}") for index in range(1, 4)]
hf_kwargs: list[dict] = []
arxiv_kwargs: list[dict] = []
@ -331,7 +353,7 @@ async def test_daily_paper_steps_forward_one_managed_proxy_endpoint(tmp_path: Pa
async def fetch_scope(self, _scope: str, _value: str):
"""Return one ranked paper."""
return [paper]
return papers
async def fetch_daily_ids(self, _day: str):
"""Return no yesterday exclusions."""
@ -363,44 +385,27 @@ async def test_daily_paper_steps_forward_one_managed_proxy_endpoint(tmp_path: Pa
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",
"title": f"代理论文解读{index}",
"desc": "Detailed note",
"body": "# Detailed reading\n\nEvidence [p. 1].",
},
}
for index in range(1, 4)
],
)
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()
await DailyPaperCollectStep(app_context=app_context)(context)
context["daily_paper_selected"] = [PaperPick(arxiv_id=paper.arxiv_id, reasoning="Relevant") for paper in papers]
context["daily_paper_candidates"] = papers
await DailyPaperAnalyzeStep(app_context=app_context, agent_wrapper=agent)(context)
assert hf_kwargs[0]["proxy_url"] == "http://127.0.0.1:43124"
assert "proxy_url" not in hf_kwargs[0]
assert len(arxiv_kwargs) == 1
assert arxiv_kwargs[0]["proxy_url"] == "http://127.0.0.1:43124"
assert "proxy_url" not in arxiv_kwargs[0]
def test_daily_paper_config_passes_dingtalk_environment(monkeypatch):
@ -424,6 +429,97 @@ def test_daily_paper_config_passes_dingtalk_environment(monkeypatch):
}
def test_daily_paper_uses_agentscope_without_tools():
"""Daily Paper uses the shared tool-free agent."""
config = _load_config("daily_cookbook")
wrapper = config["components"]["agent_wrapper"]["default"]
assert wrapper == {
"backend": "agentscope",
"as_llm": "default",
"builtin_tools": False,
}
assert "agent_wrapper" not in config["jobs"]["daily_paper"]["steps"][2]
def test_daily_paper_topics_parameter_defaults_to_empty():
"""Topics are an optional selection preference in the public job schema."""
topics = _load_config("daily_cookbook")["jobs"]["daily_paper"]["parameters"]["properties"]["topics"]
assert topics == {
"type": "string",
"description": "Optional topics to prioritize when selecting papers.",
"default": "",
}
def test_paper_pick_list_uses_an_object_root_for_tool_output():
"""AgentScope function arguments require an object-root JSON schema."""
schema = PaperPickList.model_json_schema()
assert schema["type"] == "object"
assert schema["required"] == ["papers"]
assert schema["properties"]["papers"]["type"] == "array"
def test_daily_paper_selects_three_papers_and_bounds_pdf_context():
"""The public job has no paper-count option and bounds extracted PDF text."""
job = _load_config("daily_cookbook")["jobs"]["daily_paper"]
assert "top_k" not in job
assert "top_k" not in job["parameters"]["properties"]
assert job["max_pdf_pages"] == 20
assert job["max_pdf_chars"] == 300_000
@pytest.mark.asyncio
async def test_selection_retries_invalid_id_and_keeps_three_candidates(tmp_path: Path):
"""Selection retries an out-of-pool id and stores three validated candidate ids."""
candidates = [
_paper("2607.10001", title="First paper"),
_paper("2607.10002", title="Second paper"),
_paper("2607.10003", title="Third paper"),
]
agent = _QueuedAgentWrapper(
[
{
"papers": [
{"arxiv_id": "not-a-candidate", "reasoning": "Invalid"},
{"arxiv_id": "2607.10002", "reasoning": "Second"},
{"arxiv_id": "2607.10003", "reasoning": "Third"},
],
},
{
"papers": [
{"arxiv_id": " 2607.10001 ", "reasoning": "First"},
{"arxiv_id": "2607.10002", "reasoning": "Second"},
{"arxiv_id": "2607.10003", "reasoning": "Third"},
],
},
],
)
context = RuntimeContext(topics="context engineering")
context["daily_paper_candidates"] = candidates
response = await DailyPaperSelectStep(
app_context=ApplicationContext(workspace_dir=str(tmp_path)),
agent_wrapper=agent,
)(context)
selected = context["daily_paper_selected"]
assert [item.arxiv_id for item in selected] == [
"2607.10001",
"2607.10002",
"2607.10003",
]
assert [item.reasoning for item in selected] == ["First", "Second", "Third"]
assert response.answer == "Selected 3 papers with an agent"
assert len(agent.calls) == 2
assert "outside the candidate pool" in agent.calls[1]["inputs"]
assert "用户明确感兴趣的主题context engineering" in agent.calls[1]["inputs"]
assert "仅将这些 topics 作为主题偏好" in agent.calls[1]["inputs"]
def test_reme_import_does_not_require_optional_dingtalk_stream():
"""Importing ReMe must not eagerly load the core-only DingTalk dependency."""
script = """
@ -461,6 +557,8 @@ async def test_pipeline_filters_strict_yesterday_and_writes_outputs(
"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),
"2607.10004": _paper("2607.10004", title="Second eligible paper", upvotes=70),
"2607.10005": _paper("2607.10005", title="Third eligible paper", upvotes=60),
}
prior_dir = tmp_path / "daily" / "2026-07-19"
@ -488,7 +586,12 @@ async def test_pipeline_filters_strict_yesterday_and_writes_outputs(
assert value == "2026-07"
return list(papers.values())
assert value == "2026-W30"
return [papers["2607.10001"], papers["2607.10002"]]
return [
papers["2607.10001"],
papers["2607.10002"],
papers["2607.10004"],
papers["2607.10005"],
]
async def fetch_daily_ids(self, day: str):
"""Record and return the exact requested day."""
@ -501,35 +604,49 @@ async def test_pipeline_filters_strict_yesterday_and_writes_outputs(
target.write_bytes(b"%PDF-fake")
return target
def fake_extract(_self, _path: Path, _max_pages: int, _max_chars: int):
extraction_limits: list[tuple[int, int]] = []
def fake_extract(_self, _path: Path, max_pages: int, max_chars: int):
"""Return deterministic extracted text."""
extraction_limits.append((max_pages, max_chars))
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)
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",
},
"papers": [
{"arxiv_id": "2607.10001", "reasoning": "Strong result"},
{"arxiv_id": "2607.10004", "reasoning": "Useful method"},
{"arxiv_id": "2607.10005", "reasoning": "Clear evidence"},
],
"alternates": [],
},
{
"description": "Detailed note",
"body": "# Detailed reading\n\nEvidence [p. 1].",
"title": "记忆代理研究",
"desc": "Detailed note one",
"body": "Evidence one [p. 1].",
},
{
"description": "Five-minute brief",
"body": "# 今日论文速读\n\n[[daily/2026-07-21/paper-2607.10001.md]]",
"title": "上下文压缩研究",
"desc": "Detailed note two",
"body": "Evidence two [p. 1].",
},
{
"title": "持续学习研究",
"desc": "Detailed note three",
"body": "Evidence three [p. 1].",
},
{
"title": "今日智能体论文简报",
"desc": "Five-minute brief",
"body": "# 今日一句话\n\nSummary.",
},
],
)
@ -541,71 +658,132 @@ async def test_pipeline_filters_strict_yesterday_and_writes_outputs(
)
context = RuntimeContext(
date="2026-07-21",
top_k=1,
candidate_limit=2,
memory_reserve=0,
candidate_limit=3,
)
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)
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["selected_arxiv_ids"] == [
"2607.10001",
"2607.10004",
"2607.10005",
]
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_path = tmp_path / "daily" / "2026-07-21" / "记忆代理研究.md"
digest_path = tmp_path / "daily" / "2026-07-21" / "今日智能体论文简报.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(
assert note.metadata["title"] == "记忆代理研究"
assert extraction_limits == [(20, 300_000)] * 3
assert "[[daily/2026-07-21/记忆代理研究.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 digest.metadata["title"] == "今日智能体论文简报"
assert digest.metadata["selection_reasoning"] == [
"Strong result",
"Useful method",
"Clear evidence",
]
assert digest.metadata["arxiv_ids"] == ["2607.10001", "2607.10004", "2607.10005"]
assert cc_wrapper.calls[0]["kwargs"] == {"output_schema": PaperPickList}
assert "用户感兴趣的主题" not in cc_wrapper.calls[0]["inputs"]
assert "用户未提供明确的 topic 倾向" in cc_wrapper.calls[0]["inputs"]
assert "优先选择 fused_score 更高的论文" in cc_wrapper.calls[0]["inputs"]
assert "memory_keyword_score" not in cc_wrapper.calls[0]["inputs"]
assert "Agent 长期记忆" not in cc_wrapper.calls[0]["inputs"]
assert all(call["kwargs"] == {"output_schema": DailyPaperMarkdownOutput} for call in cc_wrapper.calls[1:])
assert [call["kwargs"]["output_schema"] for call in cc_wrapper.calls] == [
PaperSelection,
PaperNoteOutput,
DailyBriefOutput,
PaperPickList,
DailyPaperMarkdownOutput,
DailyPaperMarkdownOutput,
DailyPaperMarkdownOutput,
DailyPaperMarkdownOutput,
]
analysis_prompt = cc_wrapper.calls[1]["inputs"]
assert "长期记忆相关性初筛low" in analysis_prompt
assert "必须先使用代码读取和搜索工具查看当前 ReMe 代码仓库" in analysis_prompt
assert "这应当是少数例外:一般情况下不要给建议" in analysis_prompt
assert "长期记忆相关性初筛" not in analysis_prompt
assert "ReMe" not in analysis_prompt
assert "# PDF 分页文本" in analysis_prompt
digest_prompt = cc_wrapper.calls[-1]["inputs"]
assert "Evidence one [p. 1]." in digest_prompt
assert "调用 Read" not in digest_prompt
assert "daily/2026-07-21" not in digest_prompt
assert "长期记忆" not in digest_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 rerun.get("daily_paper_digest_path") == "daily/2026-07-21/今日智能体论文简报.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):
async def test_digest_force_migrates_old_fixed_filename_to_chinese_title(tmp_path: Path):
"""A forced regeneration replaces the old generated brief without leaving a stale copy."""
old_path = tmp_path / "daily" / "2026-07-21" / "daily-paper-brief.md"
old_path.parent.mkdir(parents=True)
old_path.write_text(
frontmatter.dumps(frontmatter.Post("old", kind="daily-paper-brief")),
encoding="utf-8",
)
analyses = [
AnalyzedPaper(
arxiv_id=f"2607.1000{index}",
reasoning=f"Reason {index}",
title=f"论文解读{index}",
desc=f"Description {index}",
body=f"Body {index}",
note_path=f"daily/2026-07-21/论文解读{index}.md",
pdf_path=f"resource/papers/2607.1000{index}.pdf",
)
for index in range(1, 4)
]
context = RuntimeContext()
context["daily_paper_run_date"] = "2026-07-21"
context["daily_paper_existing_digest_path"] = "daily/2026-07-21/daily-paper-brief.md"
context["daily_paper_analyses"] = analyses
agent = _QueuedAgentWrapper(
[{"title": "全新论文简报", "desc": "Digest", "body": "Digest body"}],
)
await DailyPaperDigestStep(
app_context=ApplicationContext(workspace_dir=str(tmp_path)),
agent_wrapper=agent,
)(context)
new_path = tmp_path / "daily" / "2026-07-21" / "全新论文简报.md"
assert new_path.is_file()
assert not old_path.exists()
assert context["daily_paper_digest_path"] == "daily/2026-07-21/全新论文简报.md"
@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")),
frontmatter.dumps(
frontmatter.Post("# 今日论文\n\n测试内容", name="daily-paper-brief"),
),
encoding="utf-8",
)
token_calls = 0
@ -622,7 +800,10 @@ async def test_dingtalk_markdown_sends_groups_serially_in_configured_order(tmp_p
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)}"})
return httpx.Response(
200,
json={"processQueryKey": f"query-{len(seen_payloads)}"},
)
transport = httpx.MockTransport(handler)
transport_kwargs: dict = {}
@ -632,7 +813,11 @@ async def test_dingtalk_markdown_sends_groups_serially_in_configured_order(tmp_p
return transport
dingtalk_stream = importlib.import_module("dingtalk_stream")
monkeypatch.setattr(dingtalk_stream.DingTalkStreamClient, "get_access_token", get_access_token)
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")
@ -650,7 +835,10 @@ async def test_dingtalk_markdown_sends_groups_serially_in_configured_order(tmp_p
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 [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] == [
@ -669,7 +857,9 @@ 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)
response = await DingTalkMarkdownSendStep(
app_context=ApplicationContext(workspace_dir=str(tmp_path)),
)(context)
assert response.success is True
assert response.metadata["dingtalk_configured_count"] == 0
@ -677,12 +867,17 @@ async def test_dingtalk_markdown_without_conversations_is_a_noop(tmp_path: Path)
@pytest.mark.asyncio
async def test_existing_daily_paper_is_reused_and_sent_to_dingtalk(tmp_path: Path, monkeypatch):
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")),
frontmatter.dumps(
frontmatter.Post("# 已有日报\n\n复用正文", name="daily-paper-brief"),
),
encoding="utf-8",
)
seen_payloads: list[dict] = []
@ -700,7 +895,11 @@ async def test_existing_daily_paper_is_reused_and_sent_to_dingtalk(tmp_path: Pat
transport = httpx.MockTransport(handler)
monkeypatch.setattr(dingtalk_send.httpx, "AsyncHTTPTransport", lambda **_kwargs: transport)
monkeypatch.setattr(
dingtalk_send.httpx,
"AsyncHTTPTransport",
lambda **_kwargs: transport,
)
app_context = ApplicationContext(workspace_dir=str(tmp_path))
context = RuntimeContext(date="2026-07-22")

View file

@ -130,7 +130,11 @@ async def test_final_reply_resumes_session_and_clear_only_removes_combined_key(
await step._handle_message(message, key, sessions, handler)
assert sessions == {key: "session-1"}
assert wrapper.reply_calls == [("hello", {}), ("hello", {"resume": "session-1"})]
tool_kwargs = {"builtin_tools": False, "job_tools": []}
assert wrapper.reply_calls == [
("hello", tool_kwargs),
("hello", {"resume": "session-1", **tool_kwargs}),
]
assert handler.markdown_replies == [("ReMe Agent", "回答"), ("ReMe Agent", "回答")]
await step._handle_message(_message(text="/compact"), key, sessions, handler)
@ -174,6 +178,31 @@ async def test_final_reply_rejects_empty_agent_reply_and_dingtalk_send_failure(
await step._handle_message(message, key, {}, handler)
@pytest.mark.asyncio
async def test_final_reply_injects_only_configured_tools(tmp_path):
app_context = ApplicationContext(workspace_dir=str(tmp_path))
wrapper = _AgentWrapper(app_context=app_context)
step = DingTalkWaitStep(
app_context=app_context,
agent_wrapper=wrapper,
builtin_tools=["bash"],
job_tools=["read", "write", "edit"],
)
message = _message()
await step._handle_message(message, _session_key(message), {}, _Handler())
assert wrapper.reply_calls == [
(
"hello",
{
"builtin_tools": ["bash"],
"job_tools": ["read", "write", "edit"],
},
),
]
def test_daily_cookbook_registers_one_step_background_wait_job(monkeypatch):
for name in ("DINGTALK_APP_KEY", "DINGTALK_APP_SECRET", "DINGTALK_ROBOT_CODE"):
monkeypatch.delenv(name, raising=False)
@ -183,25 +212,29 @@ def test_daily_cookbook_registers_one_step_background_wait_job(monkeypatch):
assert job["steps"] == [
{
"backend": "dingtalk_wait_step",
"agent_wrapper": "dingtalk_wait",
"app_key": "",
"app_secret": "",
"robot_code": "",
"worker_count": 4,
"builtin_tools": ["bash"],
"job_tools": [
"memory_search",
"read",
"write",
"edit",
"daily_list",
"daily_write",
"frontmatter_read",
"frontmatter_update",
],
},
]
dingtalk_wait = config["components"]["agent_wrapper"]["dingtalk_wait"]
assert dingtalk_wait["skills"] == ["tushare-data"]
assert dingtalk_wait["job_tools"] == ["memory_search"]
assert dingtalk_wait["system_prompt"] == {
"type": "preset",
"preset": "claude_code",
"append": (
"Daily-paper Markdown is stored under the ReMe workspace. Detailed notes, including historical notes, "
"are at daily/YYYY-MM-DD/paper-<arxiv-id>.md; daily briefs are at "
"daily/YYYY-MM-DD/daily-paper-brief.md. Use memory_search to retrieve relevant long-term notes "
"across dates."
),
assert config["components"]["agent_wrapper"] == {
"default": {
"backend": "agentscope",
"as_llm": "default",
"builtin_tools": False,
},
}
assert R.get(ComponentEnum.STEP, "dingtalk_wait_step") is DingTalkWaitStep

View file

@ -0,0 +1,21 @@
"""Focused tests for TuShare source selection."""
# pylint: disable=protected-access
import sys
from types import SimpleNamespace
from reme.utils.tushare import create_tushare_api
def test_tushare_uses_configured_mirror_or_sdk_default(monkeypatch):
"""A mirror overrides the SDK endpoint while an empty setting leaves it unchanged."""
api = SimpleNamespace(_DataApi__http_url="https://api.tushare.pro")
monkeypatch.setitem(sys.modules, "tushare", SimpleNamespace(pro_api=lambda _token: api))
monkeypatch.delenv("TUSHARE_MIRROR_URL", raising=False)
assert create_tushare_api("token")._DataApi__http_url == "https://api.tushare.pro"
assert api._DataApi__timeout == 600
monkeypatch.setenv("TUSHARE_MIRROR_URL", "http://112.124.63.173:4000/tushare/")
assert create_tushare_api("token")._DataApi__http_url == "http://112.124.63.173:4000/tushare"