@femio/paper-mcp 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +239 -0
- package/bin/cli.js +76 -0
- package/package.json +40 -0
- package/paper-mcp.py +10 -0
- package/paper_mcp/__init__.py +13 -0
- package/paper_mcp/cache.py +123 -0
- package/paper_mcp/config.py +91 -0
- package/paper_mcp/content.py +329 -0
- package/paper_mcp/mineru.py +106 -0
- package/paper_mcp/output.py +70 -0
- package/paper_mcp/parsing.py +246 -0
- package/paper_mcp/server.py +228 -0
- package/requirements.txt +5 -0
|
@@ -0,0 +1,246 @@
|
|
|
1
|
+
"""解析编排层:串起 mineru(云端)与 cache(本地),对外由 server.py 暴露为单个阻塞工具。
|
|
2
|
+
|
|
3
|
+
parse():阻塞式解析。查缓存 → 对未解析的申请上传 URL 并上传 → 每 poll_interval 秒轮询,
|
|
4
|
+
直到全部完成或超时。可续等:已在解析中的论文(按文件哈希命中在途批次)不重复上传,
|
|
5
|
+
直接续等原批次,避免超时重试时白烧 MinerU 额度。
|
|
6
|
+
|
|
7
|
+
notify:Callable[[msg, completed=None, total=None], None]。parse() 在不同阶段用
|
|
8
|
+
【预置的多条文案随机选一条】调用它:
|
|
9
|
+
- 周期性"解析中"带 (completed, total) → 上层桥接成 MCP 进度条 notifications/progress;
|
|
10
|
+
- 下载中 / 解压中等瞬时子步骤只传 msg → 上层桥接成日志通知 ctx.info。
|
|
11
|
+
两者都同时写 stderr。
|
|
12
|
+
"""
|
|
13
|
+
import os
|
|
14
|
+
import random
|
|
15
|
+
import time
|
|
16
|
+
|
|
17
|
+
from . import cache, config, mineru
|
|
18
|
+
|
|
19
|
+
# ---------------- 分阶段状态文案(随机选一条) ----------------
|
|
20
|
+
_STATUS = {
|
|
21
|
+
"parsing": [
|
|
22
|
+
"MinerU 正在加速解析中,请稍候…",
|
|
23
|
+
"解析引擎全力运转中,马上就好…",
|
|
24
|
+
"正在识别文字 / 公式 / 表格,稍等片刻…",
|
|
25
|
+
"论文有点长,MinerU 正在努力啃它…",
|
|
26
|
+
"解析进行中,已经在路上啦…",
|
|
27
|
+
"AI 正在逐页拆解论文,请再等一会儿…",
|
|
28
|
+
],
|
|
29
|
+
"download": [
|
|
30
|
+
"解析完成!正在下载解析后的文档…",
|
|
31
|
+
"解析搞定,开始拉取结果包…",
|
|
32
|
+
"解析已完成,正在把结果搬回本地…",
|
|
33
|
+
],
|
|
34
|
+
"extract": [
|
|
35
|
+
"解析完成的文档解压中…",
|
|
36
|
+
"正在解压结果,马上就能用…",
|
|
37
|
+
"结果包解压中,即将就绪…",
|
|
38
|
+
],
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _msg(phase: str, extra: str = "") -> str:
|
|
43
|
+
m = random.choice(_STATUS.get(phase) or ["处理中…"])
|
|
44
|
+
return f"{m} {extra}".rstrip()
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def _progress_extra(tasks, pending) -> str:
|
|
48
|
+
"""从仍在解析的任务里取一条页进度,拼成 '(3/12 页)'。取不到则空。"""
|
|
49
|
+
for t in tasks:
|
|
50
|
+
pr = t.get("progress")
|
|
51
|
+
if t.get("name") in pending and isinstance(pr, dict):
|
|
52
|
+
ep, tp = pr.get("extracted_pages"), pr.get("total_pages")
|
|
53
|
+
if ep is not None and tp:
|
|
54
|
+
return f"({ep}/{tp} 页)"
|
|
55
|
+
return ""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _progress(tasks, pending):
|
|
59
|
+
"""把整批完成度折算成 (completed, total):以"论文数"为单位,
|
|
60
|
+
|
|
61
|
+
每篇 done/failed 记 1.0,解析中按 已解析页/总页 折算小数 → 平滑的进度条。
|
|
62
|
+
"""
|
|
63
|
+
total = len(pending)
|
|
64
|
+
done = 0.0
|
|
65
|
+
for t in tasks:
|
|
66
|
+
if t.get("name") not in pending:
|
|
67
|
+
continue
|
|
68
|
+
if t.get("state") in ("done", "failed"):
|
|
69
|
+
done += 1.0
|
|
70
|
+
else:
|
|
71
|
+
pr = t.get("progress")
|
|
72
|
+
if isinstance(pr, dict) and pr.get("total_pages"):
|
|
73
|
+
ep = pr.get("extracted_pages") or 0
|
|
74
|
+
done += min(0.99, ep / pr["total_pages"])
|
|
75
|
+
return round(done, 3), total
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _select_papers(papers):
|
|
79
|
+
"""把 "all" / 单名 / 名字列表(或逗号分隔)解析成 [(paper_key, abs_path), ...]。"""
|
|
80
|
+
if papers is None or papers == "all" or papers == ["all"]:
|
|
81
|
+
if not os.path.isdir(config.PAPERS_DIR):
|
|
82
|
+
return []
|
|
83
|
+
names = sorted(f for f in os.listdir(config.PAPERS_DIR) if f.lower().endswith(".pdf"))
|
|
84
|
+
return [(config.paper_key(n), os.path.join(config.PAPERS_DIR, n)) for n in names]
|
|
85
|
+
if isinstance(papers, str):
|
|
86
|
+
papers = [p for p in papers.split(",")] if "," in papers else [papers]
|
|
87
|
+
out = []
|
|
88
|
+
for p in papers:
|
|
89
|
+
path = config.resolve_pdf(p.strip() if isinstance(p, str) else p) # 缺失时抛 FileNotFoundError
|
|
90
|
+
out.append((config.paper_key(path), path))
|
|
91
|
+
return out
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _classify(selected, force):
|
|
95
|
+
"""分三类:已缓存 / 在途可续等(name→batch_id) / 需上传[(name,path,hash)]。"""
|
|
96
|
+
cached, resume, to_upload = [], {}, []
|
|
97
|
+
for name, path in selected:
|
|
98
|
+
h = cache.file_hash(path)
|
|
99
|
+
if not force and cache.is_ready(name, expect_hash=h):
|
|
100
|
+
cached.append({"name": name, "state": "done", "cached": True,
|
|
101
|
+
"local_dir": cache.get_entry(name)["local_dir"]})
|
|
102
|
+
continue
|
|
103
|
+
e = None if force else cache.get_entry(name)
|
|
104
|
+
if e and e.get("batch_id") and e.get("file_hash") == h \
|
|
105
|
+
and e.get("state") in ("pending", "running", "converting"):
|
|
106
|
+
resume[name] = e["batch_id"] # 已在解析中 → 续等原批次,不重复上传
|
|
107
|
+
else:
|
|
108
|
+
to_upload.append((name, path, h))
|
|
109
|
+
return cached, resume, to_upload
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _upload_batch(to_upload, model_version, enable_formula, enable_table, language, ocr):
|
|
113
|
+
"""对需上传的文件:申请上传 URL → PUT 上传(系统自动建任务)。
|
|
114
|
+
|
|
115
|
+
返回 (batch_id, uploaded_names, failed_tasks)。逐文件容错:个别上传失败记 failed 继续。
|
|
116
|
+
"""
|
|
117
|
+
files = [{"name": os.path.basename(path), "data_id": h[:32], "is_ocr": ocr}
|
|
118
|
+
for _n, path, h in to_upload]
|
|
119
|
+
batch_id, urls = mineru.request_upload(files, model_version, enable_formula, enable_table, language)
|
|
120
|
+
if len(urls) < len(to_upload):
|
|
121
|
+
raise RuntimeError(f"MinerU 返回的上传 URL 数({len(urls)})少于文件数({len(to_upload)})。")
|
|
122
|
+
|
|
123
|
+
uploaded, failed = [], []
|
|
124
|
+
for (name, path, h), url in zip(to_upload, urls):
|
|
125
|
+
try:
|
|
126
|
+
mineru.upload_file(url, path)
|
|
127
|
+
cache.set_entry(name, file_hash=h, batch_id=batch_id, data_id=h[:32],
|
|
128
|
+
state="pending", model_version=model_version, local_dir="", err_msg="")
|
|
129
|
+
uploaded.append(name)
|
|
130
|
+
except Exception as e: # noqa: BLE001
|
|
131
|
+
cache.set_entry(name, state="failed", err_msg=f"上传失败:{e}")
|
|
132
|
+
failed.append({"name": name, "state": "failed", "cached": False, "err_msg": str(e)})
|
|
133
|
+
return batch_id, uploaded, failed
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def _settle(batch_id: str, notify=None) -> list:
|
|
137
|
+
"""拉取一个批次的结果;done 且未缓存时下载+解压进 manifest。
|
|
138
|
+
|
|
139
|
+
notify(msg):可选。
|
|
140
|
+
其并不在 parse的[启动 - 轮询] 循环之内
|
|
141
|
+
下载/解压时被阻塞了,只在开始前各播报一条随机文案,如果下载或解压时间太长可能有隐患。
|
|
142
|
+
"""
|
|
143
|
+
out = []
|
|
144
|
+
for r in mineru.batch_results(batch_id):
|
|
145
|
+
name = cache.name_by_data_id(r.get("data_id")) or config.paper_key(r.get("file_name", ""))
|
|
146
|
+
state = r.get("state", "unknown")
|
|
147
|
+
item = {"name": name, "state": state, "err_msg": r.get("err_msg") or "",
|
|
148
|
+
"progress": r.get("extract_progress")}
|
|
149
|
+
if state == "done" and r.get("full_zip_url"):
|
|
150
|
+
if not cache.is_ready(name):
|
|
151
|
+
dest = cache.result_dir(name)
|
|
152
|
+
try:
|
|
153
|
+
|
|
154
|
+
if notify:
|
|
155
|
+
notify(_msg("download", f"[{name}]"))
|
|
156
|
+
blob = mineru.download_zip_bytes(r["full_zip_url"])
|
|
157
|
+
|
|
158
|
+
if notify:
|
|
159
|
+
notify(_msg("extract", f"[{name}]"))
|
|
160
|
+
mineru.extract_zip(blob, dest)
|
|
161
|
+
|
|
162
|
+
cache.set_entry(name, state="done", local_dir=dest, batch_id=batch_id, err_msg="")
|
|
163
|
+
except Exception as e: # noqa: BLE001
|
|
164
|
+
item["state"] = "failed"
|
|
165
|
+
item["err_msg"] = f"下载/解压失败:{e}"
|
|
166
|
+
cache.set_entry(name, state="failed", err_msg=item["err_msg"])
|
|
167
|
+
out.append(item)
|
|
168
|
+
continue
|
|
169
|
+
item["local_dir"] = (cache.get_entry(name) or {}).get("local_dir")
|
|
170
|
+
elif state == "failed":
|
|
171
|
+
cache.set_entry(name, state="failed", batch_id=batch_id, err_msg=r.get("err_msg") or "")
|
|
172
|
+
else:
|
|
173
|
+
cache.set_entry(name, state=state, batch_id=batch_id)
|
|
174
|
+
out.append(item)
|
|
175
|
+
return out
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def parse(papers="all", model_version=None, enable_formula=True, enable_table=True,
|
|
179
|
+
language=None, ocr=False, force=False, max_wait=None, poll_interval=None,
|
|
180
|
+
notify=None) -> dict:
|
|
181
|
+
"""阻塞式解析:提交 → 每 poll_interval 秒轮询,直到全部完成或超时。
|
|
182
|
+
|
|
183
|
+
- 已解析的按文件哈希命中缓存,直接返回;
|
|
184
|
+
- 在解析中的(上次提交尚未完成)续等原批次,不重复上传;
|
|
185
|
+
- 其余上传新批次。每轮未完成经 notify 播报进度(进度条 + 文案),再等一个周期。
|
|
186
|
+
"""
|
|
187
|
+
model_version = model_version or config.DEFAULT_MODEL_VERSION
|
|
188
|
+
max_wait = config.DEFAULT_MAX_WAIT if max_wait is None else max_wait
|
|
189
|
+
poll_interval = config.POLL_INTERVAL if poll_interval is None else poll_interval
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
selected = _select_papers(papers)
|
|
193
|
+
except FileNotFoundError as e:
|
|
194
|
+
return {"error": str(e)}
|
|
195
|
+
if not selected:
|
|
196
|
+
return {"error": f"没有可解析的 PDF(papers 目录:{config.PAPERS_DIR})"}
|
|
197
|
+
|
|
198
|
+
cached, resume, to_upload = _classify(selected, force)
|
|
199
|
+
tasks = list(cached)
|
|
200
|
+
batch_ids = set(resume.values())
|
|
201
|
+
|
|
202
|
+
if to_upload:
|
|
203
|
+
try:
|
|
204
|
+
batch_id, uploaded, failed = _upload_batch(
|
|
205
|
+
to_upload, model_version, enable_formula, enable_table, language, ocr)
|
|
206
|
+
except Exception as e: # noqa: BLE001
|
|
207
|
+
return {"error": f"提交解析失败:{type(e).__name__}: {e}"}
|
|
208
|
+
if batch_id:
|
|
209
|
+
batch_ids.add(batch_id)
|
|
210
|
+
tasks += [{"name": n, "state": "pending", "cached": False} for n in uploaded]
|
|
211
|
+
tasks += failed
|
|
212
|
+
|
|
213
|
+
pending = set(resume) | {t["name"] for t in tasks if t.get("state") == "pending"}
|
|
214
|
+
if not pending:
|
|
215
|
+
note = None if to_upload else "全部命中缓存,无需重新解析。"
|
|
216
|
+
return {"tasks": tasks, "note": note}
|
|
217
|
+
|
|
218
|
+
total_n = len(pending)
|
|
219
|
+
if notify:
|
|
220
|
+
notify("已提交,MinerU 开始解析…", 0, total_n) # 进度条起点(0%)
|
|
221
|
+
|
|
222
|
+
deadline = time.monotonic() + max_wait
|
|
223
|
+
last = tasks
|
|
224
|
+
while True:
|
|
225
|
+
try:
|
|
226
|
+
polled = []
|
|
227
|
+
for bid in batch_ids:
|
|
228
|
+
polled += _settle(bid, notify=notify) # 轮询各批次;完成的就地下载解压(带播报)
|
|
229
|
+
except Exception as e: # noqa: BLE001
|
|
230
|
+
return {"error": f"查询批次失败:{type(e).__name__}: {e}"}
|
|
231
|
+
|
|
232
|
+
last = [t for t in polled if t["name"] in pending] + cached
|
|
233
|
+
settled = {t["name"] for t in last if t["state"] in ("done", "failed")}
|
|
234
|
+
if pending.issubset(settled):
|
|
235
|
+
if notify:
|
|
236
|
+
notify("全部解析完成 ✓", total_n, total_n) # 进度条到 100%
|
|
237
|
+
return {"tasks": last}
|
|
238
|
+
if time.monotonic() >= deadline:
|
|
239
|
+
return {"tasks": last, "timeout": True,
|
|
240
|
+
"note": (f"等待超过 {max_wait}s 仍未全部完成。可再次调用 parse_papers"
|
|
241
|
+
"(已完成的命中缓存、仍在解析的自动续等同一批次,均不会重复解析)。")}
|
|
242
|
+
|
|
243
|
+
if notify: # 未完成 → 播报"解析中"+ 推进进度条,再等一个周期
|
|
244
|
+
completed, _ = _progress(last, pending)
|
|
245
|
+
notify(_msg("parsing", _progress_extra(last, pending)), completed, total_n)
|
|
246
|
+
time.sleep(poll_interval)
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
"""Paper-Assistant-MCP —— FastMCP 实例与工具/资源/提示注册(基于 MinerU 云端 API)。
|
|
2
|
+
|
|
3
|
+
分层职责:
|
|
4
|
+
A. 解析编排 parse_papers(阻塞,内含轮询与进度播报) → parsing.py
|
|
5
|
+
B. 检索/阅读 get_paper_info / get_paper_outline / read_paper /
|
|
6
|
+
search_paper / list_figures / get_figure / view_image /
|
|
7
|
+
list_tables / get_table → content.py
|
|
8
|
+
C. 产出 write_file / collect_figures → output.py
|
|
9
|
+
D. 资源/提示 papers://catalog / papers://{name}/markdown / papers_to_pre
|
|
10
|
+
"""
|
|
11
|
+
import asyncio
|
|
12
|
+
import os
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
import anyio
|
|
16
|
+
from mcp.server.fastmcp import Context, FastMCP, Image
|
|
17
|
+
|
|
18
|
+
from . import cache, config, output, parsing
|
|
19
|
+
from .content import NotParsed, load_paper
|
|
20
|
+
|
|
21
|
+
mcp = FastMCP(name="Paper-Assistant-MCP")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def _guard(fn):
|
|
25
|
+
"""统一异常兜底:NotParsed / 其它异常 → {"error": ...},避免把栈抛给客户端。"""
|
|
26
|
+
try:
|
|
27
|
+
return fn()
|
|
28
|
+
except NotParsed as e:
|
|
29
|
+
return {"error": str(e)}
|
|
30
|
+
except Exception as e: # noqa: BLE001
|
|
31
|
+
return {"error": f"{type(e).__name__}: {e}"}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
# ==================== A. 解析编排(阻塞) ====================
|
|
35
|
+
@mcp.tool(description=(
|
|
36
|
+
"把 ./papers 下的 PDF 提交给 MinerU 云端解析(端到端:文/图/表/公式)并【阻塞等待】完成:"
|
|
37
|
+
"提交后每 30s 自动轮询一次,期间以【进度条】+ 状态消息播报进度(解析中带页进度/下载中/解压中),"
|
|
38
|
+
"直到全部完成或超时(max_wait 秒,默认 1800≈30 分钟)。已解析的按文件哈希命中缓存跳过;"
|
|
39
|
+
"上次未完成的会续等原批次、不重复上传。papers 传 'all' 或文件名(可省略 .pdf)/文件名列表。"))
|
|
40
|
+
async def parse_papers(ctx: Context, papers="all", model_version: str = None,
|
|
41
|
+
enable_formula: bool = True, enable_table: bool = True,
|
|
42
|
+
language: str = None, ocr: bool = False, force: bool = False,
|
|
43
|
+
max_wait: float = None, poll_interval: float = None):
|
|
44
|
+
loop = asyncio.get_running_loop()
|
|
45
|
+
last = {"completed": 0.0, "total": None} # 记住最近一次进度,供 keep-alive 复用
|
|
46
|
+
|
|
47
|
+
def notify(msg: str, completed: float = None, total: float = None):
|
|
48
|
+
# 经 stdio 播报状态,绝不 print 到 stdout(那是 JSON-RPC 协议通道)。
|
|
49
|
+
# keep-alive:MCP 里只有【进度通知】能重置客户端的空闲超时,日志通知不能。
|
|
50
|
+
# 所以下载/解压等只带文案的播报,也【复用上次进度值补发一次 report_progress】,
|
|
51
|
+
# 确保每次播报都刷新空闲计时器,避免长时间下载被误判为卡死。
|
|
52
|
+
print(f"[MinerU] {msg}", file=sys.stderr, flush=True)
|
|
53
|
+
if completed is not None and total:
|
|
54
|
+
last["completed"], last["total"] = completed, total
|
|
55
|
+
prog, tot = last["completed"], last["total"]
|
|
56
|
+
|
|
57
|
+
async def _emit():
|
|
58
|
+
if tot:
|
|
59
|
+
await ctx.report_progress(progress=prog, total=tot, message=msg)
|
|
60
|
+
else:
|
|
61
|
+
await ctx.info(msg) # 尚无进度基准时退化为日志
|
|
62
|
+
|
|
63
|
+
try:
|
|
64
|
+
asyncio.run_coroutine_threadsafe(_emit(), loop).result(timeout=5)
|
|
65
|
+
except Exception: # noqa: BLE001 —— 客户端不支持进度/日志或跨线程调度失败都不应打断解析
|
|
66
|
+
pass
|
|
67
|
+
|
|
68
|
+
def run():
|
|
69
|
+
return parsing.parse(
|
|
70
|
+
papers, model_version, enable_formula, enable_table, language, ocr, force,
|
|
71
|
+
max_wait, poll_interval, notify=notify)
|
|
72
|
+
|
|
73
|
+
# 阻塞循环(time.sleep + 同步 httpx)放到工作线程,避免卡住事件循环,
|
|
74
|
+
# notify 再经 run_coroutine_threadsafe 把播报调度回事件循环。
|
|
75
|
+
try:
|
|
76
|
+
return await anyio.to_thread.run_sync(run)
|
|
77
|
+
except Exception as e: # noqa: BLE001
|
|
78
|
+
return {"error": f"{type(e).__name__}: {e}"}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
# ==================== B. 检索 / 阅读 ====================
|
|
82
|
+
@mcp.tool(description="返回论文概览:标题、页数、图/表/公式数量、缓存目录。需先解析。")
|
|
83
|
+
def get_paper_info(paper: str):
|
|
84
|
+
return _guard(lambda: load_paper(paper).info())
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@mcp.tool(description="返回论文章节大纲(来自 MinerU 的标题层级,含页码,保持阅读顺序)。需先解析。")
|
|
88
|
+
def get_paper_outline(paper: str):
|
|
89
|
+
return _guard(lambda: load_paper(paper).outline_text())
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@mcp.tool(description=(
|
|
93
|
+
"分段读正文(markdown)。三选一定位:section(章节名,模糊匹配)/ page_start+page_end(页范围)/ 都不填读全文。"
|
|
94
|
+
"默认 max_chars=8000 上限截断,请分段读以控制上下文。图/表在正文中以 [图 Fk]/[表 Tk] 占位,"
|
|
95
|
+
"需要时再用 view_image / get_table 获取。"))
|
|
96
|
+
def read_paper(paper: str, section: str = None, page_start: int = None,
|
|
97
|
+
page_end: int = None, max_chars: int = 8000):
|
|
98
|
+
return _guard(lambda: load_paper(paper).read(section, page_start, page_end, max_chars))
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
@mcp.tool(description="在论文正文中检索关键词或正则,返回命中片段 + 所在页/章节。用于快速定位而不必通读。")
|
|
102
|
+
def search_paper(paper: str, query: str, regex: bool = False, max_hits: int = 20):
|
|
103
|
+
return _guard(lambda: load_paper(paper).search(query, regex, max_hits))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
@mcp.tool(description=(
|
|
107
|
+
"列出论文所有图:id(F1、F2…)、页码、图注 caption、脚注、本地路径、大小。"
|
|
108
|
+
"先看清单判断哪些关键,再对关键图用 view_image 真正看图。"))
|
|
109
|
+
def list_figures(paper: str):
|
|
110
|
+
return _guard(lambda: load_paper(paper).figures())
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
@mcp.tool(description=(
|
|
114
|
+
"取某张图的路径 + 图注 + 脚注(不直接返回图片内容)。"
|
|
115
|
+
"如需查看图片本身,请把返回的 path 传给 view_image 工具。"))
|
|
116
|
+
def get_figure(paper: str, figure_id: str):
|
|
117
|
+
return _guard(lambda: load_paper(paper).figure(figure_id))
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@mcp.tool(description=(
|
|
121
|
+
"读取并返回图片内容(PNG/JPG)供模型直接'看图'理解——理解关键图表时调用。"
|
|
122
|
+
"image_path 用 list_figures / get_figure 返回的 path。仅允许读取解析缓存或项目目录内的图片。"))
|
|
123
|
+
def view_image(image_path: str):
|
|
124
|
+
p = os.path.abspath(image_path or "")
|
|
125
|
+
if not (config.is_within(config.CACHE_DIR, p) or config.is_within(config.BASE_DIR, p)):
|
|
126
|
+
return {"error": f"拒绝读取受限目录之外的文件:{p}"}
|
|
127
|
+
if not os.path.isfile(p):
|
|
128
|
+
return {"error": f"文件不存在:{p}"}
|
|
129
|
+
ext = os.path.splitext(p)[1].lower().lstrip(".") or "png"
|
|
130
|
+
if ext == "jpg":
|
|
131
|
+
ext = "jpeg"
|
|
132
|
+
with open(p, "rb") as f:
|
|
133
|
+
return Image(data=f.read(), format=ext)
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
@mcp.tool(description="列出论文所有表:id(T1、T2…)、页码、表注、是否有可解析 HTML、表格截图路径(若有)。")
|
|
137
|
+
def list_tables(paper: str):
|
|
138
|
+
return _guard(lambda: load_paper(paper).tables())
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@mcp.tool(description="取某张表:format='markdown'(默认,由 MinerU 的 HTML 转换)或 'html'(原始)。含表注/脚注/截图路径。")
|
|
142
|
+
def get_table(paper: str, table_id: str, format: str = "markdown"):
|
|
143
|
+
return _guard(lambda: load_paper(paper).table(table_id, format))
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
# ==================== C. 产出 ====================
|
|
147
|
+
@mcp.tool(description="把文本写入文件(UTF-8,自动建父目录)。仅允许写在项目目录(BASE_DIR)内。用于落盘 HTML/Markdown/讲稿。")
|
|
148
|
+
def write_file(output_path: str, content: str):
|
|
149
|
+
return _guard(lambda: output.write_file(output_path, content))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
@mcp.tool(description=(
|
|
153
|
+
"把指定图(figure_ids:如 ['F1','F3'])拷贝到 dest_dir(项目内,如 ./pre/figs/<论文名>),"
|
|
154
|
+
"返回可用于 <img src> 的相对引用路径(已 URL 转义)。用于拼装带图的汇报 HTML。"))
|
|
155
|
+
def collect_figures(paper: str, figure_ids, dest_dir: str):
|
|
156
|
+
return _guard(lambda: output.collect_figures(paper, figure_ids, dest_dir))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# ==================== D. Resources ====================
|
|
160
|
+
@mcp.resource("papers://catalog", description="列出 ./papers 下所有论文及解析状态(已解析/解析中/未解析,含页数与图表数)。")
|
|
161
|
+
def catalog() -> str:
|
|
162
|
+
if not os.path.isdir(config.PAPERS_DIR):
|
|
163
|
+
return f"(papers 目录不存在:{config.PAPERS_DIR})"
|
|
164
|
+
names = sorted(f for f in os.listdir(config.PAPERS_DIR) if f.lower().endswith(".pdf"))
|
|
165
|
+
if not names:
|
|
166
|
+
return "(papers 目录为空)"
|
|
167
|
+
entries = cache.all_entries()
|
|
168
|
+
lines = [
|
|
169
|
+
f"论文目录:{config.PAPERS_DIR}",
|
|
170
|
+
"用法:各工具 pdf_path/paper 直接填文件名(可省略 .pdf)。未解析的先调用 parse_papers 解析。",
|
|
171
|
+
"—" * 24,
|
|
172
|
+
]
|
|
173
|
+
for f in names:
|
|
174
|
+
key = config.paper_key(f)
|
|
175
|
+
e = entries.get(key)
|
|
176
|
+
if e and e.get("state") == "done" and cache.is_ready(key):
|
|
177
|
+
info = _guard(lambda: load_paper(key).info())
|
|
178
|
+
st = (f"[已解析 · {info['pages']}页 · 图{info['n_figures']} 表{info['n_tables']}]"
|
|
179
|
+
if isinstance(info, dict) and "pages" in info else "[已解析]")
|
|
180
|
+
elif e and e.get("state") in ("pending", "running", "converting"):
|
|
181
|
+
st = f"[解析中:{e.get('state')}]"
|
|
182
|
+
elif e and e.get("state") == "failed":
|
|
183
|
+
st = f"[解析失败:{e.get('err_msg') or ''}]"
|
|
184
|
+
else:
|
|
185
|
+
st = "[未解析]"
|
|
186
|
+
lines.append(f"- {f} {st}")
|
|
187
|
+
return "\n".join(lines)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
@mcp.resource("papers://{name}/markdown", description="返回某篇已解析论文的完整 markdown(MinerU 的 full.md)。")
|
|
191
|
+
def paper_markdown(name: str) -> str:
|
|
192
|
+
try:
|
|
193
|
+
return load_paper(name).markdown()
|
|
194
|
+
except NotParsed as e:
|
|
195
|
+
return str(e)
|
|
196
|
+
except Exception as e: # noqa: BLE001
|
|
197
|
+
return f"读取失败:{type(e).__name__}: {e}"
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
# ==================== E. Prompt ====================
|
|
201
|
+
@mcp.prompt(description="驱动 Agent 基于 MinerU 解析结果,把 ./papers 下论文整理成带图的汇报 HTML + 逐页讲稿。")
|
|
202
|
+
def papers_to_pre(papers: str = "all", audience: str = "组会同学", language: str = "中文",
|
|
203
|
+
output_path: str = "./pre/slides.html", script_path: str = "./pre/script.md") -> str:
|
|
204
|
+
return f"""你要基于 Paper-Assistant-MCP(MinerU 解析)把论文整理成一份用于 pre 的 HTML 展示。按以下步骤:
|
|
205
|
+
|
|
206
|
+
1. 确定要处理的论文:{papers}。若为 "all",先读取资源 papers://catalog 拿到全部 PDF 文件名及解析状态。
|
|
207
|
+
所有工具的 paper 直接填【文件名】即可(可省略 .pdf)。
|
|
208
|
+
|
|
209
|
+
2. 解析(端到端得到文/图/表):
|
|
210
|
+
- 对需要的论文调用 parse_papers(papers=...):这是【阻塞】工具,内部每 30s 轮询并播报进度条,不设置超时,直到全部完成才返回。
|
|
211
|
+
- 已解析过的论文会自动命中缓存、上次未完成的会续等原批次,均不会重复消耗额度。
|
|
212
|
+
|
|
213
|
+
3. 对每一篇论文(逐篇理解):
|
|
214
|
+
a. get_paper_info 看规模,get_paper_outline 看章节结构;
|
|
215
|
+
b. read_paper 按 section 或页范围【分段】读正文(默认已按 max_chars 截断,不要一次读全文);必要时用 search_paper 定位关键结论;
|
|
216
|
+
c. list_figures / list_tables 拿图表清单;判断哪些是关键图,对关键图用 view_image 真正【看图】理解;关键表用 get_table 取内容;
|
|
217
|
+
d. 总结该论文:研究问题、方法、关键结果、结论;
|
|
218
|
+
e. 用 write_file 把该篇总结写到 ./pre/docs/<论文名>_summary.md。
|
|
219
|
+
|
|
220
|
+
4. 汇总所有论文,生成面向【{audience}】、用【{language}】的 HTML 幻灯片:
|
|
221
|
+
- 每篇 2-4 页 slide,含标题、要点;
|
|
222
|
+
- 先用 collect_figures 把选中的关键图拷到 ./pre/figs/<论文名>/,再用返回的 src_from_base 作为 <img src>(注意空格已转义为 %20);
|
|
223
|
+
- 风格简洁、适合投屏。
|
|
224
|
+
- 用 write_file 把 HTML 写到 {output_path}。
|
|
225
|
+
|
|
226
|
+
5. 为每一页 slide 生成一段贴合内容的讲稿,用 write_file 写到 {script_path}(与 HTML 分开,勿覆盖)。
|
|
227
|
+
|
|
228
|
+
6. 完成后简要汇报:每篇用了哪几张图/表、HTML 路径({output_path})与讲稿路径({script_path})。"""
|