Spaces:
Running
Running
| """Deterministic tool requirements for common ChemGraph tasks. | |
| The LLM can still interpret natural language and choose tool arguments, but | |
| these mappings define the minimum tool outputs required before the graph is | |
| allowed to compose a final structured answer. | |
| """ | |
| from __future__ import annotations | |
| import ast | |
| import json | |
| import os | |
| import re | |
| from dataclasses import dataclass | |
| from pathlib import Path | |
| from typing import Any, Iterable, Optional | |
| class TaskRequirement: | |
| """Required tool-output shape for one inferred task type.""" | |
| task_type: str | |
| driver: Optional[str] | |
| required_output: str | |
| response_field: str | |
| class Requirement: | |
| """One machine-checkable scientific requirement in a workflow ledger.""" | |
| id: str | |
| kind: str | |
| species: Optional[str] | |
| property: Optional[str] | |
| status: str | |
| depends_on: list[str] | |
| satisfied_by: list[str] | |
| error: Optional[str] = None | |
| scope_id: Optional[str] = None | |
| run_id: Optional[str] = None | |
| def as_dict(self) -> dict[str, Any]: | |
| return { | |
| "id": self.id, | |
| "kind": self.kind, | |
| "species": self.species, | |
| "property": self.property, | |
| "status": self.status, | |
| "depends_on": self.depends_on, | |
| "satisfied_by": self.satisfied_by, | |
| "error": self.error, | |
| "scope_id": self.scope_id, | |
| "run_id": self.run_id, | |
| } | |
| class Artifact: | |
| """One tool-produced evidence item registered for validation.""" | |
| id: str | |
| requirement_id: Optional[str] | |
| species: Optional[str] | |
| kind: str | |
| path: Optional[str] | |
| calculator: Optional[str] | |
| driver: Optional[str] | |
| fields: list[str] | |
| parse_ok: bool | |
| valid: bool | |
| error: Optional[str] = None | |
| scope_id: Optional[str] = None | |
| run_id: Optional[str] = None | |
| def as_dict(self) -> dict[str, Any]: | |
| return { | |
| "id": self.id, | |
| "requirement_id": self.requirement_id, | |
| "species": self.species, | |
| "kind": self.kind, | |
| "path": self.path, | |
| "calculator": self.calculator, | |
| "driver": self.driver, | |
| "fields": self.fields, | |
| "parse_ok": self.parse_ok, | |
| "valid": self.valid, | |
| "error": self.error, | |
| "scope_id": self.scope_id, | |
| "run_id": self.run_id, | |
| } | |
| PROPERTY_TOOL_MAPPING: dict[str, TaskRequirement] = { | |
| "smiles": TaskRequirement( | |
| task_type="smiles", | |
| driver=None, | |
| required_output="smiles", | |
| response_field="smiles", | |
| ), | |
| "energy": TaskRequirement( | |
| task_type="energy", | |
| driver="energy", | |
| required_output="single_point_energy", | |
| response_field="scalar_answer", | |
| ), | |
| "gibbs_free_energy": TaskRequirement( | |
| task_type="gibbs_free_energy", | |
| driver="thermo", | |
| required_output="thermochemistry.gibbs_free_energy", | |
| response_field="scalar_answer", | |
| ), | |
| "enthalpy": TaskRequirement( | |
| task_type="enthalpy", | |
| driver="thermo", | |
| required_output="thermochemistry.enthalpy", | |
| response_field="scalar_answer", | |
| ), | |
| "dipole": TaskRequirement( | |
| task_type="dipole", | |
| driver="dipole", | |
| required_output="dipole_moment", | |
| response_field="dipole", | |
| ), | |
| "vibration": TaskRequirement( | |
| task_type="vibration", | |
| driver="vib", | |
| required_output="vibrational_frequencies.frequencies", | |
| response_field="vibrational_answer", | |
| ), | |
| "ir": TaskRequirement( | |
| task_type="ir", | |
| driver="ir", | |
| required_output="ir_spectrum", | |
| response_field="ir_spectrum", | |
| ), | |
| "reaction_gibbs_energy": TaskRequirement( | |
| task_type="reaction_gibbs_energy", | |
| driver="thermo", | |
| required_output="per_species_thermochemistry.gibbs_free_energy", | |
| response_field="scalar_answer", | |
| ), | |
| "reaction_enthalpy": TaskRequirement( | |
| task_type="reaction_enthalpy", | |
| driver="thermo", | |
| required_output="per_species_thermochemistry.enthalpy", | |
| response_field="scalar_answer", | |
| ), | |
| "unknown": TaskRequirement( | |
| task_type="unknown", | |
| driver=None, | |
| required_output="unknown", | |
| response_field="unknown", | |
| ), | |
| } | |
| REQUIRED_TOOL_STEPS: dict[str, list[str]] = { | |
| "smiles": ["molecule_name_to_smiles"], | |
| "energy": ["molecule_name_to_smiles", "smiles_to_coordinate_file", "run_ase"], | |
| "gibbs_free_energy": [ | |
| "molecule_name_to_smiles", | |
| "smiles_to_coordinate_file", | |
| "run_ase", | |
| ], | |
| "enthalpy": [ | |
| "molecule_name_to_smiles", | |
| "smiles_to_coordinate_file", | |
| "run_ase", | |
| ], | |
| "dipole": ["molecule_name_to_smiles", "smiles_to_coordinate_file", "run_ase"], | |
| "vibration": ["molecule_name_to_smiles", "smiles_to_coordinate_file", "run_ase"], | |
| "ir": ["molecule_name_to_smiles", "smiles_to_coordinate_file", "run_ase"], | |
| "reaction_gibbs_energy": [ | |
| "molecule_name_to_smiles", | |
| "smiles_to_coordinate_file", | |
| "run_ase", | |
| ], | |
| "reaction_enthalpy": [ | |
| "molecule_name_to_smiles", | |
| "smiles_to_coordinate_file", | |
| "run_ase", | |
| ], | |
| "unknown": [], | |
| } | |
| PREFERRED_CALCULATOR_BY_TASK: dict[str, str] = { | |
| "dipole": "TBLite", | |
| "vibration": "TBLite", | |
| "ir": "TBLite", | |
| "gibbs_free_energy": "TBLite", | |
| "enthalpy": "TBLite", | |
| "reaction_gibbs_energy": "TBLite", | |
| "reaction_enthalpy": "TBLite", | |
| } | |
| class ReactionSpecies: | |
| """One species in a parsed reaction side.""" | |
| name: str | |
| coefficient: float | |
| def infer_requirement(query: str) -> TaskRequirement: | |
| """Infer the deterministic output requirement from a user query.""" | |
| text = " ".join(str(query or "").lower().split()) | |
| if ("reaction" in text or "balanced reaction" in text or "combustion" in text) and ( | |
| "gibbs" in text or "free energy" in text | |
| ): | |
| return PROPERTY_TOOL_MAPPING["reaction_gibbs_energy"] | |
| if ("reaction" in text or "balanced reaction" in text or "combustion" in text) and ( | |
| "enthalpy" in text or "heat of reaction" in text | |
| ): | |
| return PROPERTY_TOOL_MAPPING["reaction_enthalpy"] | |
| if "dipole" in text: | |
| return PROPERTY_TOOL_MAPPING["dipole"] | |
| if "vibrational" in text or "vibration" in text or "frequencies" in text: | |
| return PROPERTY_TOOL_MAPPING["vibration"] | |
| if ( | |
| "infrared spectrum" in text | |
| or "ir spectrum" in text | |
| or "ftir" in text | |
| or "ft-ir" in text | |
| or re.search(r"\bfir\s+(?:of|for)\b", text) | |
| or ("infrared" in text and "spectrum" in text) | |
| ): | |
| return PROPERTY_TOOL_MAPPING["ir"] | |
| if "gibbs" in text or "thermochemical" in text or "thermo" in text: | |
| return PROPERTY_TOOL_MAPPING["gibbs_free_energy"] | |
| if "enthalpy" in text: | |
| return PROPERTY_TOOL_MAPPING["enthalpy"] | |
| if "smiles" in text and not any( | |
| token in text | |
| for token in ("energy", "dipole", "vibrational", "gibbs", "enthalpy") | |
| ): | |
| return PROPERTY_TOOL_MAPPING["smiles"] | |
| if "single point" in text or "single-point" in text or "energy" in text: | |
| return PROPERTY_TOOL_MAPPING["energy"] | |
| return PROPERTY_TOOL_MAPPING["unknown"] | |
| def validate_completion(query: str, messages: Iterable[Any]) -> dict[str, Any]: | |
| """Validate whether required deterministic tool outputs are present. | |
| Parameters | |
| ---------- | |
| query : str | |
| Original user query. | |
| messages : Iterable[Any] | |
| LangGraph message history or message-like dictionaries. | |
| Returns | |
| ------- | |
| dict | |
| Completion payload with ``complete``, ``missing``, | |
| ``repair_instruction`` and optional ``structured_output``. | |
| """ | |
| message_list = list(messages or []) | |
| requirement = infer_requirement(query) | |
| observations = _collect_observations(message_list) | |
| target_molecules = _infer_target_molecules(query, observations, message_list) | |
| if requirement.task_type == "unknown": | |
| return _complete_result(requirement, reason="No deterministic mapping.") | |
| identity_issue = _identity_needs_clarification(observations) | |
| if identity_issue: | |
| return _incomplete_result( | |
| requirement, | |
| ["identity_clarification"], | |
| _repair_identity_clarification(identity_issue), | |
| ) | |
| current_failed_ase = _unresolved_current_failed_ase( | |
| observations, | |
| driver=requirement.driver, | |
| ) | |
| if current_failed_ase and requirement.driver: | |
| return _incomplete_result( | |
| requirement, | |
| [requirement.required_output], | |
| _repair_run_ase(requirement.driver, current_failed_ase), | |
| ) | |
| if requirement.task_type == "smiles": | |
| smiles = _target_smiles(observations, target_molecules) | |
| if smiles: | |
| return _complete_result( | |
| requirement, | |
| structured_output=_empty_structured(smiles=smiles), | |
| ) | |
| return _incomplete_result(requirement, ["smiles"], _repair_smiles()) | |
| if requirement.task_type == "energy": | |
| energy = _last_matching_target( | |
| observations["energies"], target_molecules, observations | |
| ) | |
| if energy is not None: | |
| return _complete_result( | |
| requirement, | |
| structured_output=_empty_structured( | |
| scalar_answer={ | |
| "value": float(energy["value"]), | |
| "property": "single_point_energy", | |
| "unit": energy.get("unit", "eV"), | |
| } | |
| ), | |
| ) | |
| return _incomplete_result( | |
| requirement, | |
| ["single_point_energy"], | |
| _repair_run_ase( | |
| "energy", | |
| _last_failed_ase(observations), | |
| _calculator_policy(requirement, query), | |
| ), | |
| ) | |
| if requirement.task_type == "gibbs_free_energy": | |
| thermo = _last_matching_target( | |
| observations["thermo"], target_molecules, observations | |
| ) | |
| if thermo is not None: | |
| return _complete_result( | |
| requirement, | |
| structured_output=_empty_structured( | |
| scalar_answer={ | |
| "value": float(thermo["value"]), | |
| "property": "Gibbs free energy", | |
| "unit": thermo.get("unit", "eV"), | |
| } | |
| ), | |
| ) | |
| return _incomplete_result( | |
| requirement, | |
| ["thermochemistry.gibbs_free_energy"], | |
| _repair_run_ase( | |
| "thermo", | |
| _last_failed_ase(observations), | |
| _calculator_policy(requirement, query), | |
| ), | |
| ) | |
| if requirement.task_type == "enthalpy": | |
| enthalpy = _last_matching_target( | |
| observations["enthalpies"], target_molecules, observations | |
| ) | |
| if enthalpy is not None: | |
| return _complete_result( | |
| requirement, | |
| structured_output=_empty_structured( | |
| scalar_answer={ | |
| "value": float(enthalpy["value"]), | |
| "property": "Enthalpy", | |
| "unit": enthalpy.get("unit", "eV"), | |
| } | |
| ), | |
| ) | |
| return _incomplete_result( | |
| requirement, | |
| ["thermochemistry.enthalpy"], | |
| _repair_run_ase( | |
| "thermo", | |
| _last_failed_ase(observations), | |
| _calculator_policy(requirement, query), | |
| ), | |
| ) | |
| if requirement.task_type == "dipole": | |
| dipole = _last_matching_target( | |
| observations["dipoles"], target_molecules, observations | |
| ) | |
| if dipole is not None: | |
| return _complete_result( | |
| requirement, | |
| structured_output=_empty_structured( | |
| dipole={ | |
| "value": dipole["value"], | |
| "unit": dipole.get("unit", "e * Angstrom"), | |
| } | |
| ), | |
| ) | |
| return _incomplete_result( | |
| requirement, | |
| ["dipole_moment"], | |
| _repair_run_ase( | |
| "dipole", | |
| _last_failed_ase(observations), | |
| _calculator_policy(requirement, query), | |
| ), | |
| ) | |
| if requirement.task_type == "vibration": | |
| vibration = _last_matching_target( | |
| observations["vibrations"], target_molecules, observations | |
| ) | |
| if vibration is not None: | |
| return _complete_result( | |
| requirement, | |
| structured_output=_empty_structured( | |
| vibrational_answer={"frequency_cm1": vibration["frequencies"]} | |
| ), | |
| ) | |
| return _incomplete_result( | |
| requirement, | |
| ["vibrational_frequencies.frequencies"], | |
| _repair_run_ase( | |
| "vib", | |
| _last_failed_ase(observations), | |
| _calculator_policy(requirement, query), | |
| ), | |
| ) | |
| if requirement.task_type == "ir": | |
| ir_spectrum = _last_matching_target( | |
| observations["ir_spectra"], target_molecules, observations | |
| ) | |
| if ir_spectrum is not None: | |
| return _complete_result( | |
| requirement, | |
| structured_output=_empty_structured( | |
| ir_spectrum=_public_property_record(ir_spectrum) | |
| ), | |
| ) | |
| return _incomplete_result( | |
| requirement, | |
| ["ir_spectrum"], | |
| _repair_run_ase( | |
| "ir", | |
| _last_failed_ase(observations), | |
| _calculator_policy(requirement, query), | |
| ), | |
| ) | |
| if requirement.task_type == "reaction_gibbs_energy": | |
| return _validate_reaction_gibbs(query, requirement, observations, message_list) | |
| if requirement.task_type == "reaction_enthalpy": | |
| return _validate_reaction_enthalpy(query, requirement, observations, message_list) | |
| return _complete_result(requirement, reason="Mapping does not enforce this task yet.") | |
| def validate_tool_call_batch( | |
| query: str, | |
| prior_messages: Iterable[Any], | |
| tool_calls: Iterable[dict[str, Any]], | |
| ) -> dict[str, Any]: | |
| """Validate whether the next LLM tool-call batch may execute. | |
| This is a pre-tool guard. The LLM still chooses actions, but the graph | |
| prevents tools from running when the requested action violates the | |
| deterministic workflow state, for example running ASE before a coordinate | |
| artifact exists or using the wrong driver for the inferred property. | |
| """ | |
| calls = [call for call in tool_calls or [] if isinstance(call, dict)] | |
| if not calls: | |
| return _tool_guard_allowed() | |
| requirement = infer_requirement(query) | |
| if requirement.task_type == "unknown": | |
| return _validate_unknown_tool_call_batch(query, prior_messages, calls) | |
| messages = list(prior_messages or []) | |
| observations = _collect_observations(messages) | |
| validation = validate_completion(query, messages) | |
| workflow_state = build_workflow_state(query, messages, validation=validation) | |
| next_action = workflow_state.get("task_plan", {}).get("next_action") | |
| target_molecules = workflow_state.get("intent", {}).get("target_molecules", []) | |
| allowed_tool = ( | |
| next_action | |
| if next_action in REQUIRED_TOOL_STEPS.get(requirement.task_type, []) | |
| else None | |
| ) | |
| for call in calls: | |
| name = call.get("name") | |
| args = call.get("args") or {} | |
| if not isinstance(args, dict): | |
| args = {} | |
| if next_action == "clarify_identity" and name != "ask_human": | |
| return _tool_guard_blocked( | |
| blocked_tool=str(name), | |
| expected_next_action="clarify_identity", | |
| repair_instruction=( | |
| "Do not continue with coordinate generation or ASE while " | |
| "molecule identity requires clarification. Ask which component " | |
| "or representative molecule should be modeled, or explicitly " | |
| "state and confirm the representative assumption before using " | |
| "downstream chemistry tools." | |
| ), | |
| ) | |
| if name == "molecule_name_to_smiles" and next_action not in { | |
| "molecule_name_to_smiles", | |
| "clarify_identity", | |
| } and _is_observed_identity_name(observations, args.get("name")): | |
| return _tool_guard_blocked( | |
| blocked_tool="molecule_name_to_smiles", | |
| expected_next_action=next_action, | |
| repair_instruction=( | |
| "Molecule identity is already available in workflow state. " | |
| "Reuse the observed SMILES and continue with the next required " | |
| f"action: {next_action}." | |
| ), | |
| ) | |
| if name == "smiles_to_coordinate_file": | |
| explicit_smiles = _query_supplies_smiles(query, args.get("smiles")) | |
| if next_action == "molecule_name_to_smiles" and not explicit_smiles: | |
| return _tool_guard_blocked( | |
| blocked_tool="smiles_to_coordinate_file", | |
| expected_next_action="molecule_name_to_smiles", | |
| repair_instruction=( | |
| "Do not generate coordinates from an unverified SMILES for " | |
| "a named-molecule query. First call molecule_name_to_smiles " | |
| "for the requested molecule, then pass the returned SMILES " | |
| "to smiles_to_coordinate_file." | |
| ), | |
| ) | |
| continue | |
| if name != "run_ase": | |
| continue | |
| params = _run_ase_params(args) | |
| driver = params.get("driver") | |
| input_file = params.get("input_structure_file") | |
| if requirement.driver and driver != requirement.driver: | |
| return _tool_guard_blocked( | |
| blocked_tool="run_ase", | |
| expected_next_action=next_action, | |
| repair_instruction=( | |
| f"The inferred task requires run_ase driver='{requirement.driver}', " | |
| f"but the requested tool call used driver='{driver}'. " | |
| "Call the correct driver before composing the answer." | |
| ), | |
| ) | |
| explicit_coordinate = _query_supplies_coordinate_file(query, input_file) | |
| if not explicit_coordinate and not _is_observed_coordinate_file_for_targets( | |
| observations, input_file, target_molecules | |
| ): | |
| if next_action == "molecule_name_to_smiles": | |
| repair = ( | |
| "Do not run ASE yet. The user named a molecule, but no SMILES " | |
| "or coordinate artifact exists in workflow state. First call " | |
| "molecule_name_to_smiles for the requested molecule, then " | |
| "smiles_to_coordinate_file, then retry run_ase with " | |
| f"driver='{requirement.driver}'." | |
| ) | |
| else: | |
| repair = ( | |
| "Do not run ASE yet. The specific input_structure_file is not " | |
| "a coordinate artifact in workflow state. Call " | |
| "smiles_to_coordinate_file first, then retry run_ase with " | |
| f"driver='{requirement.driver}' using the generated path." | |
| ) | |
| return _tool_guard_blocked( | |
| blocked_tool="run_ase", | |
| expected_next_action=allowed_tool or next_action, | |
| repair_instruction=repair, | |
| ) | |
| calculator_policy = _calculator_policy(requirement, query) | |
| selected_calculator = _calculator_family(params.get("calculator")) | |
| explicit_calculator = calculator_policy.get("explicit_request") | |
| if explicit_calculator and selected_calculator != explicit_calculator: | |
| return _tool_guard_blocked( | |
| blocked_tool="run_ase", | |
| expected_next_action="select_calculator", | |
| repair_instruction=( | |
| f"The user explicitly requested {explicit_calculator}, but " | |
| f"the tool call selected " | |
| f"{selected_calculator or 'the environment default'}. " | |
| f"Retry run_ase with calculator " | |
| f"{_calculator_payload_hint(explicit_calculator)} if it is " | |
| "available, or ask the user before substituting a different " | |
| "calculator." | |
| ), | |
| ) | |
| if ( | |
| calculator_policy.get("enforce") | |
| and selected_calculator != calculator_policy.get("preferred") | |
| ): | |
| return _tool_guard_blocked( | |
| blocked_tool="run_ase", | |
| expected_next_action="select_calculator", | |
| repair_instruction=( | |
| f"The user did not explicitly request a calculator. For " | |
| f"{requirement.task_type} tasks, use " | |
| f"{calculator_policy['preferred']} by default instead of " | |
| f"{selected_calculator or 'the environment default'}. " | |
| "Retry run_ase with calculator " | |
| "{'calculator_type': 'TBLite', 'method': 'GFN2-xTB'}." | |
| ), | |
| ) | |
| return _tool_guard_allowed() | |
| def _validate_unknown_tool_call_batch( | |
| query: str, | |
| prior_messages: Iterable[Any], | |
| calls: list[dict[str, Any]], | |
| ) -> dict[str, Any]: | |
| """Apply safety guards when the property intent is not mapped yet. | |
| Unknown intent should not mean unguarded execution. The LLM may still | |
| clarify the task or call lightweight resolver tools, but ASE execution must | |
| have an explicit driver and a real coordinate artifact. | |
| """ | |
| observations = _public_observations(_collect_observations(list(prior_messages or []))) | |
| for call in calls: | |
| if call.get("name") != "run_ase": | |
| continue | |
| args = call.get("args") or {} | |
| if not isinstance(args, dict): | |
| args = {} | |
| params = _run_ase_params(args) | |
| driver = params.get("driver") | |
| input_file = params.get("input_structure_file") | |
| if not driver: | |
| return _tool_guard_blocked( | |
| blocked_tool="run_ase", | |
| expected_next_action="clarify_intent", | |
| repair_instruction=( | |
| "Do not run ASE until the requested property maps to an " | |
| "explicit driver. First infer or ask for the target property " | |
| "(energy, dipole, vibration, IR, or thermochemistry), then " | |
| "retry run_ase with the matching driver." | |
| ), | |
| ) | |
| explicit_coordinate = _query_supplies_coordinate_file(query, input_file) | |
| if not explicit_coordinate and not _is_observed_coordinate_file( | |
| observations, input_file | |
| ): | |
| return _tool_guard_blocked( | |
| blocked_tool="run_ase", | |
| expected_next_action="smiles_to_coordinate_file", | |
| repair_instruction=( | |
| "Do not run ASE yet. Even for an unmapped query, run_ase " | |
| "requires an explicit coordinate artifact. Resolve the " | |
| "molecule identity if needed, generate or reuse an XYZ file, " | |
| "then retry run_ase with the explicit driver and generated " | |
| "input_structure_file." | |
| ), | |
| ) | |
| return _tool_guard_allowed() | |
| def build_workflow_state( | |
| query: str, | |
| messages: Iterable[Any], | |
| validation: Optional[dict[str, Any]] = None, | |
| ) -> dict[str, Any]: | |
| """Build structured workflow state from the message trace. | |
| ``messages`` remains the LLM-visible conversation, while this state is the | |
| machine-readable workflow ledger used for validation, UI rendering, and | |
| later eval/article evidence. | |
| """ | |
| message_list = list(messages or []) | |
| requirement = infer_requirement(query) | |
| observations = _collect_observations(message_list) | |
| validation_payload = validation or validate_completion(query, message_list) | |
| target_molecules = _infer_target_molecules(query, observations, message_list) | |
| next_action = _next_action( | |
| requirement, validation_payload, observations, target_molecules | |
| ) | |
| log_dir = os.environ.get("CHEMGRAPH_LOG_DIR") | |
| tool_status = _tool_requirement_status( | |
| requirement, observations, target_molecules | |
| ) | |
| action_plan = compile_action_plan( | |
| requirement=requirement, | |
| target_molecules=target_molecules, | |
| observations=observations, | |
| validation=validation_payload, | |
| next_action=next_action, | |
| ) | |
| identity_resolution = _identity_resolution_summary(observations) | |
| clarification = _clarification_state(identity_resolution, next_action) | |
| scientific_ledger = _scientific_ledger( | |
| requirement=requirement, | |
| target_molecules=target_molecules, | |
| observations=observations, | |
| validation=validation_payload, | |
| action_plan=action_plan, | |
| ) | |
| return { | |
| "run_context": { | |
| "raw_query": str(query or ""), | |
| "log_dir": log_dir, | |
| "status": "complete" if validation_payload.get("complete") else "needs_action", | |
| }, | |
| "intent": { | |
| "raw_query": str(query or ""), | |
| "task_type": requirement.task_type, | |
| "property": requirement.response_field, | |
| "required_output": requirement.required_output, | |
| "target_molecules": target_molecules, | |
| "calculator": _infer_calculator(query), | |
| "needs_tools": requirement.task_type != "unknown", | |
| }, | |
| "task_plan": { | |
| "required_tools": REQUIRED_TOOL_STEPS.get(requirement.task_type, []), | |
| "tool_requirements": _tool_requirements(requirement, target_molecules), | |
| "satisfied_tools": tool_status["satisfied_tools"], | |
| "missing_tools": tool_status["missing_tools"], | |
| "reusable_observations": tool_status["reusable_observations"], | |
| "postprocessing": _postprocessing_steps(requirement), | |
| "required_driver": requirement.driver, | |
| "required_output": requirement.required_output, | |
| "next_action": next_action, | |
| "next_ready_actions": action_plan["next_ready_actions"], | |
| "parallelizable": _parallelization_plan(requirement, target_molecules), | |
| "calculator_policy": _calculator_policy(requirement, query), | |
| }, | |
| "action_plan": action_plan, | |
| "action_states": action_plan["actions"], | |
| "identity_resolution": identity_resolution, | |
| "clarification": clarification, | |
| "memory_refs": _memory_refs(observations), | |
| "tool_trace": observations["tool_trace"], | |
| "observations": _public_observations(observations), | |
| "artifacts": observations["artifacts"], | |
| "scientific_ledger": scientific_ledger, | |
| "validation": validation_payload, | |
| } | |
| def compile_action_plan( | |
| *, | |
| requirement: TaskRequirement, | |
| target_molecules: list[str], | |
| observations: dict[str, Any], | |
| validation: dict[str, Any], | |
| next_action: str, | |
| ) -> dict[str, Any]: | |
| """Compile an additive action ledger from the current workflow state. | |
| This is intentionally pure and conservative. It gives the LLM, UI, and | |
| future scheduler a dependency-aware view without replacing the existing | |
| LangGraph execution loop in one step. | |
| """ | |
| if requirement.task_type == "unknown": | |
| return { | |
| "mode": "direct_answer", | |
| "actions": [], | |
| "next_ready_actions": ["compose_final_answer"], | |
| "status": "complete" if validation.get("complete") else "needs_action", | |
| } | |
| targets = target_molecules or ["target_molecule"] | |
| actions: list[dict[str, Any]] = [] | |
| if next_action == "clarify_identity": | |
| actions.append( | |
| { | |
| "id": "clarify_identity", | |
| "action_type": "clarification", | |
| "tool": "ask_human", | |
| "target": targets[0], | |
| "depends_on": ["molecule_name_to_smiles"], | |
| "provides": ["identity_resolution.confirmed_component"], | |
| "status": "ready", | |
| "reason": "Identity resolver marked the target as ambiguous or mixture-like.", | |
| } | |
| ) | |
| for target in targets: | |
| target_key = _action_target_key(target) | |
| identity_status = _identity_action_status(observations, next_action, target) | |
| actions.append( | |
| { | |
| "id": f"{target_key}:identity", | |
| "action_type": "tool", | |
| "tool": "molecule_name_to_smiles", | |
| "target": target, | |
| "depends_on": [], | |
| "provides": [f"entities.{target_key}.smiles"], | |
| "status": identity_status, | |
| } | |
| ) | |
| if requirement.task_type == "smiles": | |
| continue | |
| coordinate_status = _coordinate_action_status( | |
| observations, next_action, target | |
| ) | |
| actions.append( | |
| { | |
| "id": f"{target_key}:coordinates", | |
| "action_type": "tool", | |
| "tool": "smiles_to_coordinate_file", | |
| "target": target, | |
| "depends_on": [f"{target_key}:identity"], | |
| "provides": [f"entities.{target_key}.coordinate_file"], | |
| "status": coordinate_status, | |
| } | |
| ) | |
| ase_status = _ase_action_status( | |
| requirement, observations, next_action, target | |
| ) | |
| actions.append( | |
| { | |
| "id": f"{target_key}:run_ase:{requirement.driver}", | |
| "action_type": "tool", | |
| "tool": "run_ase", | |
| "target": target, | |
| "depends_on": [f"{target_key}:coordinates"], | |
| "provides": [f"outputs.{requirement.task_type}.{target_key}"], | |
| "status": ase_status, | |
| "driver": requirement.driver, | |
| } | |
| ) | |
| if requirement.task_type in {"reaction_gibbs_energy", "reaction_enthalpy"}: | |
| actions.append( | |
| { | |
| "id": "aggregate_reaction_property", | |
| "action_type": "postprocess", | |
| "tool": None, | |
| "target": "reaction", | |
| "depends_on": [ | |
| action["id"] | |
| for action in actions | |
| if action.get("tool") == "run_ase" | |
| ], | |
| "provides": [f"outputs.{requirement.task_type}.reaction"], | |
| "status": "succeeded" if validation.get("complete") else "pending", | |
| } | |
| ) | |
| return { | |
| "mode": "action_dag", | |
| "actions": actions, | |
| "next_ready_actions": [ | |
| action["id"] for action in actions if action.get("status") == "ready" | |
| ], | |
| "status": "complete" if validation.get("complete") else "needs_action", | |
| } | |
| def _scientific_ledger( | |
| *, | |
| requirement: TaskRequirement, | |
| target_molecules: list[str], | |
| observations: dict[str, Any], | |
| validation: dict[str, Any], | |
| action_plan: dict[str, Any], | |
| ) -> dict[str, Any]: | |
| """Build a minimal requirement/artifact ledger for mapped workflows.""" | |
| run_id = os.environ.get("CHEMGRAPH_LOG_DIR") | |
| requirements = [ | |
| _requirement_from_action(action, requirement, observations, run_id).as_dict() | |
| for action in action_plan.get("actions", []) | |
| ] | |
| artifacts = [ | |
| artifact.as_dict() | |
| for artifact in _artifact_registry( | |
| requirement, | |
| observations, | |
| target_molecules, | |
| run_id, | |
| ) | |
| ] | |
| status = "complete" if validation.get("complete") else "blocked" | |
| if any(req.get("status") == "failed" for req in requirements): | |
| status = "failed" | |
| return { | |
| "status": status, | |
| "can_answer": bool(validation.get("complete")), | |
| "run_id": run_id, | |
| "current_scope_id": observations.get("current_scope_id"), | |
| "requirements": requirements, | |
| "artifacts": artifacts, | |
| "missing": list(validation.get("missing", []) or []), | |
| "validated_artifacts": [ | |
| artifact for artifact in artifacts if artifact.get("valid") | |
| ], | |
| } | |
| def _requirement_from_action( | |
| action: dict[str, Any], | |
| requirement: TaskRequirement, | |
| observations: dict[str, Any], | |
| run_id: Optional[str], | |
| ) -> Requirement: | |
| """Translate an action-plan row into a stable scientific requirement.""" | |
| tool = action.get("tool") | |
| action_type = action.get("action_type") | |
| kind = { | |
| "molecule_name_to_smiles": "identity", | |
| "smiles_to_coordinate_file": "xyz", | |
| "run_ase": requirement.driver or "ase", | |
| None: "aggregation" if action_type == "postprocess" else str(action_type), | |
| }.get(tool, str(tool or action_type or "unknown")) | |
| raw_status = str(action.get("status") or "pending") | |
| status = { | |
| "succeeded": "done", | |
| "ready": "pending", | |
| "pending": "pending", | |
| "blocked": "blocked", | |
| "failed": "failed", | |
| }.get(raw_status, raw_status) | |
| target = action.get("target") | |
| species = None if target in {None, "reaction"} else str(target) | |
| error = None | |
| if status == "failed": | |
| failed_ase = _unresolved_current_failed_ase( | |
| observations, | |
| driver=action.get("driver"), | |
| ) or _last_failed_ase(observations) | |
| if failed_ase: | |
| error = failed_ase.get("message") or failed_ase.get("error_type") | |
| satisfied_by = ( | |
| [] | |
| if status == "failed" | |
| else _satisfied_artifact_ids(kind, species, observations) | |
| ) | |
| return Requirement( | |
| id=str(action.get("id") or kind), | |
| kind=kind, | |
| species=species, | |
| property=requirement.task_type if tool == "run_ase" else None, | |
| status=status, | |
| depends_on=[str(item) for item in action.get("depends_on", [])], | |
| satisfied_by=satisfied_by, | |
| error=error, | |
| scope_id=observations.get("current_scope_id"), | |
| run_id=run_id, | |
| ) | |
| def _artifact_registry( | |
| requirement: TaskRequirement, | |
| observations: dict[str, Any], | |
| target_molecules: list[str], | |
| run_id: Optional[str], | |
| ) -> list[Artifact]: | |
| """Return compact artifacts produced by resolver, coordinate, and ASE tools.""" | |
| artifacts: list[Artifact] = [] | |
| for index, record in enumerate(observations.get("identity_resolutions", []), start=1): | |
| species = record.get("input_name") or record.get("resolved_name") | |
| valid = bool(record.get("smiles")) and not ( | |
| record.get("requires_clarification") | |
| or record.get("needs_clarification") | |
| or _identity_low_credibility(record) | |
| ) | |
| artifacts.append( | |
| Artifact( | |
| id=f"art_identity_{_action_target_key(species or index)}", | |
| requirement_id=( | |
| f"{_action_target_key(species)}:identity" if species else None | |
| ), | |
| species=str(species) if species else None, | |
| kind="smiles", | |
| path=None, | |
| calculator=None, | |
| driver=None, | |
| fields=["smiles"] if record.get("smiles") else [], | |
| parse_ok=bool(record), | |
| valid=valid, | |
| error=record.get("warning") if not valid else None, | |
| scope_id=record.get("scope_id"), | |
| run_id=run_id, | |
| ) | |
| ) | |
| for path in observations.get("coordinate_files", []): | |
| species = _species_for_file_name(observations, path) or _species_from_file_name(path) | |
| artifact_run_id = _run_id_from_path(path, default=run_id) | |
| artifacts.append( | |
| Artifact( | |
| id=f"art_xyz_{_action_target_key(species or Path(str(path)).stem)}", | |
| requirement_id=( | |
| f"{_action_target_key(species)}:coordinates" if species else None | |
| ), | |
| species=str(species) if species else None, | |
| kind="xyz", | |
| path=str(path), | |
| calculator=None, | |
| driver=None, | |
| fields=["atomic_coordinates"], | |
| parse_ok=True, | |
| valid=True, | |
| scope_id=None, | |
| run_id=artifact_run_id, | |
| ) | |
| ) | |
| for index, result in enumerate(observations.get("ase_results", []), start=1): | |
| species = result.get("species") | |
| driver = result.get("driver") | |
| status = str(result.get("status") or "").lower() | |
| failed = status in {"error", "failed", "failure"} | |
| fields = _ase_fields_for_species(requirement, observations, species) | |
| artifact_path = result.get("output_results_file") | |
| artifact_run_id = _run_id_from_path(artifact_path, default=run_id) | |
| artifacts.append( | |
| Artifact( | |
| id=f"art_ase_{_action_target_key(species or index)}_{driver or 'unknown'}", | |
| requirement_id=( | |
| f"{_action_target_key(species)}:run_ase:{driver}" | |
| if species and driver | |
| else None | |
| ), | |
| species=str(species) if species else None, | |
| kind="ase_json", | |
| path=artifact_path, | |
| calculator=result.get("calculator"), | |
| driver=str(driver) if driver else None, | |
| fields=fields, | |
| parse_ok=not failed, | |
| valid=(not failed) and bool(fields), | |
| error=result.get("message") if failed else None, | |
| scope_id=result.get("scope_id"), | |
| run_id=artifact_run_id, | |
| ) | |
| ) | |
| return artifacts | |
| def _satisfied_artifact_ids( | |
| kind: str, | |
| species: Optional[str], | |
| observations: dict[str, Any], | |
| ) -> list[str]: | |
| species_key = _action_target_key(species or "") | |
| if kind == "identity" and _target_smiles(observations, [species] if species else []): | |
| return [f"art_identity_{species_key}"] | |
| if kind == "xyz" and _target_coordinate_files( | |
| observations, [species] if species else [] | |
| ): | |
| return [f"art_xyz_{species_key}"] | |
| if kind in {"energy", "dipole", "thermo", "vib", "ir"} and species: | |
| fields = _ase_fields_for_species( | |
| PROPERTY_TOOL_MAPPING.get(kind, PROPERTY_TOOL_MAPPING["unknown"]), | |
| observations, | |
| species, | |
| ) | |
| if fields: | |
| return [f"art_ase_{species_key}_{kind}"] | |
| return [] | |
| def _ase_fields_for_species( | |
| requirement: TaskRequirement, | |
| observations: dict[str, Any], | |
| species: Optional[str], | |
| ) -> list[str]: | |
| targets = [species] if species else [] | |
| fields = [] | |
| if _last_matching_target(observations["energies"], targets, observations): | |
| fields.append("single_point_energy") | |
| if _last_matching_target(observations["enthalpies"], targets, observations): | |
| fields.append("enthalpy") | |
| if _last_matching_target(observations["thermo"], targets, observations): | |
| fields.append("gibbs_free_energy") | |
| if _last_matching_target(observations["dipoles"], targets, observations): | |
| fields.append("dipole_moment") | |
| if _last_matching_target(observations["vibrations"], targets, observations): | |
| fields.append("vibrational_frequencies") | |
| if _last_matching_target(observations["ir_spectra"], targets, observations): | |
| fields.append("ir_spectrum") | |
| if requirement.task_type == "reaction_enthalpy" and "enthalpy" in fields: | |
| fields.append("reaction_species_enthalpy") | |
| if requirement.task_type == "reaction_gibbs_energy" and "gibbs_free_energy" in fields: | |
| fields.append("reaction_species_gibbs_free_energy") | |
| return _dedupe_preserve_order(fields) | |
| def extract_query_from_messages(messages: Iterable[Any]) -> str: | |
| """Return the latest human/user message content from a message history.""" | |
| message_list = list(messages or []) | |
| for message in reversed(message_list): | |
| if _message_type(message) in {"human", "user"}: | |
| content = str(_message_content(message)) | |
| if _is_internal_repair_message(content): | |
| continue | |
| return content | |
| first = next(iter(message_list), None) | |
| return str(_message_content(first)) if first is not None else "" | |
| def _is_internal_repair_message(content: str) -> bool: | |
| """Return True for validator-generated repair prompts, not user queries.""" | |
| text = str(content or "").lstrip().lower() | |
| return text.startswith("validation failed:") | |
| def _validate_reaction_gibbs( | |
| query: str, | |
| requirement: TaskRequirement, | |
| observations: dict[str, Any], | |
| messages: Iterable[Any], | |
| ) -> dict[str, Any]: | |
| reaction = parse_reaction_from_context(query, messages) | |
| if reaction is None: | |
| return _incomplete_result( | |
| requirement, | |
| ["balanced_reaction"], | |
| ( | |
| "Determine the balanced reaction using chemistry reasoning, then " | |
| "state it exactly as `Balanced reaction: reactants -> products` " | |
| "before calling species tools." | |
| ), | |
| ) | |
| missing = [] | |
| species_gibbs = observations["species_gibbs"] | |
| for side in ("reactants", "products"): | |
| for species in reaction[side]: | |
| key = normalize_species_name(species.name) | |
| if key not in species_gibbs: | |
| missing.append(f"{side}.{species.name}.gibbs_free_energy") | |
| if missing: | |
| return _incomplete_result( | |
| requirement, | |
| missing, | |
| ( | |
| "For each missing reaction species, call molecule_name_to_smiles, " | |
| "smiles_to_coordinate_file, then run_ase with driver='thermo'. " | |
| "Do not answer until every reactant and product has a Gibbs free energy." | |
| ), | |
| ) | |
| reactant_sum = sum( | |
| species.coefficient * species_gibbs[normalize_species_name(species.name)]["value"] | |
| for species in reaction["reactants"] | |
| ) | |
| product_sum = sum( | |
| species.coefficient * species_gibbs[normalize_species_name(species.name)]["value"] | |
| for species in reaction["products"] | |
| ) | |
| value = float(product_sum - reactant_sum) | |
| unit = _first_unit(species_gibbs.values()) or "eV" | |
| gibbs_scalar = { | |
| "value": value, | |
| "property": "Gibbs free energy of reaction", | |
| "unit": unit, | |
| } | |
| scalar_answers = [gibbs_scalar] | |
| enthalpy_scalar = _reaction_scalar_if_available( | |
| reaction, | |
| observations["species_enthalpy"], | |
| "Reaction enthalpy", | |
| ) | |
| if enthalpy_scalar is not None: | |
| scalar_answers.append(enthalpy_scalar) | |
| return _complete_result( | |
| requirement, | |
| structured_output=_empty_structured( | |
| scalar_answer=gibbs_scalar, | |
| scalar_answers=scalar_answers, | |
| ), | |
| reason="Computed deterministically as products minus reactants.", | |
| ) | |
| def _validate_reaction_enthalpy( | |
| query: str, | |
| requirement: TaskRequirement, | |
| observations: dict[str, Any], | |
| messages: Iterable[Any], | |
| ) -> dict[str, Any]: | |
| reaction = parse_reaction_from_context(query, messages) | |
| if reaction is None: | |
| return _incomplete_result( | |
| requirement, | |
| ["balanced_reaction"], | |
| ( | |
| "Determine the balanced reaction using chemistry reasoning, then " | |
| "state it exactly as `Balanced reaction: reactants -> products` " | |
| "before calling species tools." | |
| ), | |
| ) | |
| missing = [] | |
| species_enthalpy = observations["species_enthalpy"] | |
| for side in ("reactants", "products"): | |
| for species in reaction[side]: | |
| key = normalize_species_name(species.name) | |
| if key not in species_enthalpy: | |
| missing.append(f"{side}.{species.name}.enthalpy") | |
| if missing: | |
| return _incomplete_result( | |
| requirement, | |
| missing, | |
| ( | |
| "For each missing reaction species, call molecule_name_to_smiles, " | |
| "smiles_to_coordinate_file, then run_ase with driver='thermo'. " | |
| "Do not answer until every reactant and product has an enthalpy." | |
| ), | |
| ) | |
| reactant_sum = sum( | |
| species.coefficient | |
| * species_enthalpy[normalize_species_name(species.name)]["value"] | |
| for species in reaction["reactants"] | |
| ) | |
| product_sum = sum( | |
| species.coefficient | |
| * species_enthalpy[normalize_species_name(species.name)]["value"] | |
| for species in reaction["products"] | |
| ) | |
| value = float(product_sum - reactant_sum) | |
| unit = _first_unit(species_enthalpy.values()) or "eV" | |
| enthalpy_scalar = { | |
| "value": value, | |
| "property": "Reaction enthalpy", | |
| "unit": unit, | |
| } | |
| scalar_answers = [enthalpy_scalar] | |
| gibbs_scalar = _reaction_scalar_if_available( | |
| reaction, | |
| observations["species_gibbs"], | |
| "Gibbs free energy of reaction", | |
| ) | |
| if gibbs_scalar is not None: | |
| scalar_answers.append(gibbs_scalar) | |
| return _complete_result( | |
| requirement, | |
| structured_output=_empty_structured( | |
| scalar_answer=enthalpy_scalar, | |
| scalar_answers=scalar_answers, | |
| ), | |
| reason="Computed deterministically as products minus reactants.", | |
| ) | |
| def _reaction_scalar_if_available( | |
| reaction: dict[str, list[ReactionSpecies]], | |
| species_values: dict[str, dict[str, Any]], | |
| property_name: str, | |
| ) -> Optional[dict[str, Any]]: | |
| """Return a reaction scalar when every species has the needed value.""" | |
| if not species_values: | |
| return None | |
| for side in ("reactants", "products"): | |
| for species in reaction[side]: | |
| if normalize_species_name(species.name) not in species_values: | |
| return None | |
| reactant_sum = sum( | |
| species.coefficient | |
| * species_values[normalize_species_name(species.name)]["value"] | |
| for species in reaction["reactants"] | |
| ) | |
| product_sum = sum( | |
| species.coefficient | |
| * species_values[normalize_species_name(species.name)]["value"] | |
| for species in reaction["products"] | |
| ) | |
| return { | |
| "value": float(product_sum - reactant_sum), | |
| "property": property_name, | |
| "unit": _first_unit(species_values.values()) or "eV", | |
| } | |
| def parse_reaction(query: str) -> Optional[dict[str, list[ReactionSpecies]]]: | |
| """Parse a simple balanced reaction phrase from a query.""" | |
| text = str(query or "") | |
| match = re.search( | |
| r"(?:the\s+)?balanced\s+(?:reaction|equation)(?:\s+is)?\s*:?\s*(.+)", | |
| text, | |
| flags=re.IGNORECASE | re.DOTALL, | |
| ) | |
| if match: | |
| reaction_text = match.group(1).strip() | |
| elif "->" in text or "→" in text: | |
| reaction_text = text.strip() | |
| else: | |
| return None | |
| reaction_text = re.split(r"(?:\n|;\s|\.\s)", reaction_text, maxsplit=1)[0].strip() | |
| if "->" in reaction_text: | |
| left, right = reaction_text.split("->", 1) | |
| elif "→" in reaction_text: | |
| left, right = reaction_text.split("→", 1) | |
| else: | |
| return None | |
| reactants = _parse_reaction_side(left) | |
| products = _parse_reaction_side(right) | |
| if not reactants or not products: | |
| return None | |
| return {"reactants": reactants, "products": products} | |
| def parse_reaction_from_context( | |
| query: str, | |
| messages: Iterable[Any], | |
| ) -> Optional[dict[str, list[ReactionSpecies]]]: | |
| """Parse a balanced reaction from the user query or agent-visible context.""" | |
| parsed = parse_reaction(query) | |
| if parsed is not None: | |
| return parsed | |
| for message in reversed(list(messages or [])): | |
| if _message_type(message) == "tool": | |
| continue | |
| content = str(_message_content(message) or "") | |
| if _is_internal_repair_message(content): | |
| continue | |
| parsed = parse_reaction(content) | |
| if parsed is not None: | |
| return parsed | |
| return None | |
| def normalize_species_name(name: str) -> str: | |
| """Normalize species names for matching query text to tool outputs.""" | |
| return "".join(ch for ch in str(name).lower() if ch.isalnum()) | |
| def _parse_reaction_side(side: str) -> list[ReactionSpecies]: | |
| species = [] | |
| for raw_term in re.split(r"\s+\+\s+", side.strip()): | |
| term = raw_term.strip() | |
| if not term: | |
| continue | |
| match = re.match(r"^(?:(\d+(?:\.\d+)?)\s+)?(.+?)\s*$", term) | |
| if not match: | |
| continue | |
| coefficient = float(match.group(1) or 1.0) | |
| name = match.group(2).strip() | |
| species.append(ReactionSpecies(name=name, coefficient=coefficient)) | |
| return species | |
| def _collect_observations(messages: Iterable[Any]) -> dict[str, Any]: | |
| observations: dict[str, Any] = { | |
| "identity_resolutions": [], | |
| "smiles": [], | |
| "energies": [], | |
| "thermo": [], | |
| "enthalpies": [], | |
| "dipoles": [], | |
| "vibrations": [], | |
| "ir_spectra": [], | |
| "species_gibbs": {}, | |
| "species_enthalpy": {}, | |
| "calculator_results": [], | |
| "coordinate_files": [], | |
| "ase_results": [], | |
| "tool_trace": [], | |
| "artifacts": { | |
| "xyz": [], | |
| "output_json": [], | |
| "ir_plot": [], | |
| "frequencies_csv": [], | |
| }, | |
| "current_scope_id": None, | |
| "_species_by_smiles": {}, | |
| "_species_by_file_key": {}, | |
| } | |
| current_species: Optional[str] = None | |
| tool_calls_by_id: dict[str, dict[str, Any]] = {} | |
| current_scope_id: Optional[str] = None | |
| scope_counter = 0 | |
| for message in messages or []: | |
| msg_type = _message_type(message) | |
| content = str(_message_content(message) or "") | |
| if msg_type in {"human", "user"} and not _is_internal_repair_message(content): | |
| scope_counter += 1 | |
| current_scope_id = f"scope_{scope_counter}" | |
| observations["current_scope_id"] = current_scope_id | |
| for tool_call in _message_tool_calls(message): | |
| call_id = tool_call.get("id") | |
| if call_id: | |
| tool_calls_by_id[str(call_id)] = tool_call | |
| name = _message_name(message) | |
| data = _content_to_data(_message_content(message)) | |
| call_args = _tool_call_args_for_message(message, tool_calls_by_id) | |
| tool_call_id = _tool_call_id(message) | |
| if name: | |
| _append_tool_trace(observations, name, data, call_args, tool_call_id) | |
| _collect_artifacts_from_value(observations, data) | |
| _collect_artifacts_from_value(observations, call_args) | |
| if name in {"molecule_name_to_smiles", "resolve_molecule_identity"} and isinstance( | |
| data, dict | |
| ): | |
| current_species = str( | |
| data.get("name") or call_args.get("name") or current_species or "" | |
| ) | |
| identity_record = _identity_resolution_record(data, call_args) | |
| if identity_record: | |
| identity_record["scope_id"] = current_scope_id | |
| observations["identity_resolutions"].append(identity_record) | |
| if data.get("requires_clarification") or data.get("needs_clarification"): | |
| continue | |
| smiles = data.get("smiles") or call_args.get("smiles") | |
| if smiles and smiles not in observations["smiles"]: | |
| observations["smiles"].append(smiles) | |
| if smiles and current_species: | |
| _remember_smiles_species(observations, str(smiles), current_species) | |
| continue | |
| if name == "smiles_to_coordinate_file" and isinstance(data, dict): | |
| smiles = data.get("smiles") or call_args.get("smiles") | |
| if smiles and smiles not in observations["smiles"]: | |
| observations["smiles"].append(smiles) | |
| species = _species_for_smiles(observations, smiles) | |
| if not species: | |
| output_file = call_args.get("output_file") or data.get("path") | |
| species = _species_for_file_name(observations, output_file) | |
| path = data.get("path") or call_args.get("output_file") | |
| if path and species: | |
| _remember_file_species(observations, str(path), species) | |
| if path and path not in observations["coordinate_files"]: | |
| observations["coordinate_files"].append(str(path)) | |
| _remember_artifact(observations, "xyz", str(path)) | |
| output_file = call_args.get("output_file") | |
| if output_file and species: | |
| _remember_file_species(observations, str(output_file), species) | |
| if output_file: | |
| _remember_artifact(observations, "xyz", str(output_file)) | |
| continue | |
| if name == "run_ase" and isinstance(data, dict): | |
| run_params = _run_ase_params(call_args) | |
| output_results_file = ( | |
| run_params.get("output_results_file") | |
| or data.get("output_results_file") | |
| or _first_path_from_value(data, suffix=".json") | |
| ) | |
| if output_results_file and not run_params.get("output_results_file"): | |
| run_params = { | |
| **run_params, | |
| "output_results_file": output_results_file, | |
| } | |
| species = _species_for_run_ase(observations, run_params, current_species) | |
| _collect_run_ase_observation(data, observations, species, current_scope_id) | |
| observations["ase_results"].append( | |
| { | |
| "driver": run_params.get("driver"), | |
| "input_structure_file": run_params.get("input_structure_file"), | |
| "output_results_file": output_results_file, | |
| "species": species, | |
| "calculator": _calculator_family(run_params.get("calculator")), | |
| "status": data.get("status"), | |
| "error_type": data.get("error_type"), | |
| "message": data.get("message"), | |
| "scope_id": current_scope_id, | |
| } | |
| ) | |
| continue | |
| if name == "calculator": | |
| numeric = _to_float(data) | |
| if numeric is not None: | |
| observations["calculator_results"].append(numeric) | |
| return observations | |
| def _message_tool_calls(message: Any) -> list[dict[str, Any]]: | |
| """Return normalized AI tool calls from a LangChain message.""" | |
| if isinstance(message, dict): | |
| raw_calls = message.get("tool_calls") or ( | |
| message.get("additional_kwargs") or {} | |
| ).get("tool_calls") | |
| else: | |
| raw_calls = getattr(message, "tool_calls", None) or ( | |
| getattr(message, "additional_kwargs", {}) or {} | |
| ).get("tool_calls") | |
| calls: list[dict[str, Any]] = [] | |
| for raw_call in raw_calls or []: | |
| if not isinstance(raw_call, dict): | |
| continue | |
| if "function" in raw_call: | |
| function = raw_call.get("function") or {} | |
| args = _content_to_data(function.get("arguments") or {}) or {} | |
| calls.append( | |
| { | |
| "id": raw_call.get("id"), | |
| "name": function.get("name"), | |
| "args": args if isinstance(args, dict) else {}, | |
| } | |
| ) | |
| continue | |
| calls.append( | |
| { | |
| "id": raw_call.get("id"), | |
| "name": raw_call.get("name"), | |
| "args": raw_call.get("args") or {}, | |
| } | |
| ) | |
| return calls | |
| def _tool_call_id(message: Any) -> Optional[str]: | |
| if isinstance(message, dict): | |
| value = message.get("tool_call_id") | |
| else: | |
| value = getattr(message, "tool_call_id", None) | |
| return str(value) if value else None | |
| def _tool_call_args_for_message( | |
| message: Any, | |
| tool_calls_by_id: dict[str, dict[str, Any]], | |
| ) -> dict[str, Any]: | |
| call_id = _tool_call_id(message) | |
| if not call_id: | |
| return {} | |
| call = tool_calls_by_id.get(call_id) or {} | |
| args = call.get("args") or {} | |
| return args if isinstance(args, dict) else {} | |
| def _run_ase_params(call_args: dict[str, Any]) -> dict[str, Any]: | |
| params = call_args.get("params") if isinstance(call_args, dict) else None | |
| if isinstance(params, dict): | |
| return params | |
| return call_args if isinstance(call_args, dict) else {} | |
| def _identity_resolution_record( | |
| data: dict[str, Any], | |
| call_args: dict[str, Any], | |
| ) -> dict[str, Any]: | |
| smiles = data.get("smiles") or call_args.get("smiles") | |
| name = data.get("name") or data.get("input_name") or call_args.get("name") | |
| if not smiles and not name: | |
| return {} | |
| return { | |
| "input_name": name, | |
| "resolved_name": data.get("resolved_name") or name, | |
| "smiles": smiles, | |
| "canonical_smiles": data.get("canonical_smiles"), | |
| "isomeric_smiles": data.get("isomeric_smiles"), | |
| "connectivity_smiles": data.get("connectivity_smiles"), | |
| "molecular_formula": data.get("molecular_formula"), | |
| "inchikey": data.get("inchikey"), | |
| "source": data.get("source") or "unknown", | |
| "cid": data.get("cid"), | |
| "confidence_score": data.get("confidence_score"), | |
| "credibility_score": data.get("credibility_score"), | |
| "score_breakdown": data.get("score_breakdown") or {}, | |
| "identity_flags": data.get("identity_flags") or {}, | |
| "resolver_provenance": data.get("resolver_provenance") or {}, | |
| "ambiguity_flag": bool(data.get("ambiguity_flag", False)), | |
| "mixture_flag": bool(data.get("mixture_flag", False)), | |
| "is_mixture": bool(data.get("is_mixture", data.get("mixture_flag", False))), | |
| "requires_clarification": bool(data.get("requires_clarification", False)), | |
| "needs_clarification": bool( | |
| data.get("needs_clarification", data.get("requires_clarification", False)) | |
| ), | |
| "representative_of": data.get("representative_of"), | |
| "selection_reason": data.get("selection_reason"), | |
| "warning": data.get("warning"), | |
| "warnings": data.get("warnings") or [], | |
| "candidates": _summarize_tool_output(data.get("candidates") or []), | |
| } | |
| def _query_supplies_coordinate_file(query: str, input_file: Any) -> bool: | |
| """Return True when the user explicitly supplied a coordinate file path.""" | |
| if not input_file: | |
| return False | |
| query_text = str(query or "") | |
| input_text = str(input_file) | |
| if input_text and input_text in query_text: | |
| return True | |
| input_name = Path(input_text).name | |
| if input_name and re.search(rf"(?<![A-Za-z0-9_.-]){re.escape(input_name)}(?![A-Za-z0-9_.-])", query_text): | |
| return True | |
| return False | |
| def _query_supplies_smiles(query: str, smiles: Any) -> bool: | |
| """Return True when the user explicitly supplied the SMILES string.""" | |
| if not smiles: | |
| return False | |
| query_text = str(query or "") | |
| smiles_text = str(smiles) | |
| if not smiles_text: | |
| return False | |
| if len(smiles_text) <= 2: | |
| return bool( | |
| re.search( | |
| rf"(?<![A-Za-z0-9]){re.escape(smiles_text)}(?![A-Za-z0-9])", | |
| query_text, | |
| ) | |
| ) | |
| if smiles_text in query_text: | |
| return True | |
| return False | |
| def _is_observed_coordinate_file( | |
| observations: dict[str, Any], | |
| input_file: Any, | |
| ) -> bool: | |
| """Return True when input_file matches a generated coordinate artifact.""" | |
| if not input_file: | |
| return False | |
| input_keys = set(_file_keys(str(input_file))) | |
| for observed in observations.get("coordinate_files", []): | |
| observed_keys = set(_file_keys(str(observed))) | |
| if input_keys & observed_keys: | |
| return True | |
| return False | |
| def _is_observed_coordinate_file_for_targets( | |
| observations: dict[str, Any], | |
| input_file: Any, | |
| target_molecules: Iterable[str], | |
| ) -> bool: | |
| """Return True only when input_file is generated for the current target.""" | |
| if not _is_observed_coordinate_file(observations, input_file): | |
| return False | |
| targets = list(target_molecules or []) | |
| if not targets: | |
| return True | |
| species = _species_for_file_name(observations, input_file) or _species_from_file_name( | |
| input_file | |
| ) | |
| return _species_matches_targets(species, targets) | |
| def _is_observed_identity_name( | |
| observations: dict[str, Any], | |
| requested_name: Any, | |
| ) -> bool: | |
| if not requested_name: | |
| return False | |
| requested = _normalize_label(str(requested_name)) | |
| for record in observations.get("identity_resolutions", []): | |
| for value in ( | |
| record.get("input_name"), | |
| record.get("resolved_name"), | |
| record.get("representative_of"), | |
| ): | |
| if value and _normalize_label(str(value)) == requested: | |
| return True | |
| return False | |
| def _normalize_label(value: str) -> str: | |
| return re.sub(r"[^a-z0-9]+", " ", str(value or "").lower()).strip() | |
| def _remember_smiles_species( | |
| observations: dict[str, Any], | |
| smiles: str, | |
| species: str, | |
| ) -> None: | |
| species_by_smiles = observations["_species_by_smiles"] | |
| species_set = species_by_smiles.setdefault(str(smiles), set()) | |
| species_set.add(str(species)) | |
| def _species_for_smiles( | |
| observations: dict[str, Any], | |
| smiles: Any, | |
| ) -> Optional[str]: | |
| if not smiles: | |
| return None | |
| species_set = observations["_species_by_smiles"].get(str(smiles), set()) | |
| if len(species_set) == 1: | |
| return next(iter(species_set)) | |
| return None | |
| def _remember_file_species( | |
| observations: dict[str, Any], | |
| file_path: str, | |
| species: str, | |
| ) -> None: | |
| for key in _file_keys(file_path): | |
| observations["_species_by_file_key"][key] = str(species) | |
| def _species_for_file_name( | |
| observations: dict[str, Any], | |
| file_path: Any, | |
| ) -> Optional[str]: | |
| if not file_path: | |
| return None | |
| species_by_file_key = observations["_species_by_file_key"] | |
| for key in _file_keys(str(file_path)): | |
| species = species_by_file_key.get(key) | |
| if species: | |
| return species | |
| return None | |
| def _species_for_run_ase( | |
| observations: dict[str, Any], | |
| run_params: dict[str, Any], | |
| fallback_species: Optional[str], | |
| ) -> Optional[str]: | |
| input_file = run_params.get("input_structure_file") | |
| species = _species_for_file_name(observations, input_file) | |
| if species: | |
| return species | |
| output_file = run_params.get("output_results_file") | |
| species = _species_for_file_name(observations, output_file) | |
| if species: | |
| return species | |
| inferred = _species_from_file_name(input_file) or _species_from_file_name(output_file) | |
| return inferred or fallback_species | |
| def _file_keys(file_path: str) -> list[str]: | |
| raw = str(file_path) | |
| path = Path(raw) | |
| keys = { | |
| raw, | |
| raw.lower(), | |
| os.path.normpath(raw).lower(), | |
| path.name.lower(), | |
| path.stem.lower(), | |
| normalize_species_name(path.stem), | |
| } | |
| return [key for key in keys if key] | |
| def _species_from_file_name(file_path: Any) -> Optional[str]: | |
| if not file_path: | |
| return None | |
| stem = Path(str(file_path)).stem | |
| if not stem: | |
| return None | |
| normalized = stem.lower() | |
| for prefix in ("output_", "result_", "results_"): | |
| if normalized.startswith(prefix): | |
| normalized = normalized[len(prefix) :] | |
| for suffix in ("_output", "_result", "_results"): | |
| if normalized.endswith(suffix): | |
| normalized = normalized[: -len(suffix)] | |
| normalized = normalized.replace("_", " ").replace("-", " ").strip() | |
| return normalized or None | |
| def _collect_run_ase_observation( | |
| data: dict[str, Any], | |
| observations: dict[str, Any], | |
| current_species: Optional[str], | |
| scope_id: Optional[str] = None, | |
| ) -> None: | |
| energy = data.get("single_point_energy") | |
| if energy is not None: | |
| numeric_energy = _to_float(energy) | |
| if numeric_energy is not None: | |
| observations["energies"].append( | |
| _with_species( | |
| {"value": numeric_energy, "unit": data.get("unit", "eV")}, | |
| current_species, | |
| scope_id, | |
| ) | |
| ) | |
| thermo = _nested(data, "result", "thermochemistry") or data.get("thermochemistry") | |
| if isinstance(thermo, dict): | |
| enthalpy = _to_float(thermo.get("enthalpy")) | |
| if enthalpy is not None: | |
| item = _with_species( | |
| {"value": enthalpy, "unit": thermo.get("unit", "eV")}, | |
| current_species, | |
| scope_id, | |
| ) | |
| observations["enthalpies"].append(item) | |
| if current_species: | |
| observations["species_enthalpy"][ | |
| normalize_species_name(current_species) | |
| ] = item | |
| gibbs = _to_float(thermo.get("gibbs_free_energy")) | |
| if gibbs is not None: | |
| item = _with_species( | |
| {"value": gibbs, "unit": thermo.get("unit", "eV")}, | |
| current_species, | |
| scope_id, | |
| ) | |
| observations["thermo"].append(item) | |
| if current_species: | |
| observations["species_gibbs"][normalize_species_name(current_species)] = item | |
| dipole = data.get("dipole_moment") | |
| if isinstance(dipole, list) and any(value is not None for value in dipole): | |
| observations["dipoles"].append( | |
| _with_species( | |
| {"value": dipole, "unit": data.get("dipole_unit", "e * Angstrom")}, | |
| current_species, | |
| scope_id, | |
| ) | |
| ) | |
| vibration = _nested(data, "result", "vibrational_frequencies") or data.get( | |
| "vibrational_frequencies" | |
| ) | |
| if isinstance(vibration, dict): | |
| frequencies = vibration.get("frequencies") or vibration.get("frequency_cm1") | |
| if frequencies: | |
| observations["vibrations"].append( | |
| _with_species( | |
| {"frequencies": list(frequencies)}, | |
| current_species, | |
| scope_id, | |
| ) | |
| ) | |
| ir_spectrum = _nested(data, "result", "ir_spectrum") or data.get("ir_spectrum") | |
| if isinstance(ir_spectrum, dict): | |
| frequencies = ir_spectrum.get("frequency_cm1") or ir_spectrum.get( | |
| "frequencies" | |
| ) | |
| intensities = ir_spectrum.get("intensity") or ir_spectrum.get("intensities") | |
| plot = ir_spectrum.get("plot") | |
| if frequencies and intensities: | |
| item = { | |
| "frequency_cm1": [str(value) for value in frequencies], | |
| "intensity": [str(value) for value in intensities], | |
| } | |
| item = _with_species(item, current_species, scope_id) | |
| if plot: | |
| item["plot"] = str(plot) | |
| _remember_artifact(observations, "ir_plot", str(plot)) | |
| observations["ir_spectra"].append(item) | |
| ir_data = _nested(data, "result", "ir_data") or data.get("ir_data") | |
| if isinstance(ir_data, dict): | |
| plot_text = ir_data.get("IR Plot") | |
| if isinstance(plot_text, str): | |
| for path in _paths_from_text(plot_text): | |
| _remember_artifact(observations, "ir_plot", path) | |
| spectrum_freqs = ir_data.get("mode_frequencies") or ir_data.get( | |
| "spectrum_frequencies" | |
| ) | |
| spectrum_ints = ir_data.get("mode_intensities") or ir_data.get( | |
| "spectrum_intensities" | |
| ) | |
| if spectrum_freqs and spectrum_ints: | |
| observations["ir_spectra"].append( | |
| _with_species( | |
| { | |
| "frequency_cm1": [str(value) for value in spectrum_freqs], | |
| "intensity": [str(value) for value in spectrum_ints], | |
| }, | |
| current_species, | |
| scope_id, | |
| ) | |
| ) | |
| def _with_species( | |
| item: dict[str, Any], | |
| species: Optional[str], | |
| scope_id: Optional[str] = None, | |
| ) -> dict[str, Any]: | |
| """Attach a target species label to a collected property value.""" | |
| if species: | |
| item["species"] = str(species) | |
| if scope_id: | |
| item["scope_id"] = str(scope_id) | |
| return item | |
| def _target_key_set(target_molecules: Iterable[str]) -> set[str]: | |
| return { | |
| normalize_species_name(target) | |
| for target in target_molecules or [] | |
| if normalize_species_name(target) | |
| } | |
| def _record_matches_target(record: dict[str, Any], target: str) -> bool: | |
| target_key = normalize_species_name(target) | |
| if not target_key: | |
| return False | |
| for value in ( | |
| record.get("input_name"), | |
| record.get("resolved_name"), | |
| record.get("representative_of"), | |
| record.get("name"), | |
| ): | |
| if value and normalize_species_name(str(value)) == target_key: | |
| return True | |
| return False | |
| def _target_smiles( | |
| observations: dict[str, Any], | |
| target_molecules: Iterable[str], | |
| ) -> list[str]: | |
| """Return SMILES that belong to the current query targets.""" | |
| targets = list(target_molecules or []) | |
| if not targets: | |
| return list(observations.get("smiles", [])) | |
| values = [] | |
| for record in observations.get("identity_resolutions", []): | |
| if any(_record_matches_target(record, target) for target in targets): | |
| smiles = record.get("smiles") | |
| if smiles: | |
| values.append(str(smiles)) | |
| return _dedupe_preserve_order(values) | |
| def _species_matches_targets(species: Any, target_molecules: Iterable[str]) -> bool: | |
| targets = _target_key_set(target_molecules) | |
| if not targets: | |
| return True | |
| if not species: | |
| return False | |
| return normalize_species_name(str(species)) in targets | |
| def _target_coordinate_files( | |
| observations: dict[str, Any], | |
| target_molecules: Iterable[str], | |
| ) -> list[str]: | |
| """Return generated coordinate files that match the current query targets.""" | |
| targets = list(target_molecules or []) | |
| if not targets: | |
| return list(observations.get("coordinate_files", [])) | |
| matches = [] | |
| for path in observations.get("coordinate_files", []): | |
| species = _species_for_file_name(observations, path) or _species_from_file_name( | |
| path | |
| ) | |
| if _species_matches_targets(species, targets): | |
| matches.append(str(path)) | |
| return _dedupe_preserve_order(matches) | |
| def _target_evidence_is_ambiguous( | |
| observations: dict[str, Any], | |
| target_molecules: Iterable[str], | |
| ) -> bool: | |
| """Return True when unlabelled property values are safe to accept. | |
| This preserves older direct unit tests where a lone run_ase result has no | |
| resolver provenance, while preventing a previous molecule's labelled result | |
| from satisfying a new target. | |
| """ | |
| targets = _target_key_set(target_molecules) | |
| if not targets: | |
| return False | |
| return bool( | |
| observations.get("identity_resolutions") | |
| or observations.get("coordinate_files") | |
| or len(observations.get("ase_results", [])) > 1 | |
| ) | |
| def _item_matches_targets( | |
| item: dict[str, Any], | |
| target_molecules: Iterable[str], | |
| observations: dict[str, Any], | |
| ) -> bool: | |
| targets = list(target_molecules or []) | |
| if not targets: | |
| return True | |
| species = item.get("species") if isinstance(item, dict) else None | |
| if species: | |
| return _species_matches_targets(species, targets) | |
| return not _target_evidence_is_ambiguous(observations, targets) | |
| def _last_matching_target( | |
| items: list[dict[str, Any]], | |
| target_molecules: Iterable[str], | |
| observations: dict[str, Any], | |
| ) -> Optional[dict[str, Any]]: | |
| for item in reversed(items or []): | |
| if isinstance(item, dict) and _item_matches_targets( | |
| item, target_molecules, observations | |
| ): | |
| return item | |
| return None | |
| def _append_tool_trace( | |
| observations: dict[str, Any], | |
| name: str, | |
| data: Any, | |
| args: dict[str, Any], | |
| tool_call_id: Optional[str], | |
| ) -> None: | |
| """Append one compact, JSON-safe tool trace entry.""" | |
| status = "success" | |
| if isinstance(data, str) and data.strip().lower().startswith("error"): | |
| status = "error" | |
| elif isinstance(data, dict) and data.get("status") in {"error", "failed", "failure"}: | |
| status = str(data.get("status")) | |
| observations["tool_trace"].append( | |
| { | |
| "tool_call_id": tool_call_id, | |
| "tool": name, | |
| "args": _json_safe(args), | |
| "status": status, | |
| "output": _summarize_tool_output(data), | |
| } | |
| ) | |
| def _summarize_tool_output(data: Any) -> Any: | |
| """Return a compact JSON-safe summary of a tool output.""" | |
| data = _json_safe(data) | |
| if isinstance(data, dict): | |
| summary = {} | |
| for key, value in data.items(): | |
| if key in {"final_structure", "simulation_input"}: | |
| continue | |
| summary[key] = _summarize_tool_output(value) | |
| return summary | |
| if isinstance(data, list): | |
| if len(data) > 12: | |
| return {"count": len(data), "sample": data[:5]} | |
| return data | |
| text = str(data) | |
| if len(text) > 1000: | |
| return text[:1000] + "...<truncated>" | |
| return data | |
| def _public_observations(observations: dict[str, Any]) -> dict[str, Any]: | |
| """Expose observations without private matching indexes.""" | |
| return { | |
| "identity_resolutions": observations["identity_resolutions"], | |
| "smiles": observations["smiles"], | |
| "coordinate_files": observations["coordinate_files"], | |
| "energies": observations["energies"], | |
| "thermo": observations["thermo"], | |
| "enthalpies": observations["enthalpies"], | |
| "dipoles": observations["dipoles"], | |
| "vibrations": observations["vibrations"], | |
| "ir_spectra": observations["ir_spectra"], | |
| "species_gibbs": observations["species_gibbs"], | |
| "species_enthalpy": observations["species_enthalpy"], | |
| "calculator_results": observations["calculator_results"], | |
| "ase_results": observations["ase_results"], | |
| } | |
| def _identity_resolution_summary(observations: dict[str, Any]) -> dict[str, Any]: | |
| records = observations.get("identity_resolutions", []) | |
| latest = records[-1] if records else None | |
| return { | |
| "records": records, | |
| "latest": latest, | |
| "requires_clarification": bool( | |
| latest | |
| and ( | |
| latest.get("requires_clarification") | |
| or latest.get("needs_clarification") | |
| or _identity_low_credibility(latest) | |
| ) | |
| ), | |
| } | |
| def _clarification_state( | |
| identity_resolution: dict[str, Any], | |
| next_action: str, | |
| ) -> dict[str, Any]: | |
| latest = identity_resolution.get("latest") or {} | |
| needed = next_action == "clarify_identity" or bool( | |
| identity_resolution.get("requires_clarification") | |
| ) | |
| questions = [] | |
| if needed: | |
| input_name = latest.get("input_name") or "the requested chemical" | |
| questions.append( | |
| f"Which specific component, composition, or representative molecule should be modeled for {input_name}?" | |
| ) | |
| return { | |
| "needed": needed, | |
| "reason": latest.get("warning") or latest.get("selection_reason") or "", | |
| "questions": questions, | |
| "blocked_actions": ( | |
| ["smiles_to_coordinate_file", "run_ase"] if needed else [] | |
| ), | |
| } | |
| def _memory_refs(observations: dict[str, Any]) -> dict[str, Any]: | |
| """Return compact reusable references instead of replaying full outputs.""" | |
| return { | |
| "smiles": observations.get("smiles", []), | |
| "coordinate_files": observations.get("coordinate_files", []), | |
| "artifacts": observations.get("artifacts", {}), | |
| "ase_results": [ | |
| { | |
| "driver": result.get("driver"), | |
| "input_structure_file": result.get("input_structure_file"), | |
| "output_results_file": result.get("output_results_file"), | |
| "species": result.get("species"), | |
| "status": result.get("status"), | |
| } | |
| for result in observations.get("ase_results", []) | |
| ], | |
| } | |
| def _next_action( | |
| requirement: TaskRequirement, | |
| validation: dict[str, Any], | |
| observations: dict[str, Any], | |
| target_molecules: Optional[list[str]] = None, | |
| ) -> str: | |
| if validation.get("complete"): | |
| return "compose_final_answer" | |
| if requirement.task_type == "unknown": | |
| return "compose_final_answer" | |
| if _identity_needs_clarification(observations): | |
| return "clarify_identity" | |
| if requirement.task_type == "smiles": | |
| return "molecule_name_to_smiles" | |
| target_smiles = _target_smiles(observations, target_molecules or []) | |
| target_coordinates = _target_coordinate_files( | |
| observations, target_molecules or [] | |
| ) | |
| all_target_identities = ( | |
| all( | |
| _target_smiles(observations, [target]) | |
| for target in (target_molecules or []) | |
| ) | |
| if target_molecules | |
| else bool(target_smiles) | |
| ) | |
| all_target_coordinates = ( | |
| all( | |
| _target_coordinate_files(observations, [target]) | |
| for target in (target_molecules or []) | |
| ) | |
| if target_molecules | |
| else bool(target_coordinates) | |
| ) | |
| failed_ase = _last_failed_ase(observations) | |
| if failed_ase and failed_ase.get("error_type") == "FileNotFoundError": | |
| if not all_target_identities: | |
| return "molecule_name_to_smiles" | |
| return "smiles_to_coordinate_file" | |
| if not all_target_identities: | |
| return "molecule_name_to_smiles" | |
| if requirement.driver and not all_target_coordinates: | |
| return "smiles_to_coordinate_file" | |
| if requirement.driver: | |
| return "run_ase" | |
| missing = set(validation.get("missing", [])) | |
| if "smiles" in missing: | |
| return "molecule_name_to_smiles" | |
| if any("coordinate" in item or item.endswith(".xyz") for item in missing): | |
| return "smiles_to_coordinate_file" | |
| if requirement.driver: | |
| return "run_ase" | |
| return "repair_workflow" | |
| def _infer_calculator(query: str) -> Optional[str]: | |
| text = str(query or "").lower() | |
| if "tblite" in text or "xtb" in text or "gfn" in text: | |
| return "TBLite" | |
| if "emt" in text: | |
| return "EMT" | |
| if "mace" in text: | |
| return "MACE" | |
| if "orca" in text: | |
| return "ORCA" | |
| if "nwchem" in text: | |
| return "NWChem" | |
| return None | |
| def _calculator_policy(requirement: TaskRequirement, query: str) -> dict[str, Any]: | |
| """Return the default calculator policy for the inferred task.""" | |
| explicit = _infer_calculator(query) | |
| preferred = PREFERRED_CALCULATOR_BY_TASK.get(requirement.task_type) | |
| return { | |
| "explicit_request": explicit, | |
| "preferred": explicit or preferred, | |
| "enforce": bool(preferred and not explicit), | |
| "reason": ( | |
| "User explicitly requested a calculator." | |
| if explicit | |
| else ( | |
| f"Use {preferred} for this molecular property by default." | |
| if preferred | |
| else "No task-specific calculator preference." | |
| ) | |
| ), | |
| } | |
| def _calculator_family(calculator: Any) -> Optional[str]: | |
| """Normalize calculator payloads to a coarse family name.""" | |
| if calculator is None: | |
| return None | |
| if isinstance(calculator, str): | |
| raw = calculator | |
| elif isinstance(calculator, dict): | |
| raw = str( | |
| calculator.get("calculator_type") | |
| or calculator.get("type") | |
| or calculator.get("name") | |
| or calculator.get("calculator") | |
| or "" | |
| ) | |
| else: | |
| raw = calculator.__class__.__name__ | |
| text = raw.lower() | |
| if not text: | |
| return None | |
| if "tblite" in text or "xtb" in text or "gfn" in text: | |
| return "TBLite" | |
| if "mace" in text: | |
| return "MACE" | |
| if "emt" in text: | |
| return "EMT" | |
| if "orca" in text: | |
| return "ORCA" | |
| if "nwchem" in text: | |
| return "NWChem" | |
| if "fairchem" in text: | |
| return "FAIRChem" | |
| if "aimnet" in text: | |
| return "AIMNET2" | |
| return raw | |
| def _calculator_payload_hint(calculator_family: str) -> str: | |
| """Return a compact run_ase calculator payload for repair instructions.""" | |
| family = str(calculator_family or "").lower() | |
| if family == "tblite": | |
| return "{'calculator_type': 'TBLite', 'method': 'GFN2-xTB'}" | |
| if family == "mace": | |
| return "{'calculator_type': 'mace_mp'}" | |
| if family == "emt": | |
| return "{'calculator_type': 'emt'}" | |
| if family == "orca": | |
| return "{'calculator_type': 'orca'}" | |
| if family == "nwchem": | |
| return "{'calculator_type': 'nwchem'}" | |
| return f"{{'calculator_type': '{calculator_family}'}}" | |
| def _parallelization_plan( | |
| requirement: TaskRequirement, | |
| target_molecules: list[str], | |
| ) -> dict[str, Any]: | |
| """Describe safe parallelism for the inferred task.""" | |
| if requirement.task_type in {"reaction_gibbs_energy", "reaction_enthalpy"}: | |
| return { | |
| "can_parallelize": True, | |
| "unit": "reaction_species", | |
| "rule": ( | |
| "For each species, preserve molecule_name_to_smiles -> " | |
| "smiles_to_coordinate_file -> run_ase(driver='thermo') order. " | |
| "Different species can run in parallel, then aggregate products " | |
| "minus reactants." | |
| ), | |
| "targets": target_molecules, | |
| } | |
| if len(target_molecules) > 1 and requirement.driver: | |
| return { | |
| "can_parallelize": True, | |
| "unit": "molecule", | |
| "rule": ( | |
| "For each molecule, preserve molecule_name_to_smiles -> " | |
| "smiles_to_coordinate_file -> run_ase order. Different molecules " | |
| "can run in parallel." | |
| ), | |
| "targets": target_molecules, | |
| } | |
| return { | |
| "can_parallelize": False, | |
| "unit": "single_molecule_chain", | |
| "rule": ( | |
| "Within one molecule, do not parallelize dependent steps. Generate " | |
| "or locate coordinates before run_ase." | |
| ), | |
| "targets": target_molecules, | |
| } | |
| def _tool_requirements( | |
| requirement: TaskRequirement, | |
| target_molecules: list[str], | |
| ) -> list[dict[str, Any]]: | |
| """Return an explicit tool dependency plan for the task.""" | |
| task_type = requirement.task_type | |
| if task_type == "unknown": | |
| return [] | |
| requirements = [] | |
| if task_type in { | |
| "smiles", | |
| "energy", | |
| "gibbs_free_energy", | |
| "enthalpy", | |
| "dipole", | |
| "vibration", | |
| "ir", | |
| "reaction_gibbs_energy", | |
| "reaction_enthalpy", | |
| }: | |
| requirements.append( | |
| { | |
| "tool": "molecule_name_to_smiles", | |
| "required_when": "query uses molecule names instead of explicit SMILES", | |
| "purpose": "resolve molecule identity to canonical SMILES", | |
| "depends_on": [], | |
| "parallel_group": "identity_resolution", | |
| "targets": target_molecules, | |
| } | |
| ) | |
| if task_type == "smiles": | |
| return requirements | |
| if task_type in { | |
| "energy", | |
| "gibbs_free_energy", | |
| "enthalpy", | |
| "dipole", | |
| "vibration", | |
| "ir", | |
| "reaction_gibbs_energy", | |
| "reaction_enthalpy", | |
| }: | |
| requirements.append( | |
| { | |
| "tool": "smiles_to_coordinate_file", | |
| "required_when": "run_ase needs a coordinate file", | |
| "purpose": "generate XYZ coordinates for ASE", | |
| "depends_on": ["molecule_name_to_smiles or explicit SMILES"], | |
| "parallel_group": "coordinate_generation", | |
| "targets": target_molecules, | |
| } | |
| ) | |
| requirements.append( | |
| { | |
| "tool": "run_ase", | |
| "required_when": f"task requires {requirement.required_output}", | |
| "purpose": f"calculate {task_type} with driver={requirement.driver}", | |
| "depends_on": ["smiles_to_coordinate_file"], | |
| "parallel_group": "ase_simulation", | |
| "driver": requirement.driver, | |
| "targets": target_molecules, | |
| } | |
| ) | |
| return requirements | |
| def _postprocessing_steps(requirement: TaskRequirement) -> list[dict[str, Any]]: | |
| """Return deterministic non-tool steps needed after tool calls.""" | |
| if requirement.task_type == "reaction_gibbs_energy": | |
| return [ | |
| { | |
| "name": "aggregate_reaction_gibbs", | |
| "required_when": "all species thermochemistry values are available", | |
| "purpose": "compute reaction Gibbs energy as products minus reactants", | |
| "depends_on": ["run_ase(driver='thermo') for all species"], | |
| } | |
| ] | |
| if requirement.task_type == "reaction_enthalpy": | |
| return [ | |
| { | |
| "name": "aggregate_reaction_enthalpy", | |
| "required_when": "all species thermochemistry values are available", | |
| "purpose": "compute reaction enthalpy as products minus reactants", | |
| "depends_on": ["run_ase(driver='thermo') for all species"], | |
| } | |
| ] | |
| return [] | |
| def _tool_requirement_status( | |
| requirement: TaskRequirement, | |
| observations: dict[str, Any], | |
| target_molecules: Optional[list[str]] = None, | |
| ) -> dict[str, Any]: | |
| """Return which required tools already have reusable outputs.""" | |
| required = REQUIRED_TOOL_STEPS.get(requirement.task_type, []) | |
| satisfied = [] | |
| reusable = {} | |
| target_molecules = target_molecules or [] | |
| target_smiles = _target_smiles(observations, target_molecules) | |
| target_coordinates = _target_coordinate_files(observations, target_molecules) | |
| all_target_identities = ( | |
| all(_target_smiles(observations, [target]) for target in target_molecules) | |
| if target_molecules | |
| else bool(target_smiles) | |
| ) | |
| all_target_coordinates = ( | |
| all( | |
| _target_coordinate_files(observations, [target]) | |
| for target in target_molecules | |
| ) | |
| if target_molecules | |
| else bool(target_coordinates) | |
| ) | |
| identity_blocked = bool(_identity_needs_clarification(observations)) | |
| if ( | |
| "molecule_name_to_smiles" in required | |
| and all_target_identities | |
| and not identity_blocked | |
| ): | |
| satisfied.append("molecule_name_to_smiles") | |
| reusable["smiles"] = target_smiles | |
| if ( | |
| "smiles_to_coordinate_file" in required | |
| and all_target_coordinates | |
| and not identity_blocked | |
| ): | |
| satisfied.append("smiles_to_coordinate_file") | |
| reusable["coordinate_files"] = target_coordinates | |
| if "run_ase" in required and _has_required_ase_result( | |
| requirement, observations, target_molecules | |
| ): | |
| satisfied.append("run_ase") | |
| reusable["ase_results"] = observations["ase_results"] | |
| return { | |
| "satisfied_tools": satisfied, | |
| "missing_tools": [tool for tool in required if tool not in satisfied], | |
| "reusable_observations": reusable, | |
| } | |
| def _has_required_ase_result( | |
| requirement: TaskRequirement, | |
| observations: dict[str, Any], | |
| target_molecules: Optional[list[str]] = None, | |
| ) -> bool: | |
| if requirement.task_type == "energy": | |
| return _last_matching_target( | |
| observations["energies"], target_molecules or [], observations | |
| ) is not None | |
| if requirement.task_type == "gibbs_free_energy": | |
| return _last_matching_target( | |
| observations["thermo"], target_molecules or [], observations | |
| ) is not None | |
| if requirement.task_type == "enthalpy": | |
| return _last_matching_target( | |
| observations["enthalpies"], target_molecules or [], observations | |
| ) is not None | |
| if requirement.task_type == "dipole": | |
| return _last_matching_target( | |
| observations["dipoles"], target_molecules or [], observations | |
| ) is not None | |
| if requirement.task_type == "vibration": | |
| return _last_matching_target( | |
| observations["vibrations"], target_molecules or [], observations | |
| ) is not None | |
| if requirement.task_type == "ir": | |
| return _last_matching_target( | |
| observations["ir_spectra"], target_molecules or [], observations | |
| ) is not None | |
| if requirement.task_type == "reaction_gibbs_energy": | |
| targets = _target_key_set(target_molecules or []) | |
| if not targets: | |
| return bool(observations["species_gibbs"]) | |
| return targets.issubset(set(observations["species_gibbs"])) | |
| if requirement.task_type == "reaction_enthalpy": | |
| targets = _target_key_set(target_molecules or []) | |
| if not targets: | |
| return bool(observations["species_enthalpy"]) | |
| return targets.issubset(set(observations["species_enthalpy"])) | |
| return False | |
| def _last_failed_ase( | |
| observations: dict[str, Any], | |
| target_molecules: Optional[Iterable[str]] = None, | |
| driver: Optional[str] = None, | |
| current_scope_only: bool = False, | |
| ) -> Optional[dict[str, Any]]: | |
| """Return the latest ASE result that did not succeed.""" | |
| current_scope_id = observations.get("current_scope_id") | |
| for result in reversed(observations.get("ase_results", [])): | |
| status = str(result.get("status") or "").lower() | |
| if not status or status == "success": | |
| continue | |
| if current_scope_only and result.get("scope_id") != current_scope_id: | |
| continue | |
| if driver and result.get("driver") and result.get("driver") != driver: | |
| continue | |
| if target_molecules and not _species_matches_targets( | |
| result.get("species"), target_molecules | |
| ): | |
| continue | |
| return result | |
| return None | |
| def _unresolved_current_failed_ase( | |
| observations: dict[str, Any], | |
| driver: Optional[str] = None, | |
| ) -> Optional[dict[str, Any]]: | |
| """Return a current-scope ASE failure that has not been repaired later.""" | |
| current_scope_id = observations.get("current_scope_id") | |
| if not current_scope_id: | |
| return None | |
| successful_species: set[str] = set() | |
| saw_any_success = False | |
| for result in reversed(observations.get("ase_results", [])): | |
| if result.get("scope_id") != current_scope_id: | |
| continue | |
| if driver and result.get("driver") and result.get("driver") != driver: | |
| continue | |
| status = str(result.get("status") or "").lower() | |
| if not status: | |
| continue | |
| species = result.get("species") | |
| species_key = normalize_species_name(str(species)) if species else "" | |
| if status == "success": | |
| saw_any_success = True | |
| if species_key: | |
| successful_species.add(species_key) | |
| continue | |
| if species_key and species_key in successful_species: | |
| continue | |
| if not species_key and saw_any_success: | |
| continue | |
| return result | |
| return None | |
| def _action_target_key(target: str) -> str: | |
| key = re.sub(r"[^A-Za-z0-9]+", "_", str(target or "target").strip()).strip("_") | |
| return key.lower() or "target" | |
| def _identity_action_status( | |
| observations: dict[str, Any], | |
| next_action: str, | |
| target: Optional[str] = None, | |
| ) -> str: | |
| if _identity_needs_clarification(observations): | |
| return "blocked" | |
| if _target_smiles(observations, [target] if target else []): | |
| return "succeeded" | |
| if next_action == "molecule_name_to_smiles": | |
| return "ready" | |
| return "pending" | |
| def _coordinate_action_status( | |
| observations: dict[str, Any], | |
| next_action: str, | |
| target: Optional[str] = None, | |
| ) -> str: | |
| if _identity_needs_clarification(observations): | |
| return "blocked" | |
| if _target_coordinate_files(observations, [target] if target else []): | |
| return "succeeded" | |
| if next_action == "smiles_to_coordinate_file": | |
| return "ready" | |
| return "pending" | |
| def _ase_action_status( | |
| requirement: TaskRequirement, | |
| observations: dict[str, Any], | |
| next_action: str, | |
| target: Optional[str] = None, | |
| ) -> str: | |
| if _identity_needs_clarification(observations): | |
| return "blocked" | |
| if _unresolved_current_failed_ase(observations, driver=requirement.driver): | |
| return "failed" | |
| if _has_required_ase_result( | |
| requirement, observations, [target] if target else [] | |
| ): | |
| return "succeeded" | |
| if _last_failed_ase(observations): | |
| return "failed" | |
| if next_action == "run_ase": | |
| return "ready" | |
| return "pending" | |
| def _identity_needs_clarification( | |
| observations: dict[str, Any], | |
| ) -> Optional[dict[str, Any]]: | |
| """Return the latest identity record that should block downstream tools.""" | |
| for record in reversed(observations.get("identity_resolutions", [])): | |
| if ( | |
| record.get("requires_clarification") | |
| or record.get("needs_clarification") | |
| or _identity_low_credibility(record) | |
| ): | |
| return record | |
| return None | |
| def _identity_low_credibility(record: dict[str, Any]) -> bool: | |
| """Return whether a resolver record should be confirmed before execution.""" | |
| try: | |
| return float(record.get("credibility_score", 1.0)) < 0.5 | |
| except (TypeError, ValueError): | |
| return False | |
| def _infer_target_molecules( | |
| query: str, | |
| observations: dict[str, Any], | |
| messages: Iterable[Any] = (), | |
| ) -> list[str]: | |
| requirement = infer_requirement(query) | |
| if requirement.task_type in {"reaction_gibbs_energy", "reaction_enthalpy"}: | |
| reaction = parse_reaction_from_context(query, messages) | |
| if reaction is not None: | |
| names = [ | |
| species.name | |
| for side in ("reactants", "products") | |
| for species in reaction[side] | |
| ] | |
| return _dedupe_preserve_order(names) | |
| query_targets = _target_molecules_from_query(query) | |
| if query_targets: | |
| return query_targets | |
| latest = _latest_identity_target(observations) | |
| if latest: | |
| return [latest] | |
| return [] | |
| def _target_molecules_from_query(query: str) -> list[str]: | |
| """Extract explicit non-reaction molecule names from the current query.""" | |
| text = str(query or "").strip() | |
| patterns = [ | |
| r"\b(?:of|for)\s+([A-Za-z][A-Za-z0-9 _-]*?)(?:\s+using|\s+with|\s+at|[?.!,]|$)", | |
| r"\bSMILES\s+for\s+([A-Za-z][A-Za-z0-9 _-]*?)(?:[?.!,]|$)", | |
| ] | |
| for pattern in patterns: | |
| match = re.search(pattern, text, flags=re.IGNORECASE) | |
| if match: | |
| candidate = _clean_target_name(match.group(1)) | |
| if candidate: | |
| return [candidate] | |
| property_phrase = ( | |
| r"dipole\s*moment|dipolemoment|dipole|ftir|ft-ir|ir\s+spectrum|" | |
| r"infrared\s+spectrum|vibrational\s+frequenc(?:y|ies)|vibration|" | |
| r"gibbs(?:\s+free\s+energy)?|enthalpy|single[- ]point\s+energy|" | |
| r"energy|geometry|geomery" | |
| ) | |
| match = re.match( | |
| rf"\s*([A-Za-z][A-Za-z0-9 _-]*?)\s+(?:{property_phrase})\b", | |
| text, | |
| flags=re.IGNORECASE, | |
| ) | |
| if match: | |
| candidate = _clean_target_name(match.group(1)) | |
| if candidate and not _looks_like_command_prefix(candidate): | |
| return [candidate] | |
| return [] | |
| def _clean_target_name(value: Any) -> str: | |
| return str(value or "").strip(" \t\r\n`*_") | |
| def _looks_like_command_prefix(candidate: str) -> bool: | |
| first = str(candidate or "").strip().split(maxsplit=1)[0].lower() | |
| return first in { | |
| "calculate", | |
| "compute", | |
| "determine", | |
| "find", | |
| "get", | |
| "give", | |
| "report", | |
| "show", | |
| "what", | |
| } | |
| def _latest_identity_target(observations: dict[str, Any]) -> Optional[str]: | |
| """Return the latest single resolved identity as follow-up context.""" | |
| for record in reversed(observations.get("identity_resolutions", [])): | |
| if record.get("requires_clarification") or record.get("needs_clarification"): | |
| continue | |
| name = record.get("input_name") or record.get("resolved_name") | |
| if name: | |
| return str(name) | |
| return None | |
| def _dedupe_preserve_order(values: Iterable[str]) -> list[str]: | |
| result = [] | |
| seen = set() | |
| for value in values: | |
| key = normalize_species_name(value) | |
| if key and key not in seen: | |
| seen.add(key) | |
| result.append(str(value)) | |
| return result | |
| def _collect_artifacts_from_value(observations: dict[str, Any], value: Any) -> None: | |
| if isinstance(value, dict): | |
| for child in value.values(): | |
| _collect_artifacts_from_value(observations, child) | |
| return | |
| if isinstance(value, list): | |
| for child in value: | |
| _collect_artifacts_from_value(observations, child) | |
| return | |
| if not isinstance(value, str): | |
| return | |
| for path in _paths_from_text(value): | |
| suffix = Path(path).suffix.lower() | |
| if suffix == ".xyz": | |
| _remember_artifact(observations, "xyz", path) | |
| elif suffix == ".json": | |
| _remember_artifact(observations, "output_json", path) | |
| elif suffix == ".png" and "ir_spectrum" in Path(path).name: | |
| _remember_artifact(observations, "ir_plot", path) | |
| elif suffix == ".csv" and "frequencies" in Path(path).name: | |
| _remember_artifact(observations, "frequencies_csv", path) | |
| def _first_path_from_value(value: Any, *, suffix: str) -> Optional[str]: | |
| """Return the first path-like string with the requested suffix.""" | |
| if isinstance(value, dict): | |
| for child in value.values(): | |
| found = _first_path_from_value(child, suffix=suffix) | |
| if found: | |
| return found | |
| return None | |
| if isinstance(value, list): | |
| for child in value: | |
| found = _first_path_from_value(child, suffix=suffix) | |
| if found: | |
| return found | |
| return None | |
| if not isinstance(value, str): | |
| return None | |
| for path in _paths_from_text(value): | |
| if Path(path).suffix.lower() == suffix.lower(): | |
| return path | |
| return None | |
| def _run_id_from_path(path: Any, *, default: Optional[str] = None) -> Optional[str]: | |
| """Use an artifact path's directory as a stable run scope when available.""" | |
| if not path: | |
| return default | |
| try: | |
| return str(Path(str(path)).expanduser().parent) | |
| except Exception: | |
| return default | |
| def _paths_from_text(text: str) -> list[str]: | |
| matches = re.findall(r"(/[^\s'\"`]+?\.(?:json|xyz|png|csv))", str(text)) | |
| return [match.rstrip(".,);]") for match in matches] | |
| def _remember_artifact( | |
| observations: dict[str, Any], | |
| artifact_type: str, | |
| path: str, | |
| ) -> None: | |
| artifacts = observations["artifacts"].setdefault(artifact_type, []) | |
| path = str(path) | |
| if path not in artifacts: | |
| artifacts.append(path) | |
| def _json_safe(value: Any) -> Any: | |
| if isinstance(value, dict): | |
| return {str(key): _json_safe(val) for key, val in value.items()} | |
| if isinstance(value, (list, tuple)): | |
| return [_json_safe(item) for item in value] | |
| if isinstance(value, set): | |
| return sorted(_json_safe(item) for item in value) | |
| if isinstance(value, (str, int, float, bool)) or value is None: | |
| return value | |
| return str(value) | |
| def _message_name(message: Any) -> Optional[str]: | |
| if isinstance(message, dict): | |
| return message.get("name") | |
| return getattr(message, "name", None) | |
| def _message_type(message: Any) -> Optional[str]: | |
| if isinstance(message, dict): | |
| return message.get("type") or message.get("role") | |
| return getattr(message, "type", None) | |
| def _message_content(message: Any) -> Any: | |
| if message is None: | |
| return "" | |
| if isinstance(message, dict): | |
| return message.get("content", "") | |
| return getattr(message, "content", "") | |
| def _content_to_data(content: Any) -> Any: | |
| if isinstance(content, (dict, list, int, float)): | |
| return content | |
| if content is None: | |
| return None | |
| text = str(content).strip() | |
| if not text: | |
| return text | |
| try: | |
| return json.loads(text) | |
| except json.JSONDecodeError: | |
| pass | |
| try: | |
| return ast.literal_eval(text) | |
| except (ValueError, SyntaxError): | |
| return text | |
| def _nested(data: dict[str, Any], *keys: str) -> Any: | |
| value: Any = data | |
| for key in keys: | |
| if not isinstance(value, dict): | |
| return None | |
| value = value.get(key) | |
| return value | |
| def _to_float(value: Any) -> Optional[float]: | |
| if isinstance(value, (int, float)): | |
| return float(value) | |
| try: | |
| return float(str(value).strip()) | |
| except (TypeError, ValueError): | |
| return None | |
| def _last(values: list[Any]) -> Any: | |
| return values[-1] if values else None | |
| def _first_unit(items: Iterable[dict[str, Any]]) -> Optional[str]: | |
| for item in items: | |
| unit = item.get("unit") | |
| if unit: | |
| return unit | |
| return None | |
| def _public_property_record(record: dict[str, Any]) -> dict[str, Any]: | |
| """Return a property record without internal ledger provenance fields.""" | |
| return { | |
| key: value | |
| for key, value in record.items() | |
| if key not in {"scope_id", "run_id", "requirement_id", "artifact_id"} | |
| } | |
| def _empty_structured(**updates: Any) -> dict[str, Any]: | |
| structured = { | |
| "smiles": None, | |
| "scalar_answer": None, | |
| "dipole": None, | |
| "vibrational_answer": None, | |
| "ir_spectrum": None, | |
| "atoms_data": None, | |
| } | |
| structured.update(updates) | |
| return structured | |
| def _tool_guard_allowed() -> dict[str, Any]: | |
| return { | |
| "allowed": True, | |
| "blocked_tool": None, | |
| "expected_next_action": None, | |
| "repair_instruction": "", | |
| "reason": "Tool call batch is consistent with workflow state.", | |
| } | |
| def _tool_guard_blocked( | |
| *, | |
| blocked_tool: str, | |
| expected_next_action: Optional[str], | |
| repair_instruction: str, | |
| ) -> dict[str, Any]: | |
| return { | |
| "allowed": False, | |
| "blocked_tool": blocked_tool, | |
| "expected_next_action": expected_next_action, | |
| "repair_instruction": repair_instruction, | |
| "reason": "Tool call batch violates the deterministic workflow order.", | |
| } | |
| def _complete_result( | |
| requirement: TaskRequirement, | |
| *, | |
| structured_output: Optional[dict[str, Any]] = None, | |
| reason: str = "", | |
| ) -> dict[str, Any]: | |
| return { | |
| "complete": True, | |
| "task_type": requirement.task_type, | |
| "driver": requirement.driver, | |
| "required_output": requirement.required_output, | |
| "response_field": requirement.response_field, | |
| "missing": [], | |
| "repair_instruction": "", | |
| "structured_output": structured_output, | |
| "can_answer": True, | |
| "status": "complete", | |
| "failure": None, | |
| "reason": reason, | |
| } | |
| def _incomplete_result( | |
| requirement: TaskRequirement, | |
| missing: list[str], | |
| repair_instruction: str, | |
| ) -> dict[str, Any]: | |
| return { | |
| "complete": False, | |
| "task_type": requirement.task_type, | |
| "driver": requirement.driver, | |
| "required_output": requirement.required_output, | |
| "response_field": requirement.response_field, | |
| "missing": missing, | |
| "repair_instruction": repair_instruction, | |
| "structured_output": None, | |
| "can_answer": False, | |
| "status": "blocked", | |
| "failure": { | |
| "missing": missing, | |
| "repair_instruction": repair_instruction, | |
| }, | |
| "reason": "Required deterministic tool outputs are missing.", | |
| } | |
| def build_validation_failure_output( | |
| validation: dict[str, Any], | |
| workflow_state: Optional[dict[str, Any]] = None, | |
| ) -> dict[str, Any]: | |
| """Return a ResponseFormatter-compatible failure payload. | |
| The payload intentionally keeps the normal structured-output fields present | |
| but empty, so downstream UI/eval code can treat it as a final formatter | |
| result while still displaying the explicit validation failure. | |
| """ | |
| workflow_state = workflow_state or {} | |
| observations = workflow_state.get("observations") or {} | |
| failed_tools = [ | |
| result | |
| for result in observations.get("ase_results", []) | |
| if str(result.get("status") or "").lower() not in {"", "success"} | |
| ] | |
| status = "failed" if failed_tools else "blocked" | |
| missing = list(validation.get("missing", []) or []) | |
| failure = { | |
| "status": status, | |
| "task_type": validation.get("task_type"), | |
| "required_output": validation.get("required_output"), | |
| "missing": missing, | |
| "repair_instruction": validation.get("repair_instruction", ""), | |
| "reason": validation.get("reason", "Validation did not pass."), | |
| "failed_tools": failed_tools, | |
| "can_answer": False, | |
| } | |
| return _empty_structured( | |
| _workflow_status=status, | |
| _failure=failure, | |
| ) | |
| def format_validation_failure_message(output: dict[str, Any]) -> str: | |
| """Create a plain-language failure composer answer from validation output.""" | |
| failure = output.get("_failure") if isinstance(output, dict) else None | |
| if not isinstance(failure, dict): | |
| return "I cannot provide the final result because validation did not pass." | |
| status = str(failure.get("status") or "blocked") | |
| task_type = failure.get("task_type") or "the requested task" | |
| missing = ", ".join(str(item) for item in failure.get("missing", []) or []) | |
| repair = str(failure.get("repair_instruction") or "").strip() | |
| failed_tools = failure.get("failed_tools") or [] | |
| lines = [ | |
| "I cannot provide the final scientific result yet.", | |
| f"Workflow status: {status}.", | |
| f"Task: {task_type}.", | |
| ] | |
| if missing: | |
| lines.append(f"Missing required evidence: {missing}.") | |
| if failed_tools: | |
| latest = failed_tools[-1] | |
| reason = latest.get("message") or latest.get("error_type") or "tool failed" | |
| lines.append(f"Latest failed tool: run_ase - {reason}") | |
| if repair: | |
| lines.append(f"Next step: {repair}") | |
| lines.append("No final numerical answer was composed from incomplete artifacts.") | |
| return "\n\n".join(lines) | |
| def _repair_smiles() -> str: | |
| return "Call molecule_name_to_smiles for the requested molecule before answering." | |
| def _repair_identity_clarification(identity_issue: dict[str, Any]) -> str: | |
| name = identity_issue.get("input_name") or "the requested chemical" | |
| warning = identity_issue.get("warning") or ( | |
| "The resolver marked this identity as ambiguous or mixture-like." | |
| ) | |
| candidates = identity_issue.get("candidates") or [] | |
| candidate_hint = "" | |
| if isinstance(candidates, list) and candidates: | |
| top = candidates[0] | |
| if isinstance(top, dict): | |
| candidate_hint = ( | |
| f" Top PubChem candidate: {top.get('iupac_name') or top.get('smiles')} " | |
| f"(credibility score {top.get('credibility_score')})." | |
| ) | |
| return ( | |
| f"Clarify molecule identity before generating coordinates or running ASE. " | |
| f"{name!r} is not a reliable single-molecule target as written. {warning}" | |
| f"{candidate_hint} Ask which component/composition to model, or proceed only " | |
| "if the user explicitly accepts a named representative molecule such as an " | |
| "active ingredient. State that assumption in the final answer." | |
| ) | |
| def _repair_run_ase( | |
| driver: str, | |
| failed_ase: Optional[dict[str, Any]] = None, | |
| calculator_policy: Optional[dict[str, Any]] = None, | |
| ) -> str: | |
| failure_prefix = "" | |
| if failed_ase: | |
| error_type = failed_ase.get("error_type") or "UnknownError" | |
| message = failed_ase.get("message") or "No error message returned." | |
| failure_prefix = ( | |
| f"Previous run_ase failed with {error_type}: {message}. " | |
| "Do not count the failed run as satisfying the requested property. " | |
| ) | |
| if error_type == "FileNotFoundError": | |
| failure_prefix += ( | |
| "The input coordinate file was missing; generate or reuse a real " | |
| "coordinate artifact before retrying run_ase. " | |
| ) | |
| elif driver == "ir": | |
| failure_prefix += ( | |
| "For IR/FTIR, if the calculator cannot provide IR intensities, " | |
| "retry with a suitable calculator such as TBLite/xTB when available, " | |
| "or ask the human which calculator to use. " | |
| ) | |
| calculator_hint = "" | |
| if calculator_policy: | |
| requested = calculator_policy.get("explicit_request") | |
| preferred = calculator_policy.get("preferred") | |
| if requested: | |
| calculator_hint = ( | |
| f" Use the user-requested calculator " | |
| f"{_calculator_payload_hint(requested)}." | |
| ) | |
| elif calculator_policy.get("enforce") and preferred: | |
| calculator_hint = ( | |
| f" Because the user did not explicitly request a calculator, " | |
| f"include calculator {_calculator_payload_hint(preferred)} " | |
| "when it is available." | |
| ) | |
| return ( | |
| failure_prefix | |
| + "Follow the deterministic tool order: if the query names a molecule, " | |
| "first call molecule_name_to_smiles for that exact name and use the " | |
| "returned SMILES; if the query already provides a SMILES, use it " | |
| "directly. Then call smiles_to_coordinate_file, then call run_ase with " | |
| f"driver='{driver}'." | |
| f"{calculator_hint} Do not invent SMILES strings and do not answer " | |
| "until the required run_ase output exists." | |
| ) | |