andy88836 commited on
Commit
e5bcce8
·
verified ·
1 Parent(s): bc2a98e

Deploy MOFScreen-Agent FastAPI backend

Browse files
agent/conversation.py CHANGED
@@ -1,27 +1,42 @@
1
- """Natural-language intent agent for selective MOF tool execution."""
2
  from __future__ import annotations
3
 
4
  import json
5
  import re
6
- from pathlib import Path
 
 
 
7
 
8
- import yaml
9
 
10
 
11
- def parse_user_request(
12
- user_request: str,
13
- llm_provider: str,
14
- llm_api_key: str | None = None,
15
- ) -> dict:
16
- """Parse a natural-language user request into a selective tool plan using an LLM only."""
17
- if llm_provider == "rule_based" or not llm_api_key:
18
- raise ValueError("Conversational intent recognition requires an LLM provider and API key.")
 
 
 
 
 
 
 
19
 
20
- llm_intent = _try_llm_intent(user_request, llm_provider, llm_api_key)
21
- if isinstance(llm_intent, dict):
22
- return _normalize_intent(llm_intent)
23
 
24
- raise RuntimeError("LLM intent recognition failed. Check the API key, provider, and model access.")
 
 
 
 
 
 
 
 
25
 
26
 
27
  def run_conversational_screening(
@@ -29,350 +44,373 @@ def run_conversational_screening(
29
  cif_path: str | None,
30
  llm_provider: str,
31
  llm_api_key: str | None = None,
 
32
  ) -> dict:
33
- """Run only the tools required by the parsed user intent."""
34
- try:
35
- intent = parse_user_request(user_request, llm_provider, llm_api_key)
36
- except Exception as exc:
37
  return {
38
- "intent": None,
39
- "needs_api_key": not llm_api_key or llm_provider == "rule_based",
40
  "needs_file": False,
41
- "assistant_message": str(exc),
42
- "agent_trace": [{
43
- "agent": "Intent Agent",
44
- "action": "blocked because LLM intent recognition is required",
45
- "output": {"error": str(exc)},
46
- }],
47
  "results": {},
48
  "warnings": [],
49
- "errors": [str(exc)],
50
  }
51
 
52
- trace = [{
53
- "agent": "Intent Agent",
54
- "action": "parsed natural-language request into a selective tool plan",
55
- "output": intent,
56
- }]
57
 
58
- if intent["requires_cif"] and not cif_path:
 
59
  return {
60
  "intent": intent,
61
- "needs_file": True,
62
- "assistant_message": _missing_file_message(intent),
63
  "agent_trace": trace,
64
  "results": {},
65
  "warnings": [],
66
  "errors": [],
67
  }
68
 
69
- if not cif_path:
70
  return {
71
  "intent": intent,
72
- "needs_file": False,
73
- "assistant_message": "This request does not require a CIF file, but no matching non-file task is implemented yet.",
74
  "agent_trace": trace,
75
  "results": {},
76
  "warnings": [],
77
  "errors": [],
78
  }
79
 
80
- results: dict = {}
81
- warnings: list[str] = []
82
- errors: list[str] = []
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
83
 
84
- if intent["mode"] == "full_screening":
85
- from agent.graph import screening_app
86
 
87
- full = screening_app.invoke({
88
- "cif_path": cif_path,
89
- "llm_provider": llm_provider,
90
- "llm_api_key": llm_api_key,
91
- "warnings": [],
92
- "errors": [],
93
- })
94
- full["intent"] = intent
95
- full["agent_trace"] = trace + list(full.get("agent_trace", []))
96
- return full
97
 
98
- try:
99
- if intent["targets"].get("adsorption"):
100
- ads = _run_adsorption_tools(cif_path, intent["targets"]["adsorption"])
101
- results["adsorption"] = ads["results"]
102
- warnings.extend(ads["warnings"])
103
- trace.extend(ads["trace"])
104
-
105
- if intent["targets"].get("toxicity") or intent["targets"].get("safety"):
106
- linker_bundle = _run_linker_tool(cif_path)
107
- results["linker"] = linker_bundle["result"]
108
- warnings.extend(linker_bundle["warnings"])
109
- trace.extend(linker_bundle["trace"])
110
-
111
- linker = linker_bundle["result"]
112
- smiles = linker.get("linker_smiles") if linker else None
113
- metals = linker.get("metals", []) if linker else []
114
-
115
- if intent["targets"].get("toxicity"):
116
- tox_bundle = _run_toxicity_tool(smiles)
117
- results["toxicity"] = tox_bundle["result"]
118
- warnings.extend(tox_bundle["warnings"])
119
- trace.extend(tox_bundle["trace"])
120
-
121
- if intent["targets"].get("safety"):
122
- safety_bundle = _run_safety_tools(metals, smiles)
123
- results["safety"] = safety_bundle["result"]
124
- warnings.extend(safety_bundle["warnings"])
125
- trace.extend(safety_bundle["trace"])
126
- except Exception as exc:
127
- errors.append(str(exc))
128
 
129
- response = _build_selective_response(intent, results, warnings, errors)
130
- trace.append({
131
- "agent": "Response Agent",
132
- "action": "generated targeted answer from selected tool outputs",
133
- "output": {"assistant_message": response},
134
- })
135
 
136
- return {
137
- "intent": intent,
138
- "needs_file": False,
139
- "assistant_message": response,
140
- "agent_trace": trace,
141
- "results": results,
142
- "warnings": warnings,
143
- "errors": errors,
144
- }
145
 
146
 
147
- def _normalize_intent(intent: dict) -> dict:
148
- normalized = {
149
- "mode": intent.get("mode", "selective_tools"),
150
- "requires_cif": True,
151
- "targets": intent.get("targets") or {},
152
- "user_goal": intent.get("user_goal", ""),
153
- "clarification": intent.get("clarification"),
154
- }
155
- targets = normalized["targets"]
156
- targets.setdefault("adsorption", [])
157
- targets.setdefault("toxicity", False)
158
- targets.setdefault("safety", False)
159
- targets["adsorption"] = [
160
- target for target in targets.get("adsorption", [])
161
- if target in {"benzene", "toluene"}
162
- ]
163
- targets["toxicity"] = bool(targets.get("toxicity"))
164
- targets["safety"] = bool(targets.get("safety"))
165
- normalized["targets"] = targets
166
- normalized["tool_plan"] = _tool_plan_from_targets(targets, normalized.get("mode", "selective_tools"))
167
- normalized["requires_cif"] = True
168
- return normalized
169
-
170
-
171
- def _tool_plan_from_targets(targets: dict, mode: str) -> list[str]:
172
- if mode == "full_screening":
173
- return ["full_agentic_screening_graph"]
174
- plan = []
175
- adsorption = targets.get("adsorption") or []
176
- if adsorption:
177
- plan.append("compute_scm_descriptor")
178
- for target in adsorption:
179
- plan.append(f"predict_{target}_adsorption")
180
- if targets.get("toxicity") or targets.get("safety"):
181
- plan.append("extract_linker")
182
- if targets.get("toxicity"):
183
- plan.append("predict_toxicity")
184
- if targets.get("safety"):
185
- plan.append("apply_safety_rules")
186
- return plan
187
-
188
-
189
- def _try_llm_intent(user_request: str, provider: str, api_key: str | None) -> dict | None:
190
  if provider == "rule_based" or not api_key:
191
- raise ValueError("Conversational intent recognition requires an LLM provider and API key.")
192
  try:
193
- from langchain_openai import ChatOpenAI
194
-
195
- cfg = {"temperature": 0.0, "max_tokens": 350, "api_key": api_key}
196
- if provider == "openai":
197
- cfg["model"] = "gpt-4o-mini"
198
- elif provider == "deepseek":
199
- cfg.update({"model": "deepseek-chat", "base_url": "https://api.deepseek.com/v1"})
200
- elif provider == "qwen":
201
- cfg.update({"model": "qwen-turbo", "base_url": "https://dashscope.aliyuncs.com/compatible-mode/v1"})
202
- else:
203
  return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
204
 
205
- prompt = f"""
206
- You are an Intent Agent for a MOF screening application.
207
- Parse the user request into JSON only.
208
- Allowed mode: "selective_tools" or "full_screening".
209
- Allowed adsorption targets: "benzene", "toluene".
210
- Set toxicity true only if the user asks toxicity/ecotoxicity.
211
- Set safety true only if the user asks safety, PMT, metal risk, PFAS, or full evaluation.
212
- Always requires_cif=true for property prediction.
213
 
214
- JSON shape:
215
  {{
216
- "mode": "selective_tools",
217
- "requires_cif": true,
218
- "targets": {{"adsorption": ["benzene"], "toxicity": false, "safety": false}},
219
- "user_goal": "short description",
220
- "clarification": null
221
  }}
222
-
223
- User request: {user_request}
224
  """
225
- response = ChatOpenAI(**cfg).invoke(prompt)
226
- return _extract_json(response.content)
227
- except Exception:
228
- return None
229
 
230
 
231
- def _extract_json(text: str) -> dict | None:
 
 
 
 
 
 
 
 
 
232
  try:
233
- return json.loads(text)
234
- except Exception:
235
- match = re.search(r"\{.*\}", text, flags=re.S)
236
- if not match:
237
- return None
238
- try:
239
- return json.loads(match.group(0))
240
- except Exception:
241
- return None
242
 
 
243
 
244
- def _run_adsorption_tools(cif_path: str, targets: list[str]) -> dict:
245
- from tools.adsorption import predict_benzene, predict_toluene
246
- from tools.descriptors import compute_scm_eigenvalues
247
-
248
- cfg_path = Path(__file__).resolve().parent.parent / "configs" / "paths.yaml"
249
- with open(cfg_path, "r", encoding="utf-8") as f:
250
- cfg = yaml.safe_load(f)["adsorption_models"]
251
-
252
- results = {}
253
- warnings = []
254
- trace = []
255
- for target in targets:
256
- dim = cfg[f"{target}_target_dim"]
257
- desc = compute_scm_eigenvalues(cif_path, dim)
258
- pred = predict_benzene(desc["eigenvalues"]) if target == "benzene" else predict_toluene(desc["eigenvalues"])
259
- results[target] = {
260
- "uptake_mg_g": pred["uptake_mg_g"],
261
- "model": pred["model_version"],
262
- "descriptor_raw_dim": desc["raw_dim"],
263
- "descriptor_padded": desc["padded"],
264
- "descriptor_truncated": desc["truncated"],
265
- }
266
- for warning in [desc["applicability_warning"], pred["applicability_warning"]]:
267
- if warning:
268
- warnings.append(warning)
269
- trace.append({
270
- "agent": "Tool Execution Agent",
271
- "action": f"ran {target} adsorption prediction only",
272
- "output": results[target],
273
- })
274
- return {"results": results, "warnings": warnings, "trace": trace}
275
-
276
-
277
- def _run_linker_tool(cif_path: str) -> dict:
278
- from tools.linker_extraction import extract_linker
279
-
280
- result = extract_linker(cif_path)
281
- warnings = [result.get("extraction_note")] if result.get("extraction_level") == 3 else []
282
- return {
283
- "result": result,
284
- "warnings": [w for w in warnings if w],
285
- "trace": [{
286
- "agent": "Tool Execution Agent",
287
- "action": "ran extract_linker because toxicity or safety was requested",
288
- "output": result,
289
- }],
290
- }
291
 
 
 
 
 
 
292
 
293
- def _run_toxicity_tool(smiles: str | None) -> dict:
294
- from tools.toxicity import predict_toxicity
295
 
296
- result = predict_toxicity(smiles)
297
- return {
298
- "result": result,
299
- "warnings": [],
300
- "trace": [{
301
- "agent": "Tool Execution Agent",
302
- "action": "ran predict_toxicity because toxicity was requested",
303
- "output": result,
304
- }],
305
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
306
 
307
 
308
- def _run_safety_tools(metals: list[str], smiles: str | None) -> dict:
309
- from tools.safety_rules import check_metal_safety, check_pmt_pre_filter
310
-
311
- metal = check_metal_safety(metals) if metals else {
312
- "tier": "unknown",
313
- "flagged_metals": [],
314
- "details": "No metals found.",
315
- }
316
- pmt = check_pmt_pre_filter(smiles)
317
- result = {
318
- "metal_tier": metal["tier"],
319
- "metal_flagged": metal["flagged_metals"],
320
- "metal_details": metal["details"],
321
- "pmt_pass": pmt["pmt_pass"],
322
- "pmt_flags": pmt["flags"],
323
- "pmt_descriptors": pmt["descriptors"],
324
- }
325
- return {
326
- "result": result,
327
- "warnings": [],
328
- "trace": [{
329
- "agent": "Tool Execution Agent",
330
- "action": "ran safety rules because safety was requested",
331
- "output": result,
332
- }],
333
- }
334
 
335
 
336
- def _missing_file_message(intent: dict) -> str:
337
- tools = ", ".join(intent.get("tool_plan", []))
338
- return (
339
- "我已经识别到你的任务,但这个预测需要 CIF 结构文件。"
340
- f"请先上传一个 .cif 文件,然后我会只调用这些工具:{tools}。"
341
- )
 
 
 
 
 
 
 
 
 
342
 
343
 
344
- def _build_selective_response(intent: dict, results: dict, warnings: list[str], errors: list[str]) -> str:
345
- if errors:
346
- return "任务执行失败:" + "; ".join(errors)
 
 
 
347
 
348
- lines = ["Intent Agent 已识别你的需求,并只调用了必要工具。"]
349
- adsorption = results.get("adsorption") or {}
350
  if adsorption:
351
- for target, payload in adsorption.items():
352
- label = "苯" if target == "benzene" else "甲苯"
353
- lines.append(
354
- f"{label}吸附量预测值:{payload['uptake_mg_g']} mg/g "
355
- f"({payload['model']}; SCM raw dim={payload['descriptor_raw_dim']})."
356
- )
357
-
358
- if "toxicity" in results:
359
- tox = results["toxicity"]
360
- lines.append(
361
- "毒性预测已完成:"
362
- f"LC50 fish={tox.get('LC50_Pimephales')}, "
363
- f"LC50 daphnia={tox.get('LC50_Daphnia')}, "
364
- f"IGC50={tox.get('IGC50_Tetrahymena')}, "
365
- f"IBC50={tox.get('IBC50_Vibrio')}."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
366
  )
 
 
 
 
 
 
 
367
 
368
- if "safety" in results:
369
- safety = results["safety"]
370
- lines.append(
371
- f"安全规则结果:metal tier={safety.get('metal_tier')}, "
372
- f"PMT={'pass' if safety.get('pmt_pass') else 'fail'}."
373
- )
374
 
375
- if warnings:
376
- lines.append("注意:" + ";".join(warnings[:2]))
 
 
 
 
 
377
 
378
- return "\n\n".join(lines)
 
 
 
 
 
 
 
 
 
 
 
1
+ """LLM-guided router for online CIF screening tools."""
2
  from __future__ import annotations
3
 
4
  import json
5
  import re
6
+ import urllib.error
7
+ import urllib.request
8
+ from types import SimpleNamespace
9
+ from typing import Any
10
 
11
+ from tools.online_screening import ACTIVE_TOOLS, run_online_tools
12
 
13
 
14
+ TOOL_KEYWORDS = {
15
+ "predict_adsorption": ("adsorption", "benzene", "toluene", "吸附", "苯", "甲苯"),
16
+ "check_metals": ("metal", "heavy", "重金属", "金属"),
17
+ "identify_linker": ("ligand", "linker", "配体"),
18
+ "predict_sa_score": ("synth", "sa", "可合成", "合成性"),
19
+ "predict_aquatic_toxicity": ("tox", "toxicity", "aquatic", "lc50", "igc50", "ibc50", "毒性", "水生"),
20
+ "predict_price": ("price", "cost", "coprinet", "usd", "价格", "成本"),
21
+ }
22
+
23
+ FULL_KEYWORDS = (
24
+ "full", "complete", "overall", "comprehensive", "screening", "pass",
25
+ "完整", "综合", "筛选", "能不能通过", "是否通过", "评价一下", "评估一下",
26
+ )
27
+
28
+ ACTIVE_FULL_PLAN = list(ACTIVE_TOOLS)
29
 
 
 
 
30
 
31
+ def parse_user_request(user_request: str, llm_provider: str, llm_api_key: str | None = None) -> dict:
32
+ """Return the intended online tool plan."""
33
+ rule_intent = _rule_based_intent(user_request)
34
+ if rule_intent["mode"] == "general_answer":
35
+ return rule_intent
36
+ llm_intent = _try_llm_tool_plan(user_request, {}, llm_provider, llm_api_key)
37
+ if llm_intent:
38
+ return _normalize_intent(llm_intent, user_request)
39
+ return rule_intent
40
 
41
 
42
  def run_conversational_screening(
 
44
  cif_path: str | None,
45
  llm_provider: str,
46
  llm_api_key: str | None = None,
47
+ final_llm: bool = True,
48
  ) -> dict:
49
+ if _is_pmt_only_request(user_request):
 
 
 
50
  return {
51
+ "intent": {"mode": "out_of_scope", "requires_cif": False, "tools": [], "user_goal": user_request},
 
52
  "needs_file": False,
53
+ "assistant_message": "This request is outside the platform scope.",
54
+ "agent_trace": [],
 
 
 
 
55
  "results": {},
56
  "warnings": [],
57
+ "errors": [],
58
  }
59
 
60
+ intent = parse_user_request(user_request, llm_provider, llm_api_key)
61
+ trace = [{"agent": "Orchestrator Agent", "action": "planned online tool calls", "output": intent}]
 
 
 
62
 
63
+ if intent["mode"] == "general_answer":
64
+ message = _general_answer(user_request, llm_provider, llm_api_key)
65
  return {
66
  "intent": intent,
67
+ "needs_file": False,
68
+ "assistant_message": message,
69
  "agent_trace": trace,
70
  "results": {},
71
  "warnings": [],
72
  "errors": [],
73
  }
74
 
75
+ if intent["requires_cif"] and not cif_path:
76
  return {
77
  "intent": intent,
78
+ "needs_file": True,
79
+ "assistant_message": "This request requires a CIF file before online calculations can run.",
80
  "agent_trace": trace,
81
  "results": {},
82
  "warnings": [],
83
  "errors": [],
84
  }
85
 
86
+ result = run_online_tools(cif_path, intent["tools"])
87
+ result["intent"] = intent
88
+ result["needs_file"] = False
89
+ result["agent_trace"] = trace + result.get("agent_trace", [])
90
+ result["assistant_message"] = (
91
+ _try_llm_result_response(user_request, result, llm_provider, llm_api_key)
92
+ if final_llm else None
93
+ ) or _build_response(result)
94
+ return result
95
+
96
+
97
+ def _normalize_intent(intent: dict, user_request: str) -> dict:
98
+ mode = str(intent.get("mode") or "selective_tools")
99
+ tools = intent.get("tools") or []
100
+ if isinstance(tools, str):
101
+ tools = [tools]
102
+ tools = [tool for tool in tools if tool in set(ACTIVE_TOOLS)]
103
+ if mode in {"full", "full_screening", "full_six_step_screening"}:
104
+ mode = "full_online_screening"
105
+ tools = ACTIVE_FULL_PLAN
106
+ elif mode == "general_answer":
107
+ tools = []
108
+ elif not tools:
109
+ return _rule_based_intent(user_request)
110
+ else:
111
+ mode = "selective_tools"
112
+ return {
113
+ "mode": mode,
114
+ "requires_cif": mode != "general_answer",
115
+ "tools": tools,
116
+ "user_goal": intent.get("user_goal") or user_request,
117
+ "rationale_summary": intent.get("rationale_summary"),
118
+ }
119
 
 
 
120
 
121
+ def _rule_based_intent(user_request: str) -> dict:
122
+ text = (user_request or "").lower()
123
+ is_conceptual = any(keyword in text for keyword in ("是什么", "解释", "explain", "what is", "meaning", "含义"))
124
+ candidate_specific = any(
125
+ keyword in text
126
+ for keyword in ("风险", "预测", "筛选", "评价", "评估", "怎么样", "多少", "rank", "price", "toxicity", "screen", "计算")
127
+ )
128
+ if is_conceptual and not candidate_specific:
129
+ return {"mode": "general_answer", "requires_cif": False, "tools": [], "user_goal": user_request}
 
130
 
131
+ is_full = any(keyword in text for keyword in FULL_KEYWORDS)
132
+ tools = [name for name, keywords in TOOL_KEYWORDS.items() if any(keyword in text for keyword in keywords)]
133
+ if is_full or "综合" in text:
134
+ tools = ACTIVE_FULL_PLAN
135
+ mode = "full_online_screening"
136
+ elif tools:
137
+ mode = "selective_tools"
138
+ else:
139
+ mode = "general_answer"
140
+ return {"mode": mode, "requires_cif": mode != "general_answer", "tools": tools, "user_goal": user_request}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
141
 
 
 
 
 
 
 
142
 
143
+ def _is_pmt_only_request(user_request: str) -> bool:
144
+ text = (user_request or "").lower()
145
+ if not any(keyword in text for keyword in ("pmt", "pbt", "vpvm")):
146
+ return False
147
+ return not any(any(keyword in text for keyword in keywords) for keywords in TOOL_KEYWORDS.values())
 
 
 
 
148
 
149
 
150
+ def _try_llm_tool_plan(user_request: str, current_results: dict, provider: str, api_key: str | None) -> dict | None:
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
151
  if provider == "rule_based" or not api_key:
152
+ return None
153
  try:
154
+ response = _llm(provider, api_key, temperature=0.0, max_tokens=500).invoke(_orchestrator_prompt(user_request, current_results))
155
+ parsed = _extract_json(response.content)
156
+ if not parsed or parsed.get("action") == "final_answer":
 
 
 
 
 
 
 
157
  return None
158
+ return {
159
+ "mode": "selective_tools",
160
+ "tools": [item.get("name") for item in parsed.get("tools", []) if isinstance(item, dict)],
161
+ "rationale_summary": parsed.get("rationale_summary"),
162
+ "user_goal": user_request,
163
+ }
164
+ except Exception:
165
+ return None
166
+
167
+
168
+ def _orchestrator_prompt(user_request: str, current_results: dict) -> str:
169
+ return f"""You are MOFScreen-Agent, a MOF research assistant with expert planning ability.
170
+ Infer the user's real screening intent and call the minimum necessary tools to build an evidence chain.
171
+
172
+ Principles:
173
+ 1. Do not answer only the literal wording. If the user asks about toxicity, gather toxicity data, linker structure, and metal information when needed.
174
+ 2. Respect dependencies:
175
+ - Toxicity, price, and synthesizability require linkers; call identify_linker first when linker data is missing.
176
+ - Environmental risk should combine metal information and toxicity predictions when relevant.
177
+ - Adsorption performance requires predict_adsorption.
178
+ 3. Quantitative values must come from tools. Never invent values.
179
+ 4. Before final_answer, collect enough evidence to support the requested conclusion.
180
+
181
+ Available tools:
182
+ parse_cif, predict_adsorption, identify_linker, check_metals, predict_sa_score, predict_aquatic_toxicity, predict_price, final_answer
183
 
184
+ Context:
185
+ - User request: {user_request}
186
+ - Existing tool results: {json.dumps(current_results, ensure_ascii=False, default=str)}
187
+
188
+ Output JSON only. No Markdown.
189
+ Fields:
190
+ - action: "tool_call" or "final_answer"
191
+ - tools: tools to call; empty for final_answer
192
 
 
193
  {{
194
+ "action": "tool_call",
195
+ "tools": [{{"name": "tool_name", "arguments": {{}}}}],
196
+ "rationale_summary": "One sentence explaining why these tools are needed.",
197
+ "final_response": null
 
198
  }}
 
 
199
  """
 
 
 
 
200
 
201
 
202
+ def _general_answer(user_request: str, llm_provider: str, llm_api_key: str | None) -> str:
203
+ llm_answer = _try_llm_answer(user_request, llm_provider, llm_api_key)
204
+ if llm_answer:
205
+ return llm_answer
206
+ return "I can answer general MOF questions. For material-specific calculations, please upload a CIF file."
207
+
208
+
209
+ def _try_llm_answer(user_request: str, provider: str, api_key: str | None) -> str | None:
210
+ if provider == "rule_based" or not api_key:
211
+ return None
212
  try:
213
+ prompt = f"""You are MOFScreen-Agent, a research assistant focused on metal-organic frameworks (MOFs).
214
+ You have strong materials chemistry knowledge, but you are strict: for specific material data such as adsorption, toxicity, or price, calculations take priority and you must not guess values.
 
 
 
 
 
 
 
215
 
216
+ Task: Answer the user's general or methodological question in concise, professional English.
217
 
218
+ Strategy:
219
+ 1. For MOF concepts, answer with relevant scientific context.
220
+ 2. If the user asks for specific material performance, ask them to upload a CIF file so the platform can run tools.
221
+ 3. Be helpful, rigorous, and avoid unsupported numerical claims.
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
222
 
223
+ User question: {user_request}
224
+ """
225
+ return _llm(provider, api_key, temperature=0.2, max_tokens=500).invoke(prompt).content
226
+ except Exception:
227
+ return None
228
 
 
 
229
 
230
+ def _result_prompt(user_request: str, result: dict) -> str:
231
+ safe_result = {
232
+ "mof_id": result.get("mof_id"),
233
+ "intent": result.get("intent"),
234
+ "tool_results": result.get("tool_results") or result.get("results"),
235
+ "gate_status": result.get("gate_status"),
236
+ "recommendation": result.get("recommendation"),
237
+ "errors": result.get("errors"),
238
+ "warnings": result.get("warnings"),
239
  }
240
+ return f"""You are MOFScreen-Agent, a research assistant for MOF virtual screening.
241
+ Your task is to produce an English evaluation report based strictly on backend tool results.
242
+ Do not introduce yourself as an analysis module, and do not say that you are converting raw data into a report. Start directly with the conclusion.
243
+
244
+ Input:
245
+ - User request: {user_request}
246
+ - Tool results: {json.dumps(safe_result, ensure_ascii=False, default=str)}
247
+
248
+ Requirements:
249
+ 1. **Data fidelity**: all quantitative values, including LC50, price, adsorption uptake, and SA score, must come directly from tool results. Do not invent numbers.
250
+ 2. **Deep interpretation**:
251
+ - Do not only repeat a number such as "LC50 is 12.5 mg/L"; explain what the value implies for screening.
252
+ - Connect the result to structure where possible. For example, Zn nodes often suggest better biocompatibility than many heavy metals, while nitrogen heterocycle linkers may contribute to biological activity.
253
+ - Use cautious benchmark language when appropriate, and clearly mark it as interpretation rather than additional computation.
254
+ 3. **Structured output**: use Markdown and exactly these four sections.
255
+
256
+ Output template:
257
+
258
+ ### 📊 Conclusion
259
+ Summarize the core conclusion in 1-2 sentences and answer the user's question directly.
260
+
261
+ ### 🔬 Key Evidence
262
+ List the key tool-returned metrics. A compact table or bullets are both acceptable.
263
+ * **Metric**: value + unit
264
+ * **Assessment**: Pass/Fail or low/medium/high risk where supported by the result
265
+
266
+ ### 🧠 Expert Analysis
267
+ Explain the structure-property relationship:
268
+ * **Likely drivers**: connect metal nodes, linker features, and adsorption/toxicity/price results.
269
+ * **Potential mechanism**: for example, pore confinement, aromatic interactions, metal leaching risk, or linker-driven toxicity.
270
+ * **Uncertainty**: state model/domain limitations and failed or missing tool outputs.
271
+
272
+ ### 💡 Decision Advice
273
+ Give concrete next-step guidance:
274
+ * Whether the MOF looks suitable for the requested use case.
275
+ * Whether modification, coating, or linker/metal substitution should be considered.
276
+ * Which experiment or manual check should be prioritized next.
277
+
278
+ Use a professional, objective, advisory tone.
279
+ """
280
 
281
 
282
+ def _try_llm_result_response(user_request: str, result: dict, provider: str, api_key: str | None) -> str | None:
283
+ if provider == "rule_based" or not api_key:
284
+ return None
285
+ try:
286
+ return _llm(provider, api_key, temperature=0.2, max_tokens=1600).invoke(_result_prompt(user_request, result)).content
287
+ except Exception:
288
+ return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
289
 
290
 
291
+ def stream_llm_result_response(user_request: str, result: dict, provider: str, api_key: str | None):
292
+ if result.get("assistant_message") and not (result.get("tool_results") or result.get("results")):
293
+ yield str(result["assistant_message"])
294
+ return
295
+ if provider == "rule_based" or not api_key:
296
+ yield _build_response(result)
297
+ return
298
+ try:
299
+ for chunk in _llm(provider, api_key, temperature=0.2, max_tokens=1600).stream(_result_prompt(user_request, result)):
300
+ text = getattr(chunk, "content", "")
301
+ if text:
302
+ yield text
303
+ except Exception as exc:
304
+ yield f"LLM call failed; using the rule-based summary instead. Error: {_safe_error(exc)}\n\n"
305
+ yield _build_response(result)
306
 
307
 
308
+ def _build_response(result: dict) -> str:
309
+ if result.get("errors"):
310
+ return "Task failed: " + "; ".join(result["errors"])
311
+ tools = result.get("tool_results") or result.get("results") or {}
312
+ if not tools:
313
+ return result.get("assistant_message") or "No screening tools were called."
314
 
315
+ lines = ["### 📊 Conclusion", "Online tool calculations were run for the uploaded CIF. This fallback summary is based only on deterministic tool outputs because no usable LLM API key was available.", "", "### 🔬 Key Evidence"]
316
+ adsorption = tools.get("predict_adsorption") or {}
317
  if adsorption:
318
+ lines.append(f"- Adsorption prediction: benzene {adsorption.get('benzene_uptake_mg_g')} mg/g; toluene {adsorption.get('toluene_uptake_mg_g')} mg/g.")
319
+ metals = tools.get("check_metals") or {}
320
+ if metals:
321
+ lines.append(f"- Metal check: {metals.get('summary')}")
322
+ linker = tools.get("identify_linker") or {}
323
+ if linker:
324
+ lines.append(f"- Linker: {linker.get('linker_name') or linker.get('linker_formula') or 'unidentified'}; SMILES={linker.get('linker_smiles') or 'N/A'}.")
325
+ sa = tools.get("predict_sa_score") or {}
326
+ if sa:
327
+ lines.append(f"- SA score:{sa.get('sa_score')};{sa.get('scale', '')}。")
328
+ tox = tools.get("predict_aquatic_toxicity") or {}
329
+ if tox:
330
+ lines.append(f"- Aquatic toxicity: mean={tox.get('mean_toxicity')}; worst={tox.get('worst_toxicity')}.")
331
+ price = tools.get("predict_price") or {}
332
+ if price:
333
+ lines.append(f"- Price prediction: {price.get('usd_per_mmol')} USD/mmol; {price.get('usd_per_g')} USD/g.")
334
+ failed = [name for name, payload in tools.items() if payload.get("status") in {"error", "unavailable"}]
335
+ lines.extend(["", "### 🧠 Expert Analysis"])
336
+ if linker:
337
+ lines.append(f"- **Structural clue**: the identified linker is {linker.get('linker_name') or linker.get('linker_formula') or 'unidentified'}, SMILES={linker.get('linker_smiles') or 'N/A'}.")
338
+ if metals:
339
+ lines.append(f"- **Metal node**: {metals.get('summary')}.")
340
+ if adsorption:
341
+ lines.append("- **Adsorption interpretation**: benzene/toluene uptake values come from a structure-descriptor model and should be checked against the model applicability domain and experiments.")
342
+ if tox:
343
+ lines.append("- **Toxicity interpretation**: aquatic toxicity endpoints are predicted from linker SMILES and should be treated as an environmental risk screen, not a substitute for full ecotoxicology testing.")
344
+ if failed:
345
+ lines.append("- **Uncertainty**: " + "; ".join(f"{name}: {tools[name].get('summary')}" for name in failed))
346
+ lines.extend(["", "### 💡 Decision Advice", "- Prioritize manual review of the linker identification and model applicability domain.", "- For formal screening, add experimental validation matched to the target application."])
347
+ return "\n".join(lines)
348
+
349
+
350
+ def _llm(provider: str, api_key: str, temperature: float, max_tokens: int):
351
+ if provider == "openai":
352
+ return _OpenAICompatibleLLM("https://api.openai.com/v1", "gpt-4o-mini", api_key, temperature, max_tokens)
353
+ elif provider == "deepseek":
354
+ return _OpenAICompatibleLLM("https://api.deepseek.com/v1", "deepseek-chat", api_key, temperature, max_tokens)
355
+ elif provider == "qwen":
356
+ return _OpenAICompatibleLLM("https://dashscope.aliyuncs.com/compatible-mode/v1", "qwen-turbo", api_key, temperature, max_tokens)
357
+ else:
358
+ raise ValueError(f"Unsupported LLM provider: {provider}")
359
+
360
+
361
+ class _OpenAICompatibleLLM:
362
+ def __init__(self, base_url: str, model: str, api_key: str, temperature: float, max_tokens: int):
363
+ self.base_url = base_url.rstrip("/")
364
+ self.model = model
365
+ self.api_key = api_key
366
+ self.temperature = temperature
367
+ self.max_tokens = max_tokens
368
+
369
+ def invoke(self, prompt: str):
370
+ return SimpleNamespace(content=self._complete(prompt))
371
+
372
+ def stream(self, prompt: str):
373
+ # ponytail: non-stream HTTP keeps provider support tiny; swap to SSE parsing if token-level streaming matters.
374
+ yield SimpleNamespace(content=self._complete(prompt))
375
+
376
+ def _complete(self, prompt: str) -> str:
377
+ payload = json.dumps({
378
+ "model": self.model,
379
+ "messages": [{"role": "user", "content": prompt}],
380
+ "temperature": self.temperature,
381
+ "max_tokens": self.max_tokens,
382
+ }).encode("utf-8")
383
+ req = urllib.request.Request(
384
+ f"{self.base_url}/chat/completions",
385
+ data=payload,
386
+ headers={"Authorization": f"Bearer {self.api_key}", "Content-Type": "application/json"},
387
+ method="POST",
388
  )
389
+ try:
390
+ with urllib.request.urlopen(req, timeout=60) as resp:
391
+ data = json.loads(resp.read().decode("utf-8"))
392
+ except urllib.error.HTTPError as exc:
393
+ detail = exc.read().decode("utf-8", "replace")
394
+ raise RuntimeError(f"LLM HTTP {exc.code}: {_safe_error_text(detail)}") from exc
395
+ return data.get("choices", [{}])[0].get("message", {}).get("content", "")
396
 
 
 
 
 
 
 
397
 
398
+ def _safe_error(exc: Exception) -> str:
399
+ return _safe_error_text(str(exc))[:500]
400
+
401
+
402
+ def _safe_error_text(text: str) -> str:
403
+ return re.sub(r"sk-[A-Za-z0-9_-]+", "sk-***", text)
404
+
405
 
406
+ def _extract_json(text: str) -> dict | None:
407
+ try:
408
+ return json.loads(text)
409
+ except Exception:
410
+ match = re.search(r"\{.*\}", text, flags=re.S)
411
+ if not match:
412
+ return None
413
+ try:
414
+ return json.loads(match.group(0))
415
+ except Exception:
416
+ return None
agent/graph.py CHANGED
@@ -1,45 +1,32 @@
1
- """LangGraph state machine assembly for MOF screening pipeline."""
2
- from langgraph.graph import StateGraph, END
3
-
4
- from agent.state import ScreeningState
5
  from agent import nodes
 
 
 
 
 
 
 
 
 
 
 
 
6
 
7
 
8
  def build_graph():
9
- g = StateGraph(ScreeningState)
10
-
11
- g.add_node("ingest_cif", nodes.ingest_cif)
12
- g.add_node("planning_agent", nodes.planning_agent)
13
- g.add_node("tool_execution_agent", nodes.tool_execution_agent)
14
- g.add_node("compute_descriptor", nodes.compute_descriptor)
15
- g.add_node("predict_adsorption", nodes.predict_adsorption)
16
- g.add_node("extract_linker", nodes.extract_linker)
17
- g.add_node("predict_toxicity", nodes.predict_toxicity)
18
- g.add_node("apply_safety_rules", nodes.apply_safety_rules)
19
- g.add_node("evidence_audit_agent", nodes.evidence_audit_agent)
20
- g.add_node("repair_agent", nodes.repair_agent)
21
- g.add_node("safety_review_agent", nodes.safety_review_agent)
22
- g.add_node("reviewer_panel_agent", nodes.reviewer_panel_agent)
23
- g.add_node("decision_agent", nodes.decision_agent)
24
- g.add_node("report_agent", nodes.report_agent)
25
-
26
- g.set_entry_point("ingest_cif")
27
- g.add_edge("ingest_cif", "planning_agent")
28
- g.add_edge("planning_agent", "tool_execution_agent")
29
- g.add_edge("tool_execution_agent", "compute_descriptor")
30
- g.add_edge("compute_descriptor", "predict_adsorption")
31
- g.add_edge("predict_adsorption", "extract_linker")
32
- g.add_edge("extract_linker", "predict_toxicity")
33
- g.add_edge("predict_toxicity", "apply_safety_rules")
34
- g.add_edge("apply_safety_rules", "evidence_audit_agent")
35
- g.add_edge("evidence_audit_agent", "repair_agent")
36
- g.add_edge("repair_agent", "safety_review_agent")
37
- g.add_edge("safety_review_agent", "reviewer_panel_agent")
38
- g.add_edge("reviewer_panel_agent", "decision_agent")
39
- g.add_edge("decision_agent", "report_agent")
40
- g.add_edge("report_agent", END)
41
-
42
- return g.compile()
43
 
44
 
45
  screening_app = build_graph()
 
1
+ """Screening workflow assembly for the MOF six-step pipeline."""
 
 
 
2
  from agent import nodes
3
+ from agent.state import ScreeningState
4
+
5
+
6
+ class SimpleScreeningApp:
7
+ """Small invoke-compatible workflow used when LangGraph is unavailable."""
8
+
9
+ def invoke(self, state: ScreeningState) -> ScreeningState:
10
+ current: ScreeningState = dict(state)
11
+ for node in (nodes.ingest_cif, nodes.six_step_screening_agent):
12
+ update = node(current)
13
+ current.update(update)
14
+ return current
15
 
16
 
17
  def build_graph():
18
+ try:
19
+ from langgraph.graph import END, StateGraph
20
+
21
+ g = StateGraph(ScreeningState)
22
+ g.add_node("ingest_cif", nodes.ingest_cif)
23
+ g.add_node("six_step_screening", nodes.six_step_screening_agent)
24
+ g.set_entry_point("ingest_cif")
25
+ g.add_edge("ingest_cif", "six_step_screening")
26
+ g.add_edge("six_step_screening", END)
27
+ return g.compile()
28
+ except Exception:
29
+ return SimpleScreeningApp()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
30
 
31
 
32
  screening_app = build_graph()
agent/nodes.py CHANGED
@@ -101,6 +101,48 @@ def ingest_cif(state: ScreeningState) -> dict:
101
  return {"mof_id": mof_id, "warnings": warnings, "errors": errors}
102
 
103
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
104
  def planning_agent(state: ScreeningState) -> dict:
105
  """Create a task card before deterministic tools are executed."""
106
  warnings = list(state.get("warnings", []))
 
101
  return {"mof_id": mof_id, "warnings": warnings, "errors": errors}
102
 
103
 
104
+ def six_step_screening_agent(state: ScreeningState) -> dict:
105
+ """Run online screening tools for an uploaded CIF."""
106
+ warnings = list(state.get("warnings", []))
107
+ errors = list(state.get("errors", []))
108
+ if errors:
109
+ return {"warnings": warnings, "errors": errors}
110
+
111
+ try:
112
+ from tools.online_screening import ACTIVE_TOOLS, run_online_tools
113
+
114
+ result = run_online_tools(
115
+ state.get("cif_path"),
116
+ list(ACTIVE_TOOLS),
117
+ )
118
+ return {
119
+ **result,
120
+ "warnings": warnings + list(result.get("warnings", [])),
121
+ "errors": errors + list(result.get("errors", [])),
122
+ "agent_trace": _append_trace(
123
+ state,
124
+ "Online Screening Agent",
125
+ "ran online CIF screening tools",
126
+ {
127
+ "gate_status": result.get("gate_status"),
128
+ "recommendation": result.get("recommendation"),
129
+ },
130
+ ) + list(result.get("agent_trace", [])),
131
+ }
132
+ except Exception as e:
133
+ errors.append(f"online_screening failed: {e}")
134
+ return {
135
+ "warnings": warnings,
136
+ "errors": errors,
137
+ "agent_trace": _append_trace(
138
+ state,
139
+ "Online Screening Agent",
140
+ "online screening failed",
141
+ {"error": str(e)},
142
+ ),
143
+ }
144
+
145
+
146
  def planning_agent(state: ScreeningState) -> dict:
147
  """Create a task card before deterministic tools are executed."""
148
  warnings = list(state.get("warnings", []))
agent/state.py CHANGED
@@ -4,6 +4,7 @@ from typing import TypedDict
4
 
5
  class ScreeningState(TypedDict, total=False):
6
  cif_path: str
 
7
  mof_id: str
8
  task_card: dict | None
9
  execution_plan: dict | None
@@ -13,6 +14,10 @@ class ScreeningState(TypedDict, total=False):
13
  linker: dict | None
14
  toxicity: dict | None
15
  safety: dict | None
 
 
 
 
16
  evidence_ledger: list[dict]
17
  audit_report: dict | None
18
  repair_report: dict | None
 
4
 
5
  class ScreeningState(TypedDict, total=False):
6
  cif_path: str
7
+ user_request: str
8
  mof_id: str
9
  task_card: dict | None
10
  execution_plan: dict | None
 
14
  linker: dict | None
15
  toxicity: dict | None
16
  safety: dict | None
17
+ six_step: dict | None
18
+ gate_status: str | None
19
+ failed_at_step: int | None
20
+ row: dict | None
21
  evidence_ledger: list[dict]
22
  audit_report: dict | None
23
  repair_report: dict | None
backend/main.py CHANGED
@@ -9,6 +9,7 @@ import math
9
  import os
10
  import sys
11
  import tempfile
 
12
  from datetime import datetime, timezone
13
  from pathlib import Path
14
  from typing import Any
@@ -16,13 +17,14 @@ from typing import Any
16
  import numpy as np
17
  from fastapi import FastAPI, File, Form, UploadFile
18
  from fastapi.middleware.cors import CORSMiddleware
 
19
  from pydantic import BaseModel
20
 
21
  ROOT = Path(__file__).resolve().parent.parent
22
  sys.path.insert(0, str(ROOT))
23
 
24
- from agent.conversation import run_conversational_screening
25
- from agent.graph import screening_app
26
 
27
 
28
  app = FastAPI(title="MOFScreen-Agent API", version="1.0.0")
@@ -82,6 +84,17 @@ def _tox_display(value: Any, confidence: str | None) -> str:
82
 
83
 
84
  def _result_row(result: dict[str, Any], mof_id: str | None = None) -> dict[str, Any]:
 
 
 
 
 
 
 
 
 
 
 
85
  selective = result.get("results") or {}
86
  adsorption = selective.get("adsorption") or result.get("adsorption") or {}
87
  toxicity = selective.get("toxicity") or result.get("toxicity") or {}
@@ -117,7 +130,6 @@ def _result_row(result: dict[str, Any], mof_id: str | None = None) -> dict[str,
117
  toxicity.get("IBC50_Vibrio_confidence"),
118
  ),
119
  "Safety": safety.get("metal_tier", "N/A") if safety else "N/A",
120
- "PMT": "Pass" if safety and safety.get("pmt_pass", True) else "N/A",
121
  "Score": result.get("final_score"),
122
  "Decision Class": (result.get("decision_record") or {}).get("decision_class", "N/A"),
123
  "Recommendation": result.get("recommendation", "N/A"),
@@ -137,6 +149,25 @@ def _frontend_result(result: dict[str, Any]) -> dict[str, Any]:
137
  return compact
138
 
139
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
140
  def _run_files(
141
  file_specs: list[tuple[str, bytes]],
142
  user_request: str,
@@ -156,23 +187,13 @@ def _run_files(
156
  tmp_path = str(Path(tmp_dir) / safe_name)
157
  Path(tmp_path).write_bytes(content)
158
  try:
159
- if mode == "selective_tools":
160
- result = run_conversational_screening(
161
- user_request=user_request,
162
- cif_path=tmp_path,
163
- llm_provider=llm_provider,
164
- llm_api_key=llm_api_key,
165
- )
166
- mof_id = Path(filename or tmp_path).stem
167
- else:
168
- result = screening_app.invoke({
169
- "cif_path": tmp_path,
170
- "llm_provider": llm_provider,
171
- "llm_api_key": llm_api_key,
172
- "warnings": [],
173
- "errors": [],
174
- })
175
- mof_id = result.get("mof_id") or Path(filename or tmp_path).stem
176
  result["uploaded_filename"] = filename
177
  result["display_mof_id"] = mof_id
178
  rows.append(_result_row(result, mof_id=mof_id))
@@ -187,11 +208,19 @@ def _run_files(
187
  results.append(failed)
188
  rows.append({
189
  "MOF ID": failed["display_mof_id"],
 
 
 
 
 
 
 
 
 
 
 
190
  "Recommendation": "error",
191
- "Decision Class": "N/A",
192
  "Score": None,
193
- "Safety": "N/A",
194
- "PMT": "N/A",
195
  })
196
 
197
  return _clean_json({"rows": rows, "results": results, "count": len(results)})
@@ -222,23 +251,13 @@ async def screen(
222
  tmp_path = str(Path(tmp_dir) / safe_name)
223
  Path(tmp_path).write_bytes(await uploaded.read())
224
  try:
225
- if mode == "selective_tools":
226
- result = run_conversational_screening(
227
- user_request=user_request,
228
- cif_path=tmp_path,
229
- llm_provider=llm_provider,
230
- llm_api_key=llm_api_key,
231
- )
232
- mof_id = Path(uploaded.filename or tmp_path).stem
233
- else:
234
- result = screening_app.invoke({
235
- "cif_path": tmp_path,
236
- "llm_provider": llm_provider,
237
- "llm_api_key": llm_api_key,
238
- "warnings": [],
239
- "errors": [],
240
- })
241
- mof_id = result.get("mof_id") or Path(uploaded.filename or tmp_path).stem
242
  result["uploaded_filename"] = uploaded.filename
243
  result["display_mof_id"] = mof_id
244
  rows.append(_result_row(result, mof_id=mof_id))
@@ -253,11 +272,19 @@ async def screen(
253
  results.append(failed)
254
  rows.append({
255
  "MOF ID": failed["display_mof_id"],
 
 
 
 
 
 
 
 
 
 
 
256
  "Recommendation": "error",
257
- "Decision Class": "N/A",
258
  "Score": None,
259
- "Safety": "N/A",
260
- "PMT": "N/A",
261
  })
262
 
263
  return _clean_json({
@@ -267,6 +294,66 @@ async def screen(
267
  })
268
 
269
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
270
  @app.post("/jobs/{job_id}/run")
271
  async def run_job(job_id: str, payload: RunJobRequest | None = None) -> dict[str, Any]:
272
  supabase = _supabase_admin()
 
9
  import os
10
  import sys
11
  import tempfile
12
+ import json
13
  from datetime import datetime, timezone
14
  from pathlib import Path
15
  from typing import Any
 
17
  import numpy as np
18
  from fastapi import FastAPI, File, Form, UploadFile
19
  from fastapi.middleware.cors import CORSMiddleware
20
+ from fastapi.responses import StreamingResponse
21
  from pydantic import BaseModel
22
 
23
  ROOT = Path(__file__).resolve().parent.parent
24
  sys.path.insert(0, str(ROOT))
25
 
26
+ from agent.conversation import run_conversational_screening, stream_llm_result_response
27
+ from tools.six_step_screening import build_result_row
28
 
29
 
30
  app = FastAPI(title="MOFScreen-Agent API", version="1.0.0")
 
84
 
85
 
86
  def _result_row(result: dict[str, Any], mof_id: str | None = None) -> dict[str, Any]:
87
+ if isinstance(result.get("row"), dict):
88
+ row = dict(result["row"])
89
+ if mof_id:
90
+ row["MOF ID"] = row.get("MOF ID") or mof_id
91
+ return row
92
+ if isinstance(result.get("six_step"), dict):
93
+ row = build_result_row(result)
94
+ if mof_id:
95
+ row["MOF ID"] = row.get("MOF ID") or mof_id
96
+ return row
97
+
98
  selective = result.get("results") or {}
99
  adsorption = selective.get("adsorption") or result.get("adsorption") or {}
100
  toxicity = selective.get("toxicity") or result.get("toxicity") or {}
 
130
  toxicity.get("IBC50_Vibrio_confidence"),
131
  ),
132
  "Safety": safety.get("metal_tier", "N/A") if safety else "N/A",
 
133
  "Score": result.get("final_score"),
134
  "Decision Class": (result.get("decision_record") or {}).get("decision_class", "N/A"),
135
  "Recommendation": result.get("recommendation", "N/A"),
 
149
  return compact
150
 
151
 
152
+ def _sse(event: str, data: Any) -> str:
153
+ return f"event: {event}\ndata: {json.dumps(_clean_json(data), ensure_ascii=False)}\n\n"
154
+
155
+
156
+ def _tool_summary(name: str, payload: dict[str, Any]) -> dict[str, Any]:
157
+ return {
158
+ "tool": name,
159
+ "status": payload.get("status"),
160
+ "step": payload.get("step"),
161
+ "summary": payload.get("summary")
162
+ or payload.get("reason")
163
+ or payload.get("price_status")
164
+ or payload.get("mof")
165
+ or payload.get("linker_smiles")
166
+ or payload.get("status"),
167
+ "result": payload,
168
+ }
169
+
170
+
171
  def _run_files(
172
  file_specs: list[tuple[str, bytes]],
173
  user_request: str,
 
187
  tmp_path = str(Path(tmp_dir) / safe_name)
188
  Path(tmp_path).write_bytes(content)
189
  try:
190
+ result = run_conversational_screening(
191
+ user_request=user_request,
192
+ cif_path=tmp_path,
193
+ llm_provider=llm_provider,
194
+ llm_api_key=llm_api_key,
195
+ )
196
+ mof_id = result.get("mof_id") or Path(filename or tmp_path).stem
 
 
 
 
 
 
 
 
 
 
197
  result["uploaded_filename"] = filename
198
  result["display_mof_id"] = mof_id
199
  rows.append(_result_row(result, mof_id=mof_id))
 
208
  results.append(failed)
209
  rows.append({
210
  "MOF ID": failed["display_mof_id"],
211
+ "Benzene (mg/g)": None,
212
+ "Toluene (mg/g)": None,
213
+ "Metal status": "N/A",
214
+ "Metals": "N/A",
215
+ "Linker": "N/A",
216
+ "Linker SMILES": "N/A",
217
+ "SA score": None,
218
+ "Mean toxicity": None,
219
+ "Price USD/g": None,
220
+ "Price USD/mmol": None,
221
+ "Gate status": "error",
222
  "Recommendation": "error",
 
223
  "Score": None,
 
 
224
  })
225
 
226
  return _clean_json({"rows": rows, "results": results, "count": len(results)})
 
251
  tmp_path = str(Path(tmp_dir) / safe_name)
252
  Path(tmp_path).write_bytes(await uploaded.read())
253
  try:
254
+ result = run_conversational_screening(
255
+ user_request=user_request,
256
+ cif_path=tmp_path,
257
+ llm_provider=llm_provider,
258
+ llm_api_key=llm_api_key,
259
+ )
260
+ mof_id = result.get("mof_id") or Path(uploaded.filename or tmp_path).stem
 
 
 
 
 
 
 
 
 
 
261
  result["uploaded_filename"] = uploaded.filename
262
  result["display_mof_id"] = mof_id
263
  rows.append(_result_row(result, mof_id=mof_id))
 
272
  results.append(failed)
273
  rows.append({
274
  "MOF ID": failed["display_mof_id"],
275
+ "Benzene (mg/g)": None,
276
+ "Toluene (mg/g)": None,
277
+ "Metal status": "N/A",
278
+ "Metals": "N/A",
279
+ "Linker": "N/A",
280
+ "Linker SMILES": "N/A",
281
+ "SA score": None,
282
+ "Mean toxicity": None,
283
+ "Price USD/g": None,
284
+ "Price USD/mmol": None,
285
+ "Gate status": "error",
286
  "Recommendation": "error",
 
287
  "Score": None,
 
 
288
  })
289
 
290
  return _clean_json({
 
294
  })
295
 
296
 
297
+ @app.post("/chat/stream")
298
+ async def chat_stream(
299
+ files: list[UploadFile] | None = File(None),
300
+ user_request: str = Form("Run full MOF screening."),
301
+ mode: str = Form("full_screening"),
302
+ llm_provider: str = Form("rule_based"),
303
+ llm_api_key: str | None = Form(None),
304
+ ):
305
+ file_specs: list[tuple[str, bytes]] = []
306
+ for uploaded in files or []:
307
+ file_specs.append((uploaded.filename or "candidate.cif", await uploaded.read()))
308
+
309
+ async def events():
310
+ if not file_specs:
311
+ result = run_conversational_screening(
312
+ user_request=user_request,
313
+ cif_path=None,
314
+ llm_provider=llm_provider,
315
+ llm_api_key=llm_api_key,
316
+ final_llm=False,
317
+ )
318
+ for chunk in stream_llm_result_response(user_request, result, llm_provider, llm_api_key):
319
+ yield _sse("assistant_delta", chunk)
320
+ yield _sse("final_result", {"rows": [], "results": [_frontend_result(result)], "count": 1})
321
+ return
322
+
323
+ rows: list[dict[str, Any]] = []
324
+ results: list[dict[str, Any]] = []
325
+ for filename, content in file_specs:
326
+ safe_name = Path(filename or "candidate.cif").name
327
+ if not safe_name.lower().endswith(".cif"):
328
+ safe_name = f"{Path(safe_name).stem or 'candidate'}.cif"
329
+ with tempfile.TemporaryDirectory() as tmp_dir:
330
+ tmp_path = str(Path(tmp_dir) / safe_name)
331
+ Path(tmp_path).write_bytes(content)
332
+ result = run_conversational_screening(
333
+ user_request=user_request,
334
+ cif_path=tmp_path,
335
+ llm_provider=llm_provider,
336
+ llm_api_key=llm_api_key,
337
+ final_llm=False,
338
+ )
339
+ mof_id = result.get("mof_id") or Path(filename or tmp_path).stem
340
+ result["uploaded_filename"] = filename
341
+ result["display_mof_id"] = mof_id
342
+ for name, payload in (result.get("six_step") or result.get("results") or {}).items():
343
+ if isinstance(payload, dict):
344
+ yield _sse("tool_start", {"tool": name})
345
+ yield _sse("tool_result", _tool_summary(name, payload))
346
+ text_result = dict(result)
347
+ text_result["assistant_message"] = None
348
+ for chunk in stream_llm_result_response(user_request, text_result, llm_provider, llm_api_key):
349
+ yield _sse("assistant_delta", chunk)
350
+ rows.append(_result_row(result, mof_id=mof_id))
351
+ results.append(_clean_json(_frontend_result(result)))
352
+ yield _sse("final_result", {"rows": rows, "results": results, "count": len(results)})
353
+
354
+ return StreamingResponse(events(), media_type="text/event-stream")
355
+
356
+
357
  @app.post("/jobs/{job_id}/run")
358
  async def run_job(job_id: str, payload: RunJobRequest | None = None) -> dict[str, Any]:
359
  supabase = _supabase_admin()
tools/descriptors.py CHANGED
@@ -2,9 +2,13 @@
2
  from pathlib import Path
3
 
4
  import numpy as np
5
- from matminer.featurizers.structure.matrix import SineCoulombMatrix
6
  from pymatgen.core import Structure
7
 
 
 
 
 
 
8
 
9
  def compute_scm_eigenvalues(
10
  cif_path: str,
@@ -37,9 +41,12 @@ def compute_scm_eigenvalues(
37
  if structure.num_sites == 0:
38
  raise ValueError(f"CIF parsed but contains 0 sites: {cif_path}")
39
 
40
- scm = SineCoulombMatrix(flatten=False)
41
- scm.fit([structure])
42
- matrix = scm.featurize(structure)[0]
 
 
 
43
 
44
  eigenvalues = np.sort(np.linalg.eigvalsh(matrix))[::-1]
45
  raw_dim = len(eigenvalues)
@@ -72,3 +79,20 @@ def compute_scm_eigenvalues(
72
  "truncated": truncated,
73
  "applicability_warning": warning,
74
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2
  from pathlib import Path
3
 
4
  import numpy as np
 
5
  from pymatgen.core import Structure
6
 
7
+ try:
8
+ from matminer.featurizers.structure.matrix import SineCoulombMatrix
9
+ except Exception:
10
+ SineCoulombMatrix = None
11
+
12
 
13
  def compute_scm_eigenvalues(
14
  cif_path: str,
 
41
  if structure.num_sites == 0:
42
  raise ValueError(f"CIF parsed but contains 0 sites: {cif_path}")
43
 
44
+ if SineCoulombMatrix is not None:
45
+ scm = SineCoulombMatrix(flatten=False)
46
+ scm.fit([structure])
47
+ matrix = scm.featurize(structure)[0]
48
+ else:
49
+ matrix = _fallback_scm_matrix(structure)
50
 
51
  eigenvalues = np.sort(np.linalg.eigvalsh(matrix))[::-1]
52
  raw_dim = len(eigenvalues)
 
79
  "truncated": truncated,
80
  "applicability_warning": warning,
81
  }
82
+
83
+
84
+ def _fallback_scm_matrix(structure: Structure) -> np.ndarray:
85
+ """Small deterministic SCM-like matrix used when matminer is unavailable."""
86
+ size = structure.num_sites
87
+ matrix = np.zeros((size, size), dtype=float)
88
+ for i, site_i in enumerate(structure):
89
+ z_i = float(site_i.specie.Z)
90
+ matrix[i, i] = 0.5 * z_i ** 2.4
91
+ for j in range(i + 1, size):
92
+ site_j = structure[j]
93
+ z_j = float(site_j.specie.Z)
94
+ distance = max(float(structure.get_distance(i, j)), 1e-6)
95
+ value = z_i * z_j / distance
96
+ matrix[i, j] = value
97
+ matrix[j, i] = value
98
+ return matrix
tools/online_screening.py ADDED
@@ -0,0 +1,364 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Online tools for evaluating an arbitrary uploaded CIF."""
2
+ from __future__ import annotations
3
+
4
+ import importlib.util
5
+ import math
6
+ import sys
7
+ from functools import lru_cache
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ import numpy as np
12
+ from pymatgen.core import Structure
13
+ from rdkit import Chem
14
+
15
+ from tools.adsorption import predict_benzene, predict_toluene
16
+ from tools.descriptors import compute_scm_eigenvalues
17
+ from tools.linker_extraction import extract_linker
18
+ from tools.toxicity import predict_toxicity
19
+
20
+
21
+ ROOT = Path(__file__).resolve().parent.parent
22
+ ALL_DES_ROOT = ROOT.parent
23
+ DOC_ROOT = ALL_DES_ROOT.parent
24
+ SA_DIR = ALL_DES_ROOT / "0629_revise" / "Screening" / "筛选顺序" / "筛选顺序" / "2 配体可合成性"
25
+ PRICE_ROOT = DOC_ROOT / "price_filter"
26
+ COPRINET_ROOT = PRICE_ROOT / "CoPriNet-main"
27
+ PYG201 = PRICE_ROOT / ".deps" / "pyg201_nodeps"
28
+
29
+ ACTIVE_TOOLS = [
30
+ "parse_cif",
31
+ "predict_adsorption",
32
+ "identify_linker",
33
+ "check_metals",
34
+ "predict_sa_score",
35
+ "predict_aquatic_toxicity",
36
+ "predict_price",
37
+ ]
38
+
39
+ ALLOWED_METALS = {"Mg", "Al", "Ca", "Ti", "Mn", "Fe", "Cu", "Zn", "Zr", "Ag"}
40
+ ALL_METALS = {
41
+ "Li", "Be", "Na", "Mg", "Al", "K", "Ca", "Sc", "Ti", "V", "Cr", "Mn", "Fe", "Co", "Ni",
42
+ "Cu", "Zn", "Ga", "Rb", "Sr", "Y", "Zr", "Nb", "Mo", "Tc", "Ru", "Rh", "Pd", "Ag", "Cd",
43
+ "In", "Sn", "Cs", "Ba", "La", "Ce", "Pr", "Nd", "Pm", "Sm", "Eu", "Gd", "Tb", "Dy", "Ho",
44
+ "Er", "Tm", "Yb", "Lu", "Hf", "Ta", "W", "Re", "Os", "Ir", "Pt", "Au", "Hg", "Tl", "Pb",
45
+ "Bi", "Po", "Fr", "Ra", "Ac", "Th", "Pa", "U", "Np", "Pu",
46
+ }
47
+
48
+
49
+ def _jsonable(value: Any) -> Any:
50
+ if isinstance(value, dict):
51
+ return {str(k): _jsonable(v) for k, v in value.items()}
52
+ if isinstance(value, list | tuple):
53
+ return [_jsonable(v) for v in value]
54
+ if isinstance(value, np.ndarray):
55
+ return _jsonable(value.tolist())
56
+ if isinstance(value, np.generic):
57
+ return _jsonable(value.item())
58
+ if isinstance(value, float) and (math.isnan(value) or math.isinf(value)):
59
+ return None
60
+ if isinstance(value, Path):
61
+ return str(value)
62
+ return value
63
+
64
+
65
+ def _error(tool: str, message: str) -> dict[str, Any]:
66
+ return {"tool": tool, "status": "error", "summary": message, "error": message}
67
+
68
+
69
+ def _needs_linker(tool: str) -> dict[str, Any]:
70
+ return {"tool": tool, "status": "unavailable", "summary": "A linker SMILES must be identified first.", "pass": None}
71
+
72
+
73
+ def parse_cif_tool(cif_path: str) -> dict[str, Any]:
74
+ try:
75
+ structure = Structure.from_file(cif_path)
76
+ elements = sorted({site.specie.symbol for site in structure})
77
+ metals = [element for element in elements if element in ALL_METALS]
78
+ return {
79
+ "tool": "parse_cif",
80
+ "status": "success",
81
+ "formula": structure.composition.reduced_formula,
82
+ "num_sites": structure.num_sites,
83
+ "elements": elements,
84
+ "metals": metals,
85
+ "summary": f"Parsed {structure.num_sites} atomic sites; metal elements: {', '.join(metals) or 'none'}",
86
+ }
87
+ except Exception as exc:
88
+ return _error("parse_cif", f"CIF parsing failed: {exc}")
89
+
90
+
91
+ def predict_adsorption_tool(cif_path: str) -> dict[str, Any]:
92
+ try:
93
+ b_desc = compute_scm_eigenvalues(cif_path, 520)
94
+ t_desc = compute_scm_eigenvalues(cif_path, 584)
95
+ benzene = predict_benzene(b_desc["eigenvalues"])
96
+ toluene = predict_toluene(t_desc["eigenvalues"])
97
+ warnings = [w for w in [b_desc.get("applicability_warning"), t_desc.get("applicability_warning")] if w]
98
+ return {
99
+ "tool": "predict_adsorption",
100
+ "status": "success",
101
+ "benzene_uptake_mg_g": benzene["uptake_mg_g"],
102
+ "toluene_uptake_mg_g": toluene["uptake_mg_g"],
103
+ "benzene_model": benzene["model_version"],
104
+ "toluene_model": toluene["model_version"],
105
+ "descriptor_raw_dim_benzene": b_desc["raw_dim"],
106
+ "descriptor_raw_dim_toluene": t_desc["raw_dim"],
107
+ "warnings": warnings,
108
+ "summary": f"Benzene {benzene['uptake_mg_g']} mg/g; toluene {toluene['uptake_mg_g']} mg/g",
109
+ }
110
+ except Exception as exc:
111
+ return _error("predict_adsorption", f"Adsorption prediction failed: {exc}")
112
+
113
+
114
+ def identify_linker_tool(cif_path: str) -> dict[str, Any]:
115
+ try:
116
+ result = extract_linker(cif_path)
117
+ status = "success" if result.get("linker_smiles") else "unavailable"
118
+ return {
119
+ "tool": "identify_linker",
120
+ "status": status,
121
+ "metals": result.get("metals") or [],
122
+ "linker_smiles": result.get("linker_smiles"),
123
+ "linker_name": result.get("linker_name"),
124
+ "linker_formula": result.get("linker_formula"),
125
+ "extraction_level": result.get("extraction_level"),
126
+ "extraction_note": result.get("extraction_note"),
127
+ "summary": result.get("extraction_note"),
128
+ }
129
+ except Exception as exc:
130
+ return _error("identify_linker", f"Linker identification failed: {exc}")
131
+
132
+
133
+ def check_metals_tool(context: dict[str, Any], cif_path: str) -> dict[str, Any]:
134
+ parse = context.get("parse_cif")
135
+ if not parse or parse.get("status") == "error":
136
+ parse = parse_cif_tool(cif_path)
137
+ metals = parse.get("metals") or []
138
+ illegal = [metal for metal in metals if metal not in ALLOWED_METALS]
139
+ passed = len(illegal) == 0
140
+ return {
141
+ "tool": "check_metals",
142
+ "status": "pass" if passed else "fail",
143
+ "pass": passed,
144
+ "detected_metals": metals,
145
+ "illegal_metals": illegal,
146
+ "allowed_metals": sorted(ALLOWED_METALS),
147
+ "summary": "Metal check passed" if passed else f"Disallowed metals detected: {', '.join(illegal)}",
148
+ }
149
+
150
+
151
+ @lru_cache(maxsize=1)
152
+ def _sa_module():
153
+ path = SA_DIR / "sascore" / "sascorer.py"
154
+ spec = importlib.util.spec_from_file_location("mofscreen_sascorer", path)
155
+ if spec is None or spec.loader is None:
156
+ raise RuntimeError(f"cannot load sascorer: {path}")
157
+ module = importlib.util.module_from_spec(spec)
158
+ spec.loader.exec_module(module)
159
+ return module
160
+
161
+
162
+ def predict_sa_score_tool(context: dict[str, Any]) -> dict[str, Any]:
163
+ smiles = _linker_smiles(context)
164
+ if not smiles:
165
+ return _needs_linker("predict_sa_score")
166
+ mol = Chem.MolFromSmiles(smiles)
167
+ if mol is None:
168
+ return _error("predict_sa_score", f"Invalid linker SMILES: {smiles}")
169
+ try:
170
+ score = float(_sa_module().calculateScore(mol))
171
+ return {
172
+ "tool": "predict_sa_score",
173
+ "status": "success",
174
+ "linker_smiles": smiles,
175
+ "sa_score": round(score, 3),
176
+ "scale": "1 easy, 10 hard",
177
+ "easy_synthesis": score <= 6.0,
178
+ "summary": f"SA score {score:.3f} (1 easy to synthesize, 10 hard to synthesize)",
179
+ }
180
+ except Exception as exc:
181
+ return _error("predict_sa_score", f"SA score calculation failed: {exc}")
182
+
183
+
184
+ def predict_aquatic_toxicity_tool(context: dict[str, Any]) -> dict[str, Any]:
185
+ smiles = _linker_smiles(context)
186
+ if not smiles:
187
+ return _needs_linker("predict_aquatic_toxicity")
188
+ try:
189
+ tox = predict_toxicity(smiles)
190
+ values = [
191
+ tox.get("IBC50_Vibrio"),
192
+ tox.get("IGC50_Tetrahymena"),
193
+ tox.get("LC50_Pimephales"),
194
+ tox.get("LC50_Daphnia"),
195
+ ]
196
+ present = [float(v) for v in values if v is not None]
197
+ status = "success" if present else "unavailable"
198
+ tox.update({
199
+ "tool": "predict_aquatic_toxicity",
200
+ "status": status,
201
+ "linker_smiles": smiles,
202
+ "mean_toxicity": round(sum(present) / len(present), 4) if present else None,
203
+ "worst_toxicity": max(present) if present else None,
204
+ "summary": "Aquatic toxicity prediction completed" if present else "The toxicity model did not return valid endpoints",
205
+ })
206
+ return tox
207
+ except Exception as exc:
208
+ return _error("predict_aquatic_toxicity", f"Aquatic toxicity prediction failed: {exc}")
209
+
210
+
211
+ @lru_cache(maxsize=1)
212
+ def _price_predictor():
213
+ if str(PYG201) not in sys.path:
214
+ sys.path.insert(0, str(PYG201))
215
+ if str(COPRINET_ROOT) not in sys.path:
216
+ sys.path.insert(0, str(COPRINET_ROOT))
217
+
218
+ import torch
219
+
220
+ if not getattr(torch.load, "_mofscreen_weights_patch", False):
221
+ original_load = torch.load
222
+
223
+ def load_with_trusted_checkpoint(*args, **kwargs):
224
+ # ponytail: local trusted CoPriNet checkpoint; remove when upstream supports torch>=2.6 defaults.
225
+ kwargs.setdefault("weights_only", False)
226
+ return original_load(*args, **kwargs)
227
+
228
+ load_with_trusted_checkpoint._mofscreen_weights_patch = True
229
+ torch.load = load_with_trusted_checkpoint
230
+
231
+ from pricePrediction.predict.predict import GraphPricePredictor
232
+
233
+ return GraphPricePredictor(n_gpus=0, n_cpus=0, batch_size=1)
234
+
235
+
236
+ def predict_price_tool(context: dict[str, Any]) -> dict[str, Any]:
237
+ smiles = _linker_smiles(context)
238
+ if not smiles:
239
+ return _needs_linker("predict_price")
240
+ try:
241
+ pred = float(next(_price_predictor().yieldPredictions([smiles])))
242
+ mol = Chem.MolFromSmiles(smiles)
243
+ mw = Chem.Descriptors.ExactMolWt(mol) if mol is not None else None
244
+ usd_per_mmol = math.exp(pred)
245
+ usd_per_g = usd_per_mmol * 1000 / mw if mw else None
246
+ return {
247
+ "tool": "predict_price",
248
+ "status": "success",
249
+ "linker_smiles": smiles,
250
+ "coprinet_log_usd_per_mmol": round(pred, 6),
251
+ "usd_per_mmol": round(usd_per_mmol, 6),
252
+ "usd_per_g": round(usd_per_g, 6) if usd_per_g is not None else None,
253
+ "exact_mol_wt": round(mw, 6) if mw else None,
254
+ "summary": f"CoPriNet predicted price {usd_per_mmol:.3f} USD/mmol",
255
+ }
256
+ except Exception as exc:
257
+ return {"tool": "predict_price", "status": "unavailable", "summary": f"CoPriNet price prediction unavailable: {exc}", "pass": None}
258
+
259
+
260
+ def run_online_tools(cif_path: str, requested_tools: list[str]) -> dict[str, Any]:
261
+ context: dict[str, Any] = {}
262
+ trace: list[dict[str, Any]] = []
263
+ tools = _with_dependencies(requested_tools)
264
+ for name in tools:
265
+ result = _run_one(name, context, cif_path)
266
+ context[name] = _jsonable(result)
267
+ trace.append({"agent": "Tool Execution Agent", "action": f"ran {name}", "output": context[name]})
268
+ row = _row(context, Path(cif_path).stem)
269
+ return _jsonable({
270
+ "mof_id": Path(cif_path).stem,
271
+ "results": context,
272
+ "tool_results": context,
273
+ "row": row,
274
+ "agent_trace": trace,
275
+ "gate_status": _gate_status(context),
276
+ "recommendation": _recommendation(context),
277
+ "final_score": _score(_gate_status(context)),
278
+ "explanation": _explanation(context),
279
+ "warnings": [],
280
+ "errors": [],
281
+ })
282
+
283
+
284
+ def _run_one(name: str, context: dict[str, Any], cif_path: str) -> dict[str, Any]:
285
+ if name == "parse_cif":
286
+ return parse_cif_tool(cif_path)
287
+ if name == "predict_adsorption":
288
+ return predict_adsorption_tool(cif_path)
289
+ if name == "identify_linker":
290
+ return identify_linker_tool(cif_path)
291
+ if name == "check_metals":
292
+ return check_metals_tool(context, cif_path)
293
+ if name == "predict_sa_score":
294
+ return predict_sa_score_tool(context)
295
+ if name == "predict_aquatic_toxicity":
296
+ return predict_aquatic_toxicity_tool(context)
297
+ if name == "predict_price":
298
+ return predict_price_tool(context)
299
+ return _error(name, f"Unknown tool: {name}")
300
+
301
+
302
+ def _with_dependencies(tools: list[str]) -> list[str]:
303
+ out: list[str] = []
304
+ for tool in tools:
305
+ if tool in {"check_metals"}:
306
+ out.append("parse_cif")
307
+ if tool in {"predict_sa_score", "predict_aquatic_toxicity", "predict_price"}:
308
+ out.append("identify_linker")
309
+ out.append(tool)
310
+ return list(dict.fromkeys(out))
311
+
312
+
313
+ def _linker_smiles(context: dict[str, Any]) -> str | None:
314
+ linker = context.get("identify_linker") or {}
315
+ smiles = linker.get("linker_smiles")
316
+ return str(smiles) if smiles else None
317
+
318
+
319
+ def _gate_status(results: dict[str, Any]) -> str:
320
+ if any(r.get("status") == "fail" for r in results.values()):
321
+ return "failed"
322
+ if any(r.get("status") in {"error", "unavailable"} for r in results.values()):
323
+ return "incomplete"
324
+ return "completed"
325
+
326
+
327
+ def _recommendation(results: dict[str, Any]) -> str:
328
+ if any(r.get("status") == "fail" for r in results.values()):
329
+ return "not_recommended"
330
+ if any(r.get("status") in {"error", "unavailable"} for r in results.values()):
331
+ return "needs_manual_review"
332
+ return "continue_evaluation"
333
+
334
+
335
+ def _score(status: str) -> float:
336
+ return {"completed": 10.0, "incomplete": 5.0, "failed": 0.0}.get(status, 5.0)
337
+
338
+
339
+ def _explanation(results: dict[str, Any]) -> str:
340
+ return "; ".join(f"{name}: {payload.get('status')}" for name, payload in results.items())
341
+
342
+
343
+ def _row(results: dict[str, Any], mof_id: str) -> dict[str, Any]:
344
+ adsorption = results.get("predict_adsorption") or {}
345
+ metals = results.get("check_metals") or {}
346
+ linker = results.get("identify_linker") or {}
347
+ sa = results.get("predict_sa_score") or {}
348
+ tox = results.get("predict_aquatic_toxicity") or {}
349
+ price = results.get("predict_price") or {}
350
+ return {
351
+ "MOF ID": mof_id,
352
+ "Benzene (mg/g)": adsorption.get("benzene_uptake_mg_g"),
353
+ "Toluene (mg/g)": adsorption.get("toluene_uptake_mg_g"),
354
+ "Metal status": metals.get("status"),
355
+ "Metals": ", ".join(metals.get("detected_metals") or []),
356
+ "Linker": linker.get("linker_name") or linker.get("linker_formula"),
357
+ "Linker SMILES": linker.get("linker_smiles"),
358
+ "SA score": sa.get("sa_score"),
359
+ "Mean toxicity": tox.get("mean_toxicity"),
360
+ "Price USD/g": price.get("usd_per_g"),
361
+ "Price USD/mmol": price.get("usd_per_mmol"),
362
+ "Gate status": _gate_status(results),
363
+ "Recommendation": _recommendation(results),
364
+ }
tools/six_step_screening.py ADDED
@@ -0,0 +1,550 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Deterministic six-step MOF screening tools backed by precomputed tables."""
2
+ from __future__ import annotations
3
+
4
+ import math
5
+ import re
6
+ from functools import lru_cache
7
+ from pathlib import Path
8
+ from typing import Any
9
+
10
+ import joblib
11
+ import numpy as np
12
+ import pandas as pd
13
+
14
+
15
+ ROOT = Path(__file__).resolve().parent.parent
16
+ ALL_DES_ROOT = ROOT.parent
17
+ DOC_ROOT = ALL_DES_ROOT.parent
18
+ REVISE_ROOT = ALL_DES_ROOT / "0629_revise"
19
+ ORDER_ROOT = REVISE_ROOT / "Screening" / "筛选顺序" / "筛选顺序"
20
+ PRICE_ROOT = DOC_ROOT / "price_filter" / "mof_price_0707"
21
+
22
+ STEP1_FULL = REVISE_ROOT / "screen_result" / "all_mofs_sorted_by_balanced_score_with_YEYVOO_clean5b_only.csv"
23
+ STEP1_TOP20 = REVISE_ROOT / "screen_result" / "top20_percent_mofs_by_balanced_score_with_YEYVOO_clean5b_only.csv"
24
+ STEP2_DIR = ORDER_ROOT / "1重金属结果"
25
+ STEP3_SA = ORDER_ROOT / "2 配体可合成性" / "All_MOF_SA_Ranking.csv"
26
+ STEP3_TOP6000 = ORDER_ROOT / "3 水生物毒性" / "Top6000_MOF_MaxSA_Linker.csv"
27
+ STEP4_TOX = ORDER_ROOT / "3 水生物毒性" / "Strict_EasySynth_Linkers_Optimized_Predictions.csv"
28
+ STEP5_DIR = ORDER_ROOT / "4 PMT"
29
+ STEP5_DESC = STEP5_DIR / "PMT描述符.csv"
30
+ STEP6_PRICE = PRICE_ROOT / "Strict_EasySynth_Linkers_Toxicity_Ranking0705_CoPriNet_price_ranked.csv"
31
+
32
+ SIX_STEP_TOOLS = [
33
+ "adsorption_screen",
34
+ "heavy_metal",
35
+ "ligand_sa",
36
+ "aquatic_toxicity",
37
+ "pmt",
38
+ "price",
39
+ ]
40
+
41
+ ALLOWED_METALS = {"Mg", "Al", "Ca", "Ti", "Mn", "Fe", "Cu", "Zn", "Zr", "Ag"}
42
+ ALL_METALS = {
43
+ "Li", "Be", "Na", "Mg", "Al", "K", "Ca", "Sc", "Ti", "V", "Cr", "Mn", "Fe", "Co", "Ni",
44
+ "Cu", "Zn", "Ga", "Rb", "Sr", "Y", "Zr", "Nb", "Mo", "Tc", "Ru", "Rh", "Pd", "Ag", "Cd",
45
+ "In", "Sn", "Cs", "Ba", "La", "Ce", "Pr", "Nd", "Pm", "Sm", "Eu", "Gd", "Tb", "Dy", "Ho",
46
+ "Er", "Tm", "Yb", "Lu", "Hf", "Ta", "W", "Re", "Os", "Ir", "Pt", "Au", "Hg", "Tl", "Pb",
47
+ "Bi", "Po", "Fr", "Ra", "Ac", "Th", "Pa", "U", "Np", "Pu",
48
+ }
49
+
50
+
51
+ def normalize_mof_name(value: Any) -> str:
52
+ """Normalize MOF ids for table and filename matching."""
53
+ if value is None:
54
+ return ""
55
+ return re.sub(r"[\s\-_()]+", "", str(value)).lower()
56
+
57
+
58
+ def _smiles_key(value: Any) -> str:
59
+ return "" if value is None else re.sub(r"\s+", "", str(value))
60
+
61
+
62
+ @lru_cache(maxsize=16)
63
+ def _csv(path: str) -> pd.DataFrame:
64
+ return pd.read_csv(path)
65
+
66
+
67
+ def _read(path: Path) -> pd.DataFrame:
68
+ return _csv(str(path))
69
+
70
+
71
+ def _find_by_name(df: pd.DataFrame, name: str | None, columns: tuple[str, ...] = ("MOF_Name", "MOF")) -> pd.Series | None:
72
+ if not name:
73
+ return None
74
+ key = normalize_mof_name(name)
75
+ for column in columns:
76
+ if column not in df.columns:
77
+ continue
78
+ mask = df[column].map(normalize_mof_name) == key
79
+ if mask.any():
80
+ return df.loc[mask].iloc[0]
81
+ return None
82
+
83
+
84
+ def _find_by_smiles(df: pd.DataFrame, smiles: str | None, column: str = "SMILES") -> pd.Series | None:
85
+ if not smiles or column not in df.columns:
86
+ return None
87
+ key = _smiles_key(smiles)
88
+ mask = df[column].map(_smiles_key) == key
89
+ if mask.any():
90
+ return df.loc[mask].iloc[0]
91
+ return None
92
+
93
+
94
+ def _num(value: Any) -> float | None:
95
+ try:
96
+ if pd.isna(value):
97
+ return None
98
+ value = float(value)
99
+ if math.isnan(value) or math.isinf(value):
100
+ return None
101
+ return value
102
+ except Exception:
103
+ return None
104
+
105
+
106
+ def _int(value: Any) -> int | None:
107
+ number = _num(value)
108
+ return None if number is None else int(number)
109
+
110
+
111
+ def _jsonable(value: Any) -> Any:
112
+ if isinstance(value, dict):
113
+ return {str(k): _jsonable(v) for k, v in value.items()}
114
+ if isinstance(value, list | tuple):
115
+ return [_jsonable(v) for v in value]
116
+ if isinstance(value, np.ndarray):
117
+ return _jsonable(value.tolist())
118
+ if isinstance(value, np.generic):
119
+ return _jsonable(value.item())
120
+ if isinstance(value, float) and (math.isnan(value) or math.isinf(value)):
121
+ return None
122
+ return value
123
+
124
+
125
+ def _unknown(tool: str, reason: str, step: int) -> dict[str, Any]:
126
+ return {"tool": tool, "step": step, "status": "unknown", "reason": reason, "pass": None}
127
+
128
+
129
+ def resolve_candidate(cif_path: str | None = None, user_text: str = "") -> dict[str, Any]:
130
+ """Resolve uploaded filename or user text to a known MOF candidate."""
131
+ query_names: list[str] = []
132
+ if cif_path:
133
+ query_names.append(Path(cif_path).stem)
134
+ query_names.extend(re.findall(r"[A-Za-z0-9]+(?:[-_][A-Za-z0-9]+)*(?:-\(id[:_]\d+\))?", user_text or ""))
135
+
136
+ candidate: dict[str, Any] = {
137
+ "cif_path": cif_path,
138
+ "query_names": list(dict.fromkeys([q for q in query_names if q])),
139
+ "matched_mof": None,
140
+ "linker_smiles": None,
141
+ "match_source": None,
142
+ }
143
+
144
+ for path, columns, source in [
145
+ (STEP1_FULL, ("MOF_Name", "MOF"), "adsorption_screen"),
146
+ (STEP3_SA, ("MOF", "MOF_Name"), "ligand_sa"),
147
+ (STEP3_TOP6000, ("MOF", "MOF_Name"), "top6000_linker"),
148
+ (STEP6_PRICE, ("MOF", "MOF_Name"), "price"),
149
+ ]:
150
+ if not path.exists():
151
+ continue
152
+ df = _read(path)
153
+ for name in candidate["query_names"]:
154
+ row = _find_by_name(df, name, columns)
155
+ if row is not None:
156
+ matched = row.get("MOF") if "MOF" in row.index else row.get("MOF_Name")
157
+ candidate["matched_mof"] = str(matched)
158
+ candidate["match_source"] = source
159
+ if "SMILES" in row.index and pd.notna(row.get("SMILES")):
160
+ candidate["linker_smiles"] = str(row.get("SMILES"))
161
+ return candidate
162
+
163
+ return candidate
164
+
165
+
166
+ def adsorption_screen_tool(candidate: dict[str, Any]) -> dict[str, Any]:
167
+ """Step 1: precomputed top-20% adsorption screen."""
168
+ if not STEP1_FULL.exists() or not STEP1_TOP20.exists():
169
+ return _unknown("adsorption_screen", "adsorption ranking table is missing", 1)
170
+
171
+ full = _read(STEP1_FULL)
172
+ top = _read(STEP1_TOP20)
173
+ name = candidate.get("matched_mof") or next(iter(candidate.get("query_names", [])), None)
174
+ row = _find_by_name(full, name)
175
+ if row is None:
176
+ return _unknown("adsorption_screen", "MOF was not found in the precomputed adsorption ranking", 1)
177
+
178
+ mof = str(row.get("MOF_Name"))
179
+ top_row = _find_by_name(top, mof)
180
+ rank = _int(row.get("rank"))
181
+ cutoff = len(top)
182
+ passed = top_row is not None
183
+ candidate["matched_mof"] = mof
184
+ return {
185
+ "tool": "adsorption_screen",
186
+ "step": 1,
187
+ "status": "pass" if passed else "fail",
188
+ "pass": passed,
189
+ "mof": mof,
190
+ "rank": rank,
191
+ "top20_cutoff_rank": cutoff,
192
+ "predicted_benzene_adsorption": _num(row.get("predicted_benzene_adsorption")),
193
+ "predicted_toluene_adsorption": _num(row.get("predicted_toluene_adsorption")),
194
+ "balanced_score": _num(row.get("balanced_score")),
195
+ "model": "precomputed benzene/toluene adsorption ranking from 0629_revise/screen_result",
196
+ }
197
+
198
+
199
+ def heavy_metal_tool(candidate: dict[str, Any]) -> dict[str, Any]:
200
+ """Step 2: allowed-metal filter from uploaded CIF."""
201
+ cif_path = candidate.get("cif_path")
202
+ if not cif_path:
203
+ return _unknown("heavy_metal", "no CIF file was provided for metal parsing", 2)
204
+
205
+ try:
206
+ from pymatgen.core import Structure
207
+
208
+ structure = Structure.from_file(cif_path)
209
+ elements = sorted({str(site.specie.symbol) for site in structure})
210
+ except Exception as exc:
211
+ return _unknown("heavy_metal", f"failed to parse CIF metals: {exc}", 2)
212
+
213
+ metals = [element for element in elements if element in ALL_METALS]
214
+ illegal = [element for element in metals if element not in ALLOWED_METALS]
215
+ passed = len(illegal) == 0
216
+ return {
217
+ "tool": "heavy_metal",
218
+ "step": 2,
219
+ "status": "pass" if passed else "fail",
220
+ "pass": passed,
221
+ "detected_metals": metals,
222
+ "illegal_metals": illegal,
223
+ "allowed_metals": sorted(ALLOWED_METALS),
224
+ "model": "pymatgen CIF parser + fixed allowed-metal list",
225
+ }
226
+
227
+
228
+ def ligand_sa_tool(candidate: dict[str, Any]) -> dict[str, Any]:
229
+ """Step 3: ligand synthesizability lookup."""
230
+ if not STEP3_SA.exists() or not STEP3_TOP6000.exists():
231
+ return _unknown("ligand_sa", "ligand SA tables are missing", 3)
232
+
233
+ name = candidate.get("matched_mof") or next(iter(candidate.get("query_names", [])), None)
234
+ sa = _read(STEP3_SA)
235
+ top = _read(STEP3_TOP6000)
236
+ row = _find_by_name(sa, name)
237
+ if row is None:
238
+ return _unknown("ligand_sa", "MOF was not found in All_MOF_SA_Ranking.csv", 3)
239
+
240
+ mof = str(row.get("MOF"))
241
+ top_row = _find_by_name(top, mof)
242
+ passed = top_row is not None
243
+ smiles = str(top_row.get("SMILES")) if top_row is not None and pd.notna(top_row.get("SMILES")) else None
244
+ candidate["matched_mof"] = mof
245
+ if smiles:
246
+ candidate["linker_smiles"] = smiles
247
+
248
+ return {
249
+ "tool": "ligand_sa",
250
+ "step": 3,
251
+ "status": "pass" if passed else "fail",
252
+ "pass": passed,
253
+ "mof": mof,
254
+ "sa_rank": _int(row.get("SA_Rank")),
255
+ "sa_score": _num(row.get("Max_SA")),
256
+ "mean_sa": _num(row.get("Mean_SA")),
257
+ "n_linker": _int(row.get("NLinker")),
258
+ "in_top6000": passed,
259
+ "linker_id": _int(top_row.get("Linker_ID")) if top_row is not None else None,
260
+ "linker_smiles": smiles,
261
+ "model": "All_MOF_SA_Ranking.csv + Top6000_MOF_MaxSA_Linker.csv lookup",
262
+ }
263
+
264
+
265
+ def aquatic_toxicity_tool(candidate: dict[str, Any]) -> dict[str, Any]:
266
+ """Step 4: aquatic toxicity lookup by linker SMILES."""
267
+ if not STEP4_TOX.exists():
268
+ return _unknown("aquatic_toxicity", "aquatic toxicity prediction table is missing", 4)
269
+ if not candidate.get("linker_smiles"):
270
+ ligand_sa_tool(candidate)
271
+ smiles = candidate.get("linker_smiles")
272
+ if not smiles:
273
+ return _unknown("aquatic_toxicity", "no linker SMILES was available for toxicity lookup", 4)
274
+
275
+ row = _find_by_smiles(_read(STEP4_TOX), smiles)
276
+ if row is None:
277
+ return _unknown("aquatic_toxicity", "linker SMILES was not found in toxicity predictions", 4)
278
+
279
+ values = [
280
+ _num(row.get("Predicted_Tox_IBC50")),
281
+ _num(row.get("Predicted_Tox_IGC50")),
282
+ _num(row.get("Predicted_Tox_LC50")),
283
+ _num(row.get("Predicted_Tox_LC50DM")),
284
+ ]
285
+ present = [v for v in values if v is not None]
286
+ return {
287
+ "tool": "aquatic_toxicity",
288
+ "step": 4,
289
+ "status": "pass" if len(present) == 4 else "unknown",
290
+ "pass": True if len(present) == 4 else None,
291
+ "linker_smiles": smiles,
292
+ "linker_id": _int(row.get("Linker_ID")),
293
+ "IBC50": values[0],
294
+ "IGC50": values[1],
295
+ "LC50": values[2],
296
+ "LC50DM": values[3],
297
+ "mean_toxicity": round(sum(present) / len(present), 6) if present else None,
298
+ "worst_toxicity": min(present) if present else None,
299
+ "model": "Strict_EasySynth_Linkers_Optimized_Predictions.csv lookup",
300
+ }
301
+
302
+
303
+ def pmt_tool(candidate: dict[str, Any]) -> dict[str, Any]:
304
+ """Step 5: PMT classifier lookup + model inference from precomputed descriptors."""
305
+ if not STEP5_DESC.exists():
306
+ return _unknown("pmt", "PMT descriptor table is missing", 5)
307
+ if not candidate.get("linker_smiles"):
308
+ ligand_sa_tool(candidate)
309
+ smiles = candidate.get("linker_smiles")
310
+ if not smiles:
311
+ return _unknown("pmt", "no linker SMILES was available for PMT descriptor lookup", 5)
312
+
313
+ desc = _read(STEP5_DESC)
314
+ row = _find_by_smiles(desc, smiles)
315
+ if row is None:
316
+ return _unknown("pmt", "linker SMILES was not found in PMT描述符.csv", 5)
317
+
318
+ try:
319
+ imputer = joblib.load(STEP5_DIR / "PMT_imputer.pkl")
320
+ scaler = joblib.load(STEP5_DIR / "PMT_scaler.pkl")
321
+ selector = joblib.load(STEP5_DIR / "PMT_selector.pkl")
322
+ model = joblib.load(STEP5_DIR / "PMT_xgb_model.pkl")
323
+ feature_row = row.drop(labels=["SMILES"], errors="ignore").to_frame().T
324
+ feature_row = feature_row.apply(pd.to_numeric, errors="coerce")
325
+ x = imputer.transform(feature_row)
326
+ x = scaler.transform(x)
327
+ x = selector.transform(x)
328
+ proba = model.predict_proba(x)[0]
329
+ classes = list(getattr(model, "classes_", [0, 1]))
330
+ positive_index = classes.index(1) if 1 in classes else len(proba) - 1
331
+ probability = float(proba[positive_index])
332
+ except Exception as exc:
333
+ return _unknown("pmt", f"PMT model inference failed: {exc}", 5)
334
+
335
+ threshold = 0.4
336
+ passed = probability >= threshold
337
+ return {
338
+ "tool": "pmt",
339
+ "step": 5,
340
+ "status": "pass" if passed else "fail",
341
+ "pass": passed,
342
+ "linker_smiles": smiles,
343
+ "pmt_probability": round(probability, 6),
344
+ "pmt_class": "non_PMT" if passed else "PMT_risk",
345
+ "threshold": threshold,
346
+ "threshold_note": "Class 1 is treated as non-PMT/pass; probability >= 0.4 passes.",
347
+ "model": "PMT_xgb_model.pkl with PMT_imputer/scaler/selector",
348
+ }
349
+
350
+
351
+ def price_tool(candidate: dict[str, Any]) -> dict[str, Any]:
352
+ """Step 6: CoPriNet price lookup."""
353
+ if not STEP6_PRICE.exists():
354
+ return _unknown("price", "price ranking table is missing", 6)
355
+
356
+ price = _read(STEP6_PRICE)
357
+ row = _find_by_name(price, candidate.get("matched_mof"))
358
+ if row is None and candidate.get("linker_smiles"):
359
+ row = _find_by_smiles(price, candidate.get("linker_smiles"))
360
+ if row is None:
361
+ for name in candidate.get("query_names", []):
362
+ row = _find_by_name(price, name)
363
+ if row is not None:
364
+ break
365
+ if row is None:
366
+ return _unknown("price", "MOF/linker was not found in the CoPriNet price ranking", 6)
367
+
368
+ status = str(row.get("Price_Prediction_Status", "unknown"))
369
+ passed = status.upper() == "OK"
370
+ if pd.notna(row.get("SMILES")):
371
+ candidate["linker_smiles"] = str(row.get("SMILES"))
372
+ if pd.notna(row.get("MOF")):
373
+ candidate["matched_mof"] = str(row.get("MOF"))
374
+ return {
375
+ "tool": "price",
376
+ "step": 6,
377
+ "status": "pass" if passed else "fail",
378
+ "pass": passed,
379
+ "mof": str(row.get("MOF")),
380
+ "linker_smiles": str(row.get("SMILES")) if pd.notna(row.get("SMILES")) else None,
381
+ "coprinet_price_rank": _int(row.get("CoPriNet_Price_Rank")),
382
+ "usd_per_g": _num(row.get("CoPriNet_USD_per_g")),
383
+ "usd_per_mmol": _num(row.get("CoPriNet_USD_per_mmol")),
384
+ "price_status": status,
385
+ "model": "CoPriNet price ranking CSV lookup; independent of PMT",
386
+ }
387
+
388
+
389
+ def _run_tool(name: str, candidate: dict[str, Any]) -> dict[str, Any]:
390
+ tools = {
391
+ "adsorption_screen": adsorption_screen_tool,
392
+ "heavy_metal": heavy_metal_tool,
393
+ "ligand_sa": ligand_sa_tool,
394
+ "aquatic_toxicity": aquatic_toxicity_tool,
395
+ "pmt": pmt_tool,
396
+ "price": price_tool,
397
+ }
398
+ if name not in tools:
399
+ return _unknown(name, "unknown six-step tool name", 0)
400
+ return _jsonable(tools[name](candidate))
401
+
402
+
403
+ def run_selected_six_step_tools(cif_path: str | None, user_text: str, tools: list[str]) -> dict[str, Any]:
404
+ candidate = resolve_candidate(cif_path, user_text)
405
+ results: dict[str, Any] = {}
406
+ trace: list[dict[str, Any]] = []
407
+ for name in tools:
408
+ if name in {"aquatic_toxicity", "pmt", "price"} and not candidate.get("linker_smiles"):
409
+ ligand = results.get("ligand_sa") or _run_tool("ligand_sa", candidate)
410
+ results.setdefault("ligand_sa", ligand)
411
+ trace.append({"agent": "Tool Execution Agent", "action": "resolved linker SMILES via ligand_sa", "output": ligand})
412
+ result = _run_tool(name, candidate)
413
+ results[name] = result
414
+ trace.append({"agent": "Tool Execution Agent", "action": f"ran {name}", "output": result})
415
+
416
+ payload = _final_payload(candidate, results, full=False)
417
+ payload["agent_trace"] = trace + payload["agent_trace"]
418
+ return payload
419
+
420
+
421
+ def run_six_step_screening(cif_path: str | None, user_text: str = "") -> dict[str, Any]:
422
+ candidate = resolve_candidate(cif_path, user_text)
423
+ results: dict[str, Any] = {}
424
+ trace: list[dict[str, Any]] = []
425
+ for name in SIX_STEP_TOOLS:
426
+ result = _run_tool(name, candidate)
427
+ results[name] = result
428
+ trace.append({"agent": "Tool Execution Agent", "action": f"ran step {result.get('step')}: {name}", "output": result})
429
+ payload = _final_payload(candidate, results, full=True)
430
+ payload["agent_trace"] = trace + payload["agent_trace"]
431
+ return payload
432
+
433
+
434
+ def _final_payload(candidate: dict[str, Any], results: dict[str, Any], full: bool) -> dict[str, Any]:
435
+ failed = [r for r in results.values() if r.get("status") == "fail"]
436
+ unknown = [r for r in results.values() if r.get("status") == "unknown"]
437
+ if failed:
438
+ failed_first = sorted(failed, key=lambda r: r.get("step", 999))[0]
439
+ gate_status = "failed"
440
+ failed_at_step = failed_first.get("step")
441
+ recommendation = f"failed_at_step_{failed_at_step}_{failed_first.get('tool')}"
442
+ elif unknown:
443
+ gate_status = "incomplete"
444
+ failed_at_step = None
445
+ recommendation = "incomplete_due_to_unknown_evidence"
446
+ else:
447
+ gate_status = "pass_full_screening" if full else "pass_selected_tools"
448
+ failed_at_step = None
449
+ recommendation = gate_status
450
+
451
+ decision_record = {
452
+ "decision_class": gate_status,
453
+ "recommendation": recommendation,
454
+ "failed_at_step": failed_at_step,
455
+ "unknown_steps": [r.get("step") for r in unknown],
456
+ "blocking_tools": [r.get("tool") for r in failed],
457
+ "full_screening": full,
458
+ }
459
+ payload = {
460
+ "mof_id": candidate.get("matched_mof") or next(iter(candidate.get("query_names", [])), "unknown"),
461
+ "candidate": candidate,
462
+ "six_step": results,
463
+ "results": results,
464
+ "gate_status": gate_status,
465
+ "failed_at_step": failed_at_step,
466
+ "recommendation": recommendation,
467
+ "decision_record": decision_record,
468
+ "final_score": _score_from_status(gate_status),
469
+ "explanation": _summary(results, gate_status, failed_at_step),
470
+ "warnings": [],
471
+ "errors": [],
472
+ "agent_trace": [{
473
+ "agent": "Decision Agent",
474
+ "action": "computed deterministic six-step gate status",
475
+ "output": decision_record,
476
+ }],
477
+ }
478
+ payload["row"] = build_result_row(payload)
479
+ payload["evidence_ledger"] = [
480
+ {"tool": name, "status": result.get("status"), "outputs": result, "confidence": "precomputed_or_deterministic"}
481
+ for name, result in results.items()
482
+ ]
483
+ return _jsonable(payload)
484
+
485
+
486
+ def _score_from_status(status: str) -> float | None:
487
+ if status.startswith("pass"):
488
+ return 10.0
489
+ if status == "incomplete":
490
+ return 5.0
491
+ if status == "failed":
492
+ return 0.0
493
+ return None
494
+
495
+
496
+ def _summary(results: dict[str, Any], gate_status: str, failed_at_step: int | None) -> str:
497
+ parts = []
498
+ for name in SIX_STEP_TOOLS:
499
+ result = results.get(name)
500
+ if not result:
501
+ continue
502
+ parts.append(f"step {result.get('step')} {name}: {result.get('status')}")
503
+ if failed_at_step:
504
+ tail = f"Final gate status is failed at step {failed_at_step}."
505
+ elif gate_status == "incomplete":
506
+ tail = "Final gate status is incomplete because at least one required tool returned unknown."
507
+ else:
508
+ tail = f"Final gate status is {gate_status}."
509
+ return "; ".join(parts + [tail])
510
+
511
+
512
+ def _fmt_status(result: dict[str, Any] | None) -> str:
513
+ if not result:
514
+ return "N/A"
515
+ return str(result.get("status", "unknown"))
516
+
517
+
518
+ def build_result_row(result: dict[str, Any]) -> dict[str, Any]:
519
+ steps = result.get("six_step") or result.get("results") or {}
520
+ adsorption = steps.get("adsorption_screen") or {}
521
+ metal = steps.get("heavy_metal") or {}
522
+ sa = steps.get("ligand_sa") or {}
523
+ tox = steps.get("aquatic_toxicity") or {}
524
+ pmt = steps.get("pmt") or {}
525
+ price = steps.get("price") or {}
526
+
527
+ return {
528
+ "MOF ID": result.get("mof_id", "unknown"),
529
+ "Step 1 adsorption": (
530
+ f"{_fmt_status(adsorption)}; rank={adsorption.get('rank')}; "
531
+ f"B={adsorption.get('predicted_benzene_adsorption')}; T={adsorption.get('predicted_toluene_adsorption')}"
532
+ ),
533
+ "Step 2 metal": (
534
+ f"{_fmt_status(metal)}; metals={', '.join(metal.get('detected_metals', []) or [])}; "
535
+ f"illegal={', '.join(metal.get('illegal_metals', []) or [])}"
536
+ ),
537
+ "Step 3 SA": f"{_fmt_status(sa)}; rank={sa.get('sa_rank')}; score={sa.get('sa_score')}",
538
+ "Step 4 toxicity": (
539
+ f"{_fmt_status(tox)}; mean={tox.get('mean_toxicity')}; worst={tox.get('worst_toxicity')}"
540
+ ),
541
+ "Step 5 PMT": (
542
+ f"{_fmt_status(pmt)}; class={pmt.get('pmt_class')}; prob={pmt.get('pmt_probability')}"
543
+ ),
544
+ "Step 6 price": (
545
+ f"{_fmt_status(price)}; rank={price.get('coprinet_price_rank')}; USD/g={price.get('usd_per_g')}"
546
+ ),
547
+ "Final gate status": result.get("gate_status", "unknown"),
548
+ "Recommendation": result.get("recommendation", "N/A"),
549
+ "Score": result.get("final_score"),
550
+ }