ReMe/reme/steps/cookbook/daily_paper/analyze.py
jinliyl d5e0d2837b
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 (#432)
* 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>
2026-08-07 23:53:14 +08:00

202 lines
8 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Download and analyze selected daily-paper PDFs."""
import asyncio
import json
from pathlib import Path
from ....components import R
from ....schema import AnalyzedPaper, DailyPaperMarkdownOutput, PaperInfo, PaperPick
from ....utils.arxiv import ArxivPdfClient
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 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]:
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
reader = PdfReader(str(path))
chunks: list[str] = []
size = 0
page_count = min(len(reader.pages), max_pages)
truncated = len(reader.pages) > max_pages
for page_number, page in enumerate(reader.pages[:page_count], start=1):
block = f"\n\n--- PAGE {page_number} ---\n\n{(page.extract_text() or '').strip()}"
if size + len(block) > max_chars:
if (remaining := max_chars - size) > 0:
chunks.append(block[:remaining])
truncated = True
break
chunks.append(block)
size += len(block)
content = "".join(chunks).strip()
if not content:
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: PaperPick,
used_titles: set[str],
) -> AnalyzedPaper:
if self.agent_wrapper is None:
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 = 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}",
)
pdf_text, page_count, truncated = await asyncio.to_thread(
self._extract_pdf_text_sync,
pdf_path,
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} "
f"chars={len(pdf_text)} truncated={truncated}",
)
self.logger.info(f"[{self.name}] agent start arxiv_id={paper.arxiv_id}")
result = await self.agent_wrapper.reply(
self.prompt_format(
"analyze_user",
paper_info=json.dumps(paper.model_dump(), ensure_ascii=False, indent=2),
selection_reason=selected.reasoning,
page_count=page_count,
truncated=str(truncated).lower(),
pdf_text=pdf_text,
),
output_schema=DailyPaperMarkdownOutput,
)
self.logger.info(f"[{self.name}] agent done arxiv_id={paper.arxiv_id}")
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.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": title,
"title": title,
"description": output.desc.strip(),
"kind": "daily-paper-analysis",
"arxiv_id": paper.arxiv_id,
"source_title": paper.title,
"authors": paper.authors,
"hf_url": paper.hf_url,
"arxiv_url": paper.arxiv_url,
"download_url": paper.pdf_url,
"source_pdf": f"[[{pdf_rel}]]",
"published_at": paper.published_at,
"monthly_rank": paper.monthly_rank,
"weekly_rank": paper.weekly_rank,
"fused_score": round(paper.fused_score, 8),
"selection_reasoning": selected.reasoning,
"generated_at": utc_now_iso(),
"pdf_pages": page_count,
"pdf_text_truncated": truncated,
},
)
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
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(selected)}")
analyses: list[AnalyzedPaper] = []
used_titles: set[str] = set()
async with ArxivPdfClient(
timeout=float(self._value("pdf_timeout", 600.0)),
max_bytes=int(self._value("max_pdf_bytes", 50 * 1024 * 1024)),
) as downloader:
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