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/...`,改物理边界替代代码护栏):
- **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"留好**,升级触发信号见下表。
- **代价**:每个 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`
最后更新: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
- **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`
最后更新: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
# (Seedance 2.0 Fast,文生视频,480p 4s ¥1.86 ~ 720p 15s ¥12+,异步等 30-90s);未设两个 tool 都不出现
ARK_API_KEY=...
# documents skill(内部知识库 document_search API):⚠️ 2026-05-29 起 sandbox 下不可用 ——
# run_python `_SENSITIVE_PATTERNS` 过滤器拦含 API_KEY 字面的 env(等 credential broker),
# 配了也读不到;documents 调用即抛 RuntimeError,LLM 应降级到 research
# documents skill(内部知识库 document_search API):可选。设了后注册
# document_list_kb / document_search / document_download 三个 host-side tool;
# key 只留宿主后端,sandbox/run_python 不读取。
DOCUMENT_SEARCH_API_KEY=...
# 可选:覆盖默认 base_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() 配了也炸;
# 离线分析(CIF / POSCAR + SpacegroupAnalyzer + XRDCalculator + CEMENT_PHASES)不受影响。
# pymatgen skill 的 Materials Project 接入:可选。设了后注册
# mp_search_summary / mp_get_structure / mp_get_entries 三个 host-side tool;
# 离线分析(CIF / POSCAR + SpacegroupAnalyzer + XRDCalculator + CEMENT_PHASES)仍在 sandbox 跑。
# 申请 https://materialsproject.org/api(免费)
MP_API_KEY=...
# 本地 / 内网部署 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(罕见) |
| `[startup] reaped N stale active run(s)` | 上次 web 进程未正常 finish 留下 N 个孤儿 run,启动 lifespan 自动标 error。info 级,无需处理 |
| `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 中间这段记账偏低,开发期接受 |
| `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` |

View File

@ -1,7 +1,7 @@
# zcbot Skill 清单
服务对象:中国建筑材料科学研究总院 —— 无机非金属材料 R&D(水泥 / 混凝土 / 玻璃 / 陶瓷 / 耐火 / 新型建材)
最后更新:2026-05-29
最后更新:2026-06-01
Skill 总数:13
zcbot 的"skill"是一份可加载的工作流脚本(`skills/<name>/SKILL.md` + 配套 templates / scripts / Python helper),模型在识别用户意图后挂载对应 skill,按其内置的阶段化流程产出可交付物。本文档面向**使用方 / 协作方**,按"做什么、什么时候用、什么时候别用、典型产物"组织。
@ -18,8 +18,8 @@ zcbot 的"skill"是一份可加载的工作流脚本(`skills/<name>/SKILL.md` +
| 演示出图 | [ppt](#ppt) | 生成 PowerPoint 演示稿(商务红主题,逐页验收) |
| 演示出图 | [plot_pub](#plot_pub) | 出版级 matplotlib 学术图(中文 + viridis + 矢量) |
| 文献检索 | [research](#research) | 查 paper_server(OpenAlex 元数据 + Sci-Hub 下载) |
| 文献检索 | [documents](#documents) | 查内部 7 学科材料知识库(21W+ 论文,跨语言检索)⚠️ sandbox 暂不可用,降级 research |
| 科研计算 | [pymatgen](#pymatgen) | 晶体结构 / XRD 模拟 / 相图 / Materials Project ⚠️ MP 联网暂不可用,离线分析正常 |
| 文献检索 | [documents](#documents) | 查内部 7 学科材料知识库(21W+ 论文,跨语言检索;host-side tool 持 key) |
| 科研计算 | [pymatgen](#pymatgen) | 晶体结构 / XRD 模拟 / 相图 / Materials Project(host-side tool 持 key) |
| 科研计算 | [stats_ml](#stats_ml) | 配方-性能建模与机器学习(三库分工) |
| 内容生成 | [imagegen](#imagegen) | 豆包 Seedream 5.0 文生图(¥0.22 / 张) |
| 内容生成 | [videogen](#videogen) | 豆包 Seedance 2.0 文生视频(¥1.86 起 / 段) |
@ -218,7 +218,7 @@ paper_server 是内部 Django 文献库:元数据来自 OpenAlex,PDF / XML 由 S
**关系**:与 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
**无机材料计算(晶体结构 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`,跟实测谱对比
- 物相对称性 → `SpacegroupAnalyzer`
- 凝胶 / 水化产物热力学稳定性 → `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.task import TaskState
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.seedance import SeedanceTool
from tools.seedream import SeedreamTool
@ -389,8 +395,40 @@ def build_agent(
wf = WebFetchTool(base_dir=tool_base, user_root=ur_path)
tools[wf.name] = wf
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:
import os
# docker backend 下 fs/shell/run_python 在容器内跑,skills/ bind mount 到
# /sandbox/skills:ro。把 LoadSkillTool 返回头里的 dir 改写成容器路径,LLM
# 拿来 read references 才能命中。host backend = None,保持原 host 绝对路径。

View File

@ -73,7 +73,7 @@ def step_a_pymatgen() -> None:
# A1: helper import
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)})")
except Exception as e:
_fail(f"import skills.pymatgen.materials: {type(e).__name__}: {e}")
@ -131,22 +131,20 @@ def step_a_pymatgen() -> None:
except Exception as 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"))
if has_key:
_info("MP_API_KEY 已配置,skip 缺 key 错验证(下面 step D 测真实查询)")
_info("MP_API_KEY 已配置,skip 缺 key 错验证(下面 step D 测真实查询)")
else:
# 显式清掉 env 再测
try:
with mp_rester() as mpr:
_fail("MP_API_KEY 未配置时 mp_rester 应抛 RuntimeError,没抛")
except RuntimeError as e:
if "MP_API_KEY" in str(e) and "materialsproject" in str(e):
_ok("mp_rester 未配 key 正确抛 RuntimeError 含申请链接")
from tools.materials_project import MaterialsProjectSearchSummaryTool
out = MaterialsProjectSearchSummaryTool().execute(formula="Ca3SiO5")
if out.startswith("[Error]") and "MP_API_KEY" in out:
_ok("mp_search_summary 未配 key 返回 [Error] 含 MP_API_KEY 提示")
else:
_fail(f"RuntimeError 抛了但 msg 不对: {e}")
_fail(f"未配 key 应返回 [Error] 含 MP_API_KEY,实际: {out[:120]}")
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:
@ -271,20 +269,26 @@ def step_d_mp_online() -> None:
return
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
t0 = time.time()
with mp_rester() as mpr:
docs = mpr.materials.summary.search(
formula=formula,
fields=["material_id", "formula_pretty", "energy_above_hull"],
)
out = MaterialsProjectSearchSummaryTool().execute(
formula=formula,
fields=["material_id", "formula_pretty", "energy_above_hull"],
limit=3,
)
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]:
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:
_fail(f"mp_rester 联网查询: {type(e).__name__}: {e}")
_fail(f"mp_search_summary 联网查询: {type(e).__name__}: {e}")
def main() -> int:

View File

@ -5,9 +5,9 @@ description: 查内部材料学科知识库(document_search API,7 个学科:胶
# 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
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]`
### `document_list_kb()`
列所有有效知识库(分类 1-7)。每条含 `id` / `kb_name` / `ch_name` / `kb_info` / `file_count` 等。
```python
kbs = list_kb()
for kb in kbs:
print(kb["id"], kb["kb_name"], kb["ch_name"], kb["file_count"])
```
**用途**:用户没指定库 → 先 `document_list_kb` 看有哪些库(中文名 `ch_name` 看分类),再选 `kb_names` / `classification_ids` 缩窄 search 范围。
**用途**:用户没指定库 → 先 `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`**(整篇 Markdown 文本)。
搜文档,返回精简列表,每条带 **截断后的 `md_content`**。默认每篇 1200 字符,需要看更多时调大 `content_chars_per_doc`,上限 5000。
- `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` 不过滤
- `max_documents`:1-20,默认 6
- `content_chars_per_doc`:每篇返回多少 Markdown 字符,默认 1200,最大 5000;不要一上来拉满
**学科库 → kb_name 速查**(`list_kb()` 拿全量,这里只列常用):
**学科库 → kb_name 速查**(`document_list_kb` 拿全量,这里只列常用):
| 学科 | kb_name |
|---|---|
@ -66,49 +51,18 @@ for kb in kbs:
| 耐火材料 | `mu_34_1740625365079` |
| 检验检测 | `mu_34_1740625376621` |
```python
# 全库搜(走 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`
### `document_download(file_name, kb_name, preview=False)`
下载原始文档(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()`** —— 用户没指定库 / 不确定分类时看一下有哪些
2. **`search(query=..., max_documents=6)`** —— 中英文均可,专业技术术语优先英文
1. **(可选)`document_list_kb`** —— 用户没指定库 / 不确定分类时看一下有哪些
2. **`document_search(query=..., max_documents=6)`** —— 中英文均可,专业技术术语优先英文
3. **看返回**:
- 用 `file_name + character_count + md_content[:300]` 判断切题
- 切题 → 直接用 `md_content` 给 LLM 引用(已结构化 Markdown,不需要再下载原件)
- 需要看图表 / 表格原貌 / 给用户附件 → `download(file_name, kb_name, working_dir)` 拿原文档,然后用主 agent 的 `read` 工具读(zcbot 已内置 PDF/Word 文本抽取)
- 用 `file_name + character_count + md_content` 判断切题
- 切题 → 直接用返回的 Markdown 摘要给 LLM 引用;需要更多上下文时提高 `content_chars_per_doc` 重搜
- 需要看图表 / 表格原貌 / 给用户附件 → `document_download(file_name, kb_name)` 拿原文档,然后用主 agent 的 `read` 工具读(zcbot 已内置 PDF/Word 文本抽取)
4. **写产出**:把 md_content 关键段落引到申报书 / 方案里,标注来源文件名
## 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
- 404 `未找到知识库`:`kb_names` 拼写错或库已下线,改 `list_kb()` 看当前有效列表
- 404 `文件不存在: xxx`:`download` 时常见,可能 server 侧文件丢失或 `file_name` 拼写错
- 404 `未找到知识库`:`kb_names` 拼写错或库已下线,改 `document_list_kb` 看当前有效列表
- 404 `文件不存在: xxx`:`document_download` 时常见,可能 server 侧文件丢失或 `file_name` 拼写错
- search 命中 0 条:同义词 / 切换中英文 / 缩短 query / 放宽 `classification_ids` 再试 2-3 次,还是 0 条就明确告诉用户"本库没覆盖,改走 research 或换关键词",**不要凭训练数据脑补文献**
- 网络超时 / server 不可达:`httpx.ConnectError` / `httpx.TimeoutException` —— 告诉用户"document_search 暂时连不上",不要重试堆栈刷屏
## 反模式
- 用 `httpx` / `requests` 裸调 API(走 helper,免得 base_url / auth / 字段名漂移时四处改)
- `search(max_documents=20)` 一次拉满后 print 全部 `md_content`(单条就可能几十 K,20 条直接爆 LLM 上下文)—— 只 print `file_name + character_count + md_content[:300]`,要看全文用 `docs[i]['md_content']`
- 用 `httpx` / `requests` 裸调 API(走 host tool,免得 base_url / auth / 字段名漂移时四处改,也避免 key 进入 sandbox)
- `document_search(max_documents=20, content_chars_per_doc=5000)` 一次拉满(20 条直接爆 LLM 上下文)—— 先用默认值判断切题,只对少数命中文档加大 `content_chars_per_doc`
- 看到 md_content 切题还 `download` 一遍原件(md_content 已是 LLM 友好的 Markdown,大多数引用场景够用)
- 凭 `ch_name`("胶凝材料学科知识库")就以为 query 要用中文 —— 文档主体是英文,复杂术语用英文更精准
- 编造 file_name / kb_name —— 不在 `list_kb()` / `search` 返回里就**明确告诉用户"未命中"**,不要瞎传 ID
- 把 `download` 返回的相对路径当绝对路径用(它是相对 `working_dir` 的)
- 不在合适的 task working_dir 里 `download`(原文档应该落到 task 目录,不要污染 repo)
- 编造 file_name / kb_name —— 不在 `document_list_kb` / `document_search` 返回里就**明确告诉用户"未命中"**,不要瞎传 ID
- 把 `document_download` 返回的相对路径当绝对路径用(它是相对 task_dir 的)
- 尝试给 `document_download``working_dir`(tool 已绑定当前 task_dir,不要让模型指定路径)

View File

@ -5,9 +5,9 @@ description: 无机材料计算工具(晶体结构 I/O、XRD 模拟、相图、
# 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
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.analysis.diffraction.xrd import XRDCalculator
from pymatgen.symmetry.analyzer import SpacegroupAnalyzer
@ -49,28 +49,22 @@ from pymatgen.analysis.phase_diagram import PhaseDiagram, PDEntry
表里没有的相,先英文学名 → 化学式后再喂,不要直接把中文丢给 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
with mp_rester() as mpr: # 自动从 env 拿 key
docs = mpr.materials.summary.search(
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_search_summary(formula?, material_ids?, elements?, fields?, limit=10)` —— 查 summary,返回裁剪 JSON
- `mp_get_structure(material_id, filename?)` —— 把结构保存到 task_dir `materials/*.cif`,再用 `Structure.from_file()` 离线计算
- `mp_get_entries(elements, filename?, limit=200)` —— 把 chemsys entries 保存到 task_dir `materials/*.json`
`MP_API_KEY` 没配 → `mp_rester()``RuntimeError("MP_API_KEY not set in env...")`,告诉用户去配,不要继续
`MP_API_KEY` 没配 → 上述 tool 不会出现,告诉用户配置或手动从 Materials Project 下载 CIF。
## 典型工作流
### A. 实测 XRD 比对(谁是这个峰)
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`)
4. 报"x° 这个峰最可能是 C3S 的 (h k l) 衍射"
@ -78,9 +72,8 @@ with mp_rester() as mpr: # 自动从 env 拿 key
from pymatgen.analysis.diffraction.xrd import XRDCalculator
xrd = XRDCalculator(wavelength="CuKa") # 默认 Cu Kα
with mp_rester() as mpr:
docs = mpr.materials.summary.search(formula="Ca3SiO5", fields=["material_id"])
struct = mpr.get_structure_by_material_id(docs[0].material_id)
# 先用 mp_search_summary / mp_get_structure tool 保存 CIF,再:
struct = Structure.from_file("materials/mp-xxxx.cif")
pattern = xrd.get_pattern(struct, two_theta_range=(5, 80))
# pattern.x = 2θ 列表, pattern.y = 强度, pattern.hkls = (h,k,l) 列表
```
@ -101,14 +94,7 @@ prim = sga.get_primitive_standard_structure() # 简约胞
### C. 凝胶 / 水化产物相图稳定性
```python
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) → 分解路径 + 能量
```
先用 `mp_get_entries(elements=["Ca", "Si", "O", "H"])` 保存 JSON。第一版 host tool 的 entries JSON 主要用于审阅 / 归档;若要直接构造 `PhaseDiagram`,优先使用用户提供的本地 entry 数据或后续补一个专门的 host-side 相图 helper。不要在 sandbox 里绕过 tool 直接连 MP。
### D. 格式转换(给计算所做 VASP 输入)
@ -120,7 +106,7 @@ struct.to(filename="output.cif")
## 反模式
- 用户报中文相名(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()`
- 不做 `SpacegroupAnalyzer.get_primitive_standard_structure()` 直接拿原胞做对称性比对(原胞可能是超胞,对称性少看出来)
- 大 cutoff 邻居搜索(`get_neighbors(r=20)`)—— 性能差,先 `r=5`
@ -139,8 +125,9 @@ mp-api>=0.41.0
## env
宿主后端配置:
```
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
import os
from contextlib import contextmanager
# 中文/简写 → 化学式映射。覆盖建材院 R&D 高频物相。
# 添加新条目时:
@ -124,30 +122,3 @@ def lookup_phase(name: str) -> str:
f"若是新相,直接把化学式喂给 pymatgen / Materials Project;"
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)}"