diff --git a/DESIGN.md b/DESIGN.md index 72ef830..0223b85 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -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/ -i python /sandbox/tool_runner.py ` + stdin 喂 JSON args(CJK / 引号 / 路径分隔符透明传,不被 shell metachar 切)。`tool_runner.py` 在镜像里 `/sandbox/`,复用 `tools/fs.py` 的 Tool 子类(`COPY tools/ /sandbox/tools/`);skill references 通过额外的 `/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` 按需读取。 + **升级触发信号(写下来防遗忘,反向兜底:无信号不升级)**: | 升级方向 | 触发信号 | 不升级的理由 | diff --git a/PROGRESS.md b/PROGRESS.md index fd5f428..1146c0f 100644 --- a/PROGRESS.md +++ b/PROGRESS.md @@ -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 改造未启动**。 diff --git a/RUN.md b/RUN.md index b61c08c..6a5b785 100644 --- a/RUN.md +++ b/RUN.md @@ -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_" /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 ` 后 `/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` | diff --git a/SKILL_LIST.md b/SKILL_LIST.md index a3b1fc8..d545736 100644 --- a/SKILL_LIST.md +++ b/SKILL_LIST.md @@ -1,7 +1,7 @@ # zcbot Skill 清单 服务对象:中国建筑材料科学研究总院 —— 无机非金属材料 R&D(水泥 / 混凝土 / 玻璃 / 陶瓷 / 耐火 / 新型建材) -最后更新:2026-05-29 +最后更新:2026-06-01 Skill 总数:13 zcbot 的"skill"是一份可加载的工作流脚本(`skills//SKILL.md` + 配套 templates / scripts / Python helper),模型在识别用户意图后挂载对应 skill,按其内置的阶段化流程产出可交付物。本文档面向**使用方 / 协作方**,按"做什么、什么时候用、什么时候别用、典型产物"组织。 @@ -18,8 +18,8 @@ zcbot 的"skill"是一份可加载的工作流脚本(`skills//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` --- diff --git a/core/agent_builder.py b/core/agent_builder.py index aab2719..d59f43b 100644 --- a/core/agent_builder.py +++ b/core/agent_builder.py @@ -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 绝对路径。 diff --git a/scripts/smoke_scientific_skills.py b/scripts/smoke_scientific_skills.py index 86f6ff5..3015f7b 100644 --- a/scripts/smoke_scientific_skills.py +++ b/scripts/smoke_scientific_skills.py @@ -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: diff --git a/skills/documents/SKILL.md b/skills/documents/SKILL.md index 50198c4..08525d1 100644 --- a/skills/documents/SKILL.md +++ b/skills/documents/SKILL.md @@ -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 / ...)到 `/documents/`,返回相对路径。已存在跳过下载直接复用。`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//", -) -# 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,不要让模型指定路径) diff --git a/skills/pymatgen/SKILL.md b/skills/pymatgen/SKILL.md index 550c965..499c475 100644 --- a/skills/pymatgen/SKILL.md +++ b/skills/pymatgen/SKILL.md @@ -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。 diff --git a/skills/pymatgen/materials.py b/skills/pymatgen/materials.py index 55ee9cf..a271dbd 100644 --- a/skills/pymatgen/materials.py +++ b/skills/pymatgen/materials.py @@ -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 diff --git a/tests/test_secret_host_tools.py b/tests/test_secret_host_tools.py new file mode 100644 index 0000000..e218f67 --- /dev/null +++ b/tests/test_secret_host_tools.py @@ -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() diff --git a/tools/documents.py b/tools/documents.py new file mode 100644 index 0000000..b5fcd30 --- /dev/null +++ b/tools/documents.py @@ -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)}" diff --git a/tools/materials_project.py b/tools/materials_project.py new file mode 100644 index 0000000..cfd61ff --- /dev/null +++ b/tools/materials_project.py @@ -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 .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_.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)}"