update Gemini API : use thinking levels

This commit is contained in:
sebastien committed 2026-10-10 08:47:39 +02:00
1 parent 7bc588e9a1
commit ec6ac9580b
9 files changed
+161 -21

No files matched your search

+9
View File
@@ -174,6 +174,15 @@ ou éventuellement, la renseigner directement dans le fichier
Copier `default_config.py` en `config.py`. Éventuellement le modifier. 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 ** Correction d'un paquet de copies
1. Créer un fichier =names= dans le dossier courant, avec les 1. Créer un fichier =names= dans le dossier courant, avec les
+17 -8
View File
@@ -20,6 +20,8 @@ from copienator import configuration as config
from copienator.commands import grouping from copienator.commands import grouping
from copienator.answer_origins import save_origins from copienator.answer_origins import save_origins
from copienator import prompting 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.feedback_boxes import valid_feedback_box
from copienator.latex_json import repair_latex_json from copienator.latex_json import repair_latex_json
from copienator import ( 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 # 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: if model_id == MODEL_ID_pro and pro_quota_exhausted and fallback_model_id:
model_id = fallback_model_id model_id = fallback_model_id
config = config.model_copy(update={"thinking_config": thinking_config(
settings.THINKING_LEVEL_FLASH
)})
try: try:
full_response_text = "" 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: 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...") tprint(f"\tGemini Pro quota hit ({e}). \n\n\tFalling back to Flash permanently...")
model_id = fallback_model_id model_id = fallback_model_id
config = config.model_copy(update={"thinking_config": thinking_config(
settings.THINKING_LEVEL_FLASH
)})
pro_quota_exhausted = True pro_quota_exhausted = True
continue # Retry immediately with Flash continue # Retry immediately with Flash
@@ -506,7 +514,11 @@ def process_single_task(task_tuple, precomputed_response=None):
flash_count += 1 flash_count += 1
try: 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 model_to_use = MODEL_ID_flash if use_flash else MODEL_ID_pro
if precomputed_response: if precomputed_response:
@@ -891,13 +903,10 @@ def run_configured(args: argparse.Namespace) -> ExitCode:
], ],
} }
], ],
"generation_config": { "generation_config": prompting.batch_generation_config(
"temperature": 1.0, settings.THINKING_LEVEL_FLASH if use_flash
"topP": 0.95, else settings.THINKING_LEVEL_PRO
"maxOutputTokens": 65535, ),
"responseMimeType": "application/json",
"responseSchema": prompting.UNROLLED_SCHEMA,
},
}, },
} }
line = json.dumps(request) line = json.dumps(request)
+6 -5
View File
@@ -26,6 +26,7 @@ from copienator import (
workspace_from_args, workspace_from_args,
) )
from copienator import configuration as config from copienator import configuration as config
from copienator.gemini_config import thinking_config
from copienator.filesystem import staged_files from copienator.filesystem import staged_files
from copienator.latex_json import normalize_literal_newlines, repair_latex_json from copienator.latex_json import normalize_literal_newlines, repair_latex_json
from copienator.platform import validate_windows_labels from copienator.platform import validate_windows_labels
@@ -249,7 +250,7 @@ def _request_rubrics(client, group_context_text: str):
config=types.GenerateContentConfig( config=types.GenerateContentConfig(
automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True), 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.", 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_mime_type="application/json",
response_json_schema=GroupRubrics.model_json_schema(), response_json_schema=GroupRubrics.model_json_schema(),
), ),
@@ -370,7 +371,7 @@ def refine_existing(workspace: EvaluationWorkspace, mode: str, *, api_client=Non
))])], ))])],
config=types.GenerateContentConfig( config=types.GenerateContentConfig(
automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True), 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(), response_json_schema=LabelGroups.model_json_schema(),
), ),
) )
@@ -488,7 +489,7 @@ def process_exam(
config_1 = types.GenerateContentConfig( config_1 = types.GenerateContentConfig(
automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True), automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
temperature=0.1, thinking_config=thinking_config(config.THINKING_LEVEL_LITE),
response_mime_type="application/json", response_mime_type="application/json",
response_json_schema=ExamQuestions.model_json_schema(), response_json_schema=ExamQuestions.model_json_schema(),
) )
@@ -505,7 +506,7 @@ def process_exam(
config_2 = types.GenerateContentConfig( config_2 = types.GenerateContentConfig(
automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True), automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
temperature=0.1, thinking_config=thinking_config(config.THINKING_LEVEL_LITE),
response_mime_type="application/json", response_mime_type="application/json",
response_json_schema=ExamSolutions.model_json_schema(), response_json_schema=ExamSolutions.model_json_schema(),
) )
@@ -540,7 +541,7 @@ def process_exam(
config_3 = types.GenerateContentConfig( config_3 = types.GenerateContentConfig(
automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True), automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
temperature=0.1, thinking_config=thinking_config(config.THINKING_LEVEL_LITE),
response_mime_type="application/json", response_mime_type="application/json",
response_json_schema=ExamContext.model_json_schema(), response_json_schema=ExamContext.model_json_schema(),
) )
+2 -2
View File
@@ -15,6 +15,7 @@ from google.genai import types
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from copienator import configuration as config from copienator import configuration as config
from copienator.gemini_config import thinking_config
from copienator import ( from copienator import (
CliError, CliError,
EvaluationWorkspace, EvaluationWorkspace,
@@ -277,8 +278,7 @@ def generate_request(
retry_options=types.HttpRetryOptions(attempts=1), retry_options=types.HttpRetryOptions(attempts=1),
), ),
automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True), automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
temperature=1.0, thinking_config=thinking_config(config.THINKING_LEVEL_FOR_LABEL),
top_p=0.95,
seed=seed, seed=seed,
max_output_tokens=65535, max_output_tokens=65535,
response_mime_type= "application/json", response_mime_type= "application/json",
+4
View File
@@ -32,6 +32,10 @@ else:
_configuration = _load_user_config(CONFIG_PATH) _configuration = _load_user_config(CONFIG_PATH)
# Keep new optional settings compatible with older personal configuration files. # 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 ALWAYS_CROP = False
SHOW_PERSONAL_STEPS = False SHOW_PERSONAL_STEPS = False
RETURN_JPEG_ENABLED = True RETURN_JPEG_ENABLED = True
+13
View File
@@ -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)
+19 -6
View File
@@ -1,6 +1,8 @@
from pathlib import Path from pathlib import Path
import io import io
from . import utils from . import utils
from . import configuration as settings
from .gemini_config import thinking_config
PERSPECTIVE_GUIDANCE = ( PERSPECTIVE_GUIDANCE = (
"Ce barème est indicatif, si une réponse est entièrement correcte mais utilise " "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 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] # 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.""" """Generates request for Gemini."""
prompt = make_prompt(input_dir, full_label) prompt = make_prompt(input_dir, full_label)
image_path = Path(file) image_path = Path(file)
@@ -194,8 +208,7 @@ def generate_request(input_dir, file, full_label):
generate_content_config = types.GenerateContentConfig( generate_content_config = types.GenerateContentConfig(
automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True), automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
temperature=1.0, thinking_config=thinking_config(thinking_level),
top_p=0.95,
seed=0, seed=0,
max_output_tokens=65535, max_output_tokens=65535,
response_mime_type= "application/json", response_mime_type= "application/json",
@@ -272,7 +285,7 @@ Commentaires d'origine :
config = types.GenerateContentConfig( config = types.GenerateContentConfig(
automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True), automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
temperature=1.0, thinking_config=thinking_config(settings.THINKING_LEVEL_FLASH),
response_mime_type="application/json", response_mime_type="application/json",
response_json_schema=TypeAdapter(List[FeedbackItem]).json_schema() 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)])] types.Part.from_text(text=prompt)])]
config = types.GenerateContentConfig( config = types.GenerateContentConfig(
automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True), automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
temperature=1.0, thinking_config=thinking_config(settings.THINKING_LEVEL_FLASH),
) )
return contents, config return contents, config
@@ -334,7 +347,7 @@ d'entre eux :
])] ])]
config = types.GenerateContentConfig( config = types.GenerateContentConfig(
automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True), automatic_function_calling=types.AutomaticFunctionCallingConfig(disable=True),
temperature=1.0, thinking_config=thinking_config(settings.THINKING_LEVEL_FLASH),
response_mime_type="application/json", response_mime_type="application/json",
) )
return contents, config return contents, config
+8
View File
@@ -27,20 +27,28 @@ FINAL_SCORE_OUTPUT_DIR = Path("Server") / "copies"
FINAL_SCORE_HISTOGRAM_PATH = Path("histogramme.pdf") FINAL_SCORE_HISTOGRAM_PATH = Path("histogramme.pdf")
FINAL_SCORE_FONT_PATH = None 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 # 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é. # 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.5-flash-lite"
MODEL_LITE_ID = "gemini-3.8-flash" MODEL_LITE_ID = "gemini-3.8-flash"
THINKING_LEVEL_LITE = "low"
# Modèle pour identifier visuellement les labels # 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.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" MODEL_FOR_LABEL_ID = "gemini-3.8-flash"
THINKING_LEVEL_FOR_LABEL = "low"
# Modèle pour des choses normales # Modèle pour des choses normales
MODEL_FLASH_ID = "gemini-3.8-flash" MODEL_FLASH_ID = "gemini-3.8-flash"
THINKING_LEVEL_FLASH = "medium"
# Modèle pour des choses dures # Modèle pour des choses dures
MODEL_PRO_ID = "gemini-3.8-flash" MODEL_PRO_ID = "gemini-3.8-flash"
THINKING_LEVEL_PRO = "medium"
# MODEL_PRO_ID = "gemini-3.1-pro-preview" # MODEL_PRO_ID = "gemini-3.1-pro-preview"
PAGE_SPLITTER_KB = { PAGE_SPLITTER_KB = {
+83
View File
@@ -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")