Spaces:
Sleeping
Sleeping
Deploy MOFScreen-Agent FastAPI backend
Browse files- agent/conversation.py +340 -302
- agent/graph.py +25 -38
- agent/nodes.py +42 -0
- agent/state.py +5 -0
- backend/main.py +130 -43
- tools/descriptors.py +28 -4
- tools/online_screening.py +364 -0
- tools/six_step_screening.py +550 -0
agent/conversation.py
CHANGED
|
@@ -1,27 +1,42 @@
|
|
| 1 |
-
"""
|
| 2 |
from __future__ import annotations
|
| 3 |
|
| 4 |
import json
|
| 5 |
import re
|
| 6 |
-
|
|
|
|
|
|
|
|
|
|
| 7 |
|
| 8 |
-
import
|
| 9 |
|
| 10 |
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
"""
|
| 17 |
-
|
| 18 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 34 |
-
try:
|
| 35 |
-
intent = parse_user_request(user_request, llm_provider, llm_api_key)
|
| 36 |
-
except Exception as exc:
|
| 37 |
return {
|
| 38 |
-
"intent":
|
| 39 |
-
"needs_api_key": not llm_api_key or llm_provider == "rule_based",
|
| 40 |
"needs_file": False,
|
| 41 |
-
"assistant_message":
|
| 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": [
|
| 50 |
}
|
| 51 |
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
"action": "parsed natural-language request into a selective tool plan",
|
| 55 |
-
"output": intent,
|
| 56 |
-
}]
|
| 57 |
|
| 58 |
-
if intent["
|
|
|
|
| 59 |
return {
|
| 60 |
"intent": intent,
|
| 61 |
-
"needs_file":
|
| 62 |
-
"assistant_message":
|
| 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":
|
| 73 |
-
"assistant_message": "This request
|
| 74 |
"agent_trace": trace,
|
| 75 |
"results": {},
|
| 76 |
"warnings": [],
|
| 77 |
"errors": [],
|
| 78 |
}
|
| 79 |
|
| 80 |
-
|
| 81 |
-
|
| 82 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
|
| 84 |
-
if intent["mode"] == "full_screening":
|
| 85 |
-
from agent.graph import screening_app
|
| 86 |
|
| 87 |
-
|
| 88 |
-
|
| 89 |
-
|
| 90 |
-
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
|
| 95 |
-
|
| 96 |
-
return full
|
| 97 |
|
| 98 |
-
|
| 99 |
-
|
| 100 |
-
|
| 101 |
-
|
| 102 |
-
|
| 103 |
-
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
|
| 107 |
-
|
| 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 |
-
|
| 137 |
-
|
| 138 |
-
|
| 139 |
-
|
| 140 |
-
|
| 141 |
-
"results": results,
|
| 142 |
-
"warnings": warnings,
|
| 143 |
-
"errors": errors,
|
| 144 |
-
}
|
| 145 |
|
| 146 |
|
| 147 |
-
def
|
| 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 |
-
|
| 192 |
try:
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 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 |
-
|
| 206 |
-
|
| 207 |
-
|
| 208 |
-
|
| 209 |
-
|
| 210 |
-
|
| 211 |
-
|
| 212 |
-
|
| 213 |
|
| 214 |
-
JSON shape:
|
| 215 |
{{
|
| 216 |
-
"
|
| 217 |
-
"
|
| 218 |
-
"
|
| 219 |
-
"
|
| 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
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 232 |
try:
|
| 233 |
-
|
| 234 |
-
|
| 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 |
-
|
| 245 |
-
|
| 246 |
-
|
| 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 |
-
|
| 297 |
-
|
| 298 |
-
"
|
| 299 |
-
"
|
| 300 |
-
"
|
| 301 |
-
|
| 302 |
-
|
| 303 |
-
|
| 304 |
-
|
| 305 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 306 |
|
| 307 |
|
| 308 |
-
def
|
| 309 |
-
|
| 310 |
-
|
| 311 |
-
|
| 312 |
-
|
| 313 |
-
|
| 314 |
-
|
| 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
|
| 337 |
-
|
| 338 |
-
|
| 339 |
-
|
| 340 |
-
|
| 341 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 342 |
|
| 343 |
|
| 344 |
-
def
|
| 345 |
-
if errors:
|
| 346 |
-
return "
|
|
|
|
|
|
|
|
|
|
| 347 |
|
| 348 |
-
lines = ["
|
| 349 |
-
adsorption =
|
| 350 |
if adsorption:
|
| 351 |
-
|
| 352 |
-
|
| 353 |
-
|
| 354 |
-
|
| 355 |
-
|
| 356 |
-
|
| 357 |
-
|
| 358 |
-
|
| 359 |
-
|
| 360 |
-
lines.append(
|
| 361 |
-
|
| 362 |
-
|
| 363 |
-
|
| 364 |
-
|
| 365 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
| 376 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 377 |
|
| 378 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
"""
|
| 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 |
-
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 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
|
| 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 |
-
|
| 160 |
-
|
| 161 |
-
|
| 162 |
-
|
| 163 |
-
|
| 164 |
-
|
| 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 |
-
|
| 226 |
-
|
| 227 |
-
|
| 228 |
-
|
| 229 |
-
|
| 230 |
-
|
| 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 |
-
|
| 41 |
-
|
| 42 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
+
}
|