from __future__ import annotations import json from pathlib import Path from types import SimpleNamespace from typing import Any import pytest import evaluation.evaluate_rag_answers as evaluator def sample_question( question_id: str = "q0001", *, question: str = ( "Aká je téma práce?" ), ) -> dict[str, Any]: return { "id": question_id, "split": "dev", "category": "specific_fact", "difficulty": "medium", "question": question, "expected_documents": [ "pages/test/README.md" ], "expected_source_urls": [ "https://example.test/source" ], "expected_answer_contains": [ "test" ], "should_answer": True, "note": None, } def sample_configuration() -> dict[ str, Any, ]: return { "schema_version": ( evaluator.PARTIAL_SCHEMA_VERSION ), "questions_file": ( "/tmp/questions.json" ), "overrides_file": ( "/tmp/overrides.json" ), "questions_fingerprint": ( "abc123" ), "selected_question_ids": [ "q0001", "q0002", ], "applied_override_ids": [], "split": "dev", "selected_question_count": 2, "requested_model": ( "model120-fast" ), "operation_id": ( "retrieve_zpwiki_context" ), "openwebui_url": ( "https://ui.example/api" ), "rag_url": ( "http://localhost:8000/rag" ), "timeout": 180, "max_attempts": 4, "backoff_base": 1.0, "backoff_max": 8.0, "delay": 0.5, } def test_save_json_results_is_atomic_and_valid( tmp_path: Path, ) -> None: path = ( tmp_path / "result.partial.json" ) payload = { "status": "partial", "results": [ { "id": "q0001" } ], } evaluator.save_json_results( path, payload, ) loaded = json.loads( path.read_text( encoding="utf-8" ) ) assert ( loaded == payload ) assert ( path.read_bytes() .endswith( b"\n" ) ) assert ( list( tmp_path.glob( ".*.tmp" ) ) == [] ) def test_questions_fingerprint_is_stable_and_detects_change() -> None: first = [ sample_question() ] same = [ sample_question() ] changed = [ sample_question( question=( "Iná otázka" ) ) ] assert ( evaluator.questions_fingerprint( first ) == evaluator.questions_fingerprint( same ) ) assert ( evaluator.questions_fingerprint( first ) != evaluator.questions_fingerprint( changed ) ) def test_validate_answer_questions_accepts_valid_question() -> None: evaluator.validate_answer_questions( [ sample_question() ] ) def test_validate_answer_questions_rejects_invalid_types() -> None: question = ( sample_question() ) question[ "should_answer" ] = "yes" with pytest.raises( ValueError, match="should_answer", ): evaluator.validate_answer_questions( [ question ] ) question = ( sample_question() ) question[ "expected_source_urls" ] = ( "https://example.test/source" ) with pytest.raises( ValueError, match="expected_source_urls", ): evaluator.validate_answer_questions( [ question ] ) def test_validate_resume_compatibility_accepts_matching_configuration() -> None: configuration = ( sample_configuration() ) evaluator.validate_resume_compatibility( configuration, dict( configuration ), ) def test_validate_resume_compatibility_rejects_model_mismatch() -> None: partial_configuration = ( sample_configuration() ) current_configuration = ( sample_configuration() ) current_configuration[ "requested_model" ] = "model2" with pytest.raises( ValueError, match="requested_model", ): evaluator.validate_resume_compatibility( partial_configuration, current_configuration, ) def test_extract_resume_state_skips_success_and_retries_errors() -> None: partial_payload = { "results": [ { "id": "q0001", "answer": "ok", "error": None, }, { "id": "q0002", "answer": "", "error": "timeout", }, ] } ( successful, failed_ids, ) = evaluator.extract_resume_state( partial_payload, selected_question_ids={ "q0001", "q0002", }, ) assert ( set( successful ) == { "q0001" } ) assert ( failed_ids == [ "q0002" ] ) def test_extract_resume_state_rejects_duplicate_ids() -> None: partial_payload = { "results": [ { "id": "q0001", "error": None, }, { "id": "q0001", "error": None, }, ] } with pytest.raises( ValueError, match="duplicitné", ): evaluator.extract_resume_state( partial_payload, selected_question_ids={ "q0001" }, ) def test_determine_run_status_distinguishes_states() -> None: assert ( evaluator.determine_run_status( [], expected_total=2, ) == "partial" ) assert ( evaluator.determine_run_status( [ { "id": "q0001", "error": None, } ], expected_total=2, ) == "partial" ) assert ( evaluator.determine_run_status( [ { "id": "q0001", "error": None, }, { "id": "q0002", "error": "timeout", }, ], expected_total=2, ) == "complete_with_errors" ) assert ( evaluator.determine_run_status( [ { "id": "q0001", "error": None, }, { "id": "q0002", "error": None, }, ], expected_total=2, ) == "complete" ) def test_prepare_output_state_requires_resume_or_overwrite_for_partial( tmp_path: Path, ) -> None: json_path = ( tmp_path / "results.json" ) csv_path = ( tmp_path / "results.csv" ) partial_path = ( tmp_path / "results.partial.json" ) partial_path.write_text( "{}\n", encoding="utf-8", ) with pytest.raises( FileExistsError, match="--resume", ): evaluator.prepare_output_state( json_path=( json_path ), csv_path=( csv_path ), partial_path=( partial_path ), resume=False, overwrite=False, ) evaluator.prepare_output_state( json_path=( json_path ), csv_path=( csv_path ), partial_path=( partial_path ), resume=False, overwrite=True, ) assert ( not partial_path.exists() ) def test_prepare_output_state_requires_existing_partial_for_resume( tmp_path: Path, ) -> None: with pytest.raises( FileNotFoundError, match="partial neexistuje", ): evaluator.prepare_output_state( json_path=( tmp_path / "results.json" ), csv_path=( tmp_path / "results.csv" ), partial_path=( tmp_path / "results.partial.json" ), resume=True, overwrite=False, ) def test_build_run_configuration_contains_fingerprint( tmp_path: Path, ) -> None: questions_path = ( tmp_path / "questions.json" ) overrides_path = ( tmp_path / "overrides.json" ) questions_path.write_text( "[]\n", encoding="utf-8", ) overrides_path.write_text( "{}\n", encoding="utf-8", ) args = SimpleNamespace( questions=( questions_path ), overrides=( overrides_path ), split="dev", model="model120-fast", timeout=180, max_attempts=4, backoff_base=1.0, backoff_max=8.0, delay=0.5, ) questions = [ sample_question() ] configuration = ( evaluator.build_run_configuration( args=args, questions=( questions ), selected_override_ids=[], operation_id=( "retrieve_zpwiki_context" ), ) ) assert ( configuration[ "schema_version" ] == evaluator.PARTIAL_SCHEMA_VERSION ) assert ( configuration[ "selected_question_ids" ] == [ "q0001" ] ) assert ( configuration[ "questions_fingerprint" ] == evaluator.questions_fingerprint( questions ) ) def test_apply_question_overrides_rejects_empty_question( tmp_path: Path, ) -> None: questions = [ sample_question() ] overrides_path = ( tmp_path / "overrides.json" ) overrides_path.write_text( json.dumps( { "q0001": { "question": " " } }, ensure_ascii=False, ) + "\n", encoding="utf-8", ) with pytest.raises( ValueError, match="override question", ): evaluator.apply_question_overrides( questions, overrides_path, )