"""Shared extraction: turn a ChemGraph agent `state` into the rich payload the site renders. Used by both app.py (live) and precompute.py (cached heavy runs) so the shape never diverges.""" from __future__ import annotations import os import re from typing import Optional # ---- element data for the client (kept here so formula/labels are consistent) ---- def parse_xyz(path: Optional[str]) -> dict: """Read an .xyz file → {atoms:[{el,x,y,z}], natoms}. Empty dict on failure.""" if not path or not os.path.exists(path): return {} try: with open(path, "r", encoding="utf-8") as f: lines = f.read().splitlines() n = int(lines[0].strip()) atoms = [] for ln in lines[2 : 2 + n]: parts = ln.split() if len(parts) < 4: continue el = parts[0] x, y, z = (float(parts[1]), float(parts[2]), float(parts[3])) atoms.append({"el": el, "x": round(x, 4), "y": round(y, 4), "z": round(z, 4)}) return {"atoms": atoms, "natoms": len(atoms)} except Exception: return {} def _num(x): try: return float(x) except Exception: return None def _clean_freqs(raw) -> list: """vib frequencies come as strings like '1592.3' or '-42.1i'. Keep real, drop translation/rotation near-zeros, round.""" out = [] for v in raw or []: s = str(v) imag = s.endswith("i") val = _num(s[:-1] if imag else s) if val is None: continue if imag: out.append({"cm1": round(-abs(val), 1), "imaginary": True}) elif abs(val) > 1.0: # drop ~0 modes (translations/rotations) out.append({"cm1": round(val, 1), "imaginary": False}) return out def _summarize(tool: str, out: dict, task: str) -> str: if not isinstance(out, dict): return str(out)[:140] if tool == "molecule_name_to_smiles": return f"{out.get('resolved_name', out.get('name',''))} → SMILES {out.get('smiles','?')} · {out.get('molecular_formula','')} (PubChem)" if tool == "smiles_to_coordinate_file": return f"built 3D structure · {out.get('natoms','?')} atoms" if tool == "run_ase": e = out.get("single_point_energy") if isinstance(e, (int, float)): return f"simulation success · energy {e:.4f} eV" if out.get("dipole_moment"): return "simulation success · dipole computed" if isinstance(out.get("result"), dict): r = out["result"] if "ir_spectrum" in r: return "vibrational + IR analysis success" if "vibrational_frequencies" in r: return "vibrational analysis success" if "thermochemistry" in r: return "thermochemistry computed" return str(out.get("status", "done")) return str(out.get("status", ""))[:140] def _detail(tool: str, args: dict, out: dict) -> dict: """Richer, foldable per-step detail (SMILES, formula, args, coords count).""" d = {} if tool == "molecule_name_to_smiles" and isinstance(out, dict): d = {"name": out.get("name"), "resolved": out.get("resolved_name"), "smiles": out.get("smiles"), "formula": out.get("molecular_formula"), "cid": out.get("cid"), "source": out.get("source")} elif tool == "smiles_to_coordinate_file" and isinstance(out, dict): d = {"smiles": out.get("smiles"), "natoms": out.get("natoms")} elif tool == "run_ase": params = (args or {}).get("params", {}) if isinstance(args, dict) else {} calc = params.get("calculator") calc_s = method = None if isinstance(calc, dict): calc_s = calc.get("calculator_type") method = calc.get("method") elif isinstance(calc, str): # live runs pass the full repr, e.g. "calculator_type='TBLite' method='GFN2-xTB' ..."; # keep just the type + method, not the whole settings dump. m = re.search(r"calculator_type=['\"]?([\w-]+)", calc) calc_s = m.group(1) if m else calc[:24] mm = re.search(r"method=['\"]?([\w-]+)", calc) method = mm.group(1) if mm else None d = {"driver": params.get("driver"), "calculator": calc_s, "method": method} return {k: v for k, v in d.items() if v is not None} def extract(state: dict, query: str, molecule: str, calc_label: str, task: str, cached: bool = False) -> dict: intent = state.get("intent", {}) or {} trace = state.get("tool_trace", []) or [] smiles = formula = resolved = None xyz_path = None steps = [] run_out = {} for s in trace: tool = s.get("tool") args = s.get("args", {}) or {} out = s.get("output", {}) or {} steps.append({ "tool": tool, "status": s.get("status"), "summary": _summarize(tool, out, task), "detail": _detail(tool, args, out), }) if tool == "molecule_name_to_smiles" and isinstance(out, dict): smiles = out.get("smiles") or smiles formula = out.get("molecular_formula") or formula resolved = out.get("resolved_name") or resolved if tool == "smiles_to_coordinate_file" and isinstance(out, dict): xyz_path = out.get("path") or xyz_path if tool == "run_ase": run_out = out if isinstance(out, dict) else {} p = (args.get("params", {}) or {}) xyz_path = xyz_path or p.get("input_structure_file") structure = parse_xyz(xyz_path) # ---- task-specific result ---- result = {"kind": task} e = run_out.get("single_point_energy") if isinstance(e, (int, float)): result["energy"] = {"value": e, "unit": run_out.get("unit", "eV")} if run_out.get("dipole_moment"): vec = [round(_num(x) or 0.0, 4) for x in run_out["dipole_moment"]] mag = round(sum(v * v for v in vec) ** 0.5, 4) result["dipole"] = {"vector": vec, "magnitude": mag, "unit": run_out.get("dipole_unit", "e·Å")} res = run_out.get("result") if isinstance(res, dict): vf = res.get("vibrational_frequencies") if isinstance(vf, dict): result["frequencies"] = _clean_freqs(vf.get("frequencies")) irs = res.get("ir_spectrum") if isinstance(irs, dict): freqs = [(_num(x) or 0.0) for x in irs.get("frequency_cm1", [])] ints = [(_num(x) or 0.0) for x in irs.get("intensity", [])] modes = [{"cm1": round(f, 1), "intensity": round(i, 4)} for f, i in zip(freqs, ints) if f > 1.0] result["ir"] = {"modes": modes} th = res.get("thermochemistry") if isinstance(th, dict): result["thermo"] = { "enthalpy": _num(th.get("enthalpy")), "entropy": _num(th.get("entropy")), "gibbs": _num(th.get("gibbs_free_energy")), "unit": th.get("unit", "eV"), } validation = state.get("validation", {}) or {} v_ok = validation.get("passed", validation.get("ok")) if isinstance(validation, dict) else None has_result = any(k in result for k in ("energy", "dipole", "frequencies", "ir", "thermo")) return { "ok": True, "cached": cached, "query": query, "molecule": molecule, "formula": formula, "smiles": smiles, "resolved": resolved, "calculator": calc_label, "task": task, "intent": { "task_type": intent.get("task_type"), "required_output": intent.get("required_output"), "calculator": intent.get("calculator"), "target_molecules": intent.get("target_molecules"), }, "structure": structure, "steps": steps, "result": result, "verified": bool(v_ok) if v_ok is not None else has_result, }