"""Custom eval task runner — load JSONL eval sets, score model outputs.""" from __future__ import annotations import json import re from dataclasses import dataclass, field from pathlib import Path from typing import Any, Optional MAX_EVAL_TASKS = 10_000 MAX_REGEX_PATTERN_LEN = 1_000 MAX_REGEX_INPUT_LEN = 50_000 VALID_SCORING = { "exact", "contains", "regex", "semantic", "tool_call_match", "tool_call_name_match", "tool_call_args_subset", } @dataclass class EvalTask: """A single evaluation task loaded from JSONL.""" prompt: str expected: str = "" category: str = "default" scoring: str = "exact" metadata: dict[str, Any] = field(default_factory=dict) @dataclass class EvalResult: """Result of evaluating a single task.""" task: EvalTask output: str score: float matched: bool @dataclass class EvalResults: """Aggregated results from a full eval run.""" results: list[EvalResult] total: int = 0 correct: int = 0 accuracy: float = 0.0 category_scores: dict[str, dict[str, float]] = field(default_factory=dict) def compute(self) -> None: """Compute aggregate scores from individual results.""" self.total = len(self.results) self.correct = sum(1 for r in self.results if r.matched) self.accuracy = self.correct / self.total if self.total > 0 else 0.0 # Per-category breakdown cats: dict[str, list[EvalResult]] = {} for result in self.results: cat = result.task.category cats.setdefault(cat, []).append(result) self.category_scores = {} for cat, cat_results in sorted(cats.items()): cat_correct = sum(1 for r in cat_results if r.matched) cat_total = len(cat_results) self.category_scores[cat] = { "total": cat_total, "correct": cat_correct, "accuracy": cat_correct / cat_total if cat_total > 0 else 0.0, } def load_eval_tasks(path: "Path | str") -> list[EvalTask]: """Load eval tasks from a JSONL file with schema validation. Each line must be a JSON object with at least a 'prompt' field. Optional: 'expected', 'category', 'scoring'. """ path = Path(path) if not path.exists(): raise FileNotFoundError(f"Eval tasks file not found: {path}") if not path.suffix.lower() == ".jsonl": raise ValueError(f"Expected .jsonl file, got: {path.suffix}") tasks: list[EvalTask] = [] with open(path, encoding="utf-8") as fh: for line_num, line in enumerate(fh, 1): line = line.strip() if not line: continue if len(tasks) >= MAX_EVAL_TASKS: raise ValueError( f"Eval file exceeds maximum of {MAX_EVAL_TASKS} tasks " f"(stopped at line {line_num})" ) try: row = json.loads(line) except json.JSONDecodeError as exc: raise ValueError( f"Invalid JSON on line {line_num}: {exc}" ) from exc if not isinstance(row, dict): raise ValueError( f"Line {line_num}: expected JSON object, got {type(row).__name__}" ) if "prompt" not in row: raise ValueError( f"Line {line_num}: missing required field 'prompt'" ) scoring = row.get("scoring", "exact") if scoring not in VALID_SCORING: raise ValueError( f"Line {line_num}: invalid scoring '{scoring}', " f"must be one of: {', '.join(sorted(VALID_SCORING))}" ) tasks.append(EvalTask( prompt=str(row["prompt"]), expected=str(row.get("expected", "")), category=str(row.get("category", "default")), scoring=scoring, metadata={ k: v for k, v in row.items() if k not in ("prompt", "expected", "category", "scoring") }, )) if not tasks: raise ValueError(f"No eval tasks found in {path}") return tasks def score_exact(output: str, expected: str) -> bool: """Exact string match (case-insensitive, stripped).""" return output.strip().lower() == expected.strip().lower() def score_contains(output: str, expected: str) -> bool: """Check if expected string is contained in output (case-insensitive).""" return expected.strip().lower() in output.strip().lower() def score_regex(output: str, expected: str) -> bool: """Check if output matches expected regex pattern. Guards against ReDoS: caps pattern and input length. """ if len(expected) > MAX_REGEX_PATTERN_LEN: return False truncated_output = output[:MAX_REGEX_INPUT_LEN] try: return bool(re.search(expected, truncated_output, re.IGNORECASE)) except re.error: return False def score_semantic(output: str, expected: str) -> float: """Compute similarity via Jaccard token overlap (bag-of-words). This is a lexical metric, not embedding-based semantic similarity. Returns a float 0-1. Considered a match if similarity >= 0.5. """ out_tokens = set(output.strip().lower().split()) exp_tokens = set(expected.strip().lower().split()) if not out_tokens or not exp_tokens: return 0.0 intersection = out_tokens & exp_tokens union = out_tokens | exp_tokens return len(intersection) / len(union) def _parse_tool_call(text: str) -> Optional[dict]: """Parse a tool-call JSON blob. Returns None on any failure.""" if not isinstance(text, str): return None try: data = json.loads(text) except (json.JSONDecodeError, ValueError): return None if not isinstance(data, dict): return None return data def _extract_function(call: dict) -> Optional[dict]: func = call.get("function") if not isinstance(func, dict): return None return func def _parse_args(func: dict) -> Optional[dict]: args = func.get("arguments") if isinstance(args, dict): return args if isinstance(args, str): try: parsed = json.loads(args) except (json.JSONDecodeError, ValueError): return None if isinstance(parsed, dict): return parsed return None def tool_call_match(output: str, expected: str) -> bool: """Exact tool-call match: function name and arguments must match.""" out = _parse_tool_call(output) exp = _parse_tool_call(expected) if out is None or exp is None: return False out_func = _extract_function(out) exp_func = _extract_function(exp) if out_func is None or exp_func is None: return False if out_func.get("name") != exp_func.get("name"): return False out_args = _parse_args(out_func) exp_args = _parse_args(exp_func) if out_args is None or exp_args is None: return False return out_args == exp_args def tool_call_name_match(output: str, expected: str) -> bool: """Function-name-only match (ignores arguments).""" out = _parse_tool_call(output) exp = _parse_tool_call(expected) if out is None or exp is None: return False out_func = _extract_function(out) exp_func = _extract_function(exp) if out_func is None or exp_func is None: return False name_out = out_func.get("name") name_exp = exp_func.get("name") return isinstance(name_out, str) and name_out == name_exp def tool_call_args_subset(output: str, expected: str) -> float: """Partial-credit score for tool-call arguments (subset matching). Returns 0.0–1.0: - 0.5 weight for function-name match - 0.5 weight for fraction of expected args present (with matching values) """ out = _parse_tool_call(output) exp = _parse_tool_call(expected) if out is None or exp is None: return 0.0 out_func = _extract_function(out) exp_func = _extract_function(exp) if out_func is None or exp_func is None: return 0.0 name_score = 0.5 if out_func.get("name") == exp_func.get("name") else 0.0 out_args = _parse_args(out_func) or {} exp_args = _parse_args(exp_func) or {} if not exp_args: args_score = 0.5 if not out_args else 0.5 else: matched = sum( 1 for k, v in exp_args.items() if k in out_args and out_args[k] == v ) args_score = 0.5 * (matched / len(exp_args)) return name_score + args_score SCORING_FUNCTIONS = { "exact": score_exact, "contains": score_contains, "regex": score_regex, "tool_call_match": tool_call_match, "tool_call_name_match": tool_call_name_match, } def score_task(task: EvalTask, output: str) -> EvalResult: """Score a single task output against the expected answer.""" if task.scoring == "semantic": similarity = score_semantic(output, task.expected) matched = similarity >= 0.5 return EvalResult( task=task, output=output, score=similarity, matched=matched, ) if task.scoring == "tool_call_args_subset": similarity = tool_call_args_subset(output, task.expected) matched = similarity >= 0.5 return EvalResult( task=task, output=output, score=similarity, matched=matched, ) scorer = SCORING_FUNCTIONS.get(task.scoring, score_exact) matched = scorer(output, task.expected) return EvalResult( task=task, output=output, score=1.0 if matched else 0.0, matched=matched, ) def run_eval( model_path: str, tasks: list[EvalTask], generate_fn: Optional[object] = None, ) -> EvalResults: """Run evaluation on a list of tasks using a model. Args: model_path: Path to model directory. tasks: List of EvalTask objects. generate_fn: Optional callable(prompt: str) -> str. If None, uses a default pipeline from transformers. Returns: EvalResults with per-task scores and aggregates. """ if generate_fn is None: generate_fn = _create_default_generator(model_path) results: list[EvalResult] = [] for task in tasks: output = generate_fn(task.prompt) result = score_task(task, output) results.append(result) eval_results = EvalResults(results=results) eval_results.compute() return eval_results def _create_default_generator(model_path: str): """Create a default text generation function using transformers pipeline.""" from rich.console import Console from rich.panel import Panel from transformers import pipeline console = Console() console.print(Panel( f"[yellow]Loading model from: {model_path}[/]\n" "This will execute model code from the specified path.", title="Model Loading", border_style="yellow", )) pipe = pipeline( "text-generation", model=model_path, max_new_tokens=512, do_sample=False, ) def generate(prompt: str) -> str: out = pipe(prompt, return_full_text=False) return out[0]["generated_text"] if out else "" return generate