mirror of
https://github.com/agentscope-ai/ReMe.git
synced 2026-08-28 05:25:04 +00:00
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>
202 lines
8 KiB
Python
202 lines
8 KiB
Python
"""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
|