Skip to content

Commit b3aeaf8

Browse files
committed
Use MinerU for PDF parsing
1 parent 78deae1 commit b3aeaf8

4 files changed

Lines changed: 232 additions & 0 deletions

File tree

‎agents/reading-agent/main.py‎

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,9 @@
2020
import shutil
2121
import tempfile
2222
import inspect
23+
import io
24+
import zipfile
25+
import time
2326
from concurrent.futures import ThreadPoolExecutor, as_completed
2427
from html import unescape
2528
from datetime import datetime
@@ -84,6 +87,10 @@
8487
PROJECT_ROOT = Path(__file__).resolve().parents[2]
8588
PDF_DOWNLOAD_TIMEOUT = float(os.environ.get("READING_REPORT_PDF_TIMEOUT", "60"))
8689
ARXIV_DETAIL_TIMEOUT = float(os.environ.get("READING_REPORT_ARXIV_TIMEOUT", "12"))
90+
MINERU_API_BASE_URL = os.environ.get("MINERU_API_BASE_URL", "https://mineru.net").strip().rstrip("/")
91+
MINERU_PARSE_TIMEOUT = float(os.environ.get("MINERU_PARSE_TIMEOUT", "300"))
92+
MINERU_PARSE_POLL_INTERVAL = float(os.environ.get("MINERU_PARSE_POLL_INTERVAL", "5"))
93+
MINERU_MODEL_VERSION = os.environ.get("MINERU_MODEL_VERSION", "vlm").strip() or "vlm"
8794
MAX_ABSTRACT_CHARS = int(os.environ.get("READING_REPORT_ABSTRACT_CHARS", "1200"))
8895
MAX_SECTION_CHARS = int(os.environ.get("READING_REPORT_SECTION_CHARS", "1800"))
8996
READING_REPORT_CHUNK_CHARS = int(os.environ.get("READING_REPORT_CHUNK_CHARS", "1200"))
@@ -2679,6 +2686,128 @@ def _parse_pdf_for_report(pdf_path: str) -> Dict[str, Any]:
26792686
}
26802687

26812688

2689+
def _mineru_enabled() -> bool:
2690+
return _env_flag("MINERU_PARSE_ENABLED", default=True) and bool(os.environ.get("MINERU_API_KEY", "").strip())
2691+
2692+
2693+
def _mineru_headers() -> Dict[str, str]:
2694+
token = os.environ.get("MINERU_API_KEY", "").strip()
2695+
return {
2696+
"Authorization": f"Bearer {token}",
2697+
"Content-Type": "application/json",
2698+
}
2699+
2700+
2701+
def _mineru_extract_data(payload: Any) -> Dict[str, Any]:
2702+
if isinstance(payload, dict):
2703+
data = payload.get("data")
2704+
if isinstance(data, dict):
2705+
return data
2706+
return payload
2707+
return {}
2708+
2709+
2710+
def _mineru_task_id(payload: Dict[str, Any]) -> str:
2711+
data = _mineru_extract_data(payload)
2712+
for key in ("task_id", "taskId", "id"):
2713+
value = data.get(key) or payload.get(key)
2714+
if value:
2715+
return str(value)
2716+
return ""
2717+
2718+
2719+
def _mineru_task_state(payload: Dict[str, Any]) -> str:
2720+
data = _mineru_extract_data(payload)
2721+
return str(data.get("state") or data.get("status") or payload.get("state") or payload.get("status") or "").strip().lower()
2722+
2723+
2724+
def _mineru_zip_url(payload: Dict[str, Any]) -> str:
2725+
data = _mineru_extract_data(payload)
2726+
for key in ("full_zip_url", "fullZipUrl", "zip_url", "zipUrl", "result_url", "resultUrl"):
2727+
value = data.get(key) or payload.get(key)
2728+
if value:
2729+
return str(value)
2730+
return ""
2731+
2732+
2733+
def _download_mineru_markdown(zip_url: str) -> str:
2734+
response = requests.get(zip_url, timeout=PDF_DOWNLOAD_TIMEOUT)
2735+
response.raise_for_status()
2736+
with zipfile.ZipFile(io.BytesIO(response.content)) as archive:
2737+
names = archive.namelist()
2738+
preferred = [
2739+
name for name in names
2740+
if name.lower().endswith("full.md") or name.lower().endswith("/full.md")
2741+
]
2742+
candidates = preferred or [name for name in names if name.lower().endswith(".md")]
2743+
if not candidates:
2744+
raise RuntimeError("MinerU result zip did not contain markdown")
2745+
with archive.open(candidates[0]) as handle:
2746+
return handle.read().decode("utf-8", errors="replace")
2747+
2748+
2749+
def _sections_from_markdown(markdown: str) -> Dict[str, str]:
2750+
sections: Dict[str, List[str]] = {}
2751+
current = "full_text"
2752+
for raw_line in str(markdown or "").splitlines():
2753+
heading = re.match(r"^\s{0,3}#{1,4}\s+(.+?)\s*$", raw_line)
2754+
if heading:
2755+
label = re.sub(r"[^a-z0-9]+", "_", heading.group(1).strip().lower()).strip("_") or "section"
2756+
current = label[:80]
2757+
sections.setdefault(current, [])
2758+
continue
2759+
sections.setdefault(current, []).append(raw_line)
2760+
return {key: _clean_text("\n".join(value)) for key, value in sections.items() if _clean_text("\n".join(value))}
2761+
2762+
2763+
def _parse_pdf_url_with_mineru(pdf_url: str) -> Dict[str, Any]:
2764+
if not _mineru_enabled():
2765+
raise RuntimeError("MinerU parser is disabled or MINERU_API_KEY is not configured")
2766+
base_url = os.environ.get("MINERU_API_BASE_URL", MINERU_API_BASE_URL).strip().rstrip("/") or "https://mineru.net"
2767+
submit_url = f"{base_url}/api/v4/extract/task"
2768+
payload = {
2769+
"url": pdf_url,
2770+
"model_version": os.environ.get("MINERU_MODEL_VERSION", MINERU_MODEL_VERSION).strip() or "vlm",
2771+
"is_ocr": True,
2772+
"enable_formula": True,
2773+
"enable_table": True,
2774+
}
2775+
response = requests.post(submit_url, headers=_mineru_headers(), json=payload, timeout=PDF_DOWNLOAD_TIMEOUT)
2776+
response.raise_for_status()
2777+
task_payload = response.json()
2778+
task_id = _mineru_task_id(task_payload)
2779+
if not task_id:
2780+
raise RuntimeError(f"MinerU did not return task_id: {task_payload}")
2781+
2782+
deadline = datetime.now().timestamp() + float(os.environ.get("MINERU_PARSE_TIMEOUT", MINERU_PARSE_TIMEOUT))
2783+
status_url = f"{base_url}/api/v4/extract/task/{task_id}"
2784+
last_payload: Dict[str, Any] = task_payload if isinstance(task_payload, dict) else {}
2785+
while datetime.now().timestamp() < deadline:
2786+
status_response = requests.get(status_url, headers=_mineru_headers(), timeout=PDF_DOWNLOAD_TIMEOUT)
2787+
status_response.raise_for_status()
2788+
last_payload = status_response.json()
2789+
state = _mineru_task_state(last_payload)
2790+
zip_url = _mineru_zip_url(last_payload)
2791+
if zip_url and state in {"done", "success", "succeeded", "completed", "finished"}:
2792+
markdown = _download_mineru_markdown(zip_url)
2793+
full_text = _clean_text(re.sub(r"!\[[^\]]*\]\([^)]+\)", " ", markdown))
2794+
sections = _sections_from_markdown(markdown)
2795+
abstract = sections.get("abstract", "")
2796+
return {
2797+
"source_kind": "mineru",
2798+
"parser": "mineru",
2799+
"task_id": task_id,
2800+
"markdown": markdown,
2801+
"full_text": full_text,
2802+
"sections": sections,
2803+
"abstract": abstract,
2804+
}
2805+
if state in {"failed", "error", "canceled", "cancelled"}:
2806+
raise RuntimeError(f"MinerU task failed: {last_payload}")
2807+
time.sleep(float(os.environ.get("MINERU_PARSE_POLL_INTERVAL", MINERU_PARSE_POLL_INTERVAL)))
2808+
raise TimeoutError(f"MinerU task timed out: {last_payload}")
2809+
2810+
26822811
def enrich_paper_for_reading_report(
26832812
paper: Dict[str, Any],
26842813
user_id: Optional[str] = None,
@@ -2802,6 +2931,42 @@ def enrich_paper_for_reading_report(
28022931
for download_candidate in download_candidates:
28032932
try:
28042933
download_referer = _pick_download_referer(enriched, download_candidate)
2934+
mineru_parsed = False
2935+
if _mineru_enabled():
2936+
try:
2937+
print(f" Parsing PDF with MinerU: {download_candidate[:120]}")
2938+
parsed_pdf = _parse_pdf_url_with_mineru(download_candidate)
2939+
parsed_pdf["source_kind"] = "mineru"
2940+
enriched["pdf_url"] = download_candidate
2941+
mineru_parsed = True
2942+
pdf_error = None
2943+
print(" MinerU PDF parsing completed")
2944+
except Exception as mineru_exc:
2945+
last_exception = mineru_exc
2946+
pdf_error = str(mineru_exc)
2947+
print(f" MinerU parsing failed; falling back to local PDF parser: {mineru_exc}")
2948+
2949+
if mineru_parsed:
2950+
try:
2951+
print(f" Downloading PDF for storage: {download_candidate[:120]}")
2952+
temp_pdf_path = _download_pdf(
2953+
download_candidate,
2954+
enriched.get("title", "paper"),
2955+
referer=download_referer,
2956+
)
2957+
stored_pdf_path = _persist_pdf_file(
2958+
temp_pdf_path,
2959+
enriched,
2960+
download_candidate,
2961+
user_id=user_id,
2962+
)
2963+
if stored_pdf_path:
2964+
enriched["pdf_path"] = stored_pdf_path
2965+
except Exception as storage_exc:
2966+
print(f" PDF storage download failed after MinerU parse: {storage_exc}")
2967+
print(" PDF enrichment parsed successfully")
2968+
break
2969+
28052970
print(f" Downloading PDF for enrichment: {download_candidate[:120]}")
28062971
temp_pdf_path = _download_pdf(
28072972
download_candidate,

‎deployments/desktop/shared/agents.py‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,11 @@ def _normalize_response_language(value: Any = None) -> str:
7171
"ANTHROPIC_API_TIMEOUT",
7272
"OLLAMA_BASE_URL",
7373
"OLLAMA_API_TIMEOUT",
74+
"MINERU_PARSE_ENABLED",
75+
"MINERU_API_KEY",
76+
"MINERU_API_BASE_URL",
77+
"MINERU_MODEL_VERSION",
78+
"MINERU_PARSE_TIMEOUT",
7479
"PAPERFLOW_NOTES_ROOT_DIR",
7580
"PAPERFLOW_PDF_DIR",
7681
"PAPERFLOW_READING_REPORTS_DIR",

‎deployments/desktop/static/desktop.js‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5143,6 +5143,11 @@
51435143
envRow(env, "PAPERFLOW_LLM_MODEL", "LLM Model"),
51445144
envRow(env, "OPENAI_API_KEY", "OpenAI API Key"),
51455145
envRow(env, "OPENAI_BASE_URL", "OpenAI Base URL"),
5146+
envRow(env, "MINERU_PARSE_ENABLED", "MinerU PDF Parse", "true"),
5147+
envRow(env, "MINERU_API_KEY", "MinerU API Key"),
5148+
envRow(env, "MINERU_API_BASE_URL", "MinerU Base URL", "https://mineru.net"),
5149+
envRow(env, "MINERU_MODEL_VERSION", "MinerU Model", "vlm"),
5150+
envRow(env, "MINERU_PARSE_TIMEOUT", "MinerU Timeout", "300"),
51465151
envRow(env, "PAPERFLOW_EMBED_PROVIDER", "Embedding Provider", env.OPENAI_API_KEY ? "openai" : "", ["PAPERFLOW_EMBEDDING_PROVIDER"]),
51475152
envRow(env, "PAPERFLOW_EMBED_MODEL", "Embedding Model", "", ["PAPERFLOW_EMBEDDING_MODEL"]),
51485153
envRow(env, "ANTHROPIC_API_KEY", "Anthropic API Key"),

‎tests/test_desktop_gui.py‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -153,6 +153,10 @@ def test_desktop_settings_page_renders_storage_paths_as_inputs() -> None:
153153
assert "Daily Note ${currentYear}" in script
154154
assert "PAPERFLOW_READING_NOTES_GIT_REMOTE" in script
155155
assert "PAPERFLOW_READING_NOTES_GIT_BRANCH" in script
156+
assert "MINERU_API_KEY" in script
157+
assert "MINERU_PARSE_ENABLED" in script
158+
assert "MINERU_MODEL_VERSION" in script
159+
assert "MINERU_API_KEY" in agents.EDITABLE_ENV_KEYS
156160
assert 'id="syncGithubBtn"' in html
157161
assert 'id="notesGitLlmReviewSetting"' in html
158162

@@ -2299,6 +2303,59 @@ def test_recommendation_calibration_uses_plain_ranking_score() -> None:
22992303
) == "值得快速浏览"
23002304

23012305

2306+
def test_reading_report_mineru_parser_downloads_markdown_zip(monkeypatch: pytest.MonkeyPatch) -> None:
2307+
import io
2308+
import zipfile
2309+
2310+
archive_buffer = io.BytesIO()
2311+
with zipfile.ZipFile(archive_buffer, "w") as archive:
2312+
archive.writestr(
2313+
"paper/full.md",
2314+
"# Title\n\n## Abstract\n\nMinerU extracted abstract.\n\n## Method\n\nMinerU extracted method.",
2315+
)
2316+
2317+
class FakeResponse:
2318+
def __init__(self, payload=None, content=b""):
2319+
self._payload = payload or {}
2320+
self.content = content
2321+
2322+
def raise_for_status(self):
2323+
return None
2324+
2325+
def json(self):
2326+
return self._payload
2327+
2328+
calls = []
2329+
2330+
def fake_post(url, **kwargs):
2331+
calls.append(("post", url, kwargs))
2332+
return FakeResponse({"code": 0, "data": {"task_id": "task-1"}})
2333+
2334+
def fake_get(url, **kwargs):
2335+
calls.append(("get", url, kwargs))
2336+
if url.endswith("/task-1"):
2337+
return FakeResponse({"code": 0, "data": {"state": "done", "full_zip_url": "https://mineru.example/full.zip"}})
2338+
return FakeResponse(content=archive_buffer.getvalue())
2339+
2340+
monkeypatch.setenv("MINERU_API_KEY", "mineru-test-key")
2341+
monkeypatch.setenv("MINERU_API_BASE_URL", "https://mineru.example")
2342+
monkeypatch.setenv("MINERU_MODEL_VERSION", "vlm")
2343+
monkeypatch.setenv("MINERU_PARSE_POLL_INTERVAL", "0")
2344+
monkeypatch.setattr(reading_agent.requests, "post", fake_post)
2345+
monkeypatch.setattr(reading_agent.requests, "get", fake_get)
2346+
2347+
parsed = reading_agent._parse_pdf_url_with_mineru("https://arxiv.org/pdf/2606.00001v1") # noqa: SLF001
2348+
2349+
assert parsed["source_kind"] == "mineru"
2350+
assert parsed["task_id"] == "task-1"
2351+
assert parsed["sections"]["abstract"] == "MinerU extracted abstract."
2352+
assert parsed["sections"]["method"] == "MinerU extracted method."
2353+
assert "MinerU extracted method" in parsed["full_text"]
2354+
assert calls[0][0] == "post"
2355+
assert calls[0][2]["headers"]["Authorization"] == "Bearer mineru-test-key"
2356+
assert calls[0][2]["json"]["model_version"] == "vlm"
2357+
2358+
23022359
def test_reading_report_english_heuristic_fallbacks_do_not_emit_chinese(monkeypatch: pytest.MonkeyPatch) -> None:
23032360
monkeypatch.setattr(agents.reading_agent, "_retrieve_report_evidence", lambda *_args, **_kwargs: {})
23042361

0 commit comments

Comments
 (0)