diff --git a/Readme.org b/Readme.org index 52b0968..57bc11d 100644 --- a/Readme.org +++ b/Readme.org @@ -174,6 +174,15 @@ ou éventuellement, la renseigner directement dans le fichier Copier `default_config.py` en `config.py`. Éventuellement le modifier. +Les niveaux de réflexion Gemini se règlent indépendamment des modèles : +=THINKING_LEVEL_LITE= (énoncés, ="low"=), =THINKING_LEVEL_FOR_LABEL= +(labels, ="medium"=), =THINKING_LEVEL_FLASH= (correction courante, +="medium"=) et =THINKING_LEVEL_PRO= (correction difficile, ="high"=). +Ces réglages s'appliquent aussi aux requêtes batch. Utiliser =None= pour +laisser le modèle choisir son défaut. Les niveaux disponibles dépendent +du modèle : Gemini 3.8 Flash accepte ="low"=, ="medium"= et ="high"=, +mais pas ="minimal"=. Avec un modèle Gemini 2.5, utiliser =None=. + ** Correction d'un paquet de copies 1. Créer un fichier =names= dans le dossier courant, avec les diff --git a/copienator/commands/correction.py b/copienator/commands/correction.py index f01965d..fb03fd6 100644 --- a/copienator/commands/correction.py +++ b/copienator/commands/correction.py @@ -20,6 +20,8 @@ from copienator import configuration as config from copienator.commands import grouping from copienator.answer_origins import save_origins from copienator import prompting +from copienator.gemini_config import thinking_config +from copienator import configuration as settings from copienator.feedback_boxes import valid_feedback_box from copienator.latex_json import repair_latex_json from copienator import ( @@ -279,6 +281,9 @@ def call_gemini_with_retries(model_id, contents, config, # Switch to fallback immediately if quota was exhausted by another thread if model_id == MODEL_ID_pro and pro_quota_exhausted and fallback_model_id: model_id = fallback_model_id + config = config.model_copy(update={"thinking_config": thinking_config( + settings.THINKING_LEVEL_FLASH + )}) try: full_response_text = "" @@ -312,6 +317,9 @@ def call_gemini_with_retries(model_id, contents, config, if is_quota_error and model_id == MODEL_ID_pro and fallback_model_id: tprint(f"\tGemini Pro quota hit ({e}). \n\n\tFalling back to Flash permanently...") model_id = fallback_model_id + config = config.model_copy(update={"thinking_config": thinking_config( + settings.THINKING_LEVEL_FLASH + )}) pro_quota_exhausted = True continue # Retry immediately with Flash @@ -506,7 +514,11 @@ def process_single_task(task_tuple, precomputed_response=None): flash_count += 1 try: - contents, config = prompting.generate_request(INPUT_DIR, file_path, label) + contents, config = prompting.generate_request( + INPUT_DIR, file_path, label, + thinking_level=(settings.THINKING_LEVEL_FLASH if use_flash + else settings.THINKING_LEVEL_PRO), + ) model_to_use = MODEL_ID_flash if use_flash else MODEL_ID_pro if precomputed_response: @@ -891,13 +903,10 @@ def run_configured(args: argparse.Namespace) -> ExitCode: ], } ], - "generation_config": { - "temperature": 1.0, - "topP": 0.95, - "maxOutputTokens": 65535, - "responseMimeType": "application/json", - "responseSchema": prompting.UNROLLED_SCHEMA, - }, + "generation_config": prompting.batch_generation_config( + settings.THINKING_LEVEL_FLASH if use_flash + else settings.THINKING_LEVEL_PRO + ), }, } line = json.dumps(request) diff --git a/copienator/commands/gemini_for_enonce.py b/copienator/commands/gemini_for_enonce.py index 04d63ec..e986be5 100644 --- a/copienator/commands/gemini_for_enonce.py +++ b/copienator/commands/gemini_for_enonce.py @@ -26,6 +26,7 @@ from copienator import ( workspace_from_args, ) from copienator import configuration as config +from copienator.gemini_config import thinking_config from copienator.filesystem import staged_files from copienator.latex_json import normalize_literal_newlines, repair_latex_json from copienator.platform import validate_windows_labels @@ -249,7 +250,7 @@ def _request_rubrics(client, group_context_text: str): config=types.GenerateContentConfig( automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True), system_instruction="Rédige tous les barèmes et consignes de notation en français. Conserve les clés JSON, les labels et les formules mathématiques.", - temperature=0.2, + thinking_config=thinking_config(config.THINKING_LEVEL_LITE), response_mime_type="application/json", response_json_schema=GroupRubrics.model_json_schema(), ), @@ -370,7 +371,7 @@ def refine_existing(workspace: EvaluationWorkspace, mode: str, *, api_client=Non ))])], config=types.GenerateContentConfig( automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True), - temperature=0.1, response_mime_type="application/json", + thinking_config=thinking_config(config.THINKING_LEVEL_LITE), response_mime_type="application/json", response_json_schema=LabelGroups.model_json_schema(), ), ) @@ -488,7 +489,7 @@ def process_exam( config_1 = types.GenerateContentConfig( automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True), - temperature=0.1, + thinking_config=thinking_config(config.THINKING_LEVEL_LITE), response_mime_type="application/json", response_json_schema=ExamQuestions.model_json_schema(), ) @@ -505,7 +506,7 @@ def process_exam( config_2 = types.GenerateContentConfig( automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True), - temperature=0.1, + thinking_config=thinking_config(config.THINKING_LEVEL_LITE), response_mime_type="application/json", response_json_schema=ExamSolutions.model_json_schema(), ) @@ -540,7 +541,7 @@ def process_exam( config_3 = types.GenerateContentConfig( automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True), - temperature=0.1, + thinking_config=thinking_config(config.THINKING_LEVEL_LITE), response_mime_type="application/json", response_json_schema=ExamContext.model_json_schema(), ) diff --git a/copienator/commands/gemini_for_labels.py b/copienator/commands/gemini_for_labels.py index 51d12f2..794d1eb 100644 --- a/copienator/commands/gemini_for_labels.py +++ b/copienator/commands/gemini_for_labels.py @@ -15,6 +15,7 @@ from google.genai import types from pydantic import BaseModel, Field from copienator import configuration as config +from copienator.gemini_config import thinking_config from copienator import ( CliError, EvaluationWorkspace, @@ -277,8 +278,7 @@ def generate_request( retry_options=types.HttpRetryOptions(attempts=1), ), automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True), - temperature=1.0, - top_p=0.95, + thinking_config=thinking_config(config.THINKING_LEVEL_FOR_LABEL), seed=seed, max_output_tokens=65535, response_mime_type= "application/json", diff --git a/copienator/configuration.py b/copienator/configuration.py index 09b8e9f..7cc3821 100644 --- a/copienator/configuration.py +++ b/copienator/configuration.py @@ -32,6 +32,10 @@ else: _configuration = _load_user_config(CONFIG_PATH) # Keep new optional settings compatible with older personal configuration files. +THINKING_LEVEL_LITE = "low" +THINKING_LEVEL_FOR_LABEL = "medium" +THINKING_LEVEL_FLASH = "medium" +THINKING_LEVEL_PRO = "high" ALWAYS_CROP = False SHOW_PERSONAL_STEPS = False RETURN_JPEG_ENABLED = True diff --git a/copienator/gemini_config.py b/copienator/gemini_config.py new file mode 100644 index 0000000..ced7e09 --- /dev/null +++ b/copienator/gemini_config.py @@ -0,0 +1,13 @@ +from google.genai import types + + +def thinking_config(level: str | None) -> types.ThinkingConfig | None: + """Use a supported reasoning level, or omit it for the model default.""" + if level is None: + return None + if level not in {"minimal", "low", "medium", "high"}: + raise ValueError( + f"Invalid Gemini thinking level {level!r}; " + 'expected "minimal", "low", "medium", "high", or None' + ) + return types.ThinkingConfig(thinking_level=level) diff --git a/copienator/prompting.py b/copienator/prompting.py index 74542f7..78e4bea 100644 --- a/copienator/prompting.py +++ b/copienator/prompting.py @@ -1,6 +1,8 @@ from pathlib import Path import io from . import utils +from . import configuration as settings +from .gemini_config import thinking_config PERSPECTIVE_GUIDANCE = ( "Ce barème est indicatif, si une réponse est entièrement correcte mais utilise " @@ -173,8 +175,20 @@ UNROLLED_SCHEMA = { from google.genai import types +def batch_generation_config(thinking_level): + """Build the REST generation config for a grading batch request.""" + config = { + "maxOutputTokens": 65535, + "responseMimeType": "application/json", + "responseSchema": UNROLLED_SCHEMA, + } + thinking = thinking_config(thinking_level) + if thinking is not None: + config["thinkingConfig"] = thinking.model_dump(mode="json", by_alias=True, exclude_none=True) + return config + # The root model for parsing is be: List[EvaluationEntry] -def generate_request(input_dir, file, full_label): +def generate_request(input_dir, file, full_label, *, thinking_level=settings.THINKING_LEVEL_PRO): """Generates request for Gemini.""" prompt = make_prompt(input_dir, full_label) image_path = Path(file) @@ -194,8 +208,7 @@ def generate_request(input_dir, file, full_label): generate_content_config = types.GenerateContentConfig( automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True), - temperature=1.0, - top_p=0.95, + thinking_config=thinking_config(thinking_level), seed=0, max_output_tokens=65535, response_mime_type= "application/json", @@ -272,7 +285,7 @@ Commentaires d'origine : config = types.GenerateContentConfig( automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True), - temperature=1.0, + thinking_config=thinking_config(settings.THINKING_LEVEL_FLASH), response_mime_type="application/json", response_json_schema=TypeAdapter(List[FeedbackItem]).json_schema() ) @@ -303,7 +316,7 @@ Voici les labels possibles. Ta réponse doit être l'un d'entre eux : types.Part.from_text(text=prompt)])] config = types.GenerateContentConfig( automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True), - temperature=1.0, + thinking_config=thinking_config(settings.THINKING_LEVEL_FLASH), ) return contents, config @@ -334,7 +347,7 @@ d'entre eux : ])] config = types.GenerateContentConfig( automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True), - temperature=1.0, + thinking_config=thinking_config(settings.THINKING_LEVEL_FLASH), response_mime_type="application/json", ) return contents, config diff --git a/default_config.py b/default_config.py index b7dd44f..b75b716 100644 --- a/default_config.py +++ b/default_config.py @@ -27,20 +27,28 @@ FINAL_SCORE_OUTPUT_DIR = Path("Server") / "copies" FINAL_SCORE_HISTOGRAM_PATH = Path("histogramme.pdf") FINAL_SCORE_FONT_PATH = None +# Niveaux de réflexion : "minimal", "low", "medium", "high", ou None +# pour utiliser le défaut du modèle. Choisir un niveau supporté par le modèle +# (Gemini 3.8 Flash ne supporte pas "minimal" ; Gemini 2.5 : utiliser None). + # Modèle pour des choses très légères # Le modèle 3.5-flash-lite est un peu moins bon pour la duplication de contexte pour des questions, dans l'analyse de l'énoncé. # MODEL_LITE_ID = "gemini-3.5-flash-lite" MODEL_LITE_ID = "gemini-3.8-flash" +THINKING_LEVEL_LITE = "low" # Modèle pour identifier visuellement les labels # MODEL_FOR_LABEL_ID = "gemini-3.5-flash-lite" # 3.5 flash lite marche plutôt mal, il insiste pour mettre les labels dans l'ordre, sans considérer ce qu'il y a écrit MODEL_FOR_LABEL_ID = "gemini-3.8-flash" +THINKING_LEVEL_FOR_LABEL = "low" # Modèle pour des choses normales MODEL_FLASH_ID = "gemini-3.8-flash" +THINKING_LEVEL_FLASH = "medium" # Modèle pour des choses dures MODEL_PRO_ID = "gemini-3.8-flash" +THINKING_LEVEL_PRO = "medium" # MODEL_PRO_ID = "gemini-3.1-pro-preview" PAGE_SPLITTER_KB = { diff --git a/tests/test_gemini_config.py b/tests/test_gemini_config.py new file mode 100644 index 0000000..06fd8ff --- /dev/null +++ b/tests/test_gemini_config.py @@ -0,0 +1,83 @@ +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 thinking_config + + +class GeminiConfigTests(unittest.TestCase): + 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") + 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) + + 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") + + 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( + correction, "MODEL_ID_pro", "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.assert_request_config(config, "high") + + def test_invalid_level_fails_before_submission(self): + with self.assertRaisesRegex(ValueError, "Invalid Gemini thinking level"): + thinking_config("ultra")