rockyaaos commited on
Commit
73523ac
·
verified ·
1 Parent(s): 351ea78

clarification node: converse for out-of-scope molecules / unavailable tasks; per-task availability; no-derivative matching

Browse files
Files changed (1) hide show
  1. app.py +61 -16
app.py CHANGED
@@ -72,7 +72,13 @@ for fp in glob.glob(os.path.join(_HERE, "precomputed", "*.json")):
72
  _PRECOMPUTED[(slug, task)] = json.load(f)
73
  except Exception:
74
  pass
 
 
 
 
75
  SPECTRA_MOLECULES = sorted({slug.replace("_", " ") for (slug, _t) in _PRECOMPUTED})
 
 
76
 
77
  app = FastAPI(title="ChemGraph Loop")
78
  app.add_middleware(
@@ -127,21 +133,31 @@ def llm_parse_query(text: str) -> dict:
127
  mols = ", ".join(sorted(MOLECULES.keys()))
128
  user = (
129
  f"Allowed molecules (canonical names): {mols}.\n"
130
- "Map the user's molecule to exactly one of these by ANY name common name, "
131
- "trivial/trade name, IUPAC name, or chemical formula. Examples: 'H2O' / "
132
- "'dihydrogen monoxide' / 'aqua' -> water; 'CO2' / 'dry ice' -> carbon dioxide; "
133
  "'EtOH' / 'ethyl alcohol' / 'grain alcohol' -> ethanol; 'NH3' / 'azane' -> ammonia; "
134
- "'benzol' -> benzene. If the molecule is NOT one of the allowed ones, use null "
135
- "(do not force a match).\n"
 
 
 
 
136
  "Tasks: 'energy' = single-point energy; 'dipole' = dipole moment; 'ir' = "
137
  "vibrational frequencies / IR spectrum; 'thermo' = thermochemistry (enthalpy, "
138
  "entropy, Gibbs free energy). Pick the closest task; default to 'energy' if none "
139
  "is implied.\n"
140
  "Calculator: 'emt' only if the user explicitly asks for EMT and the task is "
141
  "energy; otherwise 'tblite'.\n"
 
 
 
 
 
142
  f'User question: "{text}"\n'
143
  'Reply as JSON: {"molecule": <canonical name or null>, "task": '
144
- '"energy|dipole|ir|thermo", "calculator": "emt|tblite"}'
 
145
  )
146
  resp = _openai().chat.completions.create(
147
  model=INTENT_MODEL,
@@ -156,7 +172,12 @@ def llm_parse_query(text: str) -> dict:
156
  task = data.get("task") or "energy"
157
  task = task.strip().lower() if isinstance(task, str) else "energy"
158
  if mol not in MOLECULES:
159
- return {"error": "no_molecule"}
 
 
 
 
 
160
  if task not in TASKS:
161
  task = "energy"
162
  # calculator isn't "intent": TBLite (real QM) by default; EMT only for an
@@ -212,7 +233,8 @@ def health():
212
  "molecules": sorted(MOLECULES.keys()),
213
  "tasks": ["energy", "dipole", "ir", "thermo"],
214
  "live_tasks": sorted(LIVE_TASKS),
215
- "spectra_molecules": SPECTRA_MOLECULES,
 
216
  }
217
 
218
 
@@ -244,10 +266,25 @@ async def run(req: Request):
244
  parsed = {"error": "no_molecule"}
245
  user_text = None
246
 
 
247
  if parsed.get("error") == "no_molecule":
248
- return JSONResponse(
249
- {"error": "I couldn't spot a supported molecule in that. Try one of the listed molecules.",
250
- "molecules": sorted(MOLECULES.keys())}, status_code=400)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
251
 
252
  molecule, task, calc = parsed["molecule"], parsed["task"], parsed["calculator"]
253
  calc_label = CALC_LABEL[calc]
@@ -259,11 +296,19 @@ async def run(req: Request):
259
  slug = molecule.replace(" ", "_")
260
  payload = _PRECOMPUTED.get((slug, task))
261
  if payload is None:
262
- return JSONResponse(
263
- {"error": f"Spectra / thermochemistry are precomputed for: {', '.join(SPECTRA_MOLECULES)}. "
264
- f"For {molecule}, ask for its energy or dipole (those run live).",
265
- "spectra_molecules": SPECTRA_MOLECULES, "molecule": molecule, "task": task},
266
- status_code=422)
 
 
 
 
 
 
 
 
267
  out = dict(payload)
268
  out["display_query"] = display_query
269
  return JSONResponse(out)
 
72
  _PRECOMPUTED[(slug, task)] = json.load(f)
73
  except Exception:
74
  pass
75
+ def _avail(task: str) -> list:
76
+ return sorted({slug.replace("_", " ") for (slug, t) in _PRECOMPUTED if t == task})
77
+
78
+
79
  SPECTRA_MOLECULES = sorted({slug.replace("_", " ") for (slug, _t) in _PRECOMPUTED})
80
+ TASK_PHRASE = {"energy": "energy", "dipole": "dipole moment",
81
+ "ir": "IR spectrum", "thermo": "thermochemistry"}
82
 
83
  app = FastAPI(title="ChemGraph Loop")
84
  app.add_middleware(
 
133
  mols = ", ".join(sorted(MOLECULES.keys()))
134
  user = (
135
  f"Allowed molecules (canonical names): {mols}.\n"
136
+ "Map the user's molecule to one of these ONLY if it is the SAME compound, by any "
137
+ "name — common name, trivial/trade name, IUPAC name, or chemical formula. Examples: "
138
+ "'H2O' / 'dihydrogen monoxide' / 'aqua' -> water; 'CO2' / 'dry ice' -> carbon dioxide; "
139
  "'EtOH' / 'ethyl alcohol' / 'grain alcohol' -> ethanol; 'NH3' / 'azane' -> ammonia; "
140
+ "'benzol' -> benzene.\n"
141
+ "CRITICAL: a substituted or derivative molecule is a DIFFERENT compound — return "
142
+ "null, do NOT map it to the parent. e.g. dimethoxybenzene / toluene / nitrobenzene / "
143
+ "phenol / aniline are NOT benzene; acetaldehyde is NOT formaldehyde; propanol is NOT "
144
+ "ethanol. Never match just because the name contains an allowed molecule's name. If "
145
+ "the molecule is not EXACTLY one of the allowed ones, use null.\n"
146
  "Tasks: 'energy' = single-point energy; 'dipole' = dipole moment; 'ir' = "
147
  "vibrational frequencies / IR spectrum; 'thermo' = thermochemistry (enthalpy, "
148
  "entropy, Gibbs free energy). Pick the closest task; default to 'energy' if none "
149
  "is implied.\n"
150
  "Calculator: 'emt' only if the user explicitly asks for EMT and the task is "
151
  "energy; otherwise 'tblite'.\n"
152
+ "When molecule is null, also give: 'nearest' = the single closest allowed molecule "
153
+ "if the user's is a close relative/derivative (e.g. dimethoxybenzene -> benzene, "
154
+ "1-propanol -> ethanol, acetone -> null if nothing close), else null; and 'note' = "
155
+ "ONE short, friendly sentence naming the user's molecule and why it's outside this "
156
+ "small-molecule demo (e.g. \"Caffeine is too large for this small-molecule demo.\").\n"
157
  f'User question: "{text}"\n'
158
  'Reply as JSON: {"molecule": <canonical name or null>, "task": '
159
+ '"energy|dipole|ir|thermo", "calculator": "emt|tblite", '
160
+ '"nearest": <allowed name or null>, "note": <string or null>}'
161
  )
162
  resp = _openai().chat.completions.create(
163
  model=INTENT_MODEL,
 
172
  task = data.get("task") or "energy"
173
  task = task.strip().lower() if isinstance(task, str) else "energy"
174
  if mol not in MOLECULES:
175
+ nearest = data.get("nearest")
176
+ nearest = nearest.strip().lower() if isinstance(nearest, str) else None
177
+ return {"error": "no_molecule",
178
+ "note": data.get("note") if isinstance(data.get("note"), str) else None,
179
+ "nearest": nearest if nearest in MOLECULES else None,
180
+ "task": task if task in TASKS else "energy"}
181
  if task not in TASKS:
182
  task = "energy"
183
  # calculator isn't "intent": TBLite (real QM) by default; EMT only for an
 
233
  "molecules": sorted(MOLECULES.keys()),
234
  "tasks": ["energy", "dipole", "ir", "thermo"],
235
  "live_tasks": sorted(LIVE_TASKS),
236
+ "ir_molecules": _avail("ir"),
237
+ "thermo_molecules": _avail("thermo"),
238
  }
239
 
240
 
 
266
  parsed = {"error": "no_molecule"}
267
  user_text = None
268
 
269
+ # ---- CLARIFICATION NODE: molecule not in the demo's small-molecule set ----
270
  if parsed.get("error") == "no_molecule":
271
+ note = parsed.get("note") or "That doesn't look like one of the small molecules in this demo."
272
+ nearest = parsed.get("nearest")
273
+ want = parsed.get("task", "energy")
274
+ suggestions = []
275
+ if nearest:
276
+ note += f" Did you mean {nearest}?"
277
+ if want in CACHED_TASKS and nearest in _avail(want):
278
+ suggestions.append({"label": f"{TASK_PHRASE[want]} of {nearest}",
279
+ "query": f"{TASK_PHRASE[want]} of {nearest}"})
280
+ suggestions += [{"label": f"energy of {nearest}", "query": f"energy of {nearest}"},
281
+ {"label": f"dipole of {nearest}", "query": f"dipole of {nearest}"}]
282
+ else:
283
+ suggestions = [{"label": "IR spectrum of water", "query": "IR spectrum of water"},
284
+ {"label": "dipole of ammonia", "query": "dipole of ammonia"},
285
+ {"label": "energy of benzene", "query": "energy of benzene"}]
286
+ return JSONResponse({"clarify": True, "message": note,
287
+ "molecules": sorted(MOLECULES.keys()), "suggestions": suggestions})
288
 
289
  molecule, task, calc = parsed["molecule"], parsed["task"], parsed["calculator"]
290
  calc_label = CALC_LABEL[calc]
 
296
  slug = molecule.replace(" ", "_")
297
  payload = _PRECOMPUTED.get((slug, task))
298
  if payload is None:
299
+ # CLARIFICATION NODE: molecule is supported, but this heavy task wasn't
300
+ # precomputed for it offer its live options + where the task IS available.
301
+ avail = _avail(task)
302
+ msg = (f"I don't have a precomputed {TASK_PHRASE[task]} for {molecule} — those need a "
303
+ f"slow vibrational (Hessian) run, so they're prepared ahead of time"
304
+ + (f" for {', '.join(avail)}" if avail else "") + ". "
305
+ f"But I can run {molecule}'s energy or dipole live right now.")
306
+ suggestions = [{"label": f"energy of {molecule}", "query": f"energy of {molecule}"},
307
+ {"label": f"dipole of {molecule}", "query": f"dipole of {molecule}"}]
308
+ if avail:
309
+ suggestions.append({"label": f"{TASK_PHRASE[task]} of {avail[0]}",
310
+ "query": f"{TASK_PHRASE[task]} of {avail[0]}"})
311
+ return JSONResponse({"clarify": True, "message": msg, "suggestions": suggestions})
312
  out = dict(payload)
313
  out["display_query"] = display_query
314
  return JSONResponse(out)