import tempfile import unittest from pathlib import Path from types import SimpleNamespace from unittest.mock import Mock, patch from google.genai import types from copienator import configuration, prompting from copienator.commands import correction, gemini_for_enonce, gemini_for_labels from copienator.gemini_config import fresh_seed, thinking_config class GeminiConfigTests(unittest.TestCase): def test_fresh_seed_excludes_previous_even_at_int32_boundary(self): for previous in (0, 1234, 2**31 - 1): for random_value in (0, 2**31 - 2): with patch("copienator.gemini_config.secrets.randbelow", return_value=random_value): seed = fresh_seed(previous) self.assertNotEqual(seed, previous) self.assertTrue(0 <= seed < 2**31) def test_correction_retries_change_seed_without_mutating_config(self): client = Mock() client.models.generate_content_stream.side_effect = [ TimeoutError(), RuntimeError("429 minute limit retry in 1s"), [SimpleNamespace(text="result")], ] config = types.GenerateContentConfig(seed=0, thinking_config=thinking_config("medium")) with patch.object(correction, "client", client), \ patch.object(correction, "fresh_seed", side_effect=[10, 20, 30]), \ patch.object(correction, "tprint"), \ patch.object(correction, "stop_requested") as stop: stop.is_set.return_value = False stop.wait.return_value = False self.assertEqual(correction.call_gemini_with_retries("model", [], config), "result") configs = [call.kwargs["config"] for call in client.models.generate_content_stream.call_args_list] self.assertEqual([cfg.seed for cfg in configs], [10, 20, 30]) self.assertEqual(config.seed, 0) self.assertIsNone(config.http_options) for cfg in configs: self.assert_request_config(cfg, "medium") self.assertEqual(cfg.http_options.retry_options.attempts, 1) def assert_request_config(self, config, level): data = config.model_dump(mode="json", by_alias=True, exclude_none=True) for key in ("temperature", "topP", "topK", "thinkingBudget"): self.assertNotIn(key, data) self.assertNotIn(key, data.get("thinkingConfig", {})) if level is None: self.assertNotIn("thinkingConfig", data) else: self.assertEqual(data["thinkingConfig"], {"thinkingLevel": level.upper()}) def test_grading_live_and_batch_settings(self): with tempfile.TemporaryDirectory() as directory: image = Path(directory) / "answer.jpg" image.write_bytes(b"image") image.with_suffix(".json").write_text('[["01", 0, 400, 1.0, "Ex 1"]]') for level in ("minimal", "low", "medium", "high", None): with self.subTest(level=level), patch.object(prompting, "make_prompt", return_value="Grade"): _, config = prompting.generate_request(directory, image, "Ex 1", thinking_level=level) self.assert_request_config(config, level) batch = prompting.batch_generation_config(level) self.assert_request_config(types.GenerateContentConfig.model_validate(batch), level) self.assertEqual(batch["responseSchema"], prompting.UNROLLED_SCHEMA) # Let Gemini randomize each execution, including resubmission. self.assertNotIn("seed", batch) def test_labels_and_statement_use_their_own_settings(self): with tempfile.TemporaryDirectory() as directory: image = Path(directory) / "answer.jpg" image.write_bytes(b"image") with patch.object(configuration, "THINKING_LEVEL_FOR_LABEL", "low"): _, config = gemini_for_labels.generate_request(image, "Ex 1", "Student", [], []) self.assert_request_config(config, "low") client = Mock() with patch.object(configuration, "THINKING_LEVEL_LITE", "high"): gemini_for_enonce._request_rubrics(client, "Question") self.assert_request_config(client.models.generate_content.call_args.kwargs["config"], "high") self.assertIsNone(client.models.generate_content.call_args.kwargs["config"].seed) def test_correction_helpers_use_flash_level(self): with patch.object(configuration, "THINKING_LEVEL_FLASH", "low"), patch.object( prompting, "get_single_image_bytes", return_value=b"image" ): requests = [ prompting.request_for_box_correction("answer.pdf", []), prompting.request_for_wrong_label("answer.pdf", "Ex 1", "Question", "Ex 1"), prompting.request_for_additional_answer("answer.pdf", "Ex 1", "Question", "Ex 1"), ] for _, config in requests: self.assert_request_config(config, "low") def test_quota_fallback_switches_thinking_level_without_mutating_request(self): client = Mock() client.models.generate_content_stream.side_effect = [ RuntimeError("429 daily quota exhausted"), [SimpleNamespace(text="result")], ] config = types.GenerateContentConfig(thinking_config=thinking_config("high")) with patch.object(correction, "client", client), patch.object( configuration, "MODEL_PRO_ID", "pro-model" ), patch.object(correction, "pro_quota_exhausted", False), patch.object( configuration, "THINKING_LEVEL_FLASH", "low" ), patch.object(correction, "tprint"), patch.object(correction, "stop_requested") as stop: stop.is_set.return_value = False result = correction.call_gemini_with_retries("pro-model", [], config, "flash-model") self.assertEqual(result, "result") calls = client.models.generate_content_stream.call_args_list self.assert_request_config(calls[0].kwargs["config"], "high") self.assert_request_config(calls[1].kwargs["config"], "low") self.assertEqual(calls[1].kwargs["model"], "flash-model") self.assertNotEqual(calls[0].kwargs["config"].seed, calls[1].kwargs["config"].seed) self.assertIsNone(config.seed) self.assert_request_config(config, "high") def test_invalid_level_fails_before_submission(self): with self.assertRaisesRegex(ValueError, "Invalid Gemini thinking level"): thinking_config("ultra")