feat(skill): paper_server → research skill (search / get_paper / fetch_pdf)
skills/research/{SKILL.md, paper.py}: 接内部部署 paper_server 的 3 个 helper,LLM 经 load_skill("research") + run_python 调用。范式选 skill 而非 tool/MCP/裸 httpx/lib —— 频次低且 helper 范式让 API 漂移时改一处。tools/run_python.py 注入 PYTHONPATH=base_dir,让子进程能 `from skills.research.paper import ...` 不必折腾 sys.path。base_url 默 http://paper.xxhhcty.xyz:8080,可 PAPER_SERVER_URL env 覆盖。遗留:paper_server 侧 PaperDetailSerializer 加 abstract 字段由用户重新部署。
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5f0f296a23
commit
97bcd5ae1e
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 2-4 句:做了啥 + 关键判断 + 没动什么;细节查 `git log` / `git diff`。
|
||||
|
||||
最后更新:2026-05-21(dev SPA chip 维度二次校准:工具 I/O 恢复产物白名单门控、助手正文无条件挂 chip 且绕开 seenRels —— 修截图反馈"助手回复 echo 的产物路径没挂 chip"的 bug,同时止 grep/glob/read 误把引用当产物展示)
|
||||
最后更新:2026-05-21(paper_server → zcbot research skill:新增 skills/research/{SKILL.md,paper.py};run_python 注入 PYTHONPATH=base_dir 让 skill 内 helper 可 import;paper_server 侧 PaperDetailSerializer 加 abstract 由用户重新部署)
|
||||
|
||||
---
|
||||
|
||||
|
|
@ -23,6 +23,8 @@
|
|||
|
||||
### 2026-05-21
|
||||
|
||||
- **paper_server → zcbot research skill(查文献 / get abstract / 拉 PDF)**:用户要 zcbot 能查内部部署的 paper_server(`http://paper.xxhhcty.xyz:8080/`,OpenAlex 元数据 + Sci-Hub PDF 抓取)。**范式判断**:不做 tool(频次低 + zcbot 没 ToolSearch 基建,3 个函数 schema 永驻 chat context 不划算)、不做 MCP(部署/分发成本)、不裸 `run_python` 调 httpx(每次重复写 base_url / 字段名,且易漂移)、不做 helper-lib(LLM 不知道该 import 啥) → **做成 skill**(同 proposal/ppt 范式,SKILL.md + paper.py helper 同目录,LLM `load_skill("research")` 后用 `run_python` 调 helper)。**新增**:① `skills/research/SKILL.md`:何时用 / 何时不用 / 三函数签名 + 示例 / 工作流(search → 筛选 → get_paper 看 abstract → 必要时 fetch_pdf → read PDF)/ 错误处理 / 反模式。② `skills/research/paper.py`(~110 行):`search(keyword, year, doi, has_pdf, limit)` → paper_server `/api/resm/paper/` list 端点,精简 9 字段返(避 abstract 在 list 时 dump 给 LLM 太大);`get_paper(id_or_doi)` → retrieve 端点,**依赖 paper_server 侧 PaperDetailSerializer 加 abstract 字段**(由用户改 serializer + redeploy);`fetch_pdf(id_or_doi, working_dir)` → `/resm/paper/<id>/pdf/` 流式下载到 `<working_dir>/papers/<safe_doi>.pdf`,已存在跳过,`has_fulltext_pdf=False` 抛 RuntimeError;`_resolve_to_id` DOI → id(`10.` 前缀启发式);base_url 默认 `http://paper.xxhhcty.xyz:8080` 可 `PAPER_SERVER_URL` env 覆盖。③ **`tools/run_python.py` 注入 PYTHONPATH=base_dir**(关键 enabler):子进程 cwd 是 zcbot 仓库根,但默认 PYTHONPATH 不含项目根 → 不能 `from skills.research.paper import ...`;`env["PYTHONIOENCODING"]` 那行后加 `env["PYTHONPATH"] = str(self.base_dir) + os.pathsep + env.get("PYTHONPATH", "")`,LLM 能直接 import 不必折腾 sys.path。**没动**:tool 系统 / `agent_builder.py` / config / `ModelCapabilities` / ToolSearch 基建(独立决策,触发条件:tool 数 >20 或 schema 总 token >3k)/ paper_server filterset / search_fields / urls / models / paper_pdf_view / DESIGN(skill 是已有抽象)/ RUN(`PAPER_SERVER_URL` 是可选 env,有默认值)。**Tradeoffs**:① skill 内 helper 范式让 paper_server API 漂移时改一处(`paper.py`)而不是 prompt + tool schema;② DOI 启发式 `_is_doi` 容易误判像 `arxiv/2401.xxxxx` 这种非标准串(prefix 不是 `10.`),paper_server 内部用真 DOI(`10.xxx/...`)所以本库内场景稳;③ `search(limit>50)` 自动夹紧到 50 防 LLM 误用一次性拉全表。**遗留**:paper_server 侧 `PaperDetailSerializer` 加 abstract 由用户负责(handoff §A 描述);redeploy 后跑 `scripts/smoke_paper_skill.py`(三步:search list shape / get_paper abstract / fetch_pdf 落盘 + 复用)。
|
||||
|
||||
- **dev SPA chip 维度二次校准:工具 I/O 走产物白名单 + 助手正文无条件挂 chip 绕开 seenRels**:截图反馈"助手回复里 echo 的产物图路径(`rust介绍/figures/...png`)没挂 chip"。复盘上一条改动 + `febe04a`:① 上一条把工具 I/O 的 chip gate 也解了 —— 实际意图是"glob/grep 列出的引用不该挂(否则把命中的老 figures/foo.png 当新产物展示)"故 gate 该留;② `febe04a` 的 `seenRels` 全局去重把"防同图被 inline 两次"做过头了,把助手正文 echo 的同路径 chip 也吃掉。**最终模型(三条规则)**:① 工具 I/O(args/result):chip 抽取只对产物工具(seedream/seedance);② 产物工具的产物图/视频:inline 大图;③ 助手正文 echo 的路径:**永远**挂 chip(绕开 seenRels)+ 强制 `allowInlineMedia=false`(只小按钮,绝不重复 inline 大图 —— 因为产物工具上面已经 inline 过了)。**改动**:`renderMessages` 3 处(tool 卡 / assistant 正文 / assistant tool_calls args)+ SSE 2 处(tool_call / tool_result)按上面规则改写;`pickFresh`(seenRels 读写)只在产物工具的两处保留(防同图 inline 二次),assistant 正文改成 `renderArtifactBarHtml(extractArtifactRels(...), false)` —— 不读不写 seenRels,直接 chip。SSE 处 `upgradeMediaArtifacts` 同步 gate 到 `if (isProducer)` 下,非产物工具不发 blob fetch。**为什么 chip 重复出现无害**:chip 是 monospace 小字 + 5px 圆角小按钮,占 1 行;同路径在 tool 结果 + assistant 正文都出现,体感是"工具产出了它 + 助手又提到它",是合理叙事节点,跟"两张同样的大 PNG 占整屏"完全不同视觉量级。**对比方案**:① 助手正文也走 seenRels 但区分 chip/inline 类型(seen=path 同时也存 cat),只去重 inline、放过 chip — 复杂度涨,逻辑分支多;② 后端 tool_result 元信息显式标 `produced_files`(前端不再启发式抽路径)— 干净但 SSE/历史回放/seedream 全要改,成本最大,不上。当前方案 4 行实现意图。**没动**:`extractArtifactRels` regex / `_categorize` / 媒体 blob 缓存 / chip 点击委托 / 后端 / DESIGN(纯前端 UX 反复)/ RUN。**遗留**:用户提"绝对路径有些没挂 chip",等具体例子再排(可能是 wd_name 与历史路径段不齐 / 跨 task 路径)。
|
||||
|
||||
- **dev SPA chip 维度解绑产物工具白名单 + `renderArtifactBarHtml` 加 `allowInlineMedia` 参数**:用户反馈"text 里必须挂 chip(不需要图片 banner,就是原先的 chip)"。复盘 `1e4548d`:它把"图片不该被无关工具误内联"和"chip 该不该挂"绑成同一个白名单 gate,砍多了 —— 用户看到 grep/read 结果里的路径直觉上想点开预览,但 chip 被一起锁了。**修法**:把 gate 降级到"图片/视频是否 inline"那层。`renderArtifactBarHtml(rels, allowInlineMedia=true)` 加第二参,false 时图片/视频也走 `.art-chip` 按钮(点开仍弹预览 modal,跟其它格式一致);4 处 tool 调用点(`renderMessages` tool 卡 + assistant tool_calls args + SSE tool_call + SSE tool_result)解绑 `ARTIFACT_PRODUCING_TOOLS.has(...)` gate,改成无条件 `extractArtifactRels`,只把 `inlineMedia = ARTIFACT_PRODUCING_TOOLS.has(name)` 透传给 `renderArtifactBarHtml` 的第二参;SSE 两处的 `upgradeMediaArtifacts(asstCard)` 也 gate 到 `if (inlineMedia)` 下,非产物工具就不发 blob fetch(不必要 / 也不会 inline);assistant 正文(行 1416)沿用默认 true,继续 inline。**两类白名单语义**:`ARTIFACT_PRODUCING_TOOLS` 现在专管"inline 大图/视频",chip 自身不受其限;`extractMediaBanner` 的"媒体 banner"(seedream/seedance tool_result 首行 model/size/cost/elapsed)仍单独白名单(它依赖工具协议的 key=value 格式,跟产物维度独立)。**Tradeoff**:① grep 一个老 PNG → 现在会挂 chip,但**不**会内联大图 — 用户能点开预览但屏幕不被无关老图占满;② 用户提了"绝对路径有些没挂 chip" — 推后再修,先验证当前方案符合预期。**没动**:`extractArtifactRels` 本身(regex 不变,现 4 处调用点都走它)、`_categorize` / chip 点击委托 / 媒体 blob 缓存、后端、DESIGN(纯前端 UX,无架构/schema 变化)、RUN(无对外行为变化)。
|
||||
|
|
@ -37,8 +39,6 @@
|
|||
|
||||
- **dev SPA 中间产物 chip / inline 图去重 + CLAUDE.md 新增"实施前先对方案"段**:用户报"工具结果里挂了一张图,后面 assistant 正文又挂了一张同图,有点重复"。根因:`renderArtifactBarHtml(extractArtifactRels(...))` 在 5 个渲染点都跑过 — `renderMessages` 里 tool 结果卡 / assistant 正文 / assistant tool_calls args 各一处,`handleSseEvent` 里 tool_call / tool_result 各一处。同一 rel 在 tool 结果与紧随 assistant 正文里同时出现(模型 echo 路径)→ 历史回放渲两次。修法:`renderMessages` 顶部建 `const seenRels = new Set()` + `pickFresh(rels)` 闭包,3 个调用点(tool 结果 / assistant 正文 / tool_calls args)全部包一层 — chronological 顺序,首次出现保留(tool 结果常在前),后续重复丢;SSE `ctx` 加 `seenRels: new Set()`,tool_call / tool_result 两 handler 共享去重。**对比 querySelector 版**:DOM 查询版 O(n²)(每条 card 渲染时扫 wrap 已有 `[data-rel]`),Set 版 O(n) 无查询,代码量相同还把"什么是 source of truth"明确(不依赖 DOM 已挂 chip 这个隐式状态)。**CLAUDE.md 增段**:开发期需求漂移快,非平凡改动(改 >1 文件 / 行为变化 / 多候选取舍)动手前先用自然语言把方案讲给用户确认,认可后再写代码;一次性 bug 修 / 字面量 / 样式微调可直接动手。方案描述要包含问题定位(文件 / 行号)+ 至少 1 个替代方案 + 涉及性能 / 兼容 / 数据迁移时主动说。**没动**:`extractArtifactRels` / `renderArtifactBarHtml` 实现(它们内部本身已 Set 去重单次调用内重复)、`_workingDirName` / chip 点击委托 / 媒体 blob 缓存、后端、DESIGN(纯前端 UX 修复)、RUN(无对外行为变化)。
|
||||
|
||||
- **2026-05-20 / paths.py 砍 ROOT 外路径**:`to_db_path` 越界 → raise(原 `str(pp)` 静默存绝对),`from_db_path` 删 `is_absolute()` 分支(只 `ROOT / s`)。**理由**:写入入口(`web/app.py POST /v1/tasks` → `working_dir_from_name`)只接 simple name join workspace,DB 里只可能存相对串(0002 migration 已清理历史绝对);ROOT 外分支是防御性死代码,符合 CLAUDE.md「不留兼容层」。**没动**:`core/storage/utils.py` no-subtask(`from_db_path` 接口同语义)、alembic(无数据需迁)、调用方(`web/app.py` / `core/agent_builder.py` / `core/export_docx.py` 全不改)。DESIGN §7.4 注释同步。
|
||||
|
||||
- **dev SPA 顶栏加生图模型下拉 + 中间产物图片/视频内联展示**:用户要 ① 项目栏右侧的模型选区加一个生图模型选择(目前只 seedream,默认选上),② 中间产物若是图片/视频直接在对话区展示(不只点击预览)。**生图选择范式判断**:不入 task 列(seedream/seedance 是 tool 范畴,non-chat,task 切粒度太粗;且现在仅一个 variant,加 DB 列纯负债)→ 走**消息级**:UI 下拉的选择跟 `POST /v1/tasks/{id}/messages` body 的 `image_model` 字段一起发,`_run_agent_bg` → `build_agent(image_variant=...)` → seedream tool 装配时按 key 挑 yaml 里 `image` 段的对应 variant_cfg;不入 DB,本 run 内多次 tool call 共用,下条消息可重选。**后端新接口** `GET /v1/image_models`(scan `config/media/doubao.yaml` image 段返 `{variant, display_name, model_id, price_cny_per_image, is_default}` 列表;不要求 `ARK_API_KEY` 已设 — UI 只展示元数据,真调时 `ArkConfig.load()` 那侧再过 key 检查),`_resolve_image_model(variant)` 校验存在性(空串 → 透传走 fallback,非空 → 必须命中 yaml,否则 400)。`agent_builder.build_agent` 新参 `image_variant: str = ""`:非空且命中 → 用它装 SeedreamTool;不命中(yaml 改动后旧选择 stale)静默回 fallback;空 → 沿用"取第一个 variant"。**前端**:`state.imageModels` + `state.imageModel`(per-session,不持久);`loadModels()` 同时拉 `/v1/image_models` 并锁第一个为默认;`renderImageModelDropdown()` 在 `renderModelDropdown` 旁画一个 `生图 [▾]`(yaml 无 variant 时不画);`onChangeImageModel` 纯前端 state 更新无 PATCH;`sendMessage` 把 `state.imageModel` 跟在 POST body 上发出去。**内联媒体**:`_EXT_GROUPS` 加 `video: {mp4,webm,mov,mkv,m4v}` 集合;`renderArtifactBarHtml` 按 `_categorize(rel)` 分支:image/video → 占位 `<span class="art-media[-image|-video]" data-rel="...">`,其他 → 沿用 `.art-chip`;新 `upgradeMediaArtifacts(root)` DOM walk 把占位异步换 `<img>`/`<video controls>`,经 `_fetchMediaBlobUrl(rel)`(Bearer header 不能直 `<img src=>` → fetch 拿 blob 转 `URL.createObjectURL`)+ `_mediaArtifactCache` 同 rel 复用;模态 `openFilePreview` 加 `_showVideo`(`<video controls autoplay>`);chip / `.art-media-image` 点击 → 弹模态放大,视频走原生 controls 不拦截(点击=暂停/播放,全屏走浏览器按钮,弹模态反而打断播放)。**缓存生命周期**:cache 走 `selectTask` 切换时 `_flushMediaArtifactCache` 撤销 blob URL;同 task 流式 / 历史回放复用,/clear 不清(FS 文件保留,rel 仍有效);logout 走 `location.reload()` 全清。**没动**:DESIGN(无架构/schema 变化)、Task / TaskState / TaskCreateRequest / TaskPatchRequest schema(早期一稿曾加 `Task.image_model` 列 + 0008 migration,用户复盘后判定 task 粒度不合适撤回 — 测试期 DB downgrade 不留 0008,模型/字段也清干净)、`record_image_usage`(model_profile 仍走 `doubao.<variant_key>`,自然跟着 build_agent 选的 variant)、artifact chip 抽取(其他类型仍走 chip + openFilePreview)。**Tradeoff**:① 不入 DB → 浏览器刷新会回到 yaml 第一个 variant 默认值,但用户中途切换的"上次选了什么"丢了 — 改用 localStorage 即可,先简单版;② 内联图片对每条带产物的消息都触发 fetch,blob URL 累积在 cache 里直到切 task 才回收,长会话场景 + 数十张图理论上内存吃几百 MB(普通 PNG 1-3MB),后续若问题再加 IntersectionObserver 懒加载。
|
||||
|
||||
- **LLM 调用切 streaming(cancel 秒退 + 前端打字机)+ 发送/停止合并单按钮**:用户反馈"点停止要等很久"+"发送/停止可以合并"。**问题 1 根因**:`litellm.completion(...)` 是同步阻塞,Python 没标准办法外部线程打断同步 IO;`broker.is_cancelled` 只在 `core/loop.py:run()` 每轮 LLM 前 + tool_calls 之间 poll,所以 cancel 必须等当前整轮 generation 跑完才生效(deepseek v4 + thinking + 长输出几十秒)。**修法**:切 `litellm.completion(stream=True)`,`core/llm.py` 加 `chat_stream()` generator(`stream_options={"include_usage": True}` 让最后 chunk 带 usage;`_build_kwargs` 抽出来给 chat/chat_stream 共用,免重复参数装配);`core/loop.py` 主循环改 `_stream_llm()` 流式迭代,chunk 间 poll cancel,命中 `break` + generator finally `stream.close()` 关底层 httpx 连接;chunks 攒齐用 `litellm.stream_chunk_builder(chunks, messages=...)` 拼回完整 response(自动处理 tool_call name/arguments 跨 chunk 拼接)给 tool_calls 解析 + usage 记账。**cancel 语义对齐**:stream 中途 cancel → 已收 chunk 丢弃不入库不记账(下次 resume 上下文干净);stream 完结后 tool_calls 之间 cancel → 沿用原 `_fill_cancelled_tool_results` 补 cancelled tool message。**前端打字机免费 bonus**:`dev.html:1500-1510` 早就备好接 `text` 事件的 `delta` 字段(rAF 节流 + nearBottom 不抢滚动 + 流中不跑 highlight),但后端原来发的是 `{"type":"text","content":"<整段>"}` 字段名对不上 → 前端永远 match 不到。新逻辑在 `_stream_llm` 里 chunk 到达即 `_emit({"type":"text","delta":...})`,前端自然激活打字机。loop.py 主流程末尾不再 emit 整段 text(content 已通过 delta 流过)。**问题 2 UI**:`web/static/dev.html` 把 `#chat-send`(发送)+ `#chat-cancel`(停止)合并为单 `#chat-action`,新 helper `setActionMode(mode)`(idle="发送" primary 红实心 / streaming="停止" danger 红边 / cancelling="停止中…" disabled);form submit + `chatAction()` 根据 `state.streaming` 分派 sendMessage / cancelCurrentTask;streaming 期间 Enter 不触发停止(textarea 编辑下一条草稿,误触发风险高)。**Smoke 验证**:① 18 chunks 流式 + 文本拼回 ✓ ② tool_call 49 chunks 跨片拼回 `{"a":7,"b":5}` 完整 ✓ ③ 提前 break + close 仅 0.7s(模拟"写 500 字散文中途 cancel")✓。**Tradeoffs**:① streaming 重试只在连接建立阶段(没拿到第一个 chunk 前)生效,中途断流不续 — 实务罕见;② timeout 行为从"整段 timeout"变"chunk 间隔 timeout",新模型接入要测 thinking 不吐 reasoning chunk 的极端情况;③ litellm `stream_chunk_builder` + `stream_options.include_usage` 在 deepseek/doubao/glm/openai 标准协议都正常,新接非主流 provider 时验证。**没动**:probe.py(仍用同步 `chat()`,离线探测不需要 cancel)、CLI 路径(probe 走 chat 不受影响)、broker / SSE 帧格式 / `record_chat_usage` 入参 / DB schema / messages 入库时机(拼回 response 跟非流式等价)。**文档**:`DESIGN.md` §3.1 翻转 tradeoff 表「LLM 同步 call 不可中断」→「LLM 调用走 streaming」+ §7 API 表 cancel 描述改 chunk-level 延迟;`RUN.md` cancel 接口 + 故障兜底表对应行同步;`web/app.py` 两条 docstring 同步。
|
||||
|
|
|
|||
|
|
@ -0,0 +1,92 @@
|
|||
---
|
||||
name: research
|
||||
description: 查 paper_server 文献库(基于 OpenAlex 元数据 + Sci-Hub 下载的内部部署)。用户要查文献、找 DOI、拉 PDF、做文献综述、写带引文的申报书 / 研究方案 / 调研报告时使用。
|
||||
---
|
||||
|
||||
# Research
|
||||
|
||||
paper_server 是内部部署的 Django 文献库:元数据来自 OpenAlex,PDF 由 Sci-Hub 异步抓取。本 skill 给你三个 helper(`search` / `get_paper` / `fetch_pdf`),用 `run_python` 调用,**不要**自己 `httpx` 裸调 API。
|
||||
|
||||
## 何时用
|
||||
|
||||
- 用户要查 / 找 / 看 / 推荐文献
|
||||
- 要 DOI、要某篇 PDF、要 abstract
|
||||
- 写申报书 / 研究方案 / 调研报告的"国内外现状"段需要真实文献支撑
|
||||
- 配合 `proposal` skill 的「立项依据」起草 —— 先 `search` 拿候选,`get_paper` 看 abstract 决定要不要引
|
||||
|
||||
## 何时不用
|
||||
|
||||
- 用户只问通识(直接答即可,不需要文献支撑)
|
||||
- 用户已经给了具体文献清单(直接用,不要二次校验)
|
||||
- paper_server 没覆盖的领域(本库主打中文工程 / 材料 / 土木 / 化学;前沿 AI / 数学 / 纯理论 paper 命中率低 —— 命中 0 条直接告诉用户,不要瞎编)
|
||||
|
||||
## 准备
|
||||
|
||||
```python
|
||||
from skills.research.paper import search, get_paper, fetch_pdf
|
||||
```
|
||||
|
||||
(import 路径由 `run_python` 注入的 `PYTHONPATH` 提供,直接写就行,不必折腾 `sys.path`)
|
||||
|
||||
## 三个函数
|
||||
|
||||
### `search(keyword="", year=None, doi="", has_pdf=None, limit=10) -> list[dict]`
|
||||
|
||||
搜文献,返回精简列表(每条只含 id / doi / title / first_author / publication_year / publication_name / has_fulltext_pdf / has_abstract / type)。
|
||||
|
||||
- `keyword`: paper_server 的 search 字段,匹配 title / first_author / first_author_institution
|
||||
- `year`: 精确年份(目前只支持 exact)
|
||||
- `doi`: 精确 DOI(命中 0 / 1 条)
|
||||
- `has_pdf=True` 仅返已下好 PDF 的;`False` 仅返没 PDF 的;`None` 都返
|
||||
- `limit`: 默认 10,上限 50
|
||||
|
||||
```python
|
||||
# 找最近 5 篇关于"水泥水化"且已下好 PDF 的
|
||||
papers = search(keyword="水泥水化", has_pdf=True, limit=5)
|
||||
for p in papers:
|
||||
print(p["title"], p["doi"], p["publication_year"])
|
||||
```
|
||||
|
||||
### `get_paper(id_or_doi) -> dict`
|
||||
|
||||
取单条完整 metadata + abstract。`id_or_doi` 既接受 paper_server 内部 id,也接受 DOI(自动解析)。
|
||||
|
||||
```python
|
||||
paper = get_paper("10.1016/j.cemconres.2020.106156")
|
||||
print(paper["title"])
|
||||
print(paper["abstract"]) # 没 abstract 时是空串,不会抛
|
||||
```
|
||||
|
||||
### `fetch_pdf(id_or_doi, working_dir) -> str`
|
||||
|
||||
下载 PDF 到 `<working_dir>/papers/<safe_doi>.pdf`,返回相对路径 `papers/<safe_doi>.pdf`(safe_doi 把 `/` 换成 `_`)。已存在跳过下载直接复用。
|
||||
|
||||
```python
|
||||
rel = fetch_pdf("10.1016/j.cemconres.2020.106156", working_dir=r"D:/projects/zcbot/workspace/users/<uid>/<wd>")
|
||||
# rel == "papers/10.1016_j.cemconres.2020.106156.pdf"
|
||||
```
|
||||
|
||||
`has_fulltext_pdf=False` 时抛 `RuntimeError` —— 这时该 paper 服务器侧还没下到 PDF,告诉用户不要硬等。
|
||||
|
||||
## 标准工作流
|
||||
|
||||
1. **search**:按关键词 / 年份缩窄候选(`limit=10` 起,不够再扩)
|
||||
2. **筛选**:扫返回列表,挑相关的(看 title + first_author + year),`has_fulltext_pdf=True` 的优先
|
||||
3. **get_paper**:对每篇候选拿 abstract,确认确实切题再继续
|
||||
4. **fetch_pdf**:确定要全文细读时才下载(用户没说要全文就别下,abstract 已足够覆盖大多数引用场景)
|
||||
5. **read PDF**:`fetch_pdf` 返回相对路径,用主 agent 的 `read` 工具读 PDF 提取关键段(zcbot 已内置 PDF 文本提取)
|
||||
|
||||
## 错误处理
|
||||
|
||||
- 网络超时 / paper_server 不可达:`httpx.ConnectError` / `httpx.TimeoutException` —— 直接告诉用户"paper_server 暂时连不上",不要重试堆栈刷屏
|
||||
- `doi 未命中` / `doi 命中多条`:`get_paper` / `fetch_pdf` 内部 `_resolve_to_id` 抛 `ValueError` —— DOI 拼写错或库里真没收录,改 keyword 重搜
|
||||
- `has_fulltext_pdf=False`:`fetch_pdf` 抛 `RuntimeError(reason=...)` —— 服务器还没下到 PDF;告诉用户"这篇 paper_server 还没下到 PDF,可读 abstract 或换一篇"
|
||||
- `abstract` 字段为空字符串:正常情况,不是错;告诉用户"这篇没收录摘要"即可
|
||||
|
||||
## 反模式
|
||||
|
||||
- 用 `httpx` / `requests` 裸调 paper_server API(走 helper,免得 base_url / auth / 字段名漂移时四处改)
|
||||
- `search(limit=50)` 一次拉满后扔给 LLM 全文 dump(只 print 前 5-10 条精简就够,要全部让用户自己 `print(papers)`)
|
||||
- 没看 abstract 就 `fetch_pdf`(80% 场景 abstract 够用,下载 PDF 慢且费带宽)
|
||||
- 编造 DOI / title / 作者 —— 不在 paper_server 库里就**明确告诉用户"未命中"**,不要凭训练数据脑补
|
||||
- 把 `fetch_pdf` 返回的相对路径当绝对路径用(它是相对 `working_dir` 的)
|
||||
|
|
@ -0,0 +1,118 @@
|
|||
"""paper_server 客户端 helper。base_url 默认硬编码,env 可覆盖。"""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Optional
|
||||
|
||||
import httpx
|
||||
|
||||
_BASE_URL = os.environ.get("PAPER_SERVER_URL", "http://paper.xxhhcty.xyz:8080").rstrip("/")
|
||||
_API = f"{_BASE_URL}/api/resm/paper"
|
||||
_PDF = f"{_BASE_URL}/resm/paper" # /resm/paper/<id>/pdf/
|
||||
_TIMEOUT = 30.0
|
||||
|
||||
_LIST_FIELDS = (
|
||||
"id",
|
||||
"doi",
|
||||
"title",
|
||||
"first_author",
|
||||
"publication_year",
|
||||
"publication_name",
|
||||
"has_fulltext_pdf",
|
||||
"has_abstract",
|
||||
"type",
|
||||
)
|
||||
|
||||
|
||||
def _safe_doi(doi: str) -> str:
|
||||
return doi.replace("/", "_")
|
||||
|
||||
|
||||
def _is_doi(s: str) -> bool:
|
||||
return "/" in s and s.lstrip().startswith("10.")
|
||||
|
||||
|
||||
def _resolve_to_id(id_or_doi: str) -> str:
|
||||
"""传 id 直接返,传 doi → 调 list 接口取 id。命中 0 / 多条都抛。"""
|
||||
s = id_or_doi.strip()
|
||||
if not _is_doi(s):
|
||||
return s
|
||||
r = httpx.get(_API + "/", params={"doi": s}, timeout=_TIMEOUT)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
results = data.get("results") if isinstance(data, dict) and "results" in data else data
|
||||
if not results:
|
||||
raise ValueError(f"doi 未命中: {s}")
|
||||
if len(results) > 1:
|
||||
raise ValueError(f"doi 命中多条({len(results)}): {s}")
|
||||
return results[0]["id"]
|
||||
|
||||
|
||||
def search(
|
||||
keyword: str = "",
|
||||
year: Optional[int] = None,
|
||||
doi: str = "",
|
||||
has_pdf: Optional[bool] = None,
|
||||
limit: int = 10,
|
||||
) -> list[dict]:
|
||||
"""搜文献,返回精简列表。
|
||||
|
||||
keyword: paper_server search 字段,匹配 title / first_author / first_author_institution
|
||||
year: 精确年份(paper_server 当前只支持 exact)
|
||||
doi: 精确 DOI(命中 0/1 条)
|
||||
has_pdf: True 仅返已下好 PDF;False 仅返没 PDF;None 都返
|
||||
limit: 默认 10,上限 50
|
||||
"""
|
||||
if limit > 50:
|
||||
limit = 50
|
||||
params: dict[str, Any] = {"page_size": limit}
|
||||
if keyword:
|
||||
params["search"] = keyword
|
||||
if year is not None:
|
||||
params["publication_year"] = year
|
||||
if doi:
|
||||
params["doi"] = doi
|
||||
if has_pdf is True:
|
||||
params["has_fulltext"] = "true"
|
||||
elif has_pdf is False:
|
||||
params["has_fulltext"] = "false"
|
||||
r = httpx.get(_API + "/", params=params, timeout=_TIMEOUT)
|
||||
r.raise_for_status()
|
||||
data = r.json()
|
||||
results = data.get("results") if isinstance(data, dict) and "results" in data else data
|
||||
return [{k: p.get(k) for k in _LIST_FIELDS} for p in results[:limit]]
|
||||
|
||||
|
||||
def get_paper(id_or_doi: str) -> dict:
|
||||
"""取单条 metadata + abstract(走 retrieve 端点)。
|
||||
|
||||
abstract 字段由 paper_server retrieve serializer 提供;无 PaperAbstract 行时返空串。
|
||||
"""
|
||||
pid = _resolve_to_id(id_or_doi)
|
||||
r = httpx.get(f"{_API}/{pid}/", timeout=_TIMEOUT)
|
||||
r.raise_for_status()
|
||||
return r.json()
|
||||
|
||||
|
||||
def fetch_pdf(id_or_doi: str, working_dir: str) -> str:
|
||||
"""下载 PDF 到 <working_dir>/papers/<safe_doi>.pdf,返回相对路径 'papers/<safe_doi>.pdf'。
|
||||
|
||||
已存在跳过下载直接复用。paper.has_fulltext_pdf=False → 抛 RuntimeError。
|
||||
"""
|
||||
paper = get_paper(id_or_doi)
|
||||
if not paper.get("has_fulltext_pdf"):
|
||||
reason = paper.get("fail_reason") or "no PDF on server"
|
||||
raise RuntimeError(f"paper has no PDF: id={paper.get('id')} reason={reason}")
|
||||
safe = _safe_doi(paper["doi"])
|
||||
rel = f"papers/{safe}.pdf"
|
||||
dest = Path(working_dir) / rel
|
||||
if dest.exists() and dest.stat().st_size > 0:
|
||||
return rel
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
with httpx.stream("GET", f"{_PDF}/{paper['id']}/pdf/", timeout=60.0) as resp:
|
||||
resp.raise_for_status()
|
||||
with open(dest, "wb") as f:
|
||||
for chunk in resp.iter_bytes(chunk_size=64 * 1024):
|
||||
f.write(chunk)
|
||||
return rel
|
||||
|
|
@ -56,6 +56,7 @@ class RunPythonTool(Tool):
|
|||
if any(p in u for p in _SENSITIVE_PATTERNS):
|
||||
del env[k]
|
||||
env["PYTHONIOENCODING"] = "utf-8"
|
||||
env["PYTHONPATH"] = str(self.base_dir) + os.pathsep + env.get("PYTHONPATH", "")
|
||||
|
||||
result = subprocess.run(
|
||||
[sys.executable, script_path],
|
||||
|
|
|
|||
Loading…
Reference in New Issue