feat(mp): mp_search_summary 批量入参 formulas[] 定点解检索烧 token(bump 0.58.28)

诊断 diag_tool_repeat.py 全库扫:pymatgen task fe2d8b73 里 mp_search_summary
占 562/687 次 tool_call、重复率 91%——逐化学式一 formula 一轮走 agent loop,
562 份中间 JSON 全流经主上下文且每轮重发。根因是工具只收单 formula + host-side
持 MP key(禁在 run_python 里循环)被迫一式一调。

这类"已知清单批量扇出"的正解是工具批量化而非 delegate 子循环(delegate 只把
562 轮搬进子上下文、基础 token 不省)。新增 formulas: list(≤300/次)触发 batch:
复用单个 MPRester 会话逐式查(num_chunks=1 fair-use 护栏不变)、每式按
energy_above_hull 升序、单式出错不连坐整批;结果落 materials/mp_search_batch_
<hash8>.json(内容寻址幂等),只回紧凑摘要+路径,中间数据不进对话。

单查询路径字节不变保向后兼容;working_dir 设可选(裸构造/无 key 测试降级内联)。
agent_builder 注入 working_dir_path;pymatgen SKILL.md 加批量引导(WHY+WHAT)。
tests +1(去重/落盘/稳定相排序/单式错误隔离/无 key 泄漏),原测试补回 chunk_size 断言。
additive 内部工具能力,不碰对外 API/DB 契约。

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
caoqianming 2026-07-15 10:49:35 +08:00
parent 54db4fc348
commit b5ac175ccd
6 changed files with 174 additions and 7 deletions

View File

@ -23,6 +23,7 @@
### 2026-07 ### 2026-07
- **07-15 / 0.58.28**:**`mp_search_summary` 加批量入参 `formulas=[...]`(检索型烧 token 头号根因的定点解)**(诊断:`diag_tool_repeat.py` 全库扫,pymatgen task `fe2d8b73` 687 次 tool_call 里 `mp_search_summary`**562 次**、重复率 91%——逐化学式一 formula 一轮走 agent loop 建氧化物表,562 份中间 JSON 全流经主上下文且每轮重发)。根因不是"检索发散"而是**工具只收单 `formula` + host-side 持 MP key(§7.5 #7 禁在 run_python 里循环)→ 被迫一式一调**。**这类"已知清单批量扇出"的正解是工具批量化,不是 delegate 子循环**(delegate 只把 562 轮搬进子上下文、基础 token 一分不省;批量化直接把 562 次并成 1 次工具调用+全部中间数据落盘不进对话)。实现:`formulas: list[str]`(≤300/次,超则报错分批)触发 batch 分支,复用**单个 MPRester 会话**逐式查(`num_chunks=1` fair-use 护栏不变)、每式按 `energy_above_hull` 升序(稳定相优先)、**单式出错不连坐整批**(记 `{"formula","error"}` 继续);结果聚合写 `working_dir/materials/mp_search_batch_<hash8>.json`(内容寻址、同批幂等)、**只回紧凑 per-formula 摘要 + 路径**(N 个有结果/M 个空错 + 空错清单前 20),引导模型用 `read`/`run_python` 直读文件、勿逐条 dump。单查询路径(`formula`/`material_ids`/`elements`)**字节不变**保向后兼容;`working_dir` 设可选(裸构造 `MaterialsProjectSearchSummaryTool()` 不破,测试/smoke 无 working_dir 时降级内联返回)。`agent_builder` 注入 `working_dir_path`(同 `mp_get_structure`);pymatgen SKILL.md 加"多式用 `formulas` 批量"引导(WHY+WHAT,不写死步骤);`tests/test_secret_host_tools.py` +1(去重/落盘/稳定相排序/单式错误隔离/无 key 泄漏),原单查询测试补回 `chunk_size` 断言。additive 内部工具能力,不碰对外 API/DB 契约。**背景**:这是"检索型烧 token"三头(mp_search 562 批量扇出 / document_search 122 探索检索 / research code 试错抖动)里第一头的定点解;探索检索型留待 §8.11 delegate,代码试错归 RepeatGuard,不混为一谈。
- **07-15 / 0.58.27**:**对话卡片「复制」按钮(仿 GPT/DeepSeek 悬停操作栏)**(用户提议:卡片上加复制等按钮)。`renderMessages` 给每条 user 卡、以及有正文的 assistant 卡末尾挂 `.msg-actions` 栏(纯 tool_calls 轮 / error 卡不挂),默认 `opacity:0` 悬停/`focus-within` 才现、触屏(`hover:none`)常显淡态。复制取的是**正文原文**:点击经 `#chat-stream` 既有 click 委托据卡片 `data-idx``state.loadedMessages``payload.content`(Markdown 源,**不从渲染后的 `.body` HTML 反推**),`navigator.clipboard.writeText` 主路径 + `execCommand("copy")` 降级,按钮短暂反馈「✓ 已复制/复制失败」后复位。直播卡不带此栏——流结束后 `loadMessages` 整屏重渲成持久卡自然带上,省一处直播态特判。**范围收敛的理由**:后端是纯追加式会话,无「重跑最后一轮 / 截断历史 / 编辑分叉」语义,故本期只做零后端耦合的复制;重新生成 / 编辑重发若无后端截断支持会变成「复读上一句 / 历史里又追加一条」的语义欺骗,留待后端补重跑/截断能力再单独立项。只改 `chat.js`(生成+委托+剪贴板)+ `dev.html`(CSS),对外无契约变化。 - **07-15 / 0.58.27**:**对话卡片「复制」按钮(仿 GPT/DeepSeek 悬停操作栏)**(用户提议:卡片上加复制等按钮)。`renderMessages` 给每条 user 卡、以及有正文的 assistant 卡末尾挂 `.msg-actions` 栏(纯 tool_calls 轮 / error 卡不挂),默认 `opacity:0` 悬停/`focus-within` 才现、触屏(`hover:none`)常显淡态。复制取的是**正文原文**:点击经 `#chat-stream` 既有 click 委托据卡片 `data-idx``state.loadedMessages``payload.content`(Markdown 源,**不从渲染后的 `.body` HTML 反推**),`navigator.clipboard.writeText` 主路径 + `execCommand("copy")` 降级,按钮短暂反馈「✓ 已复制/复制失败」后复位。直播卡不带此栏——流结束后 `loadMessages` 整屏重渲成持久卡自然带上,省一处直播态特判。**范围收敛的理由**:后端是纯追加式会话,无「重跑最后一轮 / 截断历史 / 编辑分叉」语义,故本期只做零后端耦合的复制;重新生成 / 编辑重发若无后端截断支持会变成「复读上一句 / 历史里又追加一条」的语义欺骗,留待后端补重跑/截断能力再单独立项。只改 `chat.js`(生成+委托+剪贴板)+ `dev.html`(CSS),对外无契约变化。
- **07-15 / 0.58.26**:**用户停止后「已停止」持久提示(补齐 cancel 终态,与 error 卡对称)**(用户报:点停止后对话底部什么都不剩)。根因同 `2a1bc25d` 案:cancel 时前端 live-append 的 `已停止` badge 会被紧随的收尾 `loadMessages` 整屏重建(`renderMessages` 先 `innerHTML=""`)冲掉——闪一下即消失;而 error 之所以稳,是因为它落 `run_status="error"` 持久态 + `renderPersistedRunError` 每次重渲重派生,cancel 却在收尾直接回 `idle` 不留痕。**方案 B(架构对称):把 cancel 提升为一等持久终态**——`run_status` 是 `Text` 列(非 enum,**无 migration**),`agent.run` 在任一取消路径都 `return "[cancelled]"`(`loop.py`),`_run_agent_bg` 据此收尾写 `run_status="cancelled"`(否则 `idle`)。前端 `renderPersistedRunError`→`renderPersistedRunTerminal` 扩成同时认 error/cancelled:cancelled 渲居中 muted 虚线条「已停止 · 你中断了上次运行」(良性态,不用错误卡的醒目样式),刷新/切任务仍在,下次起新 run(`post_message` 写 running)覆盖清掉。删掉会被冲掉的 live badge(及其死 CSS `.cancelled-badge`)。**未选方案 A(客户端临时 flag)**:它会把刚被 `2a1bc25d` 修掉的「终态挂 live-only DOM」反模式请回来(刷新即死、跨标签页不通、对 broker 的 DB 态机制不可见),且与 10 行外的 error 路径不对称。兼容性:`run_status` 加新值是纯增量(不改既有值语义),蓝绿旧实例读到 `cancelled` 落到 `in ("running","cancelling")` 判断的 else 当良性态、旧前端只认 `error`→优雅退化到今天行为、不崩;全部 run_status 消费点(起 run 409 门/reaper/SSE is_active/cancel 端点/clear_messages)逐一核对 cancelled 均按 idle/error 类良性态处理。filter allowlist + DESIGN §7.4 尾句 + models.py 注释同步。 - **07-15 / 0.58.26**:**用户停止后「已停止」持久提示(补齐 cancel 终态,与 error 卡对称)**(用户报:点停止后对话底部什么都不剩)。根因同 `2a1bc25d` 案:cancel 时前端 live-append 的 `已停止` badge 会被紧随的收尾 `loadMessages` 整屏重建(`renderMessages` 先 `innerHTML=""`)冲掉——闪一下即消失;而 error 之所以稳,是因为它落 `run_status="error"` 持久态 + `renderPersistedRunError` 每次重渲重派生,cancel 却在收尾直接回 `idle` 不留痕。**方案 B(架构对称):把 cancel 提升为一等持久终态**——`run_status` 是 `Text` 列(非 enum,**无 migration**),`agent.run` 在任一取消路径都 `return "[cancelled]"`(`loop.py`),`_run_agent_bg` 据此收尾写 `run_status="cancelled"`(否则 `idle`)。前端 `renderPersistedRunError`→`renderPersistedRunTerminal` 扩成同时认 error/cancelled:cancelled 渲居中 muted 虚线条「已停止 · 你中断了上次运行」(良性态,不用错误卡的醒目样式),刷新/切任务仍在,下次起新 run(`post_message` 写 running)覆盖清掉。删掉会被冲掉的 live badge(及其死 CSS `.cancelled-badge`)。**未选方案 A(客户端临时 flag)**:它会把刚被 `2a1bc25d` 修掉的「终态挂 live-only DOM」反模式请回来(刷新即死、跨标签页不通、对 broker 的 DB 态机制不可见),且与 10 行外的 error 路径不对称。兼容性:`run_status` 加新值是纯增量(不改既有值语义),蓝绿旧实例读到 `cancelled` 落到 `in ("running","cancelling")` 判断的 else 当良性态、旧前端只认 `error`→优雅退化到今天行为、不崩;全部 run_status 消费点(起 run 409 门/reaper/SSE is_active/cancel 端点/clear_messages)逐一核对 cancelled 均按 idle/error 类良性态处理。filter allowlist + DESIGN §7.4 尾句 + models.py 注释同步。
- **07-15 / 0.58.25**:**skill 定向模型:已选国际旗舰模型时豁免切换**(用户反馈:ppt 等 skill 会 pin `glm.pro52`,但用户若已主动选了 Claude/GPT 就不该被切走)。`core/agent_builder.py::_skill_model_switch` 在算出目标 profile 后加一道判据:current profile 的 family==`unifyllm`(网关族,含 Claude/GPT/Gemini)→ 返回 `None` 不切。定向 pin 本意是给较弱的国产默认模型托底产物质量,已在旗舰模型上的用户尊重其选择、不降级。判据放在 DB 写入前(纯字符串检查,无副作用);其余取舍(只信内置 skill / 跳档位门控 / 不自动切回)不变。DESIGN §3.5 记该豁免。对外无契约变化,不影响国产默认档用户的既有热切行为。 - **07-15 / 0.58.25**:**skill 定向模型:已选国际旗舰模型时豁免切换**(用户反馈:ppt 等 skill 会 pin `glm.pro52`,但用户若已主动选了 Claude/GPT 就不该被切走)。`core/agent_builder.py::_skill_model_switch` 在算出目标 profile 后加一道判据:current profile 的 family==`unifyllm`(网关族,含 Claude/GPT/Gemini)→ 返回 `None` 不切。定向 pin 本意是给较弱的国产默认模型托底产物质量,已在旗舰模型上的用户尊重其选择、不降级。判据放在 DB 写入前(纯字符串检查,无副作用);其余取舍(只信内置 skill / 跳档位门控 / 不自动切回)不变。DESIGN §3.5 记该豁免。对外无契约变化,不影响国产默认档用户的既有热切行为。

View File

@ -1,3 +1,3 @@
# zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。 # zcbot 版本号单一事实源:web/app.py 的 FastAPI version、/healthz 返回、前端展示都引这里。
# 改版本只动这一行。 # 改版本只动这一行。
__version__ = "0.58.27" __version__ = "0.58.28"

View File

@ -600,7 +600,11 @@ def build_agent(
if os.getenv("MP_API_KEY", "").strip(): if os.getenv("MP_API_KEY", "").strip():
for t in ( for t in (
MaterialsProjectSearchSummaryTool(base_dir=tool_base, user_root=ur_path), MaterialsProjectSearchSummaryTool(
working_dir=working_dir_path,
base_dir=tool_base,
user_root=ur_path,
),
MaterialsProjectGetStructureTool( MaterialsProjectGetStructureTool(
working_dir=working_dir_path, working_dir=working_dir_path,
base_dir=tool_base, base_dir=tool_base,

View File

@ -53,7 +53,7 @@ from pymatgen.analysis.phase_diagram import PhaseDiagram, PDEntry
API key 只在宿主后端读取,不要在 `run_python` 里读 env。可用 tool: API key 只在宿主后端读取,不要在 `run_python` 里读 env。可用 tool:
- `mp_search_summary(formula?, material_ids?, elements?, fields?, limit=10)` —— 查 summary,返回裁剪 JSON - `mp_search_summary(formula?, formulas?, material_ids?, elements?, fields?, limit=10)` —— 查 summary,返回裁剪 JSON。**要查多个化学式时传 `formulas=[...]` 一次批量查完**(结果落 `materials/mp_search_batch_*.json`,只回摘要),不要一个式子调一次 —— 逐条调既慢又把中间数据全灌进对话
- `mp_get_structure(material_id, filename?)` —— 把结构保存到 task_dir `materials/*.cif`,再用 `Structure.from_file()` 离线计算 - `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_get_entries(elements, filename?, limit=200)` —— 把 chemsys entries 保存到 task_dir `materials/*.json`

View File

@ -164,6 +164,75 @@ class TestMaterialsProjectHostTools(unittest.TestCase):
self.assertEqual(captured["num_chunks"], 1) self.assertEqual(captured["num_chunks"], 1)
self.assertEqual(captured["chunk_size"], 2) self.assertEqual(captured["chunk_size"], 2)
def test_mp_search_summary_batch_writes_file_and_isolates_failure(self):
from tools.materials_project import MaterialsProjectSearchSummaryTool
class FakeDoc:
def __init__(self, mid, formula, hull):
self.material_id = mid
self.formula_pretty = formula
self.energy_above_hull = hull
seen: list[str] = []
class FakeSummary:
def search(self, **kwargs):
f = kwargs["formula"]
seen.append(f)
if f == "BadX":
raise RuntimeError("boom")
if f == "Empty1":
return []
# unsorted on purpose: stable phase (0.0) comes second
return [FakeDoc("mp-9", f, 0.2), FakeDoc("mp-1", f, 0.0)]
class FakeMaterials:
def __init__(self):
self.summary = FakeSummary()
class FakeMPRester:
def __init__(self, api_key):
self.materials = FakeMaterials()
def __enter__(self):
return self
def __exit__(self, *a):
return False
with tempfile.TemporaryDirectory() as td:
wd = Path(td) / "proj"
wd.mkdir()
with patch.dict("os.environ", {"MP_API_KEY": "host-secret"}, clear=False), patch(
"tools.materials_project.MPRester", FakeMPRester
):
tool = MaterialsProjectSearchSummaryTool(working_dir=wd)
out = tool.execute(
formulas=["Ca3SiO5", "Ca3SiO5", "Empty1", "BadX"],
fields=["material_id", "formula_pretty", "energy_above_hull"],
limit=5,
)
# dedup: repeated Ca3SiO5 queried once
self.assertEqual(seen, ["Ca3SiO5", "Empty1", "BadX"])
# digest, not the full payload, and no secret leak
self.assertIn("batch: 3 个化学式", out)
self.assertIn("1 个有结果", out)
self.assertNotIn("host-secret", out)
# empty/error formulas surfaced
self.assertIn("Empty1", out)
self.assertIn("BadX", out)
# full results persisted to materials/*.json
files = list((wd / "materials").glob("mp_search_batch_*.json"))
self.assertEqual(len(files), 1)
agg = json.loads(files[0].read_text(encoding="utf-8"))
by_formula = {a["formula"]: a for a in agg}
# stable phase sorted first
self.assertEqual(by_formula["Ca3SiO5"]["results"][0]["energy_above_hull"], 0.0)
self.assertIn("error", by_formula["BadX"])
self.assertEqual(by_formula["Empty1"]["n"], 0)
class TestHostFileToolPathResolution(unittest.TestCase): class TestHostFileToolPathResolution(unittest.TestCase):
"""send_email / wechat_push 在宿主进程读附件:agent 给的相对/容器 `/workspace` 路径 """send_email / wechat_push 在宿主进程读附件:agent 给的相对/容器 `/workspace` 路径

View File

@ -5,6 +5,7 @@ written to task_dir, but it must not receive the Materials Project key.
""" """
from __future__ import annotations from __future__ import annotations
import hashlib
import json import json
import os import os
from pathlib import Path from pathlib import Path
@ -62,22 +63,40 @@ class MaterialsProjectSearchSummaryTool(Tool):
name = "mp_search_summary" name = "mp_search_summary"
description = ( description = (
"Search Materials Project summary data using the host MP_API_KEY. " "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." "Single query: pass formula / material_ids / elements → returns trimmed JSON. "
"Batch: pass `formulas` (a list) to look up MANY formulae in ONE tool call — "
"full results are written to task_dir/materials/*.json and only a compact "
"per-formula digest is returned. Always prefer batch over calling this tool "
"once per formula. Use mp_get_structure to save a CIF for offline pymatgen analysis."
) )
parameters = { parameters = {
"type": "object", "type": "object",
"properties": { "properties": {
"formula": {"type": "string", "description": "Optional formula such as Ca3SiO5."}, "formula": {"type": "string", "description": "Optional single formula such as Ca3SiO5."},
"formulas": {"type": "array", "items": {"type": "string"}, "description": "Optional list of formulae for a BATCH lookup, e.g. ['Ca3SiO5','Ca2SiO4',...]. One tool call instead of one per formula; full results saved to a JSON file, only a digest returned. Max 300 per call — split larger lists. When you have >1 formula to look up, use THIS, not repeated formula= calls."},
"material_ids": {"type": "array", "items": {"type": "string"}, "description": "Optional Materials Project ids such as mp-123."}, "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."}, "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_pretty/symmetry/energy_above_hull. Do NOT put the search formula here — use formula_pretty (not 'formula'). Common fields: formula_pretty, symmetry, energy_above_hull, band_gap, density, volume, is_stable, structure."}, "fields": {"type": "array", "items": {"type": "string"}, "description": "Fields to return; defaults to material_id/formula_pretty/symmetry/energy_above_hull. Do NOT put the search formula here — use formula_pretty (not 'formula'). Common fields: formula_pretty, symmetry, energy_above_hull, band_gap, density, volume, is_stable, structure."},
"limit": {"type": "integer", "default": 10, "description": "Maximum records returned, 1-50. Server-side bounded — keep small to stay within MP fair-use."}, "limit": {"type": "integer", "default": 10, "description": "Maximum records returned, 1-50. In batch mode this is PER formula (keep small — 1-3 gives the most-stable phases). Server-side bounded to stay within MP fair-use."},
}, },
} }
_MAX_BATCH = 300
def __init__(
self,
*,
working_dir: Optional[Path] = None,
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) if working_dir is not None else None
def execute( def execute(
self, self,
formula: str = "", formula: str = "",
formulas: Optional[list[str]] = None,
material_ids: Optional[list[str]] = None, material_ids: Optional[list[str]] = None,
elements: Optional[list[str]] = None, elements: Optional[list[str]] = None,
fields: Optional[list[str]] = None, fields: Optional[list[str]] = None,
@ -85,6 +104,9 @@ class MaterialsProjectSearchSummaryTool(Tool):
) -> str: ) -> str:
limit = min(max(int(limit), 1), 50) limit = min(max(int(limit), 1), 50)
chosen_fields = fields or _DEFAULT_SUMMARY_FIELDS chosen_fields = fields or _DEFAULT_SUMMARY_FIELDS
batch = [str(f).strip() for f in (formulas or []) if str(f).strip()]
if batch:
return self._batch(batch, chosen_fields, limit)
kwargs: dict[str, Any] = {"fields": chosen_fields} kwargs: dict[str, Any] = {"fields": chosen_fields}
if formula: if formula:
kwargs["formula"] = formula kwargs["formula"] = formula
@ -93,7 +115,7 @@ class MaterialsProjectSearchSummaryTool(Tool):
if elements: if elements:
kwargs["elements"] = elements kwargs["elements"] = elements
if len(kwargs) == 1: if len(kwargs) == 1:
return "[Error] formula / material_ids / elements 至少传一个" return "[Error] formula / formulas / material_ids / elements 至少传一个"
try: try:
with _mpr() as mpr: with _mpr() as mpr:
# num_chunks=1 + chunk_size=limit 让服务端单次只回 limit 条、不翻页。 # num_chunks=1 + chunk_size=limit 让服务端单次只回 limit 条、不翻页。
@ -107,6 +129,77 @@ class MaterialsProjectSearchSummaryTool(Tool):
plain = [_to_plain(d) for d in list(docs)[:limit]] plain = [_to_plain(d) for d in list(docs)[:limit]]
return json.dumps(plain, ensure_ascii=False, indent=2) return json.dumps(plain, ensure_ascii=False, indent=2)
@staticmethod
def _hull(rec: Any) -> float:
v = rec.get("energy_above_hull") if isinstance(rec, dict) else None
return v if isinstance(v, (int, float)) else float("inf")
def _batch(self, formulas: list[str], fields: list[str], limit: int) -> str:
if len(formulas) > self._MAX_BATCH:
return (
f"[Error] 一次最多 {self._MAX_BATCH} 个化学式(收到 {len(formulas)});请分批调用。"
)
seen: set[str] = set()
uniq = [f for f in formulas if not (f in seen or seen.add(f))]
try:
session = _mpr()
except Exception as e:
return f"[Error] mp_search_summary batch failed: {type(e).__name__}: {e}"
agg: list[dict[str, Any]] = []
n_ok = 0
# 单个 formula 出错不连坐整批;复用一个 MPRester 会话避免逐条重认证。
with session as mpr:
for f in uniq:
try:
docs = mpr.materials.summary.search(
formula=f, fields=fields, num_chunks=1, chunk_size=limit
)
results = [_to_plain(d) for d in list(docs)[:limit]]
results.sort(key=self._hull) # 稳定相优先
if results:
n_ok += 1
agg.append({"formula": f, "n": len(results), "results": results})
except Exception as e:
agg.append({"formula": f, "error": f"{type(e).__name__}: {e}"})
return self._render_batch(agg, fields, n_ok)
def _render_batch(self, agg: list[dict[str, Any]], fields: list[str], n_ok: int) -> str:
empties = [a["formula"] for a in agg if "error" in a or a.get("n", 0) == 0]
header = (
f"mp_search_summary batch: {len(agg)} 个化学式 → {n_ok} 个有结果,"
f"{len(empties)} 个空/错误。\n"
f"每条记录字段: {', '.join(fields)}\n"
)
if self.working_dir is not None:
h = hashlib.sha1(
",".join(a["formula"] for a in agg).encode("utf-8")
).hexdigest()[:8]
dest = self.working_dir / "materials" / f"mp_search_batch_{h}.json"
try:
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_text(
json.dumps(agg, ensure_ascii=False, indent=2), encoding="utf-8"
)
body = (
f"完整结果已存: {self._display(dest)}\n"
" 结构 [{\"formula\",\"n\",\"results\":[...]}...];"
"用 read 或 run_python/pandas 直接读该文件处理,勿逐条 dump 进对话。\n"
)
except Exception as e:
body = (
f"[warn] 写文件失败({type(e).__name__}: {e}),改为内联返回完整 JSON:\n"
+ json.dumps(agg, ensure_ascii=False, indent=2)
+ "\n"
)
else:
body = json.dumps(agg, ensure_ascii=False, indent=2) + "\n"
tail = ""
if empties:
shown = ", ".join(empties[:20])
more = f" …(+{len(empties) - 20})" if len(empties) > 20 else ""
tail = f"空/错误化学式: {shown}{more}\n"
return header + body + tail
class MaterialsProjectGetStructureTool(Tool): class MaterialsProjectGetStructureTool(Tool):
name = "mp_get_structure" name = "mp_get_structure"