diff --git a/tests/test_from_dict_malformed.py b/tests/test_from_dict_malformed.py new file mode 100644 index 0000000..7d6ac93 --- /dev/null +++ b/tests/test_from_dict_malformed.py @@ -0,0 +1,38 @@ +import pytest + +from voiceeval.turns import from_dict + + +def _turn(**overrides) -> dict: + turn = {"speaker": "user", "text": "hello", "start_s": 0.0, "end_s": 1.0} + turn.update(overrides) + return turn + + +@pytest.mark.parametrize( + "turn,field", + [ + ({}, "speaker"), + (_turn(speaker="robot"), "speaker"), + (_turn(text=None), "text"), + ({"speaker": "user", "text": "hello", "end_s": 1.0}, "start_s"), + (_turn(start_s="soon"), "start_s"), + (_turn(start_s=None), "start_s"), + ({"speaker": "user", "text": "hello", "start_s": 0.0}, "end_s"), + (_turn(end_s="gone"), "end_s"), + ], +) +def test_from_dict_malformed_turn_raises_descriptive_value_error(turn, field): + with pytest.raises(ValueError, match=f"turn 0.*{field}"): + from_dict({"turns": [turn]}) + + +def test_from_dict_malformed_second_turn_names_turn_index(): + turns = [_turn(), {"speaker": "agent", "text": "ok", "start_s": "later", "end_s": 5.0}] + with pytest.raises(ValueError, match="turn 1.*start_s"): + from_dict({"turns": turns}) + + +def test_from_dict_missing_turns_key_is_descriptive(): + with pytest.raises(ValueError, match="turns"): + from_dict({}) \ No newline at end of file diff --git a/voiceeval/turns.py b/voiceeval/turns.py index 7885b67..be2488a 100644 --- a/voiceeval/turns.py +++ b/voiceeval/turns.py @@ -88,12 +88,35 @@ def load(path: str | Path) -> Interaction: def from_dict(data: dict) -> Interaction: + if not isinstance(data, dict): + raise ValueError("interaction must be a JSON object with a 'turns' list") + raw_turns = data.get("turns") + if not isinstance(raw_turns, list): + raise ValueError("interaction 'turns' must be a list") + + def turn_field(turn, i, field, *, convert=None): + if not isinstance(turn, dict): + raise ValueError(f"turn {i} must be an object") + if field not in turn: + raise ValueError(f"turn {i} missing field '{field}'") + value = turn[field] + if convert is not None: + try: + return convert(value) + except (TypeError, ValueError): + raise ValueError(f"turn {i} field '{field}' must be a number, got {value!r}") from None + if field == "speaker" and value not in ("user", "agent"): + raise ValueError(f"turn {i} field 'speaker' must be 'user' or 'agent', got {value!r}") + if field == "text" and not isinstance(value, str): + raise ValueError(f"turn {i} field 'text' must be a string, got {value!r}") + return value + turns = [ Turn( - speaker=t["speaker"], - text=t["text"], - start_s=float(t["start_s"]), - end_s=float(t["end_s"]), + speaker=turn_field(t, i, "speaker"), + text=turn_field(t, i, "text"), + start_s=turn_field(t, i, "start_s", convert=float), + end_s=turn_field(t, i, "end_s", convert=float), truth=t.get("truth"), actions=[ Action( @@ -104,7 +127,7 @@ def from_dict(data: dict) -> Interaction: for a in t.get("actions", []) ], ) - for t in data["turns"] + for i, t in enumerate(raw_turns) ] return Interaction( id=data.get("id", "unnamed"),