Skip to content

Commit 73f41ba

Browse files
evrttAbhiPrasad
andauthored
fix: normalize list score dicts in remote evals (#595)
## Context Valid multi-score remote scorer results from remote eval scorers are rejected. Remote eval scorers can return `list[Score]`, but that result crosses an HTTP/JSON boundary and the caller receives `list[dict]` rather than real `Score` objects. `await_or_run_scorer` normalizes a single score dict with `Score.from_dict()`, but is missing normalization for `list[dict]` responses. ## Changes - Normalize dict entries inside scorer result lists with `Score.from_dict()`. - Keep existing validation for malformed score entries. - Add regression coverage for a scorer returning a list of score dicts. - Verify both per-result scores and summary scores are populated correctly. ## Testing * Added `test_run_evaluator_normalizes_list_of_dict_scores` - creates an evaluator with two data rows - uses a scorer that returns a list of two score dicts: - `{"name": "per_result", "score": 1.0}` - `"name": "summary_only", "score": 1.0}` - runs the evaluator locally with `run_evaluator(...)` - asserts each eval result contains both scores - asserts the final summary contains both score names with score 1.0 * Ran the focused core regression test * Ran a local smoke test where a scorer returns multiple score dicts, confirming the eval completes and emits both expected scores. --------- Co-authored-by: Abhijeet Prasad <abhijeet@braintrustdata.com>
1 parent a095270 commit 73f41ba

2 files changed

Lines changed: 61 additions & 10 deletions

File tree

‎py/src/braintrust/framework.py‎

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1324,6 +1324,19 @@ def _build_classification_span_output(
13241324
return grouped_output
13251325

13261326

1327+
def _normalize_score(value: Any, error_message: str) -> ScoreLike:
1328+
original_value = value
1329+
if isinstance(value, dict):
1330+
try:
1331+
value = Score.from_dict(value)
1332+
except Exception as e:
1333+
raise ValueError(f"{error_message} Got: {original_value}") from e
1334+
1335+
if not is_score(value):
1336+
raise ValueError(f"{error_message} Got: {original_value}")
1337+
return value
1338+
1339+
13271340
def _validate_classification_result(value: Any, classifier_name: str) -> Classification:
13281341
if isinstance(value, Mapping):
13291342
if not value:
@@ -1455,18 +1468,16 @@ async def await_or_run_scorer(root_span, scorer, name, **kwargs):
14551468

14561469
result = await call_user_fn(event_loop, score, **kwargs)
14571470
if isinstance(result, dict):
1458-
try:
1459-
result = Score.from_dict(result)
1460-
except Exception as e:
1461-
raise ValueError(f"When returning a dict, it must be a valid Score object. Got: {result}") from e
1471+
result = _normalize_score(result, "When returning a dict, it must be a valid Score object.")
14621472

14631473
if isinstance(result, Iterable) and not isinstance(result, (str, bytes, Mapping)):
1464-
for s in result:
1465-
if not is_score(s):
1466-
raise ValueError(
1467-
f"When returning an array of scores, each score must be a valid Score object. Got: {s}"
1468-
)
1469-
result = list(result)
1474+
result = [
1475+
_normalize_score(
1476+
score,
1477+
"When returning an array of scores, each score must be a valid Score object.",
1478+
)
1479+
for score in result
1480+
]
14701481
elif is_score(result):
14711482
result = [result]
14721483
else:

‎py/src/braintrust/test_framework.py‎

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,6 +296,46 @@ def _run_eval_sync(self, *args, **kwargs):
296296
assert result.summary.scores[scorer_name].score == 1.0
297297

298298

299+
@pytest.mark.asyncio
300+
async def test_run_evaluator_normalizes_list_of_dict_scores():
301+
data = [
302+
EvalCase(input=1, expected=1),
303+
EvalCase(input=2, expected=2),
304+
]
305+
306+
def simple_task(input_value):
307+
return input_value
308+
309+
def list_dict_scorer(input_value, output, expected):
310+
return [
311+
{"name": "per_result", "score": 1.0},
312+
{"name": "summary_only", "score": 1.0},
313+
]
314+
315+
evaluator = Evaluator(
316+
project_name="test-project",
317+
eval_name="test-list-dict-scorer",
318+
data=data,
319+
task=simple_task,
320+
scores=[list_dict_scorer],
321+
experiment_name=None,
322+
metadata=None,
323+
)
324+
325+
result = await run_evaluator(None, evaluator, None, [])
326+
327+
assert isinstance(result, EvalResultWithSummary)
328+
assert len(result.results) == 2
329+
330+
for eval_result in result.results:
331+
assert eval_result.scores["per_result"] == 1.0
332+
assert eval_result.scores["summary_only"] == 1.0
333+
334+
assert result.summary.project_name == "test-project"
335+
assert result.summary.scores["per_result"].score == 1.0
336+
assert result.summary.scores["summary_only"].score == 1.0
337+
338+
299339
@pytest.mark.asyncio
300340
@pytest.mark.skipif(
301341
sys.version_info < (3, 14),

0 commit comments

Comments
 (0)