Ctrl K
ComunidadGuías…

test/eval/dataset/test_suite.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# Copyright (c) 2026, AG2ai, Inc., AG2ai open-source projects maintainers and core contributors
#
# SPDX-License-Identifier: Apache-2.0

"""Public-API tests for :class:`Suite`, :meth:`Suite.from_list`, and :meth:`Suite.from_jsonl`."""

import json
from dataclasses import dataclass
from pathlib import Path

import pytest
from pydantic import BaseModel

from ag2.eval import Suite, Task


class _RefModel(BaseModel):
    answer: str
    confidence: float = 1.0


@dataclass
class _RefDataclass:
    answer: str


class TestFromList:
    def test_minimal_task(self) -> None:
        suite = Suite.from_list([{"inputs": {"input": "hi"}}])

        assert len(suite) == 1
        [task] = list(suite)
        assert task == Task(task_id="task-0000", inputs={"input": "hi"})

    def test_auto_generated_task_ids_are_zero_padded_and_sequential(self) -> None:
        suite = Suite.from_list([
            {"inputs": {"input": "first"}},
            {"inputs": {"input": "second"}},
            {"inputs": {"input": "third"}},
        ])

        assert tuple(t.task_id for t in suite) == ("task-0000", "task-0001", "task-0002")

    def test_explicit_task_id_is_preserved(self) -> None:
        suite = Suite.from_list([
            {"task_id": "weather-001", "inputs": {"input": "Tokyo?"}},
        ])

        [task] = list(suite)
        assert task.task_id == "weather-001"

    def test_full_payload_round_trips(self) -> None:
        suite = Suite.from_list([
            {
                "task_id": "weather-001",
                "inputs": {"input": "Tokyo?"},
                "reference_outputs": {"city": "Tokyo"},
                "tags": ["happy-path"],
                "metadata": {"difficulty": "easy"},
            }
        ])

        [task] = list(suite)
        assert task == Task(
            task_id="weather-001",
            inputs={"input": "Tokyo?"},
            reference_outputs={"city": "Tokyo"},
            tags=("happy-path",),
            metadata={"difficulty": "easy"},
        )

    def test_missing_inputs_raises_value_error(self) -> None:
        with pytest.raises(ValueError, match="missing required field 'inputs'"):
            Suite.from_list([{"task_id": "x"}])

    def test_inputs_not_a_dict_raises_value_error(self) -> None:
        with pytest.raises(ValueError, match="must be a dict"):
            Suite.from_list([{"inputs": "what is the weather?"}])

    def test_default_name_and_source_are_inline(self) -> None:
        suite = Suite.from_list([{"inputs": {"input": "hi"}}])

        assert suite.name == "inline"
        assert suite.source == "inline"

    def test_custom_name_overrides_default(self) -> None:
        suite = Suite.from_list([{"inputs": {"input": "hi"}}], name="weather-mini")

        assert suite.name == "weather-mini"
        assert suite.source == "inline"

    def test_null_tags_and_metadata_default_to_empty(self) -> None:
        suite = Suite.from_list([{"inputs": {"q": "x"}, "tags": None, "metadata": None}])

        [task] = list(suite)
        assert task.tags == ()
        assert task.metadata == {}

    def test_single_string_tag_is_not_split_into_characters(self) -> None:
        suite = Suite.from_list([{"inputs": {"q": "x"}, "tags": "smoke"}])

        [task] = list(suite)
        assert task.tags == ("smoke",)

    def test_invalid_tags_type_raises_with_task_index(self) -> None:
        with pytest.raises(ValueError, match=r"task at index 1 has invalid 'tags'"):
            Suite.from_list([{"inputs": {"q": "x"}}, {"inputs": {"q": "y"}, "tags": 5}])

    @pytest.mark.parametrize("tags", [[1], ["valid", 2], [None], ["valid", False], ("valid", 3)])
    def test_tags_with_non_string_elements_are_rejected(self, tags: list[object] | tuple[object, ...]) -> None:
        with pytest.raises(ValueError, match=r"task at index 0 has invalid 'tags'"):
            Suite.from_list([{"inputs": {"input": "hi"}, "tags": tags}])

    def test_string_tuple_tags_are_allowed(self) -> None:
        suite = Suite.from_list([{"inputs": {"input": "hi"}, "tags": ("smoke", "fast")}])

        [task] = list(suite)
        assert task.tags == ("smoke", "fast")

    def test_invalid_metadata_type_raises_with_task_index(self) -> None:
        with pytest.raises(ValueError, match=r"task at index 0 has invalid 'metadata'"):
            Suite.from_list([{"inputs": {"q": "x"}, "metadata": "abc"}])


class TestFromJsonl:
    def test_loads_one_task_per_line(self, tmp_path: Path) -> None:
        path = tmp_path / "dataset.jsonl"
        path.write_text(
            "\n".join([
                json.dumps({"task_id": "a", "inputs": {"input": "1"}}),
                json.dumps({"task_id": "b", "inputs": {"input": "2"}}),
            ]),
            encoding="utf-8",
        )

        suite = Suite.from_jsonl(path)

        assert tuple(t.task_id for t in suite) == ("a", "b")

    def test_name_is_filename_stem_and_source_is_path(self, tmp_path: Path) -> None:
        path = tmp_path / "weather.jsonl"
        path.write_text(json.dumps({"inputs": {"input": "hi"}}) + "\n", encoding="utf-8")

        suite = Suite.from_jsonl(path)

        assert suite.name == "weather"
        assert suite.source == str(path)

    def test_blank_lines_are_skipped(self, tmp_path: Path) -> None:
        path = tmp_path / "dataset.jsonl"
        path.write_text(
            "\n".join([
                json.dumps({"inputs": {"input": "1"}}),
                "",
                "   ",
                json.dumps({"inputs": {"input": "2"}}),
            ]),
            encoding="utf-8",
        )

        suite = Suite.from_jsonl(path)

        assert len(suite) == 2

    def test_accepts_str_and_path_inputs(self, tmp_path: Path) -> None:
        path = tmp_path / "dataset.jsonl"
        path.write_text(json.dumps({"inputs": {"input": "hi"}}) + "\n", encoding="utf-8")

        assert len(Suite.from_jsonl(str(path))) == 1
        assert len(Suite.from_jsonl(path)) == 1

    def test_invalid_json_reports_line_number(self, tmp_path: Path) -> None:
        path = tmp_path / "dataset.jsonl"
        path.write_text(
            "\n".join([
                json.dumps({"inputs": {"input": "ok"}}),
                "{not json",
            ]),
            encoding="utf-8",
        )

        with pytest.raises(ValueError, match="line 2"):
            Suite.from_jsonl(path)

    def test_non_object_json_line_raises(self, tmp_path: Path) -> None:
        path = tmp_path / "dataset.jsonl"
        path.write_text("[1, 2, 3]\n", encoding="utf-8")

        with pytest.raises(ValueError, match="must be a JSON object"):
            Suite.from_jsonl(path)

    def test_null_tags_and_metadata_in_jsonl(self, tmp_path: Path) -> None:
        path = tmp_path / "dataset.jsonl"
        path.write_text(
            json.dumps({"inputs": {"q": "x"}, "tags": None, "metadata": None}) + "\n",
            encoding="utf-8",
        )

        suite = Suite.from_jsonl(path)

        [task] = list(suite)
        assert task.tags == ()
        assert task.metadata == {}

    def test_jsonl_rejects_non_string_tags(self, tmp_path: Path) -> None:
        path = tmp_path / "dataset.jsonl"
        path.write_text(
            json.dumps({"inputs": {"input": "hi"}, "tags": ["valid", 2]}) + "\n",
            encoding="utf-8",
        )

        with pytest.raises(ValueError, match=r"task at index 0 has invalid 'tags'"):
            Suite.from_jsonl(path)

    def test_leading_bom_is_stripped(self, tmp_path: Path) -> None:
        path = tmp_path / "bom.jsonl"
        path.write_text(
            "\ufeff"
            + "\n".join([
                json.dumps({"task_id": "a", "inputs": {"input": "1"}}),
                json.dumps({"task_id": "b", "inputs": {"input": "2"}}),
            ])
            + "\n",
            encoding="utf-8",
        )

        suite = Suite.from_jsonl(path)

        assert [(t.task_id, t.inputs) for t in suite] == [("a", {"input": "1"}), ("b", {"input": "2"})]

    def test_bom_after_the_first_line_is_still_invalid(self, tmp_path: Path) -> None:
        path = tmp_path / "dataset.jsonl"
        path.write_text(
            json.dumps({"inputs": {"input": "1"}}) + "\n\ufeff" + json.dumps({"inputs": {"input": "2"}}) + "\n",
            encoding="utf-8",
        )

        with pytest.raises(ValueError, match="line 2"):
            Suite.from_jsonl(path)


def test_suite_iterates_in_dataset_order() -> None:
    suite = Suite.from_list([{"inputs": {"input": str(i)}} for i in range(3)])

    assert [t.inputs["input"] for t in suite] == ["0", "1", "2"]


def test_suite_tasks_is_a_tuple() -> None:
    suite = Suite.from_list([{"inputs": {"input": "hi"}}])

    assert isinstance(suite.tasks, tuple)
    assert len(suite.tasks) == len(suite)


class TestReferenceOutputsCoercion:
    """reference_outputs is normalised to a plain dict; non-mapping values raise."""

    def test_pydantic_model_is_coerced_to_dict(self) -> None:
        task = Task(task_id="t", inputs={"input": "q"}, reference_outputs=_RefModel(answer="Paris"))

        assert task.reference_outputs == {"answer": "Paris", "confidence": 1.0}

    def test_dataclass_instance_is_coerced_to_dict(self) -> None:
        task = Task(task_id="t", inputs={"input": "q"}, reference_outputs=_RefDataclass(answer="Paris"))

        assert task.reference_outputs == {"answer": "Paris"}

    def test_dict_is_kept(self) -> None:
        task = Task(task_id="t", inputs={"input": "q"}, reference_outputs={"answer": "Paris"})

        assert task.reference_outputs == {"answer": "Paris"}

    def test_none_is_kept(self) -> None:
        assert Task(task_id="t", inputs={"input": "q"}).reference_outputs is None

    def test_non_mapping_raises_type_error(self) -> None:
        with pytest.raises(TypeError, match="reference_outputs must be a dict"):
            Task(task_id="t", inputs={"input": "q"}, reference_outputs=["Paris"])

    def test_coercion_applies_through_from_list(self) -> None:
        suite = Suite.from_list([{"inputs": {"input": "q"}, "reference_outputs": _RefModel(answer="Paris")}])

        [task] = list(suite)
        assert task.reference_outputs == {"answer": "Paris", "confidence": 1.0}