chemgraph-loop / src /chemgraph /utils /tool_call_eval.py
rockyaaos's picture
ChemGraph Loop: guarded real-agent API (EMT/TBLite single-point energy)
c509967 verified
Raw
History Blame Contribute Delete
9.95 kB
"""Module for quick LLM evaluations"""
from deepdiff import DeepDiff
from chemgraph.schemas.ase_input import ASEInputSchema
def remove_ignored_fields(obj, ignored_keys=("cell", "pbc")):
"""Remove ignored fields from object
Args:
obj (_type_): _description_
ignored_keys (tuple, optional): _description_. Defaults to ("cell", "pbc").
Returns:
_type_: _description_
"""
if isinstance(obj, dict):
return {
k: remove_ignored_fields(v, ignored_keys)
for k, v in obj.items()
if k not in ignored_keys
}
elif isinstance(obj, list):
return [remove_ignored_fields(item, ignored_keys) for item in obj]
else:
return obj
def apply_defaults(args: dict, schema: dict) -> dict:
"""Fill missing fields with default values from a JSON-like schema.
Handles nested objects and anyOf/default combinations.
Parameters
----------
args : dict
Tool-call arguments to augment.
schema : dict
JSON schema containing properties and defaults.
Returns
-------
dict
Arguments with applicable default values filled in.
"""
if not isinstance(args, dict):
return args # Only process dicts
args_with_defaults = dict(args) # shallow copy
properties = schema.get("properties", {})
for key, prop_schema in properties.items():
# Skip if already set
if key in args_with_defaults:
# Recurse into nested object
if isinstance(args_with_defaults[key], dict) and prop_schema.get("type") == "object":
args_with_defaults[key] = apply_defaults(args_with_defaults[key], prop_schema)
continue
# Handle default at top level
if "default" in prop_schema:
args_with_defaults[key] = prop_schema["default"]
continue
# Handle nested default inside anyOf (take first subschema with default)
if "anyOf" in prop_schema:
for option in prop_schema["anyOf"]:
if isinstance(option, dict) and "default" in option:
args_with_defaults[key] = option["default"]
break
# Handle nested object with defaults even if not explicitly present
if prop_schema.get("type") == "object" and "properties" in prop_schema:
args_with_defaults[key] = apply_defaults({}, prop_schema)
return args_with_defaults
def lowercase_dict(obj):
"""Recursively lowercase string keys and string values.
Parameters
----------
obj : Any
Dictionary, list, string, or scalar value to normalize.
Returns
-------
Any
Normalized object with lowercased string keys/values.
"""
if isinstance(obj, dict):
return {(k.lower() if isinstance(k, str) else k): lowercase_dict(v) for k, v in obj.items()}
elif isinstance(obj, list):
return [lowercase_dict(i) for i in obj]
elif isinstance(obj, str):
return obj.lower()
else:
return obj
def single_function_checker(
func_description: dict,
model_output: dict,
answer: dict,
ignore_fields=None,
) -> dict:
"""
Compare model tool call output with expected answer, validating with function schema.
Args:
func_description (dict): Tool function schema (JSON schema style)
model_output (dict): LLM's tool call output (with "arguments" and optionally "result")
answer (dict): Reference tool call output
Returns:
"""
if ignore_fields is None:
ignore_fields = ["cell", "pbc"]
# Extract schema and values
schema = func_description.get("parameters", {})
result = {"valid": False, "error": ""}
tool_name_model, model_args_raw = next(iter(model_output.items()))
tool_name_answer, answer_args_raw = next(iter(answer.items()))
if tool_name_model != tool_name_answer:
error = "different tool_name"
result = {"valid": False, "error": error}
return result
# Have a special case for run_ase due to complex input schema
if tool_name_model == "run_ase":
try:
model_args = ASEInputSchema(**model_args_raw["params"]).model_dump()
answer_args = ASEInputSchema(**answer_args_raw["params"]).model_dump()
except Exception as e:
result = {"valid": False, "error": e}
return result
# Apply lower case to both sides
model_args = lowercase_dict(model_args)
answer_args = lowercase_dict(answer_args)
# Remove ignored fields
model_args = remove_ignored_fields(model_args, ignore_fields)
answer_args = remove_ignored_fields(answer_args, ignore_fields)
else:
# Apply lower case to both sides
model_args_lower = lowercase_dict(model_args_raw)
answer_args_lower = lowercase_dict(answer_args_raw)
# Apply defaults to both sides
model_args_full = apply_defaults(model_args_lower, schema)
answer_args_full = apply_defaults(answer_args_lower, schema)
# Remove ignored fields
model_args = remove_ignored_fields(model_args_full, ignore_fields)
answer_args = remove_ignored_fields(answer_args_full, ignore_fields)
diff = DeepDiff(
model_args,
answer_args,
significant_digits=3, # Controls float tolerance
ignore_order=True, # Ignores order in dicts and lists
)
if not diff:
result = {"valid": True, "error": ""}
else:
result = {"valid": False, "error": diff.to_dict()}
return result
def find_description(func_descriptions: list, func_name: str) -> dict:
"""Find the function description by name
Args:
func_descriptions (list): list of function descriptions
func_name (str): function name
Returns:
dict: dictionary of the
"""
if isinstance(func_descriptions, list):
for func_description in func_descriptions:
if func_description["name"] == func_name:
return func_description
return None
else:
return func_descriptions
def multi_function_checker_with_order(
func_descriptions: dict,
model_outputs: list,
answers: list,
ignore_fields=None,
) -> dict:
"""Evaluate multiple function calls.
Args:
func_description (dict): _description_
model_output (list): _description_
answer (list): _description_
ignore_fields (_type_, optional): _description_. Defaults to None.
Returns:
dict: _description_
"""
if ignore_fields is None:
ignore_fields = ["cell", "pbc"]
# Initialize result
result = {
"valid": True,
"error": "",
"n_true_toolcalls": len(answers),
"n_llm_tool_calls": len(model_outputs),
"acc_n_toolcalls": 0,
"args_differences": {},
}
if len(model_outputs) != len(answers):
result['error'] = "Different number of tool calls"
result['valid'] = False
return result
for model_output, answer in zip(model_outputs, answers):
tool_name_model, model_args_raw = next(iter(model_output.items()))
# Get function description
func_description = find_description(
func_descriptions=func_descriptions,
func_name=tool_name_model,
)
if func_description is None:
result["error"] += f"Function {tool_name_model} is not in the given functions.\n"
continue
else:
result_single = single_function_checker(
func_description=func_description,
model_output=model_output,
answer=answer,
)
if result_single["valid"] is True:
result["acc_n_toolcalls"] += 1
else:
result["args_differences"][tool_name_model] = result_single["error"]
return result
def multi_function_checker_without_order(
func_descriptions: dict,
model_outputs: list,
answers: list,
ignore_fields=None,
) -> dict:
"""Evaluate multiple function calls.
Args:
func_description (dict): _description_
model_output (list): _description_
answer (list): _description_
ignore_fields (_type_, optional): _description_. Defaults to None.
Returns:
dict: _description_
"""
if ignore_fields is None:
ignore_fields = ["cell", "pbc"]
# Initialize result
result = {
"valid": True,
"error": "",
"n_true_toolcalls": len(answers),
"n_llm_tool_calls": len(model_outputs),
"acc_n_toolcalls": 0,
"answers_without_match": [],
}
for model_id, model_output in enumerate(model_outputs):
for answer_id, answer in enumerate(answers):
tool_name_model, model_args_raw = next(iter(model_output.items()))
# Get function description
func_description = find_description(
func_descriptions=func_descriptions,
func_name=tool_name_model,
)
if func_description is None:
result["error"] += f"Function {tool_name_model} is not in the given functions.\n"
continue
else:
result_single = single_function_checker(
func_description=func_description,
model_output=model_output,
answer=answer,
)
if result_single["valid"] is True:
result["acc_n_toolcalls"] += 1
# Remove accurate answer from future comparison after a match.
answers.remove(answer)
else:
continue
if len(answers) != 0:
for answer in answers:
result["answers_without_match"].append(answer)
return result