ReMe/reme/utils/arxiv.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

107 lines
4.3 KiB
Python

"""arXiv validation and PDF download helpers."""
import os
import re
from pathlib import Path
from typing import Any
from uuid import uuid4
import aiofiles
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:
"""Download validated arXiv PDFs to local files."""
def __init__(
self,
*,
client: httpx.AsyncClient | None = None,
timeout: float = 600.0,
max_bytes: int = 50 * 1024 * 1024,
logger: Any | None = None,
) -> None:
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
self.logger = logger or get_logger()
async def __aenter__(self) -> "ArxivPdfClient":
if self.client is None:
self.client = httpx.AsyncClient(
timeout=self.timeout,
follow_redirects=True,
headers={"User-Agent": "ReMe arXiv client"},
)
self.logger.info(f"[ArxivPdfClient] base_url={self.base_url}")
else:
self.logger.debug("[ArxivPdfClient] network mode=injected_client")
return self
async def __aexit__(self, _exc_type, _exc_value, _traceback) -> None:
if self._owns_client and self.client is not None:
await self.client.aclose()
self.client = None
def _require_client(self) -> httpx.AsyncClient:
if self.client is None:
raise RuntimeError("ArxivPdfClient must be used as an async context manager")
return self.client
async def download(self, arxiv_id: str, target: Path) -> Path:
"""Download one PDF atomically, reusing an existing valid target."""
client = self._require_client()
if not ARXIV_ID_PATTERN.fullmatch(arxiv_id):
raise ValueError(f"Invalid arXiv id: {arxiv_id!r}")
if target.is_file() and target.stat().st_size > 5:
with target.open("rb") as existing:
if existing.read(5) == b"%PDF-":
self.logger.debug(
f"[ArxivPdfClient] cache hit arxiv_id={arxiv_id} path={target} bytes={target.stat().st_size}",
)
return target
target.parent.mkdir(parents=True, exist_ok=True)
part_path = target.with_name(f".{target.name}.{uuid4().hex}.part")
size = 0
self.logger.info(
f"[ArxivPdfClient] download start arxiv_id={arxiv_id} path={target} timeout={self.timeout:g}s",
)
try:
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:
raise ValueError(f"PDF exceeds maximum size: {content_length} > {self.max_bytes}")
async with aiofiles.open(part_path, "wb") as stream:
async for chunk in response.aiter_bytes():
size += len(chunk)
if size > self.max_bytes:
raise ValueError(f"PDF exceeds maximum size: {size} > {self.max_bytes}")
await stream.write(chunk)
if size <= 5:
raise ValueError(f"Downloaded PDF is empty for {arxiv_id}")
async with aiofiles.open(part_path, "rb") as stream:
header = await stream.read(5)
if header != b"%PDF-":
raise ValueError(f"Downloaded content is not a PDF for {arxiv_id}")
os.replace(part_path, target)
self.logger.info(
f"[ArxivPdfClient] download done arxiv_id={arxiv_id} path={target} bytes={size}",
)
return target
except Exception as exc:
detail = str(exc) or "-"
self.logger.warning(
f"[ArxivPdfClient] download failed arxiv_id={arxiv_id} error={type(exc).__name__} detail={detail}",
)
raise
finally:
if part_path.exists():
part_path.unlink()