Compare commits

...

2 Commits

Author SHA1 Message Date
caoqianming 85415470d2 sandbox+infra: Python 3.10→3.12(host+Dockerfile)+ docker PYTHONPATH 加 /sandbox
为通过 3 个科学 skill 的 smoke 解决两件基础设施问题。

1. emmet-core 0.86.0rc1 用了 typing.NotRequired(3.11+),host .venv 是 3.10
   → mp_rester ImportError。选 3.12(当下 ML 生态默认稳定版,比 3.11 多一年
   优化,比 3.13 wheel 覆盖更全 Windows 不踩坑)。Dockerfile python:3.11
   → 3.12 同步升,部署机 rebuild image 时生效。

2. executor_docker.py:172 PYTHONPATH 由 /workspace 改 /sandbox:/workspace,
   修历史 bug —— skills/ bind mount 到容器 /sandbox/skills:ro,SKILL.md 教
   LLM `from skills.xxx import yyy`,docker backend 之前根本 import 不到
   (research/paper 同款受影响,只是 dogfood 一直跑 host backend 没暴露)。
   test_executor_docker.py:243 regression 测试改为 assertIn 含 /sandbox,
   15/15 PASS。

smoke 验证:pymatgen XRD / sklearn / statsmodels / plot_pub 全通,
mp_rester 联网遇 MP 服务侧 IP/ASN 403(LBNL 对国内 IP 临时封,非代码问题)。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 11:35:02 +08:00
caoqianming 52f201404c skills: 加 pymatgen / stats_ml / plot_pub(建材院无机材料场景)
服务中国建材院无机非金属材料 R&D。从 K-Dense AI scientific-agent-skills 仓库挑
3 个 ★★★ skill fork,不走 npx 一键装(138 个 description 入 prompt 噪声 + 误触发)。

- pymatgen: 晶体结构 / XRD / 对称性 / 相图 / Materials Project。helper
  materials.py 内 CEMENT_PHASES 收 66 条中英文相名映射(水泥熟料 / 水化产物 /
  陶瓷耐火 / 玻璃晶相 / 常见矿物)、lookup_phase 大小写不敏感、mp_rester 从
  env MP_API_KEY 拿 key
- stats_ml: 纯指南 skill,场景导航 sklearn / statsmodels / PyMC 三选一,
  5 个工作流(配方-性能回归 / DoE 二阶响应面 / 显著性分析 / 贝叶斯小样本 /
  DBSCAN 异常配方)+ 16 条反模式
- plot_pub: 出版级 matplotlib,helper style.py 内 apply_pub_style() 一键
  设置中文字体跨平台 fallback (SimHei / YaHei / WenQuanYi) + viridis +
  dpi + PDF Type 42

requirements.txt 加 pymatgen / mp-api / scikit-learn / statsmodels
(pymc 注释,装包重按需开)。RUN.md env 段加 MP_API_KEY(可选)。
SCIENTIFIC_SKILLS.md 根目录沉淀整体评估,后续 materials_db 落地参考。
scripts/smoke_scientific_skills.py 三 skill 链路通路验证脚本。

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-28 11:33:59 +08:00
13 changed files with 1287 additions and 4 deletions

View File

@ -2,7 +2,7 @@
> 配合 `DESIGN.md`。本文件只记 phase 状态、决策偏差、文件量、下一步。每条 1-2 句:做了啥 + 关键判断;细节查 `git log` / `git diff` / `DESIGN §7.9`
最后更新:2026-05-27(ppt skill 歧义反问 + general_v1 加"产物形式歧义先问"通用原则)
最后更新:2026-05-28(Python 3.10→3.12 升级 + Docker backend PYTHONPATH 修 + 3 个科学计算 skill smoke 通过)
---
@ -23,6 +23,8 @@
### 2026-05-28
- **Python 3.10→3.12 升级(host + Dockerfile)+ DockerExecutor PYTHONPATH 加 `/sandbox` 修历史 import bug + 3 个科学 skill smoke 通过**:上一条加完 3 个科学 skill 后跑 smoke 发现 step D mp_rester 联网炸 `ImportError: cannot import name 'NotRequired' from 'typing'` —— Materials Project 官方依赖 `emmet-core 0.86.0rc1``outcar_adapter.py` 直接 `from typing import NotRequired`(3.11+ 才有,没走 `typing_extensions` 兜底),原 host .venv 是 Python 3.10.9 → mp-api 整链路 import 不进。**选 3.12 而非 3.11/3.13**:3.12 是当下 ML/AI 生态默认推荐版本(稳一年半 + 所有主流包预编译 wheel 覆盖完整),3.11 跟容器对齐但少一年优化,3.13 释放才半年冷门 wheel 偶尔退源码编译 Windows 上易踩坑(没新特性需求,激进升只是踩雷概率)。**实施**:① host py -3.12 -m venv 重建 .venv,pip install -r requirements.txt 装齐(pymatgen 2026.5.4 / mp-api 0.46.1 / emmet-core 0.86.4 / sklearn 1.8.0 / statsmodels 0.14.6 / numpy 2.4 / scipy 1.17 / matplotlib 3.10.9 / litellm / fastapi / sqlalchemy / 全套传递依赖);② Dockerfile FROM `python:3.11-slim``python:3.12-slim`(host / 容器同步升,部署机 rebuild image 时生效);③ **顺手修 `core/executor_docker.py:172` PYTHONPATH** `/workspace``/sandbox:/workspace`:历史 bug —— 多个 skill(`research/paper`、新加 `pymatgen/materials`、`plot_pub/style`)SKILL.md 都教 LLM `from skills.xxx.yyy import zzz`,host backend 因 base_dir=Path.cwd()(zcbot repo 根)注入 PYTHONPATH 能 work;docker backend 下容器只有 `PYTHONPATH=/workspace` + skills/ bind mount 到 `/sandbox/skills:ro`,`import skills.xxx` 找不到。本次加 `/sandbox` 前缀(在 /workspace 前,让 skills 优先级高于用户 task 目录的同名 shadow),`tests/test_executor_docker.py:243-245` regression test 改 `assertIn("PYTHONPATH=/sandbox:/workspace", ...)`,**全套 15/15 PASS**。**smoke 实跑**:step A pymatgen helper + XRDCalculator MgO 11 个峰 ✅ / step B sklearn R²=0.575 + statsmodels R²=0.911 p≪0.05 ✅ / step C plot_pub SimHei + PNG+PDF 出图 ✅ / step D mp_rester 联网 ⚠️ 返 403 "Your IP/ASN blocked"(Materials Project 服务侧 IP 临时封禁,跟代码无关,LBNL 服务对中国大陆 IP 段或同 ASN abusive traffic 触发 → 等几小时自动解 / 邮件 support@materialsproject.org 报公网 IP 申请解封 / VPS 走代理 fallback)。**非阻塞**:pymatgen 本地功能(CIF I/O / XRDCalculator / SpacegroupAnalyzer / PhaseDiagram / VASP 输入)100% 能用,只是 `mp_rester` 在线查询暂不能用。否决:(a) 升 3.11(只跟容器对齐,少一年优化,3.12 同样兼容容器);(b) 升 3.13(释放半年,冷门 wheel 偶尔退源码编译 Windows 踩坑,激进升无收益);(c) pin `emmet-core<0.86` + `mp-api<0.45`(临时,下次 pip install 不 pin 又炸,且丢 emmet 新功能);(d) monkey patch `typing.NotRequired = typing_extensions.NotRequired`(hacky 且挡不住 mp_api 下游其他 3.11+ 假设);(e) executor PYTHONPATH 改 `/workspace:/sandbox`(/workspace 优先 → 用户 task 目录如果手贱建 `skills/` 同名子目录会 shadow 真 skills,/sandbox 在前更稳)。`DESIGN.md` 不动(纯实施层 Python 版本 + 容器 PYTHONPATH 修);`RUN.md` 不动(env 段 MP_API_KEY 已在上一条 skill commit 加入,Python 版本要求记 `requirements.txt` + Dockerfile 自表)。
- **新增 3 个科学计算 skill(pymatgen / stats_ml / plot_pub),服务建材院无机非金属材料 R&D**:`SCIENTIFIC_SKILLS.md` 评估完 K-Dense/scientific-agent-skills 仓库后落地选 4 个 ★★★ 中前 3 个动手(materials_db 后置,USPTO 部分留并入 `skills/patent`)。命名取**工具名直接**(`pymatgen` / `plot_pub`)+ **业务前缀**(`stats_ml` 因合三库需要场景导航),贴合现有 skill 命名风格(coding/ppt/research/...)。① **`skills/pymatgen/`**:`SKILL.md`(无机相中文→化学式映射表说明 / XRD 比对 / 对称性 / 相图 / VASP 输入文件,八条反模式)+ `materials.py`(`CEMENT_PHASES` dict 覆盖水泥熟料 / 水化产物 / 石膏 / 碳酸盐 / 陶瓷耐火 / 玻璃晶相 / 常见矿物共 50+ 条目,中英文 / 简写多 key 指同一化学式;`lookup_phase()` 大小写不敏感查找;`mp_rester()` context manager 自动从 env 拿 `MP_API_KEY`,缺则 RuntimeError 带申请链接;mp_api 局部 import 避免装包前 import 即崩)。② **`skills/stats_ml/`**:`SKILL.md` 纯指南(场景导航表选 sklearn / statsmodels / PyMC、5 个工作流示例 A-E 含配方-性能回归 / DoE 二阶响应面 / 显著性分析 / 贝叶斯小样本 / DBSCAN 异常配方、16 条反模式分库列示)+ 无 helper(三库 API 直接用)。③ **`skills/plot_pub/`**:`SKILL.md`(XRD 多相叠图 / TG-DSC 双 Y 轴 / 强度发展曲线 / 多 panel 论文 figure 4 个工作流 + 中文字体说明 + 10 条反模式)+ `style.py`(`apply_pub_style()` 一键设置:中文字体跨平台 fallback SimHei→YaHei→WenQuanYi→DejaVu / dpi 150 屏幕 300 保存 / viridis 默 cmap / 刻度朝内 / legend 无框 / PDF Type 42 字体合规期刊 / `font.size` `linewidth` 等可参数化;`_find_chinese_font()` 在 `font_manager.fontManager.ttflist` 查实装字体而非靠 try-load)。**关键决策**:(a) **不一键装 138 个 skill** —— 上下文噪声 + 误触发(用户"分析一下"模型可能跳 Scanpy),挑 4 个 ★★★ fork 单装;(b) **PyMC 装包延后** —— 带 pytensor 装 5min+ 体积大,真要做贝叶斯再装;requirements.txt 注释掉以 `# pymc>=5.10.0` 形式留接口;(c) **MP_API_KEY 走 .env** —— 跟 DEEPSEEK_API_KEY / ARK_API_KEY 同范式,litellm 不读但 `os.environ.get` 拿到;(d) **化学式映射表对中文 / 简写 / 英文学名同等待遇** —— 用户报相名习惯混杂(C3S / 硅酸三钙 / alite 都常见),多 key 同 value 比强迫归一化体验好;(e) **不写示例数据/单元测试**:开发期 LLM 工作流场景多变,跑了 SKILL.md 工作流验证而非脚本测试 —— skill 是 prompt 不是代码模块。requirements.txt 加 pymatgen / mp-api / scikit-learn / statsmodels(PyMC 注释)。`RUN.md` env 段加 MP_API_KEY 说明(可选 + 申请链接 + 未设抛 RuntimeError)。`DESIGN.md` 不动(纯 skill 加,无架构变化);`SCIENTIFIC_SKILLS.md`(根目录调研笔记)已沉淀整体评估,后续 materials_db 落地参考。装包未执行 —— 等用户跑 `.venv/Scripts/pip install pymatgen mp-api scikit-learn statsmodels` 装上才能验证 import 路径生效。否决:(a) 三个 skill 合并成一个 `science` skill —— 触发语义糊,LLM 难判,各做各的更清;(b) `materials_pymatgen` 这种业务前缀全打 —— pymatgen 本身就是材料库,前缀冗余;(c) helper 过度封装(写 `simulate_xrd(formula)` 全自动)—— 隐藏 pymatgen 真实 API,LLM 学不到本来好用的上游能力,反模式段在 SKILL.md 里讲清更轻;(d) plot_pub 内 `apply_pub_style()` 失败抛错 —— 中文字体没装也应该继续画(图能看就行,只是中文方块),warn 比 raise 友好。
- **DESIGN §7.5 增"image 体积 / 多 user 资源 / 后续加包策略"决策段**:dogfood 推进到加 npm + chromium + mermaid-cli 后,sandbox image 1.5G+,后续 domain 包(rdkit / pymatgen / ASE / pandoc-tex 等)还会进一步推大,把"image 大 = 资源占用大 = 多 user 容器爆炸"这条直觉关联的事实链拆开沉淀,免得未来花减肥功夫减错地方。三条认知校准:① **image 大 ≠ 运行时吃更多 RAM**(空载 `tini → sleep infinity` RSS 个位数 MB,layer 共享让磁盘乘数 = 1,真吃 RAM 的是 active exec 行为而非 image 字节);② **多 user 同时在线瓶颈在并发 exec 不在 idle 容器数**(杠杆全在 `docker run --memory --cpus --pids-limit` + 同 user 并发 semaphore + 整机 active user cap + idle 回收,减 image 体积对这条曲线无效);③ **新增依赖采用"base 收敛 + per-user 持久化 venv + 使用频次沉淀"**:base 只放高频共用轻量包(`requirements.txt` 当前形状),长尾 domain 包模型用 `pip install --target=/workspace/.venv/site-packages` 装到 per-user 持久化路径(随 user_root bind mount,idle 回收不丢);加 audit 统计哪些包累计被 >30% user 装过 ≥ 3 次 → 下次 build 合并进 `requirements.txt`,base 跟着真实使用模式收敛而非拍脑袋。否决:(a) 全塞 base —— 重包(torch / texlive)+ 长尾包打死磁盘 + 强迫所有 user 陪绑;(b) 运行时临时装(不持久化)—— 容器 idle 回收即丢冷启重装,高频复用差;(c) 多 image 按场景分 —— per-user 容器模型下 user 不知道选哪个,切 skill 还得换 image 心智不通;(d) per-user venv 用 named volume / 共享 image cache —— 包 install 脚本是任意代码,跨 user 共享 venv 破坏跨 user 隔离边界;(e) 依赖 pip cache 解决问题 —— pip cache 只省网络不省落盘,容器回收照样丢。落地点排进 Stage C Step 6 Executor 实施:cgroup limits / 并发 semaphore / idle 回收 / per-user venv mount 一并进 `DockerExecutor`(audit 沉淀机制延后,需要 dogfood 安装日志积累再开)。`RUN.md` 不动(当前无 CLI / env 变化,真做 venv mount 时再加);`DESIGN.md` §7.5 升级触发信号表后新增该段。
### 2026-05-27

3
RUN.md
View File

@ -21,6 +21,9 @@
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 接入:可选。设了 pymatgen.materials.mp_rester() 才能用,
# 未设调用即抛 RuntimeError。申请 https://materialsproject.org/api(免费)
MP_API_KEY=...
ZCBOT_DB_URL=postgresql://user:pass@host:5432/zcbot
# main.py web 必填(probe/db/user 不验)
PLATFORM_KEY=<≥16 字符随机串,platform 机器对机器入口校验>

189
SCIENTIFIC_SKILLS.md Normal file
View File

@ -0,0 +1,189 @@
# 科学 Skill 集成评估(scientific-agent-skills → zcbot)
来源:https://github.com/K-Dense-AI/scientific-agent-skills(MIT,K-Dense AI,138 个 skill)
适用对象:zcbot,服务于中国建筑材料科学研究总院 —— 无机非金属材料 R&D(水泥/混凝土/玻璃/陶瓷/耐火/新型建材)。
## 1. 总体结论
- **不整包装**(`npx skills add`),138 个 description 全进 prompt → 上下文噪声爆炸 + 误触发(用户说"分析一下"模型可能跳去 Scanpy)。
- **挑 4 个核心新能力 fork 进来**,适配 zcbot 现有 skill 格式(`SKILL.md` + Python helper + `run_python` 调用)。
- **现有 6 个 skill 不替换,抽对方的 best practice 增强**
## 2. 现状与目标格式
zcbot 已有 skill(`D:/projects/zcbot/skills/`):
`coding / documents / imagegen / patent / ppt / proposal / research / review / videogen`
每个 skill = `SKILL.md`(frontmatter + 何时用 + 反模式 + 工作流)+ 配套 Python(走 `run_python` 注入的 PYTHONPATH 直接 import)。
scientific-agent-skills 的 skill 格式语义基本一致 —— **可以单个 fork,不要批量装**
## 3. 推荐移植的 4 个新能力
### 3.1 Pymatgen ★★★
无机材料计算核心库。
**对你们的价值面**
- 晶体结构 I/O(CIF/POSCAR)—— 水泥熟料相(C3S/C2S/C3A/C4AF)、玻璃晶化产物、陶瓷相、耐火砖矿物相
- `XRDCalculator` —— 给定结构正向算 XRD pattern,跟实测对比
- `PhaseDiagram` —— 凝胶/水化产物的热力学稳定性
- `SpacegroupAnalyzer` —— 物相对称性
- `MPRester` —— Materials Project 直连查无机化合物
- 转换工具:VASP / Gaussian / Quantum ESPRESSO 输入输出文件
**落地动作**
```bash
.venv/Scripts/pip install pymatgen mp-api
```
新建 `skills/materials_pymatgen/SKILL.md`,在仓库原版基础上加两段:
- **中文相名 → 标准化学式映射表**(给 LLM 看):
- C3S → Ca3SiO5,C2S → Ca2SiO4,C3A → Ca3Al2O6,C4AF → Ca4Al2Fe2O10
- 钙矾石 → Ca6Al2(SO4)3(OH)12·26H2O
- 莫来石 → Al6Si2O13,堇青石 → Mg2Al4Si5O18
- **Materials Project API key 配置走 zcbot env** —— 用户得去 https://materialsproject.org/api 申请,写进 `RUN.md` 一次性初始化段
**坑**
- `MPRester` 必须 context manager(`with MPRester() as mpr:`),否则连接泄漏 —— 抄进反模式段
- `from_file()` 优先,不要自己 parse CIF
### 3.2 stats_ml(scikit-learn + statsmodels + PyMC) ★★★
合并成一个 skill,按场景导航。
**对你们的价值面**
- 配方-性能建模:掺合料比例 → 28 天抗压强度回归
- DoE 响应面拟合
- 配方聚类 / 异常实验检测
- 工艺参数 → 性能的特征重要性
**落地动作**
`skills/stats_ml/SKILL.md` 入口先做场景导航:
| 你要做 | 用 |
|---|---|
| 要 p-value、置信区间、假设检验 | statsmodels(OLS / ANOVA) |
| 要预测精度高、不在乎模型可解释性 | sklearn(RF / GBDT) |
| 样本小(< 30)且要不确定度估计 | PyMC(贝叶斯) |
| 要可解释的线性关系 | statsmodels OLS / sklearn LinearRegression |
**抄仓库的反模式段**(高频踩坑):
1. 别在 pipeline 外预处理 → 交叉验证会数据泄漏
2. scaler 别 fit 在 test 上
3. 分类任务记得 `StratifiedKFold`
4. 树模型不用 scale 特征
5. 收敛 warning 别忽略,要么加 `max_iter` 要么 scale
依赖:你们 requirements.txt 多半已有 sklearn,确认下 statsmodels + pymc 装没装。
### 3.3 matplotlib(出版级) ★★★
**对你们的价值面**
- XRD pattern(底图 + 标峰)
- TG-DSC 双 Y 轴曲线
- 应力-应变曲线多组样本对比
- 多 panel 出版图(Cement and Concrete Research、JACerS 风格)
**落地动作**
新建 `skills/plot_pub/SKILL.md`,关键内容(原仓库的反模式 + 你们的中文场景补丁):
```python
# zcbot 出版级绘图规范
# 1. OO 接口(必须),不用 plt.xxx 隐式
fig, ax = plt.subplots(figsize=(6, 4), dpi=150)
ax.plot(...)
# 2. 中文字体(Windows 必配,否则方块)
plt.rcParams['font.sans-serif'] = ['SimHei', 'Microsoft YaHei']
plt.rcParams['axes.unicode_minus'] = False
# 3. colormap 用 viridis / cividis,不用 jet(色觉障碍不友好,审稿人会喷)
# 4. 画完必须 close,run_python 多任务跑容易堆积
fig.savefig(out_path, bbox_inches='tight')
plt.close(fig)
# 5. 矢量优先:SVG / PDF 给论文,PNG 给 PPT(dpi >= 300)
```
仓库还提到一个 `scientific-visualization` skill 专门做期刊样式多面板,这个可以一并扒进来作为 `plot_pub` 的进阶段。
### 3.4 materials_db(COD + NIST + USPTO 三合一) ★★
**原仓库 `database-lookup` 78 个库,只 5 个对你们直接有用,不要整体抄,做精简子集**:
| DB | 用途 |
|---|---|
| **Materials Project** | 已被 Pymatgen 的 MPRester 覆盖,不重复 |
| **COD**(Crystallography Open Database) | 免费晶体结构库,补充 MP 没收的实验结构 |
| **NIST** | 标准参考数据,材料热物性、相平衡 |
| **USPTO** | 美国专利全文检索 → 并入 `skills/patent` |
| **PubChem / ChEMBL** | 只在做外加剂(减水剂、缓凝剂)这类有机分子时用 → 不常用,可后置 |
**落地动作**
- `skills/patent` 加一个 `query_uspto(keyword, ...)` helper(USPTO Open Data Portal API)
- 新建 `skills/materials_db/SKILL.md`,封 COD + NIST 两个查询函数
- Materials Project 让 Pymatgen 走,本 skill 不重复
**坑**
- USPTO 要 API key(免费),申请走 https://developer.uspto.gov/
- COD 是 RESTful,免 key,但限速,加 sleep
- NIST 各子库 API 不统一(WebBook / Materials Data Repository / ICSD 镜像),要分别封装
## 4. 现有 6 个 skill 的增强参考(不替换)
把仓库对应 skill 的 SKILL.md 拿来读,把"何时用 / 反模式 / 标准工作流"段落里你们没写的合理项抽进现有 SKILL.md。**不要整体替换**,你们的 paper_server / proposal 模板 / patent 流程都是定制化的。
| 仓库 skill | 对应你们 | 可抄的部分 |
|---|---|---|
| Literature Review / Paper Lookup | `skills/research` | 多库 fallback 策略、引文格式标准化 |
| Scientific Writing | `skills/proposal` | 章节结构、claim-evidence 写作规范 |
| Scientific Slides / LaTeX Posters | `skills/ppt` | 多 panel 布局、color palette 规范 |
| Document Skills(PDF/DOCX/XLSX/PPTX) | `skills/documents` | 表格读取、复杂 PDF 抽取 |
| Peer Review | `skills/review` | 审稿 checklist |
| Scientific Schematics | (新加) | 工艺流程图、装置示意图自动生成 |
## 5. 不推荐(直接跳过)
| 领域 | 仓库代表 skill | 跳过原因 |
|---|---|---|
| 生物信息 / 基因组 | BioPython / Scanpy / scVelo / Arboreto / anndata / esm | 跟无机材料无交集 |
| 药物发现 | DiffDock / DeepChem / Datamol / adaptyv | 同上,RDKit 偶尔有边但太窄 |
| 临床研究 | ClinicalTrials.gov / ClinVar / COSMIC / Open Targets | 同上 |
| 医学影像 | pydicom / histolab / PathML | 同上 |
| 量子计算 | Cirq / Qiskit / PennyLane / QuTiP | 除非真做第一性原理(成本极高) |
| 代谢建模 | COBRApy | 同上 |
| 地理空间 | GeoPandas | 同上 |
| 天文 | astropy | 同上 |
## 6. 中间档(暂不动,记下来,有场景再说)
| skill | 触发场景 |
|---|---|
| **OpenMM + MDAnalysis** | 水泥水化分子模拟、玻璃网络结构 MD —— 门槛高,看院里有没有人真做计算 |
| **NetworkX** | 知识图谱、合作网络分析 |
| **SymPy** | 推传热扩散、强度模型公式 |
| **scikit-image** | SEM 图像分析(粒径分布、形貌识别) —— 仓库没单列,如要做自己装 |
## 7. 落地排期建议
| 顺序 | skill | 工作量估计 | 收益 |
|---|---|---|---|
| 1 | `plot_pub`(matplotlib)SKILL.md | 0.5 day | 立刻见效,所有出图任务受益,中文字体坑也一次扫掉 |
| 2 | `materials_pymatgen` 装包 + SKILL.md + 化学式映射 | 1 day | 打开无机材料计算能力(XRD/相图/对称性) |
| 3 | `stats_ml` SKILL.md(sklearn + statsmodels + PyMC) | 1 day | 配方-性能建模常态化 |
| 4 | `materials_db`(COD + NIST + USPTO) | 2 day(每个 API 都要踩坑) | 中等,可后置;USPTO 部分先并入 `patent` |
| 5 | 现有 6 个 skill 抽 best practice 增强 | 各 0.5 day | 渐进改善,不阻塞 |
## 8. 三个决策点(写代码前需要你确认)
1. **新 skill 命名风格**:用 `materials_pymatgen` / `stats_ml` / `plot_pub` / `materials_db` 这种业务前缀,还是直接 `pymatgen` / `sklearn` 这种工具名?
- 业务前缀:更贴你们院的场景导航
- 工具名:跟开源生态对齐,容易回查官方文档
2. **Materials Project API key**`.env` 还是单独配置文件?
3. **USPTO 并入 `skills/patent` 还是单独建 `skills/uspto`**?(我倾向并入,因为 patent skill 已经有中国专利逻辑)
定下这三点我就开始动 #1(matplotlib)。

View File

@ -169,7 +169,10 @@ class DockerExecutor(Executor):
container,
extra_env={
"PYTHONIOENCODING": "utf-8",
"PYTHONPATH": "/workspace",
# /sandbox 在前:让 `from skills.xxx.helper import ...` work
# (skills/ bind mount 到 /sandbox/skills:ro,SKILL.md 教 LLM
# 这条 import path);/workspace 在后:用户 task 目录的本地脚本
"PYTHONPATH": "/sandbox:/workspace",
},
) + ["setsid", "python", container_script]
result = self._run_subprocess(argv, timeout=timeout, ctx=ctx)

View File

@ -8,7 +8,7 @@
# 构建上下文 = repo 根(用 -f 指定 Dockerfile 路径):
# docker build -f deploy/sandbox/Dockerfile -t zcbot-sandbox \
# --build-arg HOST_UID=$(id -u zcbot) --build-arg HOST_GID=$(id -g zcbot) .
FROM python:3.11-slim
FROM python:3.12-slim
# apt 源可配(同 pip / npm 同款,境内访问 deb.debian.org 慢):
# --build-arg APT_MIRROR=https://mirrors.cloud.tencent.com # 腾讯云内网

View File

@ -26,3 +26,14 @@ uvicorn[standard]>=0.30.0
python-multipart>=0.0.9 # files upload multipart 解析
pyjwt>=2.8.0 # /v1/auth/login HS256 token mint/verify(§7 D' 过渡形态)
bcrypt>=4.1.0 # /v1/auth/login_password 密码哈希(users.password_hash)
# 科学计算 skill(2026-05-28 加)
# pymatgen skill: 无机材料计算(晶体结构/XRD/相图/Materials Project)
pymatgen>=2024.0
mp-api>=0.41.0
# stats_ml skill: 统计建模与 ML(sklearn 必装,statsmodels 必装,PyMC 可选)
scikit-learn>=1.4.0
statsmodels>=0.14.0
# pymc>=5.10.0 # 贝叶斯小样本估计,装包重(带 pytensor),按需打开
# arviz>=0.17.0 # PyMC 后验诊断,跟 pymc 一起开
# plot_pub skill: 出版级 matplotlib(中文字体 + viridis 配色),依赖 matplotlib 已在上面

View File

@ -0,0 +1,307 @@
"""Smoke: 3 个新加的科学计算 skill 通路验证(pymatgen / stats_ml / plot_pub)。
跑法: .venv/Scripts/python.exe scripts/smoke_scientific_skills.py
依赖:`pip install pymatgen mp-api scikit-learn statsmodels`(PyMC 可选,装了就测)
不依赖网络默认情况下(MP_API_KEY 没配则跳过 mp_rester 联网那一段)
不动 DB / workspace,产物落系统临时目录,跑完即丢
skill 顺序 4 :
step A pymatgen import + CEMENT_PHASES 几个查询 + mp_rester 未配 key 抛错
step B stats_ml 三库装包 + OLS / RandomForest smoke
step C plot_pub apply_pub_style + 最小 XRD-like 图出 PNG
step D (可选)MP_API_KEY 配了就联网拉一条 Materials Project 数据
任一步异常 [FAIL] 标注后继续下一步,保证整条链路看一遍
"""
from __future__ import annotations
import io
import os
import sys
import tempfile
import time
from pathlib import Path
ROOT = Path(__file__).resolve().parent.parent
sys.path.insert(0, str(ROOT))
# Windows GBK 控制台编码问题: 强制 stdout / stderr utf-8(memory 里这条已踩过)
if hasattr(sys.stdout, "buffer"):
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", errors="replace")
if hasattr(sys.stderr, "buffer"):
sys.stderr = io.TextIOWrapper(sys.stderr.buffer, encoding="utf-8", errors="replace")
# 读 .env 注入 MP_API_KEY 等(litellm 链路外手动加载)
env_file = ROOT / ".env"
if env_file.exists():
for line in env_file.read_text(encoding="utf-8").splitlines():
line = line.strip()
if not line or line.startswith("#") or "=" not in line:
continue
k, _, v = line.partition("=")
os.environ.setdefault(k.strip(), v.strip())
def _hr(title: str) -> None:
print()
print("=" * 60)
print(f"[{title}]")
print("=" * 60)
def _ok(msg: str) -> None:
print(f"[OK] {msg}")
def _fail(msg: str) -> None:
print(f"[FAIL] {msg}")
def _skip(msg: str) -> None:
print(f"[SKIP] {msg}")
def _info(msg: str) -> None:
print(f"[INFO] {msg}")
def step_a_pymatgen() -> None:
_hr("step A: pymatgen skill")
# A1: helper import
try:
from skills.pymatgen.materials import CEMENT_PHASES, lookup_phase, mp_rester
_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}")
return
# A2: 典型查询
cases = [
("C3S", "Ca3SiO5"),
("硅酸三钙", "Ca3SiO5"),
("alite", "Ca3SiO5"), # 大小写不敏感
("钙矾石", "Ca6Al2(SO4)3(OH)12·26H2O"),
("莫来石", "Al6Si2O13"),
("方镁石", "MgO"),
("石英", "SiO2"),
]
for name, expected in cases:
try:
got = lookup_phase(name)
if got == expected:
_ok(f"lookup_phase({name!r}) -> {got}")
else:
_fail(f"lookup_phase({name!r}) -> {got},期望 {expected}")
except Exception as e:
_fail(f"lookup_phase({name!r}) raised {type(e).__name__}: {e}")
# A3: 未命中抛 KeyError
try:
lookup_phase("根本不存在的相_xyz123")
_fail("lookup_phase 未命中应抛 KeyError,没抛")
except KeyError as e:
_ok(f"lookup_phase 未命中正确抛 KeyError (msg 含建议: {'补到' in str(e)})")
except Exception as e:
_fail(f"lookup_phase 未命中应抛 KeyError,实际 {type(e).__name__}")
# A4: pymatgen 本体 import
try:
from pymatgen.core import Structure, Lattice, Molecule
from pymatgen.analysis.diffraction.xrd import XRDCalculator
from pymatgen.symmetry.analyzer import SpacegroupAnalyzer
_ok("pymatgen 核心类 import 全通 (Structure / XRDCalculator / SpacegroupAnalyzer)")
except Exception as e:
_fail(f"pymatgen 本体 import: {type(e).__name__}: {e}")
return
# A5: 构造一个简单的立方结构 + XRDCalculator 跑一次
try:
from pymatgen.core import Structure, Lattice
from pymatgen.analysis.diffraction.xrd import XRDCalculator
# MgO,方镁石,典型耐火材料相
lattice = Lattice.cubic(4.21) # MgO a≈4.21Å
struct = Structure(lattice, ["Mg", "O"], [[0, 0, 0], [0.5, 0.5, 0.5]])
xrd = XRDCalculator(wavelength="CuKa")
pattern = xrd.get_pattern(struct, two_theta_range=(20, 80))
_ok(f"XRDCalculator on MgO 结构: {len(pattern.x)} 个峰,2θ 范围 [{pattern.x[0]:.1f}, {pattern.x[-1]:.1f}]")
except Exception as e:
_fail(f"XRDCalculator smoke: {type(e).__name__}: {e}")
# A6: mp_rester 未配 key 抛 RuntimeError
has_key = bool(os.environ.get("MP_API_KEY"))
if has_key:
_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 含申请链接")
else:
_fail(f"RuntimeError 抛了但 msg 不对: {e}")
except Exception as e:
_fail(f"应抛 RuntimeError 实际 {type(e).__name__}: {e}")
def step_b_stats_ml() -> None:
_hr("step B: stats_ml skill")
# B1: sklearn
try:
import numpy as np
from sklearn.ensemble import RandomForestRegressor
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline
from sklearn.model_selection import cross_val_score
# 造一个 fake 配方-强度数据(50 样本 6 特征)
rng = np.random.default_rng(42)
X = rng.uniform(0, 1, size=(50, 6)) # 6 个掺合料比例
y = (X @ rng.uniform(20, 80, size=6)) + rng.normal(0, 5, size=50) # 强度 MPa
pipe = Pipeline([
("scaler", StandardScaler()),
("model", RandomForestRegressor(n_estimators=50, random_state=42)),
])
scores = cross_val_score(pipe, X, y, cv=5, scoring="r2")
_ok(f"sklearn RandomForest 5-fold R²: mean={scores.mean():.3f} std={scores.std():.3f}")
except Exception as e:
_fail(f"sklearn smoke: {type(e).__name__}: {e}")
# B2: statsmodels
try:
import numpy as np
import pandas as pd
import statsmodels.formula.api as smf
rng = np.random.default_rng(42)
df = pd.DataFrame({
"x1": rng.uniform(0, 1, 50),
"x2": rng.uniform(0, 1, 50),
})
df["y"] = 3 * df["x1"] - 2 * df["x2"] + rng.normal(0, 0.3, 50)
model = smf.ols("y ~ x1 + x2", data=df).fit()
r2 = model.rsquared
p_x1 = model.pvalues["x1"]
_ok(f"statsmodels OLS: R²={r2:.3f}, p(x1)={p_x1:.4f} (应 << 0.05)")
except Exception as e:
_fail(f"statsmodels smoke: {type(e).__name__}: {e}")
# B3: PyMC(可选)
try:
import pymc as pm
import arviz as az
_ok(f"pymc import OK (version={pm.__version__})")
# 不真跑采样(慢),只验 import
except ImportError:
_skip("PyMC / arviz 未装(可选依赖,要做贝叶斯再 pip install pymc arviz)")
except Exception as e:
_fail(f"PyMC import: {type(e).__name__}: {e}")
def step_c_plot_pub() -> None:
_hr("step C: plot_pub skill")
# C1: import + apply_pub_style
try:
from skills.plot_pub.style import apply_pub_style, reset_style, _find_chinese_font
font = _find_chinese_font()
if font:
_ok(f"_find_chinese_font 返 {font!r}")
else:
_info("系统未装中文字体候选 (SimHei/YaHei/WenQuanYi/Heiti),中文将显示方块")
apply_pub_style()
_ok("apply_pub_style() 调用通过")
except Exception as e:
_fail(f"plot_pub import / apply_pub_style: {type(e).__name__}: {e}")
return
# C2: 跑一个 minimal XRD-like 图
try:
import numpy as np
import matplotlib.pyplot as plt
tmp_dir = Path(tempfile.mkdtemp(prefix="zcbot_smoke_plot_"))
out_png = tmp_dir / "smoke_xrd.png"
out_pdf = tmp_dir / "smoke_xrd.pdf"
two_theta = np.linspace(5, 80, 1000)
# 假装是 MgO 衍射(几个高斯峰)
peaks = [(36.9, 1.0), (42.9, 0.7), (62.3, 0.5), (74.7, 0.3), (78.6, 0.4)]
intensity = np.zeros_like(two_theta)
for pos, h in peaks:
intensity += h * np.exp(-((two_theta - pos) ** 2) / (2 * 0.3 ** 2))
intensity += np.random.normal(0, 0.02, len(two_theta)) # 噪声
fig, ax = plt.subplots(figsize=(6, 4))
ax.plot(two_theta, intensity, "k-", lw=1.0, label="MgO 模拟谱")
ax.set_xlabel(r"$2\theta$ / °")
ax.set_ylabel("强度 / a.u.")
ax.set_xlim(5, 80)
ax.legend(frameon=False)
fig.tight_layout()
fig.savefig(out_png, dpi=200)
fig.savefig(out_pdf)
plt.close(fig)
png_size = out_png.stat().st_size
pdf_size = out_pdf.stat().st_size
_ok(f"出图 PNG ({png_size/1024:.1f}KB) + PDF ({pdf_size/1024:.1f}KB) -> {tmp_dir}")
except Exception as e:
_fail(f"plot_pub 出图: {type(e).__name__}: {e}")
# C3: 还原 rcParams 防污染后续步骤
try:
reset_style()
_ok("reset_style() 还原 matplotlib defaults")
except Exception as e:
_fail(f"reset_style: {type(e).__name__}: {e}")
def step_d_mp_online() -> None:
_hr("step D: Materials Project 联网(可选,需 MP_API_KEY)")
if not os.environ.get("MP_API_KEY"):
_skip("MP_API_KEY 未配,跳过联网查询(.env 加 MP_API_KEY=... 即可,免费申请 https://materialsproject.org/api)")
return
try:
from skills.pymatgen.materials import mp_rester, lookup_phase
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"],
)
dt = (time.time() - t0) * 1000
_ok(f"mp_rester 查 {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}")
except Exception as e:
_fail(f"mp_rester 联网查询: {type(e).__name__}: {e}")
def main() -> int:
print("=" * 60)
print("zcbot scientific skills smoke (pymatgen / stats_ml / plot_pub)")
print("=" * 60)
for fn in (step_a_pymatgen, step_b_stats_ml, step_c_plot_pub, step_d_mp_online):
try:
fn()
except Exception as e:
_fail(f"[{fn.__name__} crashed] {type(e).__name__}: {e}")
print()
print("=" * 60)
print("smoke done")
print("=" * 60)
return 0
if __name__ == "__main__":
sys.exit(main())

168
skills/plot_pub/SKILL.md Normal file
View File

@ -0,0 +1,168 @@
---
name: plot_pub
description: 出版级 matplotlib 绘图(论文 / 报告 / 申报书用,中文字体 + viridis 配色 + dpi 设定一键到位)。✅ 触发:用户要画 XRD 谱、TG-DSC 曲线、应力-应变曲线、SEM 标注、多 panel 学术图、要 SVG/PDF 矢量图给论文。⛔ 不触发:PPT 内嵌图(走 `ppt` skill 的 matplotlib 配图流程);只要快速看一眼数据(直接 `df.plot()` 即可,不用本 skill)。
---
# plot_pub
服务建材院的论文 / 申报书 / 调研报告出图。**核心是 `apply_pub_style()` 一键设置 rcParams**(中文字体、viridis 默认、字号、dpi、矢量优先),避免每次手动设置十几行还忘配中文字体。
## 何时用
- 写论文要投 *Cement and Concrete Research* / *J. Am. Ceram. Soc.* / *Construction and Building Materials* 等期刊
- 报告 / 申报书要中文标题 + 中文图例的高 dpi 图
- 多组样本数据对比(XRD 多谱叠图、强度发展曲线、温度场)
- 双 Y 轴(TG-DSC 是典型)
## 何时不用
- PPT 里要嵌图 → 走 `skills/ppt`(那边已经有配色 / 尺寸规范)
- 交互式探查数据 → 用 plotly / 直接 jupyter
- 只要看趋势不出版 → `df.plot()` 一行搞定,不用本 skill 的 ceremony
## 准备
```python
from skills.plot_pub.style import apply_pub_style
import matplotlib.pyplot as plt
import numpy as np
apply_pub_style() # 调一次,后面所有 plt 都按这个 style
```
## `apply_pub_style()` 参数
| 参数 | 默认 | 说明 |
|---|---|---|
| `chinese` | `True` | 加载 SimHei / Microsoft YaHei,中文不显示方块 |
| `dpi` | `150` | 屏幕显示 dpi;保存图用 `savefig(..., dpi=300)` 单独控 |
| `font_size` | `10` | 论文双栏图标准字号 |
| `linewidth` | `1.2` | 线粗,出版图细些显精致 |
| `cmap` | `"viridis"` | 默认 colormap,**审稿人不会喷**(jet 现在是雷区) |
## 典型工作流
### A. XRD pattern(多相叠图 + 标峰)
```python
from skills.plot_pub.style import apply_pub_style
import matplotlib.pyplot as plt
import numpy as np
apply_pub_style()
fig, ax = plt.subplots(figsize=(6, 4))
# 实测谱
ax.plot(two_theta_exp, intensity_exp, "k-", lw=1.0, label="实测")
# 理论谱(各物相,用 pymatgen XRDCalculator 算的)
for phase, pattern in pymatgen_patterns.items():
ax.vlines(pattern.x, 0, pattern.y / pattern.y.max() * intensity_exp.max() * 0.3,
label=phase, alpha=0.7)
ax.set_xlabel(r"$2\theta$ / °")
ax.set_ylabel("强度 / a.u.")
ax.set_xlim(5, 80)
ax.legend(frameon=False, loc="upper right")
fig.tight_layout()
fig.savefig("xrd_compare.pdf", bbox_inches="tight") # PDF 矢量,期刊偏好
fig.savefig("xrd_compare.png", dpi=300, bbox_inches="tight") # PNG 给 PPT
plt.close(fig) # 关键!run_python 多任务下不关会堆 figure 泄漏内存
```
### B. TG-DSC 双 Y 轴
```python
fig, ax_tg = plt.subplots(figsize=(6, 4))
ax_dsc = ax_tg.twinx()
ax_tg.plot(T, tg_mass, "b-", lw=1.2, label="TG")
ax_dsc.plot(T, dsc_flow, "r-", lw=1.2, label="DSC")
ax_tg.set_xlabel("温度 / °C")
ax_tg.set_ylabel("质量 / %", color="b")
ax_dsc.set_ylabel("热流 / mW·mg$^{-1}$", color="r")
ax_tg.tick_params(axis="y", labelcolor="b")
ax_dsc.tick_params(axis="y", labelcolor="r")
# 合并两个 ax 的 legend
lines1, labels1 = ax_tg.get_legend_handles_labels()
lines2, labels2 = ax_dsc.get_legend_handles_labels()
ax_tg.legend(lines1 + lines2, labels1 + labels2, loc="best", frameon=False)
fig.tight_layout()
fig.savefig("tg_dsc.pdf", bbox_inches="tight")
plt.close(fig)
```
### C. 多组样本对比(强度发展曲线)
```python
fig, ax = plt.subplots(figsize=(6, 4))
ages = [3, 7, 14, 28, 56, 90]
# 用 viridis 给多个样本上色,顺序色觉友好
import matplotlib.cm as cm
colors = cm.viridis(np.linspace(0.1, 0.9, len(samples)))
for (label, strengths), c in zip(samples.items(), colors):
ax.plot(ages, strengths, "o-", color=c, lw=1.2, ms=5, label=label)
ax.set_xlabel("龄期 / d")
ax.set_ylabel("抗压强度 / MPa")
ax.set_xscale("log") # 龄期用对数刻度更清楚
ax.legend(frameon=False)
ax.grid(True, ls="--", alpha=0.3)
fig.tight_layout()
fig.savefig("strength_dev.pdf", bbox_inches="tight")
plt.close(fig)
```
### D. 多 panel(2×2 figure,论文 figure 1 标配)
```python
fig, axes = plt.subplots(2, 2, figsize=(10, 7), constrained_layout=True)
axes[0, 0].plot(...) # (a) XRD
axes[0, 1].plot(...) # (b) FTIR
axes[1, 0].plot(...) # (c) TG
axes[1, 1].plot(...) # (d) 强度
# 子图编号(论文图 1 标准做法)
for ax, letter in zip(axes.flat, "abcd"):
ax.text(0.02, 0.95, f"({letter})", transform=ax.transAxes,
fontweight="bold", va="top")
fig.savefig("fig1_characterization.pdf", bbox_inches="tight")
plt.close(fig)
```
## 中文字体配置(Windows 注意)
`apply_pub_style(chinese=True)` 默认按以下顺序找字体:
1. `SimHei`(Windows 自带,黑体)
2. `Microsoft YaHei`(Windows 自带,雅黑)
3. `WenQuanYi Micro Hei`(Linux 兜底)
都没有 → 退回 DejaVu Sans,中文显示方块。装字体方式:Windows 控制面板 → 字体,丢 .ttf 进去。
**LaTeX 公式渲染**:matplotlib 用 `$...$` 自动走 mathtext,中英文混排正常。要更精细的 LaTeX(自定义 macro)就 `plt.rcParams["text.usetex"] = True`,但要本地装 TeX Live。
## 反模式
1. **用 pyplot 状态机不用 OO 接口** —— 写 `plt.plot(); plt.xlabel(...)` 一时爽,多 figure 时变量乱;一律 `fig, ax = plt.subplots()`
2. **不 close figure** —— `run_python` 跑多次会堆 figure 内存涨,必须 `plt.close(fig)` 收尾
3. **用 jet colormap** —— 色觉障碍审稿人会喷,近年期刊都偏 viridis / plasma / cividis
4. **保存图不 `bbox_inches="tight"`** —— legend / 标题被裁掉,反复看图反复改尺寸
5. **同图 dpi 跟 figsize 不匹配** —— figsize=(10,7) dpi=300 给 PPT 嵌入太大,字会很小;论文图 figsize=(6,4) 双栏刚好
6. **PNG 给论文** —— 期刊要矢量,PDF / SVG / EPS 优先;PNG 只给 PPT / 网页
7. **中文显示方块还坚持跑** —— `apply_pub_style()` 失败会 warn,看到 warning 就停下查字体
8. **legend 框线 / 灰底** —— 出版图 `legend(frameon=False)`,干净
9. **轴标签没单位** —— `"温度"` 是错的,`"温度 / °C"` 才对(SI 推荐 `/` 分隔)
10. **`ax.set_xticks([1,2,3,...])` 手动写一堆** —— matplotlib 自动 tick 已经足够,手动覆盖只在特殊场景(对数轴 / 离散类别轴)
## 依赖
matplotlib 已经在 `requirements.txt`(>=3.8.0)。本 skill 无新增依赖。

114
skills/plot_pub/style.py Normal file
View File

@ -0,0 +1,114 @@
"""
plot_pub skill 出版级 matplotlib rcParams 一键设置
LLM 通过 `from skills.plot_pub.style import apply_pub_style; apply_pub_style()` 使用
"""
from __future__ import annotations
import warnings
import matplotlib
import matplotlib.pyplot as plt
from matplotlib import font_manager
# 中文字体候选,按平台优先级排
_CHINESE_FONTS = [
"SimHei", # Windows 黑体
"Microsoft YaHei", # Windows 雅黑
"WenQuanYi Micro Hei", # Linux
"Heiti TC", # macOS
"Arial Unicode MS", # macOS 兜底
]
def _find_chinese_font() -> str | None:
"""从候选清单里挑系统装了的第一个,都没有返 None。"""
installed = {f.name for f in font_manager.fontManager.ttflist}
for name in _CHINESE_FONTS:
if name in installed:
return name
return None
def apply_pub_style(
chinese: bool = True,
dpi: int = 150,
font_size: int = 10,
linewidth: float = 1.2,
cmap: str = "viridis",
) -> None:
"""
一键设置出版级 matplotlib rcParams
Args:
chinese: True 时加载中文字体,失败 warn 但不抛(图继续画,中文显示方块)
dpi: 屏幕显示 dpi保存图请用 savefig(..., dpi=300) 单独指定
font_size: 基础字号,论文双栏图标准 10pt
linewidth: 线粗,出版图细些更精致
cmap: 默认 colormap,viridis / plasma / cividis 是色觉友好选择
"""
rc = matplotlib.rcParams
# ---- 字体 ----
if chinese:
font = _find_chinese_font()
if font:
# 中文字体放第一位,后面接 Arial / DejaVu 处理英文
rc["font.sans-serif"] = [font, "Arial", "DejaVu Sans"]
else:
warnings.warn(
"[plot_pub] 未找到中文字体(候选: "
+ ", ".join(_CHINESE_FONTS)
+ ")。中文将显示为方块。"
+ "Windows 控制面板 → 字体,Linux 装 wqy-microhei 包。",
RuntimeWarning,
stacklevel=2,
)
rc["font.sans-serif"] = ["Arial", "DejaVu Sans"]
else:
rc["font.sans-serif"] = ["Arial", "DejaVu Sans"]
rc["axes.unicode_minus"] = False # 负号不显示成方块
rc["font.family"] = "sans-serif"
rc["font.size"] = font_size
# ---- 尺寸 / dpi ----
rc["figure.dpi"] = dpi
rc["savefig.dpi"] = 300 # 默认保存高 dpi,临时图用 savefig(..., dpi=...) 覆盖
rc["savefig.bbox"] = "tight"
rc["savefig.pad_inches"] = 0.05
# ---- 线条 ----
rc["lines.linewidth"] = linewidth
rc["lines.markersize"] = 4
rc["axes.linewidth"] = 0.8
# ---- 刻度 ----
rc["xtick.direction"] = "in" # 期刊偏好刻度朝内
rc["ytick.direction"] = "in"
rc["xtick.major.size"] = 4
rc["ytick.major.size"] = 4
rc["xtick.minor.size"] = 2
rc["ytick.minor.size"] = 2
rc["xtick.minor.visible"] = True
rc["ytick.minor.visible"] = True
# ---- legend ----
rc["legend.frameon"] = False # 出版图 legend 无框
rc["legend.fontsize"] = font_size - 1
# ---- colormap ----
rc["image.cmap"] = cmap
# ---- 数学字体 ----
rc["mathtext.fontset"] = "stix" # 跟 Times / Arial 配,公式不突兀
# ---- 兜底:防止 PDF 嵌入 Type 3 字体(期刊要求 Type 42) ----
rc["pdf.fonttype"] = 42
rc["ps.fonttype"] = 42
def reset_style() -> None:
"""还原 matplotlib 默认 rcParams(测试 / 切换主题时用)。"""
matplotlib.rcdefaults()

144
skills/pymatgen/SKILL.md Normal file
View File

@ -0,0 +1,144 @@
---
name: pymatgen
description: 无机材料计算工具(晶体结构 I/O、XRD 模拟、相图、对称性、Materials Project 查询)。✅ 触发:用户问水泥熟料相 / 玻璃陶瓷物相 / 耐火砖矿物相、XRD 谱图反演与正向模拟、晶格参数、空间群、相稳定性、查 Materials Project 数据。⛔ 不触发:用户只问材料宏观性能(强度/导热),不涉及晶体结构;或属于有机分子/药物(那是 RDKit 范畴)。
---
# Pymatgen
无机材料计算的核心库,服务建材院的水泥 / 混凝土 / 玻璃 / 陶瓷 / 耐火材料场景。**底层用 pymatgen 官方 API,本 skill 提供两个轻量 helper**:`CEMENT_PHASES` 常量(中文相名→化学式映射)和 `mp_rester()`(从 env 拿 MP_API_KEY)。
## 何时用
- 用户给晶体结构文件(.cif / POSCAR / .xyz)要分析
- 要正向算 XRD pattern 跟实测谱对比
- 要查某矿物相的空间群 / 晶格参数 / 配位环境
- 要做凝胶相 / 水化产物的相图 / 稳定性分析
- 要从 Materials Project 拉某化合物的已知结构 / 性质
- 写 VASP / Gaussian / Quantum ESPRESSO 输入文件
## 何时不用
- 用户只问宏观性能(28d 抗压、导热系数、热膨胀)→ 走 `stats_ml`(回归建模)
- 用户问有机外加剂分子结构 → 走 RDKit(本仓库未集成)
- 用户只要画图,没有晶体计算需求 → 走 `plot_pub`
## 准备
```python
from skills.pymatgen.materials import CEMENT_PHASES, mp_rester
from pymatgen.core import Structure, Lattice, Molecule
from pymatgen.analysis.diffraction.xrd import XRDCalculator
from pymatgen.symmetry.analyzer import SpacegroupAnalyzer
from pymatgen.analysis.phase_diagram import PhaseDiagram, PDEntry
```
(import 路径走 `run_python` 注入的 PYTHONPATH,直接写)
## 关键:中文相名 → 化学式
`CEMENT_PHASES` dict 收了水泥 / 陶瓷 / 耐火 / 玻璃常见相的中英文 ↔ 化学式映射(见 `materials.py`)。**用户用中文报相名时,先查这张表转化学式再喂 pymatgen / Materials Project**:
| 用户原话 | 不要这样 | 这样 |
|---|---|---|
| C3S | `Structure.from_file("C3S")` | 先 `CEMENT_PHASES["C3S"]``"Ca3SiO5"`,再走 mp 查或自己拼 |
| 钙矾石(AFt) | mp 直接搜 "AFt" | `CEMENT_PHASES["钙矾石"]``"Ca6Al2(SO4)3(OH)12·26H2O"` |
| 莫来石 | mp 直接搜 "mullite" | `CEMENT_PHASES["莫来石"]``"Al6Si2O13"` |
| 方镁石 | 直接搜 "magnesite"(错,那是菱镁矿) | `CEMENT_PHASES["方镁石"]``"MgO"` |
表里没有的相,先英文学名 → 化学式后再喂,不要直接把中文丢给 mp。
## Materials Project 接入
API key 走 env:`MP_API_KEY`(申请:https://materialsproject.org/api)。**必须用 context manager**:
```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_API_KEY` 没配 → `mp_rester()``RuntimeError("MP_API_KEY not set in env...")`,告诉用户去配,不要继续。
## 典型工作流
### A. 实测 XRD 比对(谁是这个峰)
1. 用户给疑似相清单(中文 / 英文 / 简写都行)
2. 各相分别:`CEMENT_PHASES` 查化学式 → `mp_rester()` 拿 Structure → `XRDCalculator().get_pattern(structure)` 算理论谱
3. 把各相理论谱跟实测谱(用户给的 xy 数据)叠图(走 `plot_pub`)
4. 报"x° 这个峰最可能是 C3S 的 (h k l) 衍射"
```python
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)
pattern = xrd.get_pattern(struct, two_theta_range=(5, 80))
# pattern.x = 2θ 列表, pattern.y = 强度, pattern.hkls = (h,k,l) 列表
```
### B. 给定结构问对称性
```python
from pymatgen.core import Structure
from pymatgen.symmetry.analyzer import SpacegroupAnalyzer
struct = Structure.from_file("path/to/sample.cif")
sga = SpacegroupAnalyzer(struct)
print(sga.get_space_group_symbol()) # 例 "P21/c"
print(sga.get_space_group_number()) # 例 14
print(sga.get_crystal_system()) # 例 "monoclinic"
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) → 分解路径 + 能量
```
### D. 格式转换(给计算所做 VASP 输入)
```python
struct.to(filename="POSCAR") # 自动按后缀写
struct.to(filename="output.cif")
```
## 反模式
- 用户报中文相名(C3S / 钙矾石 / 莫来石)直接喂 mp / pymatgen,不查 `CEMENT_PHASES` —— mp 不认中文,简写也不认
- `MPRester` 不走 context manager(`mpr = MPRester(); ...`) —— 连接泄漏
- 手写 CIF parser → 一律 `Structure.from_file()`
- 不做 `SpacegroupAnalyzer.get_primitive_standard_structure()` 直接拿原胞做对称性比对(原胞可能是超胞,对称性少看出来)
- 大 cutoff 邻居搜索(`get_neighbors(r=20)`)—— 性能差,先 `r=5`
- 编造 material_id / 化学式 —— mp 查不到就告诉用户"库里没收录",不要凭训练数据脑补
- 自己写 INCAR(VASP 输入)—— 用 `MPRelaxSet` / `MPStaticSet` 拿 mp 验证过的参数
- 把 pymatgen 算的理论 XRD 当实验值 —— 永远说清是"理论 pattern",实测有择优取向 / 仪器展宽差异
## 依赖
`requirements.txt` 加:
```
pymatgen>=2024.0
mp-api>=0.41.0
```
装:`.venv/Scripts/pip install pymatgen mp-api`
## env
```
MP_API_KEY=your_key_from_materialsproject_org
```
写到 `.env`(项目根)即可,`mp_rester()` 自动读。

View File

@ -0,0 +1,153 @@
"""
pymatgen skill helpers 建材院无机材料场景常用映射 + MPRester 封装
LLM 通过 `from skills.pymatgen.materials import CEMENT_PHASES, mp_rester` 使用
"""
from __future__ import annotations
import os
from contextlib import contextmanager
# 中文/简写 → 化学式映射。覆盖建材院 R&D 高频物相。
# 添加新条目时:
# - key 用用户最自然的称呼(中文 / 行业简写),value 是 mp 能识别的化学式
# - 同一物相多种叫法都加 key,指向同一化学式(C3S / 硅酸三钙 / 阿利特 都是 Ca3SiO5)
CEMENT_PHASES: dict[str, str] = {
# ---- 硅酸盐水泥熟料矿物 ----
"C3S": "Ca3SiO5",
"硅酸三钙": "Ca3SiO5",
"阿利特": "Ca3SiO5",
"alite": "Ca3SiO5",
"C2S": "Ca2SiO4",
"硅酸二钙": "Ca2SiO4",
"贝利特": "Ca2SiO4",
"belite": "Ca2SiO4",
"C3A": "Ca3Al2O6",
"铝酸三钙": "Ca3Al2O6",
"C4AF": "Ca4Al2Fe2O10",
"铁铝酸四钙": "Ca4Al2Fe2O10",
"铁相": "Ca4Al2Fe2O10",
# ---- 水化产物 ----
"C-S-H": "Ca1.5SiO3.5·xH2O",
"水化硅酸钙": "Ca1.5SiO3.5·xH2O",
"CH": "Ca(OH)2",
"氢氧化钙": "Ca(OH)2",
"portlandite": "Ca(OH)2",
"钙矾石": "Ca6Al2(SO4)3(OH)12·26H2O",
"AFt": "Ca6Al2(SO4)3(OH)12·26H2O",
"ettringite": "Ca6Al2(SO4)3(OH)12·26H2O",
"AFm": "Ca4Al2(OH)12(SO4)·6H2O",
"单硫型水化硫铝酸钙": "Ca4Al2(OH)12(SO4)·6H2O",
# ---- 石膏 / 硫酸盐 ----
"石膏": "CaSO4·2H2O",
"二水石膏": "CaSO4·2H2O",
"gypsum": "CaSO4·2H2O",
"半水石膏": "CaSO4·0.5H2O",
"硬石膏": "CaSO4",
"anhydrite": "CaSO4",
# ---- 碳酸盐 / 碳化产物 ----
"方解石": "CaCO3",
"calcite": "CaCO3",
"文石": "CaCO3",
"aragonite": "CaCO3",
# ---- 陶瓷 / 耐火常见相 ----
"莫来石": "Al6Si2O13",
"mullite": "Al6Si2O13",
"堇青石": "Mg2Al4Si5O18",
"cordierite": "Mg2Al4Si5O18",
"刚玉": "Al2O3",
"α-Al2O3": "Al2O3",
"corundum": "Al2O3",
"方镁石": "MgO",
"periclase": "MgO",
"尖晶石": "MgAl2O4",
"spinel": "MgAl2O4",
"锆英石": "ZrSiO4",
"zircon": "ZrSiO4",
"石英": "SiO2",
"quartz": "SiO2",
"方石英": "SiO2",
"cristobalite": "SiO2",
"鳞石英": "SiO2",
"tridymite": "SiO2",
# ---- 玻璃常见组分晶相 ----
"钙长石": "CaAl2Si2O8",
"anorthite": "CaAl2Si2O8",
"钠长石": "NaAlSi3O8",
"albite": "NaAlSi3O8",
"硅灰石": "CaSiO3",
"wollastonite": "CaSiO3",
"透辉石": "CaMgSi2O6",
"diopside": "CaMgSi2O6",
# ---- 其他常见 ----
"白云石": "CaMg(CO3)2",
"dolomite": "CaMg(CO3)2",
"赤铁矿": "Fe2O3",
"hematite": "Fe2O3",
"磁铁矿": "Fe3O4",
"magnetite": "Fe3O4",
}
def lookup_phase(name: str) -> str:
"""中文/简写相名 → 化学式。命中返回化学式,未命中抛 KeyError(带建议)。"""
if name in CEMENT_PHASES:
return CEMENT_PHASES[name]
# 大小写不敏感再查一遍
lower = name.lower()
for k, v in CEMENT_PHASES.items():
if k.lower() == lower:
return v
raise KeyError(
f"{name!r} 不在 CEMENT_PHASES 映射表里。"
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

187
skills/stats_ml/SKILL.md Normal file
View File

@ -0,0 +1,187 @@
---
name: stats_ml
description: 统计建模与机器学习(sklearn / statsmodels / PyMC 三库合一,场景导航选库)。✅ 触发:配方-性能建模、DoE 响应面、强度预测、特征重要性、假设检验、置信区间、贝叶斯小样本估计、聚类异常实验检测。⛔ 不触发:用户只问描述性统计(均值方差),pandas 一行搞定;深度学习场景(走单独的 torch / lightning skill,本仓库未集成)。
---
# stats_ml
服务建材院典型场景:**掺合料配比 → 性能(强度 / 流动度 / 凝结时间)** 的建模与推断。三库分工:
| 你要做 | 用 | 一句话理由 |
|---|---|---|
| 要 p-value、置信区间、假设检验 | **statsmodels** | OLS / ANOVA / 假设检验是 statsmodels 主场,sklearn 不给 p-value |
| 要预测精度高,可解释性次要 | **sklearn** | RandomForest / GBDT / XGBoost 是性能预测的现代基线 |
| 样本 < 30 且要不确定度估计 | **PyMC** | 贝叶斯给出参数后验分布,小样本下比频率派的置信区间更稳 |
| 要可解释的线性关系 | statsmodels OLS / sklearn LinearRegression | 都可以,统计推断走 statsmodels,纯预测走 sklearn |
| 要 DoE 响应面拟合 | statsmodels(二次回归 + ANOVA) | 主流做法,系数显著性能直接读 |
| 要聚类找异常配方 | sklearn (KMeans / DBSCAN) | 标准工具 |
| 要降维可视化(配方空间) | sklearn (PCA / t-SNE) | 标准工具 |
## 何时用 / 何时不用
✅ 用:
- 用户给一张配方-性能表(N 行配方 + 性能列),要建模 / 预测 / 推断
- 要回答"哪个因素影响最大"(特征重要性 / 系数显著性)
- 要回答"这个新配方预测强度多少 + 置信区间多大"
- 要找异常实验点 / 配方聚类
⛔ 不用:
- 用户只要看均值方差直方图 → pandas + matplotlib 直接搞,杀鸡不用牛刀
- 用户给的"数据"只有 3-5 个点 → 任何 ML / 统计都不可靠,告诉用户先补数据
- 深度学习需求(CNN / Transformer)→ 不在本 skill 范围
## 准备
```python
# 按需 import,不要全 import
import numpy as np
import pandas as pd
# sklearn
from sklearn.model_selection import train_test_split, KFold, StratifiedKFold, GridSearchCV
from sklearn.preprocessing import StandardScaler
from sklearn.pipeline import Pipeline
from sklearn.metrics import r2_score, mean_squared_error
from sklearn.ensemble import RandomForestRegressor, GradientBoostingRegressor
# statsmodels
import statsmodels.api as sm
import statsmodels.formula.api as smf
# PyMC(重,按需 import)
# import pymc as pm
# import arviz as az
```
## 典型工作流
### A. 配方-性能回归(sklearn,要预测精度)
```python
# 数据:每行一个配方,X 是组分比例,y 是 28d 抗压强度
X = df[["cement", "fly_ash", "slag", "water", "sand", "stone"]].values
y = df["strength_28d"].values
# Pipeline 把预处理和模型绑死 —— 反模式见末尾
pipe = Pipeline([
("scaler", StandardScaler()), # 线性 / 距离类模型必须;树模型可省
("model", RandomForestRegressor(n_estimators=300, random_state=42)),
])
# 数据小(< 200) 5-fold CV,不要单次 train/test
from sklearn.model_selection import cross_val_score
scores = cross_val_score(pipe, X, y, cv=5, scoring="r2")
print(f"R2 mean={scores.mean():.3f} std={scores.std():.3f}")
# 训完看特征重要性
pipe.fit(X, y)
imp = pipe.named_steps["model"].feature_importances_
for name, val in sorted(zip(df.columns, imp), key=lambda t: -t[1]):
print(f"{name}: {val:.3f}")
```
### B. 显著性分析(statsmodels,要 p-value)
```python
import statsmodels.formula.api as smf
# formula API 让模型 spec 看起来跟 R 一样
model = smf.ols(
formula="strength_28d ~ cement + fly_ash + slag + water + water:cement", # 含交互
data=df,
).fit()
print(model.summary()) # 系数、std err、t、p>|t|、95% CI 全在这
# ANOVA 比较两个嵌套模型
m1 = smf.ols("strength_28d ~ cement + fly_ash", data=df).fit()
m2 = smf.ols("strength_28d ~ cement + fly_ash + slag", data=df).fit()
from statsmodels.stats.anova import anova_lm
print(anova_lm(m1, m2)) # slag 加进去显著吗
```
### C. DoE 响应面(statsmodels 二次回归)
```python
# 中心复合设计后,做二阶模型
model = smf.ols(
formula="strength ~ x1 + x2 + x3 + I(x1**2) + I(x2**2) + I(x3**2) + x1:x2 + x1:x3 + x2:x3",
data=df,
).fit()
print(model.summary())
# 看哪些二阶项 / 交互项显著,定优化方向
```
### D. 小样本贝叶斯(PyMC,N<30 要不确定度)
```python
import pymc as pm
import arviz as az
with pm.Model() as model:
# 系数先验:弱信息正态
intercept = pm.Normal("intercept", mu=0, sigma=10)
beta = pm.Normal("beta", mu=0, sigma=5, shape=X.shape[1])
sigma = pm.HalfNormal("sigma", sigma=10)
mu = intercept + pm.math.dot(X, beta)
y_obs = pm.Normal("y_obs", mu=mu, sigma=sigma, observed=y)
trace = pm.sample(2000, tune=1000, chains=4, target_accept=0.95)
az.summary(trace) # 后验均值 / std / 94% HDI
az.plot_posterior(trace) # 后验分布图
```
### E. 配方聚类找异常实验(sklearn KMeans / DBSCAN)
```python
from sklearn.cluster import DBSCAN
from sklearn.preprocessing import StandardScaler
Xs = StandardScaler().fit_transform(X)
labels = DBSCAN(eps=0.5, min_samples=5).fit_predict(Xs)
# labels == -1 的点 = 离群,可能是录入错或工艺异常,人工核实
```
## 反模式
### sklearn
1. **预处理在 pipeline 外** —— 交叉验证会数据泄漏(test fold 的统计量被 train 看到)
2. **scaler 在 test 上 fit** —— 一律 `fit(train)` / `transform(test)`,Pipeline 自动保证
3. **分类任务忘 stratified split** —— 类别不平衡时 `train_test_split(..., stratify=y)`
4. **不必要地 scale 树模型** —— RF / GBDT / XGBoost 不受单调变换影响
5. **忽略 convergence warning** —— LogisticRegression / SVM 不收敛要加 `max_iter` 或 scale 特征
6. **小数据集还 80/20 切** —— N<200 5-fold CV,N<50 LOOCV
### statsmodels
7. **直接读系数不看 condition number** —— `summary()` 末尾的 condition number > 30 = 严重共线性,系数解读无效
8. **多重比较忘修正** —— 同时检验多个系数显著性时,用 Bonferroni / FDR(`statsmodels.stats.multitest`)
9. **OLS 残差不检验** —— `model.resid` 不正态 / 异方差时,OLS 推断不可靠,改 robust SE(`fit(cov_type="HC3")`)
### PyMC
10. **不看 R-hat / ESS** —— `az.summary(trace)``r_hat` > 1.01 或 `ess_bulk` < 400 = 没收敛, tune / target_accept,不能用
11. **强先验当数据用** —— 先验定太窄会主导后验,先用弱信息先验试
12. **PyMC 装包巨大** —— 仅小样本需要时才用,大样本(N>500)频率派结果一样可信且快 100 倍
### 通用
13. **没看数据分布就建模** —— 先 `df.describe()` + `df.hist()` 扫一遍,有 NaN / 量纲极差 / 长尾分布要先处理
14. **R2 > 0.95 就开香槟** —— 大概率是数据泄漏 / 过拟合 / 训练集等于测试集,先查
15. **预测新配方不报置信区间** —— 工程决策不能只给点估计,sklearn 用 `RandomForestRegressor` 的 tree-level prediction std,statsmodels / PyMC 直接给 CI / HDI
16. **数据点不到 10 还硬上 ML** —— 告诉用户先做 DoE 扩样本,再建模
## 依赖
`requirements.txt` 加:
```
scikit-learn>=1.4.0
statsmodels>=0.14.0
pymc>=5.10.0 # 重,装包带 pytensor,可选
arviz>=0.17.0 # PyMC 后验诊断
```
装:`.venv/Scripts/pip install scikit-learn statsmodels`
PyMC 单独装:`.venv/Scripts/pip install pymc arviz`(确认要做贝叶斯再装,首次装包 5 分钟起)

View File

@ -240,7 +240,9 @@ class TestRunPython(unittest.TestCase):
# PYTHONIOENCODING / PYTHONPATH 注入
env_kvs = [argv[i + 1] for i, a in enumerate(argv) if a == "-e"]
self.assertIn("PYTHONIOENCODING=utf-8", env_kvs)
self.assertIn("PYTHONPATH=/workspace", env_kvs)
# PYTHONPATH 必须含 /sandbox(让 SKILL.md 教的 `from skills.xxx import` work,
# skills/ bind mount 到 /sandbox/skills:ro)+ /workspace(用户 task 目录)
self.assertIn("PYTHONPATH=/sandbox:/workspace", env_kvs)
# host 侧 tmp 已 unlink(目录可能仍在,无所谓 —— ensure 容器时会重新 mkdir)
tmp_subroot = executor.user_root / TMP_SUBDIR / str(ctx.task_id)