feat(tools): documents/pymatgen secret-bearing 能力改 host-side tools,key 不进 sandbox

新增 tools/documents.py(document_list_kb/search/download)和 tools/materials_project.py
(mp_search_summary/get_structure/get_entries),key 只在宿主读取,sandbox/run_python 拿不到。
agent_builder 仅在对应 env 存在时注册。删 skills/pymatgen/materials.py::mp_rester() 旧入口,
smoke 改走 host tool。同步 DESIGN §6.7 secret-bearing 规则 + RUN/SKILL_LIST/两个 SKILL.md。

实测:MP step D 真连 api.materialsproject.org 返 403(工具行为正确,干净透传 [Error]),
疑似 .env 里 legacy key 在新版 mp-api 失效,待换 next-gen key 再验。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@
This commit is contained in:
caoqianming 2026-06-01 09:30:15 +08:00
parent 4bd074079a
commit 81ecfd7d37
12 changed files with 614 additions and 166 deletions

View File

@ -404,10 +404,19 @@ create index on usage_events (model_profile, created_at);
6. **工具按信任域二分,Executor 内部 dispatch**(2026-05-26 修正,原"host 工具走 paths.py::resolve_user_path 校验"是假命题,代码里没那函数;Ubuntu dogfood 第一次切 docker backend 发现 glob 工具仍列 host repo `.git/.venv/...`,改物理边界替代代码护栏): 6. **工具按信任域二分,Executor 内部 dispatch**(2026-05-26 修正,原"host 工具走 paths.py::resolve_user_path 校验"是假命题,代码里没那函数;Ubuntu dogfood 第一次切 docker backend 发现 glob 工具仍列 host repo `.git/.venv/...`,改物理边界替代代码护栏):
- **Container exec backend**:`shell` / `run_python` / `read` / `write` / `edit` / `glob` / `grep` — 全走 docker exec。shell/run_python 是任意代码必须隔离;fs 工具(read 等 5 个)以前在 host 跑 `base_dir = Path.cwd()` 无 user_root 校验,能读 host 全 fs(`/etc/passwd` / zcbot 源码 / `~/.ssh/`),改进容器后 `user_root=/workspace` 是物理边界。fs 工具调用形态:`docker exec --user zcbot --workdir /workspace/<wd> -i <c> python /sandbox/tool_runner.py <name>` + stdin 喂 JSON args(CJK / 引号 / 路径分隔符透明传,不被 shell metachar 切)。`tool_runner.py` 在镜像里 `/sandbox/`,复用 `tools/fs.py` 的 Tool 子类(`COPY tools/ /sandbox/tools/`);skill references 通过额外的 `<repo>/skills:/sandbox/skills:ro` mount 暴露(只读)。 - **Container exec backend**:`shell` / `run_python` / `read` / `write` / `edit` / `glob` / `grep` — 全走 docker exec。shell/run_python 是任意代码必须隔离;fs 工具(read 等 5 个)以前在 host 跑 `base_dir = Path.cwd()` 无 user_root 校验,能读 host 全 fs(`/etc/passwd` / zcbot 源码 / `~/.ssh/`),改进容器后 `user_root=/workspace` 是物理边界。fs 工具调用形态:`docker exec --user zcbot --workdir /workspace/<wd> -i <c> python /sandbox/tool_runner.py <name>` + stdin 喂 JSON args(CJK / 引号 / 路径分隔符透明传,不被 shell metachar 切)。`tool_runner.py` 在镜像里 `/sandbox/`,复用 `tools/fs.py` 的 Tool 子类(`COPY tools/ /sandbox/tools/`);skill references 通过额外的 `<repo>/skills:/sandbox/skills:ro` mount 暴露(只读)。
- **Host in-process backend**:`load_skill` / `web_search` / `web_fetch` / `seedream` / `seedance` — 持 Bocha/ARK API key 不能塞容器 env(SaaS 时 key 泄漏面增加);`load_skill` 是 SkillRegistry 内存查找,无 fs 访问越界可能。Step 4 egress proxy 之后再讨论这几个工具的容器化方案(media tool 调远端 API 走 proxy 比 key 入容器更直)。 - **Host in-process backend**:`load_skill` / `web_search` / `web_fetch` / `seedream` / `seedance` / `document_*` / `mp_*` — 持 Bocha/ARK/document_search/MP API key 不能塞容器 env(SaaS 时 key 泄漏面增加);`load_skill` 是 SkillRegistry 内存查找,无 fs 访问越界可能。Step 4 egress proxy 之后再讨论这几个工具的容器化方案(media tool 调远端 API 走 proxy 比 key 入容器更直)。
- Dispatcher(`DockerExecutor`)内部分流,使用方(`AgentLoop`)零感知。**接口形状按"未来若需全部进容器 + 内部 tool-runner unix socket RPC"留好**,升级触发信号见下表。 - Dispatcher(`DockerExecutor`)内部分流,使用方(`AgentLoop`)零感知。**接口形状按"未来若需全部进容器 + 内部 tool-runner unix socket RPC"留好**,升级触发信号见下表。
- **代价**:每个 fs tool call 多 ~200ms docker exec overhead;对话级 N≤15 → 总 1-3s,LLM 推理时间 5-30s 下面噪声。镜像 build 多一步 `COPY tools/`,rebuild 增量 ~5s。 - **代价**:每个 fs tool call 多 ~200ms docker exec overhead;对话级 N≤15 → 总 1-3s,LLM 推理时间 5-30s 下面噪声。镜像 build 多一步 `COPY tools/`,rebuild 增量 ~5s。
7. **Secret-bearing domain tools 不进 sandbox,不做 key 下发**(2026-06-01 补充):
- 原则:凡是需要 `*_API_KEY` / OAuth token / DB credential 的能力,**不能**以 `run_python` helper 形态要求容器读 env,也不能做"credential broker 给 sandbox 发短期 key"。sandbox 内的任意代码可 `print(os.environ)` / monkeypatch SDK / exfiltrate 请求头;短期 token 只缩短有效期,不改变"不可信代码拿到 secret"这个根因。
- 正确形态:把这类能力做成 **host-side JSON tool**。LLM 传非敏感业务参数 → host tool 从宿主 env / secret manager 取 key 调远端 API → 对返回做字段裁剪 / 大小限制 / 配额计量 / 审计 → 只把业务结果或写入 workspace 的文件路径返回给模型。容器最多读到落盘产物,永远读不到 key / Authorization header。
- 现有命中问题:
- `documents` skill 当前 `skills/documents/client.py` 要在 `run_python` 里读 `DOCUMENT_SEARCH_API_KEY`,所以 docker sandbox 下必然不可用;应改为 host tool `document_list_kb` / `document_search` / `document_download`,key 只在 host client 内使用,download 写入当前 task_dir 的 `documents/` 后返相对路径。
- `pymatgen` skill 的离线计算(`Structure.from_file` / `XRDCalculator` / `SpacegroupAnalyzer`)继续在 sandbox 跑;Materials Project 联网查询不能让 `mp_rester()` 在 sandbox 读 `MP_API_KEY`,应拆成 host tool `mp_search_summary` / `mp_get_structure` / `mp_get_entries`。host tool 返回裁剪后的 summary 或把 CIF/JSON 写到 task_dir,后续离线 pymatgen 再读文件计算。
- 注册规则:host tool 仅在对应 env 存在时注册;未配置时 schema 不暴露,skill 文档提示降级路径。这样模型不会看到一个永远失败的工具,也不会被诱导在 sandbox 中寻找 secret。
- 审计与配额:这类 host tool 按 user/task 记录 `usage_events` 或专门 audit 表(请求类型、目标服务、返回条数、下载字节、耗时、错误码),纳入月度 cost / 网络下载量 / 单次结果大小限制。返回给 LLM 的正文必须默认截断,大文本落文件后由 `read` 按需读取。
**升级触发信号(写下来防遗忘,反向兜底:无信号不升级)**: **升级触发信号(写下来防遗忘,反向兜底:无信号不升级)**:
| 升级方向 | 触发信号 | 不升级的理由 | | 升级方向 | 触发信号 | 不升级的理由 |

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9` > 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-05-29(删 docker exec argv 里的 setsid 修延迟 stdout 丢失 + `_run_subprocess` 重写修 communicate poll loop bug + 3 个 SKILL.md sandbox 凭证可用性校准 + web 端 tool_call 标题行改显中文活动描述并修 args 字段 bug + Seedream 5.0 i2i base64 通路实测 + DESIGN §8.1 落 i2i/vision 后续步骤) 最后更新:2026-06-01(documents / Materials Project secret-bearing 能力改 host-side tools,key 不进 sandbox)
--- ---
@ -21,6 +21,11 @@
## 已完成关键能力 ## 已完成关键能力
### 2026-06-01
- **documents / Materials Project secret-bearing 能力改 host-side tools,key 不进 sandbox**:新增 `tools/documents.py` 三工具(`document_list_kb` / `document_search` / `document_download`)和 `tools/materials_project.py` 三工具(`mp_search_summary` / `mp_get_structure` / `mp_get_entries`),`core/agent_builder.py` 仅在宿主 env `DOCUMENT_SEARCH_API_KEY` / `MP_API_KEY` 存在时注册。`document_download` / `mp_get_structure` / `mp_get_entries` 绑定当前 task_dir 写文件,模型不能传 working_dir;`document_search` 默认截断 `md_content`,避免整篇论文进上下文。同步更新 `DESIGN.md` secret-bearing domain tools 规则、`RUN.md` env / 故障兜底、`SKILL_LIST.md`、`skills/documents/SKILL.md`、`skills/pymatgen/SKILL.md`;旧 `run_python` helper 不再是带 key API 主路径。测试 `tests/test_secret_host_tools.py` 覆盖 documents search 截断、download 固定 task_dir、MP tool 不泄露 host key。
- **删 `skills/pymatgen/materials.py::mp_rester()` + `scripts/smoke_scientific_skills.py` 改走 host tool**:`mp_rester` 是 sandbox 内读 `MP_API_KEY` 的旧入口,host tool 化后多余且违背"key 不进 sandbox",直接删(连带清 `import os` / `contextlib.contextmanager`,只留 `CEMENT_PHASES` / `lookup_phase`);smoke A6 / step D 改用 `MaterialsProjectSearchSummaryTool`。**实测发现**:step D 真连 `api.materialsproject.org`**403**(工具行为正确,403 干净透传成 `[Error]` 不崩) —— 当前 `.env` 里的 `MP_API_KEY` 被 MP 服务端拒,疑似 legacy 旧版 key 在新版 `mp-api` 上失效,需用户去 next-gen materialsproject.org dashboard 重新生成长 key 再验。documents 工具未联网实测(无现成可验证调用),逻辑同 web_search 形态。
### 2026-05-29 ### 2026-05-29
- **Seedream 5.0 i2i base64 通路 probe + DESIGN §8.1 后续步骤落册**:用户场景"调 seedream 出图 → 基于该图二次修改" / "上传外部参考图让 agent 据此干活"两条路径,主模型 DeepSeek V4 纯文本覆盖不了。详评 3 方案后选 **E + C 组合**(`tools/seedream.py` 加 `reference_images` 参数走 seedream 5.0 i2i + 新增 `tools/look_at_image.py` 走豆包 Seed 1.6 vision tool 调度),否决 A(换豆包当主 chat,降 code / tool calling 质量 + 改 loop/memory 工程面 5×)/ B(后台隐式 vision 路由,失 agentic 控制 + 描述质量黑盒 + token 浪费)。**写探针 `scripts/probe_seedream_i2i.py` 实测**:豆包 Seedream 5.0(`doubao-seedream-5-0-260128`)`/images/generations` endpoint **接受 `image_urls=["data:image/png;base64,..."]`**,200 返回新图 TOS URL + `usage.generated_images=1`(约束:输出 `size` ≥3686400 像素 / ~1920²,单张参考 ≤10MB,最多 14 张);base64 通路成立 → **内网部署无需对象存储中介**,排除最大工程不确定性。**E+C 实施清单 / 风险 / 升级到 A 的信号已落 DESIGN §8.1,本版仅 probe + design,tool 与 prompt 改造未启动**。 - **Seedream 5.0 i2i base64 通路 probe + DESIGN §8.1 后续步骤落册**:用户场景"调 seedream 出图 → 基于该图二次修改" / "上传外部参考图让 agent 据此干活"两条路径,主模型 DeepSeek V4 纯文本覆盖不了。详评 3 方案后选 **E + C 组合**(`tools/seedream.py` 加 `reference_images` 参数走 seedream 5.0 i2i + 新增 `tools/look_at_image.py` 走豆包 Seed 1.6 vision tool 调度),否决 A(换豆包当主 chat,降 code / tool calling 质量 + 改 loop/memory 工程面 5×)/ B(后台隐式 vision 路由,失 agentic 控制 + 描述质量黑盒 + token 浪费)。**写探针 `scripts/probe_seedream_i2i.py` 实测**:豆包 Seedream 5.0(`doubao-seedream-5-0-260128`)`/images/generations` endpoint **接受 `image_urls=["data:image/png;base64,..."]`**,200 返回新图 TOS URL + `usage.generated_images=1`(约束:输出 `size` ≥3686400 像素 / ~1920²,单张参考 ≤10MB,最多 14 张);base64 通路成立 → **内网部署无需对象存储中介**,排除最大工程不确定性。**E+C 实施清单 / 风险 / 升级到 A 的信号已落 DESIGN §8.1,本版仅 probe + design,tool 与 prompt 改造未启动**。

15
RUN.md
View File

@ -2,7 +2,7 @@
> 怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看 `DESIGN.md`,进度看 `PROGRESS.md` > 怎么把 zcbot 跑起来。env / 常用命令 / 故障兜底。设计看 `DESIGN.md`,进度看 `PROGRESS.md`
最后更新:2026-05-28(新增 `local.r1` / `local.qwen3` 内网模型档案,共享 `LOCAL_LLM_API_KEY`,涉密任务用) 最后更新:2026-06-01(documents / Materials Project 改 host-side tools 持 key,sandbox 不读 secret)
--- ---
@ -17,14 +17,15 @@
# 豆包(火山方舟)图像/视频生成:可选。设了同时挂 seedream tool(0.22 元/张)与 seedance tool # 豆包(火山方舟)图像/视频生成:可选。设了同时挂 seedream tool(0.22 元/张)与 seedance tool
# (Seedance 2.0 Fast,文生视频,480p 4s ¥1.86 ~ 720p 15s ¥12+,异步等 30-90s);未设两个 tool 都不出现 # (Seedance 2.0 Fast,文生视频,480p 4s ¥1.86 ~ 720p 15s ¥12+,异步等 30-90s);未设两个 tool 都不出现
ARK_API_KEY=... ARK_API_KEY=...
# documents skill(内部知识库 document_search API):⚠️ 2026-05-29 起 sandbox 下不可用 —— # documents skill(内部知识库 document_search API):可选。设了后注册
# run_python `_SENSITIVE_PATTERNS` 过滤器拦含 API_KEY 字面的 env(等 credential broker), # document_list_kb / document_search / document_download 三个 host-side tool;
# 配了也读不到;documents 调用即抛 RuntimeError,LLM 应降级到 research # key 只留宿主后端,sandbox/run_python 不读取。
DOCUMENT_SEARCH_API_KEY=... DOCUMENT_SEARCH_API_KEY=...
# 可选:覆盖默认 base_url(默认 https://ai.ctc-zc.com:8100/api) # 可选:覆盖默认 base_url(默认 https://ai.ctc-zc.com:8100/api)
# DOCUMENT_SEARCH_URL=https://ai.ctc-zc.com:8100/api # DOCUMENT_SEARCH_URL=https://ai.ctc-zc.com:8100/api
# pymatgen skill 的 Materials Project 接入:⚠️ 同上,sandbox 下被过滤器拦,mp_rester() 配了也炸; # pymatgen skill 的 Materials Project 接入:可选。设了后注册
# 离线分析(CIF / POSCAR + SpacegroupAnalyzer + XRDCalculator + CEMENT_PHASES)不受影响。 # mp_search_summary / mp_get_structure / mp_get_entries 三个 host-side tool;
# 离线分析(CIF / POSCAR + SpacegroupAnalyzer + XRDCalculator + CEMENT_PHASES)仍在 sandbox 跑。
# 申请 https://materialsproject.org/api(免费) # 申请 https://materialsproject.org/api(免费)
MP_API_KEY=... MP_API_KEY=...
# 本地 / 内网部署 LLM(`config/models/local.yaml`,DeepSeek-R1 满血 / QwQ-32B 原生 32K, # 本地 / 内网部署 LLM(`config/models/local.yaml`,DeepSeek-R1 满血 / QwQ-32B 原生 32K,
@ -591,6 +592,8 @@ sudo xfs_quota -x -c "limit -p bhard=10g zcbot_<user_uuid>" /opt
| 点 stop 后流式没立刻停 | streaming 改造后正常路径秒退;若仍卡可能是 ① httpx 连接 close 没立刻关(GC 时机)/ ② 模型 thinking 阶段长时间不吐 chunk,等下一个 chunk 到达才能 poll cancel(罕见) | | 点 stop 后流式没立刻停 | streaming 改造后正常路径秒退;若仍卡可能是 ① httpx 连接 close 没立刻关(GC 时机)/ ② 模型 thinking 阶段长时间不吐 chunk,等下一个 chunk 到达才能 poll cancel(罕见) |
| `[startup] reaped N stale active run(s)` | 上次 web 进程未正常 finish 留下 N 个孤儿 run,启动 lifespan 自动标 error。info 级,无需处理 | | `[startup] reaped N stale active run(s)` | 上次 web 进程未正常 finish 留下 N 个孤儿 run,启动 lifespan 自动标 error。info 级,无需处理 |
| `seedream` tool 没出现在对话里 | `.env` 没设 `ARK_API_KEY`,build_agent 跳过注册。设了重启 web 即可;无需迁移、无需 DB 改动 | | `seedream` tool 没出现在对话里 | `.env` 没设 `ARK_API_KEY`,build_agent 跳过注册。设了重启 web 即可;无需迁移、无需 DB 改动 |
| `document_*` tool 没出现在对话里 | `.env` 没设 `DOCUMENT_SEARCH_API_KEY`,build_agent 跳过注册。设了重启 web 即可;key 不进入 sandbox。 |
| `mp_*` tool 没出现在对话里 | `.env` 没设 `MP_API_KEY`,build_agent 跳过注册。设了重启 web 即可;Materials Project 联网查询走 host-side tool,离线 pymatgen 不受影响。 |
| 豆包调价了 | 改 `config/media/doubao.yaml``price_cny_per_image` 一行 → 重启 web。**历史 usage_events 不受影响**(units jsonb 里有当时单价 snapshot,聚合查仍按旧价);新写入按新价。涨价瞬间到改 YAML 中间这段记账偏低,开发期接受 | | 豆包调价了 | 改 `config/media/doubao.yaml``price_cny_per_image` 一行 → 重启 web。**历史 usage_events 不受影响**(units jsonb 里有当时单价 snapshot,聚合查仍按旧价);新写入按新价。涨价瞬间到改 YAML 中间这段记账偏低,开发期接受 |
| `kill -HUP <pid>``/openapi.json` 没新接口 | uvicorn **不响应 SIGHUP**(没装 handler,落 Python 默认终止;Windows 上信号本身无效)。Ubuntu 上用 `systemctl restart zcbot`,或 unit 加 `--reload` 让 uvicorn 监听文件自动重起(见"部署"段)。验证:`curl -s http://127.0.0.1:8765/openapi.json \| python3 -c 'import sys,json;print([p for p in json.load(sys.stdin)["paths"] if "auth" in p])'` | | `kill -HUP <pid>``/openapi.json` 没新接口 | uvicorn **不响应 SIGHUP**(没装 handler,落 Python 默认终止;Windows 上信号本身无效)。Ubuntu 上用 `systemctl restart zcbot`,或 unit 加 `--reload` 让 uvicorn 监听文件自动重起(见"部署"段)。验证:`curl -s http://127.0.0.1:8765/openapi.json \| python3 -c 'import sys,json;print([p for p in json.load(sys.stdin)["paths"] if "auth" in p])'` |
| `systemctl restart zcbot` 卡 10s 才退 | 有 SSE 长连接,uvicorn graceful shutdown 等 in-flight。unit 已设 `TimeoutStopSec=10` 兜 SIGKILL,正常现象;真急用 `systemctl kill -s KILL zcbot` | | `systemctl restart zcbot` 卡 10s 才退 | 有 SSE 长连接,uvicorn graceful shutdown 等 in-flight。unit 已设 `TimeoutStopSec=10` 兜 SIGKILL,正常现象;真急用 `systemctl kill -s KILL zcbot` |

View File

@ -1,7 +1,7 @@
# zcbot Skill 清单 # zcbot Skill 清单
服务对象:中国建筑材料科学研究总院 —— 无机非金属材料 R&D(水泥 / 混凝土 / 玻璃 / 陶瓷 / 耐火 / 新型建材) 服务对象:中国建筑材料科学研究总院 —— 无机非金属材料 R&D(水泥 / 混凝土 / 玻璃 / 陶瓷 / 耐火 / 新型建材)
最后更新:2026-05-29 最后更新:2026-06-01
Skill 总数:13 Skill 总数:13
zcbot 的"skill"是一份可加载的工作流脚本(`skills/<name>/SKILL.md` + 配套 templates / scripts / Python helper),模型在识别用户意图后挂载对应 skill,按其内置的阶段化流程产出可交付物。本文档面向**使用方 / 协作方**,按"做什么、什么时候用、什么时候别用、典型产物"组织。 zcbot 的"skill"是一份可加载的工作流脚本(`skills/<name>/SKILL.md` + 配套 templates / scripts / Python helper),模型在识别用户意图后挂载对应 skill,按其内置的阶段化流程产出可交付物。本文档面向**使用方 / 协作方**,按"做什么、什么时候用、什么时候别用、典型产物"组织。
@ -18,8 +18,8 @@ zcbot 的"skill"是一份可加载的工作流脚本(`skills/<name>/SKILL.md` +
| 演示出图 | [ppt](#ppt) | 生成 PowerPoint 演示稿(商务红主题,逐页验收) | | 演示出图 | [ppt](#ppt) | 生成 PowerPoint 演示稿(商务红主题,逐页验收) |
| 演示出图 | [plot_pub](#plot_pub) | 出版级 matplotlib 学术图(中文 + viridis + 矢量) | | 演示出图 | [plot_pub](#plot_pub) | 出版级 matplotlib 学术图(中文 + viridis + 矢量) |
| 文献检索 | [research](#research) | 查 paper_server(OpenAlex 元数据 + Sci-Hub 下载) | | 文献检索 | [research](#research) | 查 paper_server(OpenAlex 元数据 + Sci-Hub 下载) |
| 文献检索 | [documents](#documents) | 查内部 7 学科材料知识库(21W+ 论文,跨语言检索)⚠️ sandbox 暂不可用,降级 research | | 文献检索 | [documents](#documents) | 查内部 7 学科材料知识库(21W+ 论文,跨语言检索;host-side tool 持 key) |
| 科研计算 | [pymatgen](#pymatgen) | 晶体结构 / XRD 模拟 / 相图 / Materials Project ⚠️ MP 联网暂不可用,离线分析正常 | | 科研计算 | [pymatgen](#pymatgen) | 晶体结构 / XRD 模拟 / 相图 / Materials Project(host-side tool 持 key) |
| 科研计算 | [stats_ml](#stats_ml) | 配方-性能建模与机器学习(三库分工) | | 科研计算 | [stats_ml](#stats_ml) | 配方-性能建模与机器学习(三库分工) |
| 内容生成 | [imagegen](#imagegen) | 豆包 Seedream 5.0 文生图(¥0.22 / 张) | | 内容生成 | [imagegen](#imagegen) | 豆包 Seedream 5.0 文生图(¥0.22 / 张) |
| 内容生成 | [videogen](#videogen) | 豆包 Seedance 2.0 文生视频(¥1.86 起 / 段) | | 内容生成 | [videogen](#videogen) | 豆包 Seedance 2.0 文生视频(¥1.86 起 / 段) |
@ -218,7 +218,7 @@ paper_server 是内部 Django 文献库:元数据来自 OpenAlex,PDF / XML 由 S
**关系**:与 research 互补 —— research 搜全网,documents 是本地预收的材料学科子集。**找材料类文献优先 documents,找其他学科或要 DOI 走 research,两者命中不重叠时可并用**。 **关系**:与 research 互补 —— research 搜全网,documents 是本地预收的材料学科子集。**找材料类文献优先 documents,找其他学科或要 DOI 走 research,两者命中不重叠时可并用**。
**四个 helper**:`list_kb` / `search` / `download` / `health` **三个 host-side tool**:`document_list_kb` / `document_search` / `document_download`。只有宿主配置 `DOCUMENT_SEARCH_API_KEY` 时注册;key 不进入 sandbox
--- ---
@ -227,7 +227,7 @@ paper_server 是内部 Django 文献库:元数据来自 OpenAlex,PDF / XML 由 S
### pymatgen ### pymatgen
**无机材料计算(晶体结构 I/O、XRD 模拟、相图、对称性、Materials Project 查询)。** **无机材料计算(晶体结构 I/O、XRD 模拟、相图、对称性、Materials Project 查询)。**
底层用 pymatgen 官方 API,本 skill 提供两个轻量 helper:`CEMENT_PHASES` 常量(中文相名 → 化学式映射)和 `mp_rester()`(从 env 拿 `MP_API_KEY` 并以 context manager 暴露) 底层用 pymatgen 官方 API。离线计算在 sandbox 里跑,本 skill 提供 `CEMENT_PHASES` 常量(中文相名 → 化学式映射);Materials Project 联网查询走 host-side tool,`MP_API_KEY` 不进入 sandbox
**触发**: **触发**:
- ✅ 水泥熟料相 / 玻璃陶瓷物相 / 耐火砖矿物相 - ✅ 水泥熟料相 / 玻璃陶瓷物相 / 耐火砖矿物相
@ -249,7 +249,7 @@ paper_server 是内部 Django 文献库:元数据来自 OpenAlex,PDF / XML 由 S
- 正向算 XRD pattern → `XRDCalculator`,跟实测谱对比 - 正向算 XRD pattern → `XRDCalculator`,跟实测谱对比
- 物相对称性 → `SpacegroupAnalyzer` - 物相对称性 → `SpacegroupAnalyzer`
- 凝胶 / 水化产物热力学稳定性 → `PhaseDiagram` + `PDEntry` - 凝胶 / 水化产物热力学稳定性 → `PhaseDiagram` + `PDEntry`
- 从 MP 拉已知结构 / 性质 → `mp_rester() + summary.search` - 从 MP 拉已知结构 / 性质 → `mp_search_summary` / `mp_get_structure` / `mp_get_entries`
--- ---

View File

@ -38,6 +38,12 @@ from core.skills import SkillRegistry
from core.storage import check_no_subtask from core.storage import check_no_subtask
from core.task import TaskState from core.task import TaskState
from tools.fs import EditTool, GlobTool, GrepTool, ReadTool, WriteTool from tools.fs import EditTool, GlobTool, GrepTool, ReadTool, WriteTool
from tools.documents import DocumentDownloadTool, DocumentListKbTool, DocumentSearchTool
from tools.materials_project import (
MaterialsProjectGetEntriesTool,
MaterialsProjectGetStructureTool,
MaterialsProjectSearchSummaryTool,
)
from tools.run_python import RunPythonTool from tools.run_python import RunPythonTool
from tools.seedance import SeedanceTool from tools.seedance import SeedanceTool
from tools.seedream import SeedreamTool from tools.seedream import SeedreamTool
@ -389,8 +395,40 @@ def build_agent(
wf = WebFetchTool(base_dir=tool_base, user_root=ur_path) wf = WebFetchTool(base_dir=tool_base, user_root=ur_path)
tools[wf.name] = wf tools[wf.name] = wf
if skills.skills:
import os import os
# Secret-bearing domain tools stay host-side. Never expose DOCUMENT_SEARCH_API_KEY
# / MP_API_KEY to run_python or the sandbox; only register typed tools when the
# corresponding host env exists.
if os.getenv("DOCUMENT_SEARCH_API_KEY", "").strip():
for t in (
DocumentListKbTool(base_dir=tool_base, user_root=ur_path),
DocumentSearchTool(base_dir=tool_base, user_root=ur_path),
DocumentDownloadTool(
working_dir=working_dir_path,
base_dir=tool_base,
user_root=ur_path,
),
):
tools[t.name] = t
if os.getenv("MP_API_KEY", "").strip():
for t in (
MaterialsProjectSearchSummaryTool(base_dir=tool_base, user_root=ur_path),
MaterialsProjectGetStructureTool(
working_dir=working_dir_path,
base_dir=tool_base,
user_root=ur_path,
),
MaterialsProjectGetEntriesTool(
working_dir=working_dir_path,
base_dir=tool_base,
user_root=ur_path,
),
):
tools[t.name] = t
if skills.skills:
# docker backend 下 fs/shell/run_python 在容器内跑,skills/ bind mount 到 # docker backend 下 fs/shell/run_python 在容器内跑,skills/ bind mount 到
# /sandbox/skills:ro。把 LoadSkillTool 返回头里的 dir 改写成容器路径,LLM # /sandbox/skills:ro。把 LoadSkillTool 返回头里的 dir 改写成容器路径,LLM
# 拿来 read references 才能命中。host backend = None,保持原 host 绝对路径。 # 拿来 read references 才能命中。host backend = None,保持原 host 绝对路径。

View File

@ -73,7 +73,7 @@ def step_a_pymatgen() -> None:
# A1: helper import # A1: helper import
try: try:
from skills.pymatgen.materials import CEMENT_PHASES, lookup_phase, mp_rester from skills.pymatgen.materials import CEMENT_PHASES, lookup_phase
_ok(f"import skills.pymatgen.materials (CEMENT_PHASES 条目数={len(CEMENT_PHASES)})") _ok(f"import skills.pymatgen.materials (CEMENT_PHASES 条目数={len(CEMENT_PHASES)})")
except Exception as e: except Exception as e:
_fail(f"import skills.pymatgen.materials: {type(e).__name__}: {e}") _fail(f"import skills.pymatgen.materials: {type(e).__name__}: {e}")
@ -131,22 +131,20 @@ def step_a_pymatgen() -> None:
except Exception as e: except Exception as e:
_fail(f"XRDCalculator smoke: {type(e).__name__}: {e}") _fail(f"XRDCalculator smoke: {type(e).__name__}: {e}")
# A6: mp_rester 未配 key 抛 RuntimeError # A6: MP host tool 未配 key 返回 [Error](key 只在宿主读,不进 sandbox)
has_key = bool(os.environ.get("MP_API_KEY")) has_key = bool(os.environ.get("MP_API_KEY"))
if has_key: if has_key:
_info("MP_API_KEY 已配置,skip 缺 key 错验证(下面 step D 测真实查询)") _info("MP_API_KEY 已配置,skip 缺 key 错验证(下面 step D 测真实查询)")
else: else:
# 显式清掉 env 再测
try: try:
with mp_rester() as mpr: from tools.materials_project import MaterialsProjectSearchSummaryTool
_fail("MP_API_KEY 未配置时 mp_rester 应抛 RuntimeError,没抛") out = MaterialsProjectSearchSummaryTool().execute(formula="Ca3SiO5")
except RuntimeError as e: if out.startswith("[Error]") and "MP_API_KEY" in out:
if "MP_API_KEY" in str(e) and "materialsproject" in str(e): _ok("mp_search_summary 未配 key 返回 [Error] 含 MP_API_KEY 提示")
_ok("mp_rester 未配 key 正确抛 RuntimeError 含申请链接")
else: else:
_fail(f"RuntimeError 抛了但 msg 不对: {e}") _fail(f"未配 key 应返回 [Error] 含 MP_API_KEY,实际: {out[:120]}")
except Exception as e: except Exception as e:
_fail(f"应抛 RuntimeError 实际 {type(e).__name__}: {e}") _fail(f"mp_search_summary 未配 key 应返回 [Error] 而非抛异常: {type(e).__name__}: {e}")
def step_b_stats_ml() -> None: def step_b_stats_ml() -> None:
@ -271,20 +269,26 @@ def step_d_mp_online() -> None:
return return
try: try:
from skills.pymatgen.materials import mp_rester, lookup_phase import json
from skills.pymatgen.materials import lookup_phase
from tools.materials_project import MaterialsProjectSearchSummaryTool
formula = lookup_phase("C3S") # Ca3SiO5 formula = lookup_phase("C3S") # Ca3SiO5
t0 = time.time() t0 = time.time()
with mp_rester() as mpr: out = MaterialsProjectSearchSummaryTool().execute(
docs = mpr.materials.summary.search(
formula=formula, formula=formula,
fields=["material_id", "formula_pretty", "energy_above_hull"], fields=["material_id", "formula_pretty", "energy_above_hull"],
limit=3,
) )
dt = (time.time() - t0) * 1000 dt = (time.time() - t0) * 1000
_ok(f"mp_rester 查 {formula}: 返回 {len(docs)} 条 in {dt:.0f}ms") if out.startswith("[Error]"):
_fail(f"mp_search_summary 查 {formula}: {out[:160]}")
return
docs = json.loads(out)
_ok(f"mp_search_summary 查 {formula}: 返回 {len(docs)} 条 in {dt:.0f}ms")
for d in docs[:3]: for d in docs[:3]:
print(f" {d.material_id} {d.formula_pretty} ehull={d.energy_above_hull:.3f}") print(f" {d.get('material_id')} {d.get('formula_pretty')} ehull={d.get('energy_above_hull')}")
except Exception as e: except Exception as e:
_fail(f"mp_rester 联网查询: {type(e).__name__}: {e}") _fail(f"mp_search_summary 联网查询: {type(e).__name__}: {e}")
def main() -> int: def main() -> int:

View File

@ -5,9 +5,9 @@ description: 查内部材料学科知识库(document_search API,7 个学科:胶
# Documents # Documents
部署在 `https://ai.ctc-zc.com:8100/api` 的文档检索 API。后端按 `kb_name` 分库存储 7 个材料学科库(中文命名:胶凝 / 陶瓷基 / 玻璃基 / 晶体材料 / 复合材料 / 耐火材料 / 检验检测,共 21W+ 文件),**文档主体是英文学术论文**(Elsevier 期刊为主,DOI 前缀文件名),每个文档带 `md_content`(整篇 Markdown,LLM 友好)+ 可选的原 PDF 下载。**API 后端有跨语言语义检索**,中英文 query 都能命中英文文档。本 skill 给四个 helper(`list_kb` / `search` / `download` / `health`),用 `run_python` 调用,**不要**自己 `httpx` 裸调 部署在 `https://ai.ctc-zc.com:8100/api` 的文档检索 API。后端按 `kb_name` 分库存储 7 个材料学科库(中文命名:胶凝 / 陶瓷基 / 玻璃基 / 晶体材料 / 复合材料 / 耐火材料 / 检验检测,共 21W+ 文件),**文档主体是英文学术论文**(Elsevier 期刊为主,DOI 前缀文件名),每个文档带 `md_content`(整篇 Markdown,LLM 友好)+ 可选的原 PDF 下载。**API 后端有跨语言语义检索**,中英文 query 都能命中英文文档。本 skill 使用三个 host-side tool:`document_list_kb` / `document_search` / `document_download`,**不要**自己 `httpx` 裸调,也不要在 `run_python` 里读 `DOCUMENT_SEARCH_API_KEY`
> ⚠️ **2026-05-29 整体不可用**:`DOCUMENT_SEARCH_API_KEY` 被 `run_python` 安全过滤器拦掉,四个函数调用全炸 `RuntimeError`(等 credential broker)。**降级**:`research` skill(OpenAlex + Sci-Hub,不受影响,中文 query 先转专业英文术语)/ 用户自己导出文档落 task 目录后用 `read` 工具读。**别让 LLM 误推**:research 跟本 skill 不同范式,research 不持 secret,任何模式都能用。 > ⚠️ **配置条件**:只有宿主后端配置了 `DOCUMENT_SEARCH_API_KEY` 时,上述 tool 才会出现在可用工具列表里。若没有 `document_*` tool,降级走 `research` skill(OpenAlex + Sci-Hub,不受影响,中文 query 先转专业英文术语)/ 用户自己导出文档落 task 目录后用 `read` 工具读。**别让 LLM 误推**:research 跟本 skill 不同范式,research 不持 secret,任何模式都能用。
## 何时用 ## 何时用
@ -21,40 +21,25 @@ description: 查内部材料学科知识库(document_search API,7 个学科:胶
- 用户只问通识(直接答) - 用户只问通识(直接答)
- 用户已经给了具体内部文档路径(直接读,不要二次校验) - 用户已经给了具体内部文档路径(直接读,不要二次校验)
## 准备 ## 三个 tool
```python ### `document_list_kb()`
from skills.documents.client import list_kb, search, download, health
```
(import 路径由 `run_python` 注入的 `PYTHONPATH` 提供,直接写就行)
API key 走 env `DOCUMENT_SEARCH_API_KEY`,未设会抛 `RuntimeError`。base_url 走 env `DOCUMENT_SEARCH_URL`(默认 `https://ai.ctc-zc.com:8100/api`)。
## 四个函数
### `list_kb() -> list[dict]`
列所有有效知识库(分类 1-7)。每条含 `id` / `kb_name` / `ch_name` / `kb_info` / `file_count` 等。 列所有有效知识库(分类 1-7)。每条含 `id` / `kb_name` / `ch_name` / `kb_info` / `file_count` 等。
```python **用途**:用户没指定库 → 先 `document_list_kb` 看有哪些库(中文名 `ch_name` 看分类),再选 `kb_names` / `classification_ids` 缩窄 search 范围。
kbs = list_kb()
for kb in kbs:
print(kb["id"], kb["kb_name"], kb["ch_name"], kb["file_count"])
```
**用途**:用户没指定库 → 先 `list_kb()` 看有哪些库(中文名 `ch_name` 看分类),再选 `kb_names` / `classification_ids` 缩窄 search 范围。 ### `document_search(query, kb_names=None, classification_ids=None, max_documents=6, content_chars_per_doc=1200)`
### `search(query, kb_names=None, classification_ids=None, max_documents=6) -> list[dict]` 搜文档,返回精简列表,每条带 **截断后的 `md_content`**。默认每篇 1200 字符,需要看更多时调大 `content_chars_per_doc`,上限 5000。
搜文档,返回精简列表,每条带 **`md_content`**(整篇 Markdown 文本)。
- `query`:搜索词。**中英文均可** —— 文档主体是英文学术论文,但 API 后端有跨语言语义检索;复杂技术术语用**英文**更精准(`cement hydration` > `水泥水化`),日常概念中文 OK - `query`:搜索词。**中英文均可** —— 文档主体是英文学术论文,但 API 后端有跨语言语义检索;复杂技术术语用**英文**更精准(`cement hydration` > `水泥水化`),日常概念中文 OK
- `kb_names`:知识库白名单(从 `list_kb()` 选);`None` 走 server 默认(单库 `mu_34_1740625285897` 胶凝)。**多库联查就显式传**,如 `kb_names=["mu_34_1740625285897", "mu_34_1740625303475"]` - `kb_names`:知识库白名单(从 `document_list_kb` 选);`None` 走 server 默认(单库 `mu_34_1740625285897` 胶凝)。**多库联查就显式传**,如 `kb_names=["mu_34_1740625285897", "mu_34_1740625303475"]`
- `classification_ids`:分类 ID 白名单(1-7,对应 7 个学科库);`None` 不过滤 - `classification_ids`:分类 ID 白名单(1-7,对应 7 个学科库);`None` 不过滤
- `max_documents`:1-20,默认 6 - `max_documents`:1-20,默认 6
- `content_chars_per_doc`:每篇返回多少 Markdown 字符,默认 1200,最大 5000;不要一上来拉满
**学科库 → kb_name 速查**(`list_kb()` 拿全量,这里只列常用): **学科库 → kb_name 速查**(`document_list_kb` 拿全量,这里只列常用):
| 学科 | kb_name | | 学科 | kb_name |
|---|---| |---|---|
@ -66,49 +51,18 @@ for kb in kbs:
| 耐火材料 | `mu_34_1740625365079` | | 耐火材料 | `mu_34_1740625365079` |
| 检验检测 | `mu_34_1740625376621` | | 检验检测 | `mu_34_1740625376621` |
```python ### `document_download(file_name, kb_name, preview=False)`
# 全库搜(走 server 默认单库:胶凝)
docs = search(query="cement hydration", max_documents=10)
for d in docs:
print(d["file_name"], d["character_count"])
# 只看前 300 字判断切题 ——— md_content 整体动辄几十 K(实测命中文档常 50K-200K 字符)
print((d["md_content"] or "")[:300])
```
```python
# 跨多个学科库联查(如同时找胶凝 + 陶瓷)
docs = search(
query="concrete carbonation",
kb_names=["mu_34_1740625285897", "mu_34_1740625303475"],
max_documents=6,
)
```
### `download(file_name, kb_name, working_dir, preview=False) -> str`
下载原始文档(PDF / Word / ...)到 `<working_dir>/documents/<safe_file_name>`,返回相对路径。已存在跳过下载直接复用。`file_name` 支持原始文件名(`example.pdf`)或 Markdown 名(`example.md`),server 自动回退。 下载原始文档(PDF / Word / ...)到 `<working_dir>/documents/<safe_file_name>`,返回相对路径。已存在跳过下载直接复用。`file_name` 支持原始文件名(`example.pdf`)或 Markdown 名(`example.md`),server 自动回退。
```python
rel = download(
file_name="材料性能手册.pdf",
kb_name="mu_34_1740625285897",
working_dir=r"D:/projects/zcbot/workspace/users/<uid>/<wd>",
)
# rel == "documents/材料性能手册.pdf"
```
### `health() -> dict`
健康检查,公开端点(无需 API key)。主要给 smoke / 排障用:`{"status": "healthy", "service": "document_search_api"}`。
## 标准工作流 ## 标准工作流
1. **(可选)`list_kb()`** —— 用户没指定库 / 不确定分类时看一下有哪些 1. **(可选)`document_list_kb`** —— 用户没指定库 / 不确定分类时看一下有哪些
2. **`search(query=..., max_documents=6)`** —— 中英文均可,专业技术术语优先英文 2. **`document_search(query=..., max_documents=6)`** —— 中英文均可,专业技术术语优先英文
3. **看返回**: 3. **看返回**:
- 用 `file_name + character_count + md_content[:300]` 判断切题 - 用 `file_name + character_count + md_content` 判断切题
- 切题 → 直接用 `md_content` 给 LLM 引用(已结构化 Markdown,不需要再下载原件) - 切题 → 直接用返回的 Markdown 摘要给 LLM 引用;需要更多上下文时提高 `content_chars_per_doc` 重搜
- 需要看图表 / 表格原貌 / 给用户附件 → `download(file_name, kb_name, working_dir)` 拿原文档,然后用主 agent 的 `read` 工具读(zcbot 已内置 PDF/Word 文本抽取) - 需要看图表 / 表格原貌 / 给用户附件 → `document_download(file_name, kb_name)` 拿原文档,然后用主 agent 的 `read` 工具读(zcbot 已内置 PDF/Word 文本抽取)
4. **写产出**:把 md_content 关键段落引到申报书 / 方案里,标注来源文件名 4. **写产出**:把 md_content 关键段落引到申报书 / 方案里,标注来源文件名
## md_content 优先 vs 原件下载 ## md_content 优先 vs 原件下载
@ -121,19 +75,19 @@ rel = download(
## 错误处理 ## 错误处理
- `DOCUMENT_SEARCH_API_KEY` 未设:`RuntimeError`(client 启动时立即报,而不是裸 401) - 没有 `document_*` tool:`DOCUMENT_SEARCH_API_KEY` 未在宿主配置,改走降级路径
- 401 / 403 `Invalid API key`:`httpx.HTTPStatusError` —— key 错或失效,告诉用户检查 env - 401 / 403 `Invalid API key`:`httpx.HTTPStatusError` —— key 错或失效,告诉用户检查 env
- 404 `未找到知识库`:`kb_names` 拼写错或库已下线,改 `list_kb()` 看当前有效列表 - 404 `未找到知识库`:`kb_names` 拼写错或库已下线,改 `document_list_kb` 看当前有效列表
- 404 `文件不存在: xxx`:`download` 时常见,可能 server 侧文件丢失或 `file_name` 拼写错 - 404 `文件不存在: xxx`:`document_download` 时常见,可能 server 侧文件丢失或 `file_name` 拼写错
- search 命中 0 条:同义词 / 切换中英文 / 缩短 query / 放宽 `classification_ids` 再试 2-3 次,还是 0 条就明确告诉用户"本库没覆盖,改走 research 或换关键词",**不要凭训练数据脑补文献** - search 命中 0 条:同义词 / 切换中英文 / 缩短 query / 放宽 `classification_ids` 再试 2-3 次,还是 0 条就明确告诉用户"本库没覆盖,改走 research 或换关键词",**不要凭训练数据脑补文献**
- 网络超时 / server 不可达:`httpx.ConnectError` / `httpx.TimeoutException` —— 告诉用户"document_search 暂时连不上",不要重试堆栈刷屏 - 网络超时 / server 不可达:`httpx.ConnectError` / `httpx.TimeoutException` —— 告诉用户"document_search 暂时连不上",不要重试堆栈刷屏
## 反模式 ## 反模式
- 用 `httpx` / `requests` 裸调 API(走 helper,免得 base_url / auth / 字段名漂移时四处改) - 用 `httpx` / `requests` 裸调 API(走 host tool,免得 base_url / auth / 字段名漂移时四处改,也避免 key 进入 sandbox)
- `search(max_documents=20)` 一次拉满后 print 全部 `md_content`(单条就可能几十 K,20 条直接爆 LLM 上下文)—— 只 print `file_name + character_count + md_content[:300]`,要看全文用 `docs[i]['md_content']` - `document_search(max_documents=20, content_chars_per_doc=5000)` 一次拉满(20 条直接爆 LLM 上下文)—— 先用默认值判断切题,只对少数命中文档加大 `content_chars_per_doc`
- 看到 md_content 切题还 `download` 一遍原件(md_content 已是 LLM 友好的 Markdown,大多数引用场景够用) - 看到 md_content 切题还 `download` 一遍原件(md_content 已是 LLM 友好的 Markdown,大多数引用场景够用)
- 凭 `ch_name`("胶凝材料学科知识库")就以为 query 要用中文 —— 文档主体是英文,复杂术语用英文更精准 - 凭 `ch_name`("胶凝材料学科知识库")就以为 query 要用中文 —— 文档主体是英文,复杂术语用英文更精准
- 编造 file_name / kb_name —— 不在 `list_kb()` / `search` 返回里就**明确告诉用户"未命中"**,不要瞎传 ID - 编造 file_name / kb_name —— 不在 `document_list_kb` / `document_search` 返回里就**明确告诉用户"未命中"**,不要瞎传 ID
- 把 `download` 返回的相对路径当绝对路径用(它是相对 `working_dir` 的) - 把 `document_download` 返回的相对路径当绝对路径用(它是相对 task_dir 的)
- 不在合适的 task working_dir 里 `download`(原文档应该落到 task 目录,不要污染 repo) - 尝试给 `document_download``working_dir`(tool 已绑定当前 task_dir,不要让模型指定路径)

View File

@ -5,9 +5,9 @@ description: 无机材料计算工具(晶体结构 I/O、XRD 模拟、相图、
# Pymatgen # Pymatgen
无机材料计算的核心库,服务建材院的水泥 / 混凝土 / 玻璃 / 陶瓷 / 耐火材料场景。**底层用 pymatgen 官方 API,本 skill 提供两个轻量 helper**:`CEMENT_PHASES` 常量(中文相名→化学式映射)和 `mp_rester()`(从 env 拿 MP_API_KEY)。 无机材料计算的核心库,服务建材院的水泥 / 混凝土 / 玻璃 / 陶瓷 / 耐火材料场景。离线计算在 sandbox 里用 pymatgen 官方 API;联网查 Materials Project 走 host-side tool。**本 skill 提供一个轻量 helper**:`CEMENT_PHASES` 常量(中文相名→化学式映射)。
> ⚠️ **2026-05-29 限制**:`mp_rester()` 及一切 Materials Project 联网查询**在 sandbox 下不可用** —— `run_python` 安全过滤器拦 `MP_API_KEY` env,配了也读不到(等 credential broker)。**离线全部正常**:用户给 `.cif` / `POSCAR``Structure.from_file()` / `SpacegroupAnalyzer` / `XRDCalculator`(对已有 Structure)/ `CEMENT_PHASES` 查表 / 格式转换 / `MPRelaxSet`。要 mp 拿结构 → 让用户从 https://materialsproject.org 下个 CIF 丢 task 目录;**不要脑补晶格 / 原子坐标**。 > ⚠️ **配置条件**:只有宿主后端配置了 `MP_API_KEY` 时,`mp_search_summary` / `mp_get_structure` / `mp_get_entries` 才会出现在可用工具列表里。没有这些 tool 时,Materials Project 联网查询不可用;离线全部正常:用户给 `.cif` / `POSCAR``Structure.from_file()` / `SpacegroupAnalyzer` / `XRDCalculator`(对已有 Structure)/ `CEMENT_PHASES` 查表 / 格式转换 / `MPRelaxSet`。要 mp 拿结构 → 让用户从 https://materialsproject.org 下个 CIF 丢 task 目录;**不要脑补晶格 / 原子坐标**。
## 何时用 ## 何时用
@ -27,7 +27,7 @@ description: 无机材料计算工具(晶体结构 I/O、XRD 模拟、相图、
## 准备 ## 准备
```python ```python
from skills.pymatgen.materials import CEMENT_PHASES, mp_rester from skills.pymatgen.materials import CEMENT_PHASES
from pymatgen.core import Structure, Lattice, Molecule from pymatgen.core import Structure, Lattice, Molecule
from pymatgen.analysis.diffraction.xrd import XRDCalculator from pymatgen.analysis.diffraction.xrd import XRDCalculator
from pymatgen.symmetry.analyzer import SpacegroupAnalyzer from pymatgen.symmetry.analyzer import SpacegroupAnalyzer
@ -49,28 +49,22 @@ from pymatgen.analysis.phase_diagram import PhaseDiagram, PDEntry
表里没有的相,先英文学名 → 化学式后再喂,不要直接把中文丢给 mp。 表里没有的相,先英文学名 → 化学式后再喂,不要直接把中文丢给 mp。
## Materials Project 接入 ## Materials Project 接入(host-side tools)
API key 走 env:`MP_API_KEY`(申请:https://materialsproject.org/api)。**必须用 context manager**: API key 只在宿主后端读取,不要在 `run_python` 里读 env。可用 tool:
```python - `mp_search_summary(formula?, material_ids?, elements?, fields?, limit=10)` —— 查 summary,返回裁剪 JSON
with mp_rester() as mpr: # 自动从 env 拿 key - `mp_get_structure(material_id, filename?)` —— 把结构保存到 task_dir `materials/*.cif`,再用 `Structure.from_file()` 离线计算
docs = mpr.materials.summary.search( - `mp_get_entries(elements, filename?, limit=200)` —— 把 chemsys entries 保存到 task_dir `materials/*.json`
formula="Ca3SiO5",
fields=["material_id", "formula_pretty", "symmetry", "energy_above_hull"],
)
for d in docs[:5]:
print(d.material_id, d.formula_pretty, d.symmetry.symbol, d.energy_above_hull)
```
`MP_API_KEY` 没配 → `mp_rester()``RuntimeError("MP_API_KEY not set in env...")`,告诉用户去配,不要继续 `MP_API_KEY` 没配 → 上述 tool 不会出现,告诉用户配置或手动从 Materials Project 下载 CIF。
## 典型工作流 ## 典型工作流
### A. 实测 XRD 比对(谁是这个峰) ### A. 实测 XRD 比对(谁是这个峰)
1. 用户给疑似相清单(中文 / 英文 / 简写都行) 1. 用户给疑似相清单(中文 / 英文 / 简写都行)
2. 各相分别:`CEMENT_PHASES` 查化学式 → `mp_rester()` 拿 Structure`XRDCalculator().get_pattern(structure)` 算理论谱 2. 各相分别:`CEMENT_PHASES` 查化学式 → `mp_search_summary(formula=...)` 找 material_id → `mp_get_structure(material_id=...)` 保存 CIF`XRDCalculator().get_pattern(structure)` 算理论谱
3. 把各相理论谱跟实测谱(用户给的 xy 数据)叠图(走 `plot_pub`) 3. 把各相理论谱跟实测谱(用户给的 xy 数据)叠图(走 `plot_pub`)
4. 报"x° 这个峰最可能是 C3S 的 (h k l) 衍射" 4. 报"x° 这个峰最可能是 C3S 的 (h k l) 衍射"
@ -78,9 +72,8 @@ with mp_rester() as mpr: # 自动从 env 拿 key
from pymatgen.analysis.diffraction.xrd import XRDCalculator from pymatgen.analysis.diffraction.xrd import XRDCalculator
xrd = XRDCalculator(wavelength="CuKa") # 默认 Cu Kα xrd = XRDCalculator(wavelength="CuKa") # 默认 Cu Kα
with mp_rester() as mpr: # 先用 mp_search_summary / mp_get_structure tool 保存 CIF,再:
docs = mpr.materials.summary.search(formula="Ca3SiO5", fields=["material_id"]) struct = Structure.from_file("materials/mp-xxxx.cif")
struct = mpr.get_structure_by_material_id(docs[0].material_id)
pattern = xrd.get_pattern(struct, two_theta_range=(5, 80)) pattern = xrd.get_pattern(struct, two_theta_range=(5, 80))
# pattern.x = 2θ 列表, pattern.y = 强度, pattern.hkls = (h,k,l) 列表 # pattern.x = 2θ 列表, pattern.y = 强度, pattern.hkls = (h,k,l) 列表
``` ```
@ -101,14 +94,7 @@ prim = sga.get_primitive_standard_structure() # 简约胞
### C. 凝胶 / 水化产物相图稳定性 ### C. 凝胶 / 水化产物相图稳定性
```python 先用 `mp_get_entries(elements=["Ca", "Si", "O", "H"])` 保存 JSON。第一版 host tool 的 entries JSON 主要用于审阅 / 归档;若要直接构造 `PhaseDiagram`,优先使用用户提供的本地 entry 数据或后续补一个专门的 host-side 相图 helper。不要在 sandbox 里绕过 tool 直接连 MP。
from pymatgen.analysis.phase_diagram import PhaseDiagram
with mp_rester() as mpr:
entries = mpr.get_entries_in_chemsys(["Ca", "Si", "O", "H"])
pd = PhaseDiagram(entries)
# pd.get_decomp_and_e_above_hull(some_entry) → 分解路径 + 能量
```
### D. 格式转换(给计算所做 VASP 输入) ### D. 格式转换(给计算所做 VASP 输入)
@ -120,7 +106,7 @@ struct.to(filename="output.cif")
## 反模式 ## 反模式
- 用户报中文相名(C3S / 钙矾石 / 莫来石)直接喂 mp / pymatgen,不查 `CEMENT_PHASES` —— mp 不认中文,简写也不认 - 用户报中文相名(C3S / 钙矾石 / 莫来石)直接喂 mp / pymatgen,不查 `CEMENT_PHASES` —— mp 不认中文,简写也不认
- `MPRester` 不走 context manager(`mpr = MPRester(); ...`) —— 连接泄漏 - `run_python` 里直接用 `MPRester` / 读 `MP_API_KEY` —— key 不进 sandbox,联网查询走 host-side tool
- 手写 CIF parser → 一律 `Structure.from_file()` - 手写 CIF parser → 一律 `Structure.from_file()`
- 不做 `SpacegroupAnalyzer.get_primitive_standard_structure()` 直接拿原胞做对称性比对(原胞可能是超胞,对称性少看出来) - 不做 `SpacegroupAnalyzer.get_primitive_standard_structure()` 直接拿原胞做对称性比对(原胞可能是超胞,对称性少看出来)
- 大 cutoff 邻居搜索(`get_neighbors(r=20)`)—— 性能差,先 `r=5` - 大 cutoff 邻居搜索(`get_neighbors(r=20)`)—— 性能差,先 `r=5`
@ -139,8 +125,9 @@ mp-api>=0.41.0
## env ## env
宿主后端配置:
``` ```
MP_API_KEY=your_key_from_materialsproject_org MP_API_KEY=your_key_from_materialsproject_org
``` ```
写到 `.env`(项目根)即可,`mp_rester()` 自动读 配置后重启 web,`mp_*` tools 才会注册。sandbox / `run_python` 不读取这个 key

View File

@ -1,14 +1,12 @@
""" """
pymatgen skill helpers 建材院无机材料场景常用映射 + MPRester 封装 pymatgen skill helpers 建材院无机材料场景常用映射(中文相名 化学式)
LLM 通过 `from skills.pymatgen.materials import CEMENT_PHASES, mp_rester` 使用 LLM sandbox 中只应使用 `CEMENT_PHASES` / `lookup_phase` 和离线 pymatgen
Materials Project 联网查询走 host-side `mp_*` tools,不要在 sandbox 里读 MP_API_KEY
""" """
from __future__ import annotations from __future__ import annotations
import os
from contextlib import contextmanager
# 中文/简写 → 化学式映射。覆盖建材院 R&D 高频物相。 # 中文/简写 → 化学式映射。覆盖建材院 R&D 高频物相。
# 添加新条目时: # 添加新条目时:
@ -124,30 +122,3 @@ def lookup_phase(name: str) -> str:
f"若是新相,直接把化学式喂给 pymatgen / Materials Project;" f"若是新相,直接把化学式喂给 pymatgen / Materials Project;"
f"若高频用,补到 skills/pymatgen/materials.py 的 CEMENT_PHASES。" f"若高频用,补到 skills/pymatgen/materials.py 的 CEMENT_PHASES。"
) )
@contextmanager
def mp_rester(api_key: str | None = None):
"""
MPRester 上下文管理器封装,自动从 env(MP_API_KEY) key
用法:
with mp_rester() as mpr:
docs = mpr.materials.summary.search(formula="Ca3SiO5")
Args:
api_key: 显式传入则用,否则读 env MP_API_KEY
Raises:
RuntimeError: env 未配置且未传入 api_key
"""
key = api_key or os.environ.get("MP_API_KEY")
if not key:
raise RuntimeError(
"MP_API_KEY not set in env. "
"申请: https://materialsproject.org/api,然后写到项目根 .env 文件。"
)
from mp_api.client import MPRester # 局部 import,避免装包前 import skill 就崩
with MPRester(api_key=key) as mpr:
yield mpr

View File

@ -0,0 +1,116 @@
from __future__ import annotations
import json
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
class TestDocumentHostTools(unittest.TestCase):
def test_document_search_truncates_content_without_requiring_key_arg(self):
from tools.documents import DocumentSearchTool
docs = [
{
"file_name": "paper.md",
"kb_name": "mu_1",
"character_count": 50000,
"md_content": "A" * 200,
}
]
with patch("tools.documents.doc_client.search", return_value=docs) as search:
out = DocumentSearchTool().execute(
query="cement hydration",
max_documents=3,
content_chars_per_doc=20,
)
search.assert_called_once_with(
query="cement hydration",
kb_names=None,
classification_ids=None,
max_documents=3,
)
self.assertIn("paper.md", out)
self.assertIn("mu_1", out)
self.assertIn("A" * 20, out)
self.assertIn("truncated", out)
def test_document_download_uses_constructor_working_dir(self):
from tools.documents import DocumentDownloadTool
with tempfile.TemporaryDirectory() as tmp:
working_dir = Path(tmp) / "task"
working_dir.mkdir()
with patch(
"tools.documents.doc_client.download",
return_value="documents/paper.pdf",
) as download:
tool = DocumentDownloadTool(
working_dir=working_dir,
base_dir=working_dir,
user_root=Path(tmp),
)
out = tool.execute(file_name="paper.pdf", kb_name="mu_1")
download.assert_called_once_with(
file_name="paper.pdf",
kb_name="mu_1",
working_dir=str(working_dir),
preview=False,
)
self.assertIn("saved: task/documents/paper.pdf", out)
class TestMaterialsProjectHostTools(unittest.TestCase):
def test_mp_search_summary_uses_host_key_and_returns_json(self):
from tools.materials_project import MaterialsProjectSearchSummaryTool
class FakeDoc:
material_id = "mp-1"
formula_pretty = "Ca3SiO5"
energy_above_hull = 0.0123
class FakeSummary:
def search(self, **kwargs):
self.kwargs = kwargs
return [FakeDoc()]
class FakeMaterials:
def __init__(self):
self.summary = FakeSummary()
class FakeMPRester:
def __init__(self, api_key):
self.api_key = api_key
self.materials = FakeMaterials()
def __enter__(self):
return self
def __exit__(self, exc_type, exc, tb):
return False
with patch.dict("os.environ", {"MP_API_KEY": "host-secret"}, clear=False), patch(
"tools.materials_project.MPRester",
FakeMPRester,
):
out = MaterialsProjectSearchSummaryTool().execute(
formula="Ca3SiO5",
fields=["material_id", "formula_pretty", "energy_above_hull"],
limit=2,
)
data = json.loads(out)
self.assertEqual(data[0]["material_id"], "mp-1")
self.assertEqual(data[0]["formula_pretty"], "Ca3SiO5")
self.assertEqual(data[0]["energy_above_hull"], 0.0123)
self.assertNotIn("host-secret", out)
if __name__ == "__main__":
unittest.main()

164
tools/documents.py Normal file
View File

@ -0,0 +1,164 @@
"""Host-side document_search tools.
These tools intentionally keep DOCUMENT_SEARCH_API_KEY on the host side. The
sandbox receives only business arguments and trimmed results / saved paths.
"""
from __future__ import annotations
from pathlib import Path
from typing import Optional
from skills.documents import client as doc_client
from .base import Tool
def _clip(text: str, max_chars: int) -> tuple[str, bool]:
max_chars = max(0, int(max_chars))
if len(text) <= max_chars:
return text, False
return text[:max_chars], True
class DocumentListKbTool(Tool):
name = "document_list_kb"
description = (
"List internal materials knowledge bases available in document_search. "
"Use before document_search when the user did not specify a materials domain."
)
parameters = {"type": "object", "properties": {}}
def execute(self) -> str:
try:
kbs = doc_client.list_kb()
except Exception as e:
return f"[Error] document_list_kb failed: {type(e).__name__}: {e}"
if not kbs:
return "(no knowledge bases returned)"
lines = ["Knowledge bases:"]
for kb in kbs:
lines.append(
"- id={id} kb_name={kb_name} ch_name={ch_name} file_count={file_count}".format(
id=kb.get("id", ""),
kb_name=kb.get("kb_name", ""),
ch_name=kb.get("ch_name", ""),
file_count=kb.get("file_count", ""),
)
)
return "\n".join(lines)
class DocumentSearchTool(Tool):
name = "document_search"
description = (
"Search the internal materials document knowledge base. "
"Returns file metadata and truncated markdown content; increase content_chars_per_doc only when needed."
)
parameters = {
"type": "object",
"properties": {
"query": {"type": "string", "description": "Search query, Chinese or English; technical terms are usually better in English."},
"kb_names": {
"type": "array",
"items": {"type": "string"},
"description": "Optional knowledge-base names from document_list_kb.",
},
"classification_ids": {
"type": "array",
"items": {"type": "integer"},
"description": "Optional materials domain ids, 1-7.",
},
"max_documents": {
"type": "integer",
"default": 6,
"description": "Number of documents to return, 1-20.",
},
"content_chars_per_doc": {
"type": "integer",
"default": 1200,
"description": "Maximum markdown characters returned per document, 0-5000.",
},
},
"required": ["query"],
}
def execute(
self,
query: str,
kb_names: Optional[list[str]] = None,
classification_ids: Optional[list[int]] = None,
max_documents: int = 6,
content_chars_per_doc: int = 1200,
) -> str:
query = (query or "").strip()
if not query:
return "[Error] query 不能为空"
max_documents = min(max(int(max_documents), 1), 20)
content_chars_per_doc = min(max(int(content_chars_per_doc), 0), 5000)
try:
docs = doc_client.search(
query=query,
kb_names=kb_names or None,
classification_ids=classification_ids or None,
max_documents=max_documents,
)
except Exception as e:
return f"[Error] document_search failed: {type(e).__name__}: {e}"
if not docs:
return f"(no documents found for query: {query!r})"
lines = [f"Document search results for: {query!r}"]
for i, d in enumerate(docs, 1):
content = d.get("md_content") or ""
snippet, truncated = _clip(str(content), content_chars_per_doc)
lines.append("")
lines.append(f"{i}. file_name={d.get('file_name') or ''}")
lines.append(f" kb_name={d.get('kb_name') or ''}")
lines.append(f" character_count={d.get('character_count') or 0}")
if d.get("md_filename"):
lines.append(f" md_filename={d.get('md_filename')}")
if snippet:
suffix = " ...(truncated)" if truncated else ""
lines.append(f" md_content[:{content_chars_per_doc}]={snippet}{suffix}")
return "\n".join(lines)
class DocumentDownloadTool(Tool):
name = "document_download"
description = (
"Download an original document from document_search into the current task_dir/documents/. "
"Use file_name and kb_name returned by document_search."
)
parameters = {
"type": "object",
"properties": {
"file_name": {"type": "string", "description": "Original file_name or md_filename returned by document_search."},
"kb_name": {"type": "string", "description": "Knowledge-base name returned by document_search."},
"preview": {"type": "boolean", "default": False, "description": "Request inline preview disposition from the upstream API. Usually false."},
},
"required": ["file_name", "kb_name"],
}
def __init__(
self,
*,
working_dir: Path,
base_dir: Optional[Path] = None,
user_root: Optional[Path] = None,
) -> None:
super().__init__(base_dir=base_dir, user_root=user_root)
self.working_dir = Path(working_dir)
def execute(self, file_name: str, kb_name: str, preview: bool = False) -> str:
if not (file_name or "").strip() or not (kb_name or "").strip():
return "[Error] file_name / kb_name 不可为空"
try:
rel = doc_client.download(
file_name=file_name,
kb_name=kb_name,
working_dir=str(self.working_dir),
preview=bool(preview),
)
except Exception as e:
return f"[Error] document_download failed: {type(e).__name__}: {e}"
return f"saved: {self._display(self.working_dir / rel)}"

197
tools/materials_project.py Normal file
View File

@ -0,0 +1,197 @@
"""Host-side Materials Project tools.
MP_API_KEY stays on the host. The sandbox can use offline pymatgen on files
written to task_dir, but it must not receive the Materials Project key.
"""
from __future__ import annotations
import json
import os
from pathlib import Path
from typing import Any, Optional
from .base import Tool
try: # patched in tests; missing dependency should produce a clean tool error.
from mp_api.client import MPRester # type: ignore
except Exception: # pragma: no cover - exercised when mp-api is not installed
MPRester = None # type: ignore
_DEFAULT_SUMMARY_FIELDS = [
"material_id",
"formula_pretty",
"symmetry",
"energy_above_hull",
]
def _mp_key() -> str:
key = os.environ.get("MP_API_KEY", "").strip()
if not key:
raise RuntimeError("MP_API_KEY env 未设置,无法查询 Materials Project")
return key
def _to_plain(obj: Any) -> Any:
if obj is None or isinstance(obj, (str, int, float, bool)):
return obj
if isinstance(obj, (list, tuple)):
return [_to_plain(x) for x in obj]
if isinstance(obj, dict):
return {str(k): _to_plain(v) for k, v in obj.items()}
if hasattr(obj, "model_dump"):
return _to_plain(obj.model_dump())
if hasattr(obj, "as_dict"):
return _to_plain(obj.as_dict())
# mp-api documents expose fields as attributes.
out: dict[str, Any] = {}
for name in _DEFAULT_SUMMARY_FIELDS:
if hasattr(obj, name):
out[name] = _to_plain(getattr(obj, name))
return out or str(obj)
def _mpr():
if MPRester is None:
raise RuntimeError("mp-api 未安装,请在宿主环境安装 mp-api 后再启用 MP tool")
return MPRester(_mp_key())
class MaterialsProjectSearchSummaryTool(Tool):
name = "mp_search_summary"
description = (
"Search Materials Project summary data using the host MP_API_KEY. "
"Returns trimmed JSON; use mp_get_structure to save a CIF for offline pymatgen analysis."
)
parameters = {
"type": "object",
"properties": {
"formula": {"type": "string", "description": "Optional formula such as Ca3SiO5."},
"material_ids": {"type": "array", "items": {"type": "string"}, "description": "Optional Materials Project ids such as mp-123."},
"elements": {"type": "array", "items": {"type": "string"}, "description": "Optional element symbols for a chemical system search."},
"fields": {"type": "array", "items": {"type": "string"}, "description": "Fields to return; defaults to material_id/formula/symmetry/energy_above_hull."},
"limit": {"type": "integer", "default": 10, "description": "Maximum records returned, 1-50."},
},
}
def execute(
self,
formula: str = "",
material_ids: Optional[list[str]] = None,
elements: Optional[list[str]] = None,
fields: Optional[list[str]] = None,
limit: int = 10,
) -> str:
limit = min(max(int(limit), 1), 50)
chosen_fields = fields or _DEFAULT_SUMMARY_FIELDS
kwargs: dict[str, Any] = {"fields": chosen_fields}
if formula:
kwargs["formula"] = formula
if material_ids:
kwargs["material_ids"] = material_ids
if elements:
kwargs["elements"] = elements
if len(kwargs) == 1:
return "[Error] formula / material_ids / elements 至少传一个"
try:
with _mpr() as mpr:
docs = mpr.materials.summary.search(**kwargs)
except Exception as e:
return f"[Error] mp_search_summary failed: {type(e).__name__}: {e}"
plain = [_to_plain(d) for d in list(docs)[:limit]]
return json.dumps(plain, ensure_ascii=False, indent=2)
class MaterialsProjectGetStructureTool(Tool):
name = "mp_get_structure"
description = (
"Download a Materials Project structure by material_id and save it as CIF in task_dir/materials/."
)
parameters = {
"type": "object",
"properties": {
"material_id": {"type": "string", "description": "Materials Project id, e.g. mp-123."},
"filename": {"type": "string", "description": "Optional CIF filename. Defaults to <material_id>.cif."},
},
"required": ["material_id"],
}
def __init__(
self,
*,
working_dir: Path,
base_dir: Optional[Path] = None,
user_root: Optional[Path] = None,
) -> None:
super().__init__(base_dir=base_dir, user_root=user_root)
self.working_dir = Path(working_dir)
def execute(self, material_id: str, filename: str = "") -> str:
material_id = (material_id or "").strip()
if not material_id:
return "[Error] material_id 不可为空"
safe_name = (filename or f"{material_id}.cif").replace("/", "_").replace("\\", "_").replace("..", "_")
if not safe_name.lower().endswith(".cif"):
safe_name += ".cif"
dest = self.working_dir / "materials" / safe_name
try:
with _mpr() as mpr:
struct = mpr.get_structure_by_material_id(material_id)
dest.parent.mkdir(parents=True, exist_ok=True)
struct.to(filename=str(dest))
except Exception as e:
return f"[Error] mp_get_structure failed: {type(e).__name__}: {e}"
return f"saved: {self._display(dest)}"
class MaterialsProjectGetEntriesTool(Tool):
name = "mp_get_entries"
description = (
"Fetch Materials Project computed entries for a chemical system and save trimmed JSON to task_dir/materials/."
)
parameters = {
"type": "object",
"properties": {
"elements": {"type": "array", "items": {"type": "string"}, "description": "Chemical system elements, e.g. ['Ca','Si','O','H']."},
"filename": {"type": "string", "description": "Optional JSON filename. Defaults to mp_entries_<chemsys>.json."},
"limit": {"type": "integer", "default": 200, "description": "Maximum entries saved, 1-1000."},
},
"required": ["elements"],
}
def __init__(
self,
*,
working_dir: Path,
base_dir: Optional[Path] = None,
user_root: Optional[Path] = None,
) -> None:
super().__init__(base_dir=base_dir, user_root=user_root)
self.working_dir = Path(working_dir)
def execute(
self,
elements: list[str],
filename: str = "",
limit: int = 200,
) -> str:
elems = [e.strip() for e in (elements or []) if str(e).strip()]
if not elems:
return "[Error] elements 不可为空"
limit = min(max(int(limit), 1), 1000)
chemsys = "-".join(elems)
safe_name = filename or f"mp_entries_{chemsys}.json"
safe_name = safe_name.replace("/", "_").replace("\\", "_").replace("..", "_")
if not safe_name.lower().endswith(".json"):
safe_name += ".json"
dest = self.working_dir / "materials" / safe_name
try:
with _mpr() as mpr:
entries = mpr.get_entries_in_chemsys(elems)
payload = [_to_plain(e) for e in list(entries)[:limit]]
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
except Exception as e:
return f"[Error] mp_get_entries failed: {type(e).__name__}: {e}"
return f"saved: {self._display(dest)}"