Spaces:
Running
Running
| """Reporting utilities for ChemGraph evaluation benchmarks. | |
| Produces structured JSON summaries and human-readable Markdown tables | |
| from LLM-as-judge benchmark results. | |
| """ | |
| import json | |
| from pathlib import Path | |
| from typing import Dict, List, Optional | |
| from chemgraph.utils.logging_config import setup_logger | |
| logger = setup_logger(__name__) | |
| def _safe_pct(value: float) -> str: | |
| """Format a 0-1 fraction as a percentage string. | |
| Parameters | |
| ---------- | |
| value : float | |
| Fractional value to format. | |
| Returns | |
| ------- | |
| str | |
| Percentage string with one decimal place. | |
| """ | |
| return f"{value * 100:.1f}%" | |
| # ---- JSON report --------------------------------------------------------- | |
| def write_json_report( | |
| results: Dict[str, Dict[str, dict]], | |
| metadata: dict, | |
| output_path: str, | |
| ) -> str: | |
| """Write the full benchmark results to a JSON file. | |
| Parameters | |
| ---------- | |
| results : dict | |
| Nested dict: ``{model_name: {workflow_type: {judge_aggregate + details}}}``. | |
| metadata : dict | |
| Run metadata (timestamp, config, etc.). | |
| output_path : str | |
| Destination file path. | |
| Returns | |
| ------- | |
| str | |
| Absolute path to the written file. | |
| """ | |
| report = { | |
| "metadata": metadata, | |
| "results": _make_serializable(results), | |
| } | |
| p = Path(output_path) | |
| p.parent.mkdir(parents=True, exist_ok=True) | |
| with open(p, "w", encoding="utf-8") as f: | |
| json.dump(report, f, indent=2, default=str) | |
| logger.info(f"JSON report written to {p}") | |
| return str(p.resolve()) | |
| # ---- Markdown report ----------------------------------------------------- | |
| def generate_markdown_report( | |
| results: Dict[str, Dict[str, dict]], | |
| metadata: dict, | |
| ) -> str: | |
| """Generate a Markdown comparison report with LLM-judge scores. | |
| Parameters | |
| ---------- | |
| results : dict | |
| ``{model_name: {workflow_type: {"judge_aggregate": {...}, ...}}}`` | |
| metadata : dict | |
| Run metadata. | |
| Returns | |
| ------- | |
| str | |
| Markdown-formatted report string. | |
| """ | |
| lines: List[str] = [] | |
| lines.append("# ChemGraph Evaluation Report") | |
| lines.append("") | |
| # Metadata | |
| lines.append("## Run Metadata") | |
| lines.append("") | |
| for key, val in metadata.items(): | |
| lines.append(f"- **{key}**: {val}") | |
| lines.append("") | |
| # Collect all (model, workflow) combinations | |
| all_workflows = sorted({wf for model_data in results.values() for wf in model_data}) | |
| for workflow in all_workflows: | |
| lines.append(f"## Workflow: `{workflow}`") | |
| lines.append("") | |
| # Check if any model has LLM judge results for this workflow. | |
| has_llm_judge = any( | |
| model_data.get(workflow, {}).get("judge_aggregate") | |
| for model_data in results.values() | |
| ) | |
| # Check if any model has structured judge results. | |
| has_struct_judge = any( | |
| model_data.get(workflow, {}).get("structured_judge_aggregate") | |
| for model_data in results.values() | |
| ) | |
| if has_llm_judge: | |
| lines.append("### LLM Judge (Final Answer Accuracy)") | |
| lines.append("") | |
| header = "| Model | Queries | Correct | Accuracy | Parse Errors |" | |
| sep = "|---|---|---|---|---|" | |
| lines.append(header) | |
| lines.append(sep) | |
| for model_name, model_data in results.items(): | |
| if workflow not in model_data: | |
| continue | |
| jagg = model_data[workflow].get("judge_aggregate") | |
| if not jagg: | |
| continue | |
| row = ( | |
| f"| {model_name} " | |
| f"| {jagg.get('n_queries', 0)} " | |
| f"| {jagg.get('n_correct', 0)} " | |
| f"| {_safe_pct(jagg.get('accuracy', 0))} " | |
| f"| {jagg.get('n_parse_errors', 0)} |" | |
| ) | |
| lines.append(row) | |
| lines.append("") | |
| if has_struct_judge: | |
| lines.append("### Structured Output Judge (Deterministic)") | |
| lines.append("") | |
| header = "| Model | Queries | Correct | Accuracy | Parse Errors |" | |
| sep = "|---|---|---|---|---|" | |
| lines.append(header) | |
| lines.append(sep) | |
| for model_name, model_data in results.items(): | |
| if workflow not in model_data: | |
| continue | |
| sagg = model_data[workflow].get("structured_judge_aggregate") | |
| if not sagg: | |
| continue | |
| row = ( | |
| f"| {model_name} " | |
| f"| {sagg.get('n_queries', 0)} " | |
| f"| {sagg.get('n_correct', 0)} " | |
| f"| {_safe_pct(sagg.get('accuracy', 0))} " | |
| f"| {sagg.get('n_parse_errors', 0)} |" | |
| ) | |
| lines.append(row) | |
| lines.append("") | |
| return "\n".join(lines) | |
| def write_markdown_report( | |
| results: Dict[str, Dict[str, dict]], | |
| metadata: dict, | |
| output_path: str, | |
| ) -> str: | |
| """Write the Markdown report to a file. | |
| Parameters | |
| ---------- | |
| results : dict | |
| Benchmark results. | |
| metadata : dict | |
| Run metadata. | |
| output_path : str | |
| Destination file path. | |
| Returns | |
| ------- | |
| str | |
| Absolute path to the written file. | |
| """ | |
| md = generate_markdown_report(results, metadata) | |
| p = Path(output_path) | |
| p.parent.mkdir(parents=True, exist_ok=True) | |
| with open(p, "w", encoding="utf-8") as f: | |
| f.write(md) | |
| logger.info(f"Markdown report written to {p}") | |
| return str(p.resolve()) | |
| # ---- Per-model detail dumps ---------------------------------------------- | |
| def write_model_detail( | |
| model_name: str, | |
| workflow_type: str, | |
| raw_tool_calls: list, | |
| per_query_results: list, | |
| output_dir: str, | |
| judge_results: Optional[list] = None, | |
| structured_judge_results: Optional[list] = None, | |
| ) -> str: | |
| """Write per-model raw tool calls and evaluation details. | |
| Parameters | |
| ---------- | |
| model_name : str | |
| Model identifier. | |
| workflow_type : str | |
| Workflow type used. | |
| raw_tool_calls : list | |
| Raw tool-call dicts extracted from the agent. | |
| per_query_results : list | |
| Per-query evaluation result dicts. | |
| output_dir : str | |
| Output directory. | |
| judge_results : list, optional | |
| Per-query LLM judge result dicts. | |
| structured_judge_results : list, optional | |
| Per-query structured-output judge result dicts. | |
| Returns | |
| ------- | |
| str | |
| Path to the written detail file. | |
| """ | |
| detail = { | |
| "model_name": model_name, | |
| "workflow_type": workflow_type, | |
| "raw_tool_calls": raw_tool_calls, | |
| "per_query_results": _make_serializable(per_query_results), | |
| } | |
| if judge_results is not None: | |
| detail["judge_results"] = _make_serializable(judge_results) | |
| if structured_judge_results is not None: | |
| detail["structured_judge_results"] = _make_serializable( | |
| structured_judge_results | |
| ) | |
| safe_name = model_name.replace("/", "_").replace(":", "_") | |
| fname = f"{safe_name}_{workflow_type}_detail.json" | |
| p = Path(output_dir) / fname | |
| p.parent.mkdir(parents=True, exist_ok=True) | |
| with open(p, "w", encoding="utf-8") as f: | |
| json.dump(detail, f, indent=2, default=str) | |
| logger.info(f"Detail file written to {p}") | |
| return str(p.resolve()) | |
| # ---- Printing to console ------------------------------------------------- | |
| def print_summary_table(results: Dict[str, Dict[str, dict]]) -> None: | |
| """Print a concise comparison table to stdout. | |
| Displays columns for whichever judges have results (LLM judge, | |
| structured judge, or both). | |
| Parameters | |
| ---------- | |
| results : dict | |
| ``{model_name: {workflow_type: {"judge_aggregate": {...}, ...}}}`` | |
| """ | |
| all_workflows = sorted({wf for model_data in results.values() for wf in model_data}) | |
| for workflow in all_workflows: | |
| # Detect which judges have results for this workflow. | |
| has_llm = any( | |
| model_data.get(workflow, {}).get("judge_aggregate") | |
| for model_data in results.values() | |
| ) | |
| has_struct = any( | |
| model_data.get(workflow, {}).get("structured_judge_aggregate") | |
| for model_data in results.values() | |
| ) | |
| print(f"\n{'=' * 60}") | |
| print(f" Workflow: {workflow}") | |
| print(f"{'=' * 60}") | |
| # Build header dynamically. | |
| cols = [] | |
| if has_llm: | |
| cols.append(("Judge Acc", 10)) | |
| if has_struct: | |
| cols.append(("Struct Acc", 10)) | |
| header = f" {'Model':<40}" | |
| sep = f" {'-' * 40}" | |
| for col_name, col_width in cols: | |
| header += f" {col_name:>{col_width}}" | |
| sep += f" {'-' * col_width}" | |
| print(header) | |
| print(sep) | |
| for model_name, model_data in results.items(): | |
| if workflow not in model_data: | |
| continue | |
| row = f" {model_name:<40}" | |
| if has_llm: | |
| jagg = model_data[workflow].get("judge_aggregate") | |
| j = _safe_pct(jagg.get("accuracy", 0)) if jagg else "N/A" | |
| row += f" {j:>10}" | |
| if has_struct: | |
| sagg = model_data[workflow].get("structured_judge_aggregate") | |
| s = _safe_pct(sagg.get("accuracy", 0)) if sagg else "N/A" | |
| row += f" {s:>10}" | |
| print(row) | |
| print() | |
| # ---- Helpers -------------------------------------------------------------- | |
| def _make_serializable(obj): | |
| """Recursively convert non-serializable objects to strings. | |
| Parameters | |
| ---------- | |
| obj : Any | |
| Object to convert. | |
| Returns | |
| ------- | |
| Any | |
| JSON-serializable object. | |
| """ | |
| if isinstance(obj, dict): | |
| return {k: _make_serializable(v) for k, v in obj.items()} | |
| elif isinstance(obj, (list, tuple)): | |
| return [_make_serializable(item) for item in obj] | |
| elif isinstance(obj, (int, float, bool, str, type(None))): | |
| return obj | |
| else: | |
| return str(obj) | |