chemgraph-loop / app.py
rockyaaos's picture
clarification node: converse for out-of-scope molecules / unavailable tasks; per-task availability; no-derivative matching
73523ac verified
Raw
History Blame Contribute Delete
15.8 kB
"""
ChemGraph Loop — a guarded, natural-language API around the real ChemGraph agent.
A visitor asks a chemistry question in plain language ("what's the IR spectrum of
water?"). An LLM intent router identifies the target molecule (by any name /
synonym / formula) + the task; the real ChemGraph LLM agent then plans the
workflow and tool calls for that intent, runs (with its deterministic workflow
guard), and we return the full agentic trace + a 3D structure + the task-specific
result so the site can render the loop.
Two tiers, by physics cost on a free CPU:
• energy / dipole → single-point, ~10-25s → run LIVE.
• vibrations·IR / thermochemistry → finite-difference Hessian, ~1-3 min →
served from PRECOMPUTED real agent runs (flagged cached:true).
Public-safe guards: molecule + task allow-list, per-call timeout, hourly rate
limit. OPENAI_API_KEY comes from a Space secret.
"""
import asyncio
import glob
import json
import os
import time
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import JSONResponse
from cg_extract import extract
# --- molecule allow-list + synonyms/formulae (only a fallback; the LLM router
# below is the primary, agentic intent path) ------------------------------
MOLECULES = {
"water": ["water", "h2o", "dihydrogen monoxide", "dihydrogen oxide", "oxidane", "aqua", "hydrogen oxide"],
"methane": ["methane", "ch4", "marsh gas", "carbane", "natural gas", "methyl hydride"],
"ammonia": ["ammonia", "nh3", "azane", "hydrogen nitride", "spirit of hartshorn"],
"methanol": ["methanol", "ch3oh", "ch4o", "methyl alcohol", "wood alcohol", "carbinol", "meoh"],
"ethanol": ["ethanol", "c2h5oh", "ch3ch2oh", "c2h6o", "ethyl alcohol", "grain alcohol", "drinking alcohol", "etoh"],
"carbon dioxide": ["carbon dioxide", "co2", "carbonic anhydride", "carbonic acid gas", "dry ice"],
"benzene": ["benzene", "c6h6", "benzol", "cyclohexatriene"],
"acetic acid": ["acetic acid", "ch3cooh", "ch3co2h", "c2h4o2", "ethanoic acid", "glacial acetic acid", "vinegar acid"],
"formaldehyde": ["formaldehyde", "ch2o", "hcho", "methanal", "formalin", "methylene oxide"],
"hydrogen peroxide": ["hydrogen peroxide", "h2o2", "dihydrogen dioxide", "hydrogen dioxide", "peroxide"],
}
CALC_LABEL = {"emt": "EMT", "tblite": "TBLite"}
# unicode sub/superscripts → ascii digits, so "CO₂" / "H²O" resolve like "CO2"
_DIGITS = {"₀": "0", "₁": "1", "₂": "2", "₃": "3", "₄": "4", "₅": "5", "₆": "6", "₇": "7", "₈": "8", "₉": "9",
"⁰": "0", "¹": "1", "²": "2", "³": "3", "⁴": "4", "⁵": "5", "⁶": "6", "⁷": "7", "⁸": "8", "⁹": "9"}
def normalize(text: str) -> str:
return "".join(_DIGITS.get(c, c) for c in (text or "")).lower()
TASKS = ("energy", "dipole", "ir", "thermo")
LIVE_TASKS = {"energy", "dipole"}
CACHED_TASKS = {"ir", "thermo"}
MODEL = os.environ.get("CHEMGRAPH_MODEL", "gpt-4o-mini")
INTENT_MODEL = os.environ.get("CHEMGRAPH_INTENT_MODEL", MODEL)
RUN_TIMEOUT = int(os.environ.get("CHEMGRAPH_TIMEOUT", "95"))
RATE_MAX = int(os.environ.get("CHEMGRAPH_RATE_MAX", "40"))
# --- load precomputed heavy runs (real agent, cached) -------------------------
_PRECOMPUTED = {}
_HERE = os.path.dirname(os.path.abspath(__file__))
for fp in glob.glob(os.path.join(_HERE, "precomputed", "*.json")):
try:
base = os.path.basename(fp)[:-5] # e.g. carbon_dioxide__ir
slug, task = base.rsplit("__", 1)
with open(fp, "r", encoding="utf-8") as f:
_PRECOMPUTED[(slug, task)] = json.load(f)
except Exception:
pass
def _avail(task: str) -> list:
return sorted({slug.replace("_", " ") for (slug, t) in _PRECOMPUTED if t == task})
SPECTRA_MOLECULES = sorted({slug.replace("_", " ") for (slug, _t) in _PRECOMPUTED})
TASK_PHRASE = {"energy": "energy", "dipole": "dipole moment",
"ir": "IR spectrum", "thermo": "thermochemistry"}
app = FastAPI(title="ChemGraph Loop")
app.add_middleware(
CORSMiddleware,
allow_origins=[
"https://sciencesloop.com", "https://www.sciencesloop.com",
"http://localhost:4321", "http://127.0.0.1:4321",
],
allow_origin_regex=r"https://[a-z0-9-]+\.vercel\.app",
allow_methods=["GET", "POST", "OPTIONS"],
allow_headers=["*"],
)
_hits: dict[str, list[float]] = {}
def _rate_ok(ip: str) -> bool:
now = time.time()
w = _hits.setdefault(ip, [])
w[:] = [t for t in w if now - t < 3600]
if len(w) >= RATE_MAX:
return False
w.append(now)
return True
# --- PRIMARY: agentic LLM intent router --------------------------------------
# An LLM reads the plain-language question and identifies the target molecule
# (by any name/synonym/formula) + the task. The ChemGraph agent then plans the
# actual workflow and tool calls for that intent. This is intentionally not a
# rule/keyword engine — the model handles the open-ended naming space.
_client = None
def _openai():
global _client
if _client is None:
from openai import OpenAI
_client = OpenAI()
return _client
_INTENT_SYS = (
"You are the intent router for a computational-chemistry agent that is limited "
"to a fixed set of small molecules and four tasks. Read the user's natural-language "
"question, identify the target molecule and the task, and reply with ONLY a compact "
"JSON object. Do not add commentary."
)
def llm_parse_query(text: str) -> dict:
mols = ", ".join(sorted(MOLECULES.keys()))
user = (
f"Allowed molecules (canonical names): {mols}.\n"
"Map the user's molecule to one of these ONLY if it is the SAME compound, by any "
"name — common name, trivial/trade name, IUPAC name, or chemical formula. Examples: "
"'H2O' / 'dihydrogen monoxide' / 'aqua' -> water; 'CO2' / 'dry ice' -> carbon dioxide; "
"'EtOH' / 'ethyl alcohol' / 'grain alcohol' -> ethanol; 'NH3' / 'azane' -> ammonia; "
"'benzol' -> benzene.\n"
"CRITICAL: a substituted or derivative molecule is a DIFFERENT compound — return "
"null, do NOT map it to the parent. e.g. dimethoxybenzene / toluene / nitrobenzene / "
"phenol / aniline are NOT benzene; acetaldehyde is NOT formaldehyde; propanol is NOT "
"ethanol. Never match just because the name contains an allowed molecule's name. If "
"the molecule is not EXACTLY one of the allowed ones, use null.\n"
"Tasks: 'energy' = single-point energy; 'dipole' = dipole moment; 'ir' = "
"vibrational frequencies / IR spectrum; 'thermo' = thermochemistry (enthalpy, "
"entropy, Gibbs free energy). Pick the closest task; default to 'energy' if none "
"is implied.\n"
"Calculator: 'emt' only if the user explicitly asks for EMT and the task is "
"energy; otherwise 'tblite'.\n"
"When molecule is null, also give: 'nearest' = the single closest allowed molecule "
"if the user's is a close relative/derivative (e.g. dimethoxybenzene -> benzene, "
"1-propanol -> ethanol, acetone -> null if nothing close), else null; and 'note' = "
"ONE short, friendly sentence naming the user's molecule and why it's outside this "
"small-molecule demo (e.g. \"Caffeine is too large for this small-molecule demo.\").\n"
f'User question: "{text}"\n'
'Reply as JSON: {"molecule": <canonical name or null>, "task": '
'"energy|dipole|ir|thermo", "calculator": "emt|tblite", '
'"nearest": <allowed name or null>, "note": <string or null>}'
)
resp = _openai().chat.completions.create(
model=INTENT_MODEL,
temperature=0,
response_format={"type": "json_object"},
messages=[{"role": "system", "content": _INTENT_SYS},
{"role": "user", "content": user}],
)
data = json.loads(resp.choices[0].message.content or "{}")
mol = data.get("molecule")
mol = mol.strip().lower() if isinstance(mol, str) else ""
task = data.get("task") or "energy"
task = task.strip().lower() if isinstance(task, str) else "energy"
if mol not in MOLECULES:
nearest = data.get("nearest")
nearest = nearest.strip().lower() if isinstance(nearest, str) else None
return {"error": "no_molecule",
"note": data.get("note") if isinstance(data.get("note"), str) else None,
"nearest": nearest if nearest in MOLECULES else None,
"task": task if task in TASKS else "energy"}
if task not in TASKS:
task = "energy"
# calculator isn't "intent": TBLite (real QM) by default; EMT only for an
# energy query where the user explicitly named it. Deterministic, not model-guessed.
calc = "emt" if (task == "energy" and "emt" in normalize(text)) else "tblite"
return {"molecule": mol, "task": task, "calculator": calc}
def keyword_fallback(text: str) -> dict:
"""Non-LLM fallback used ONLY if the intent model call fails. Token-level
alias match (no regex) so e.g. 'ch4' does not match the token 'ch4o'."""
t = normalize(text)
toks = set(t.replace(",", " ").replace("?", " ").replace("(", " ").replace(")", " ").split())
molecule = None
for canonical, aliases in MOLECULES.items():
for a in aliases:
hit = (a in t) if " " in a else (a in toks)
if hit:
molecule = canonical
break
if molecule:
break
if molecule is None:
return {"error": "no_molecule"}
task = "energy"
for name, kws in (("thermo", ("thermo", "enthalpy", "entropy", "gibbs")),
("ir", ("ir", "infrared", "vibration", "vibrational", "frequency", "frequencies", "spectrum", "spectra")),
("dipole", ("dipole", "polarity"))):
if toks & set(kws):
task = name
break
calc = "emt" if ("emt" in toks and task == "energy") else "tblite"
return {"molecule": molecule, "task": task, "calculator": calc}
def canonical_query(molecule: str, task: str, calc: str) -> str:
label = CALC_LABEL[calc]
if task == "energy":
return f"Compute the single point energy of {molecule} using the {label} calculator."
if task == "dipole":
return f"Compute the dipole moment of {molecule} using the {label} calculator."
if task == "ir":
return f"Compute the IR spectrum of {molecule} using the {label} calculator."
if task == "thermo":
return f"Compute the thermochemistry (enthalpy, entropy, Gibbs free energy) of {molecule} using the {label} calculator at 298.15 K."
return f"Compute the single point energy of {molecule} using the {label} calculator."
@app.get("/")
def health():
return {
"ok": True, "service": "chemgraph-loop", "model": MODEL,
"molecules": sorted(MOLECULES.keys()),
"tasks": ["energy", "dipole", "ir", "thermo"],
"live_tasks": sorted(LIVE_TASKS),
"ir_molecules": _avail("ir"),
"thermo_molecules": _avail("thermo"),
}
@app.post("/run")
async def run(req: Request):
ip = (req.headers.get("x-forwarded-for") or (req.client.host if req.client else "?") or "?").split(",")[0].strip()
if not _rate_ok(ip):
return JSONResponse({"error": "Too many requests — try again later."}, status_code=429)
try:
body = await req.json()
except Exception:
body = {}
# Accept NL {query}, or legacy {molecule, calculator}.
if body.get("query"):
user_text = str(body["query"])
# PRIMARY: LLM intent router; keyword fallback only if the model call fails.
try:
parsed = llm_parse_query(user_text)
except Exception:
parsed = keyword_fallback(user_text)
else:
mol = str(body.get("molecule", "water")).strip().lower()
calc = str(body.get("calculator", "emt")).strip().lower()
parsed = {"molecule": mol if mol in MOLECULES else None,
"task": "energy", "calculator": calc if calc in CALC_LABEL else "emt"}
if parsed["molecule"] is None:
parsed = {"error": "no_molecule"}
user_text = None
# ---- CLARIFICATION NODE: molecule not in the demo's small-molecule set ----
if parsed.get("error") == "no_molecule":
note = parsed.get("note") or "That doesn't look like one of the small molecules in this demo."
nearest = parsed.get("nearest")
want = parsed.get("task", "energy")
suggestions = []
if nearest:
note += f" Did you mean {nearest}?"
if want in CACHED_TASKS and nearest in _avail(want):
suggestions.append({"label": f"{TASK_PHRASE[want]} of {nearest}",
"query": f"{TASK_PHRASE[want]} of {nearest}"})
suggestions += [{"label": f"energy of {nearest}", "query": f"energy of {nearest}"},
{"label": f"dipole of {nearest}", "query": f"dipole of {nearest}"}]
else:
suggestions = [{"label": "IR spectrum of water", "query": "IR spectrum of water"},
{"label": "dipole of ammonia", "query": "dipole of ammonia"},
{"label": "energy of benzene", "query": "energy of benzene"}]
return JSONResponse({"clarify": True, "message": note,
"molecules": sorted(MOLECULES.keys()), "suggestions": suggestions})
molecule, task, calc = parsed["molecule"], parsed["task"], parsed["calculator"]
calc_label = CALC_LABEL[calc]
query = canonical_query(molecule, task, calc)
display_query = user_text or query
# ---- cached heavy tasks (vibrations/IR, thermochemistry) ----
if task in CACHED_TASKS:
slug = molecule.replace(" ", "_")
payload = _PRECOMPUTED.get((slug, task))
if payload is None:
# CLARIFICATION NODE: molecule is supported, but this heavy task wasn't
# precomputed for it — offer its live options + where the task IS available.
avail = _avail(task)
msg = (f"I don't have a precomputed {TASK_PHRASE[task]} for {molecule} — those need a "
f"slow vibrational (Hessian) run, so they're prepared ahead of time"
+ (f" for {', '.join(avail)}" if avail else "") + ". "
f"But I can run {molecule}'s energy or dipole live right now.")
suggestions = [{"label": f"energy of {molecule}", "query": f"energy of {molecule}"},
{"label": f"dipole of {molecule}", "query": f"dipole of {molecule}"}]
if avail:
suggestions.append({"label": f"{TASK_PHRASE[task]} of {avail[0]}",
"query": f"{TASK_PHRASE[task]} of {avail[0]}"})
return JSONResponse({"clarify": True, "message": msg, "suggestions": suggestions})
out = dict(payload)
out["display_query"] = display_query
return JSONResponse(out)
# ---- live single-point tasks (energy, dipole) ----
try:
from chemgraph.agent.llm_agent import ChemGraph
agent = ChemGraph(model_name=MODEL, return_option="state", recursion_limit=20)
state = await asyncio.wait_for(agent.run(query), timeout=RUN_TIMEOUT)
except asyncio.TimeoutError:
return JSONResponse({"error": "The workflow took too long — please retry."}, status_code=504)
except Exception as e: # noqa: BLE001
return JSONResponse({"error": f"Agent error: {str(e)[:200]}"}, status_code=500)
out = extract(state, query, molecule, calc_label, task, cached=False)
out["display_query"] = display_query
return JSONResponse(out)