Faster Persp generation

This commit is contained in:
sebastien committed 2026-11-02 11:43:57 +01:00
1 parent 213576f83d
commit 28e3b5bfb9
3 files changed
+156 -31

No files matched your search

+51 -11
View File
@@ -6,7 +6,7 @@ import io
import json
import re
from collections.abc import Sequence
from concurrent.futures import ThreadPoolExecutor
from concurrent.futures import ThreadPoolExecutor, as_completed
from datetime import UTC, datetime
from pathlib import Path
from uuid import uuid4
@@ -112,7 +112,7 @@ N'utilise pas de caractères mathématiques Unicode dans `rubric_content`.
Par exemple :
- Au moins 2 points si le résultat est correct.
- Mettre la moitié des points si le raisonnement est correct mais pas le résultat.
- Retirer 1,5 point si les hypothèses d'un théorème ou d'une question précédente ne sont pas vérifiées.
- Retirer 1.5 point si les hypothèses d'un théorème ou d'une question précédente ne sont pas vérifiées.
Renvoie uniquement un objet JSON contenant une liste `rubrics`. Pour chaque
question fournie, cette liste contient un objet avec son `label` exact et
@@ -333,6 +333,26 @@ def validate_groups(groups: list[list[str]], labels: list[str]) -> None:
raise CliError("Groups must contain every existing label exactly once")
def batch_rubric_groups(groups: list[list[str]]) -> list[list[str]]:
"""Pack consecutive whole groups into requests of at most six questions.
An existing group larger than six is sent alone, without splitting it.
"""
batches: list[list[str]] = []
current: list[str] = []
for group in groups:
if current and len(current) + len(group) > 6:
batches.append(current)
current = []
current.extend(group)
if len(current) >= 6:
batches.append(current)
current = []
if current:
batches.append(current)
return batches
def refine_existing(workspace: EvaluationWorkspace, mode: str, *, api_client=None,
delete_persp: bool = False) -> ExitCode:
"""Regroup questions or generate missing rubrics, retaining completed groups."""
@@ -401,26 +421,46 @@ def refine_existing(workspace: EvaluationWorkspace, mode: str, *, api_client=Non
pass
present.clear()
print('Deleted current Persp; generating all rubrics.')
generated = 0
pending_groups = []
for group in groups:
missing = [label for label in group if label not in present]
if not missing:
if all(label in present for label in group):
print(f"Skipping existing Persp for group: {', '.join(group)}.")
continue
pending_groups.append(group)
batches = batch_rubric_groups(pending_groups)
print(f"Generating rubrics in {len(batches)} requests, with up to 4 concurrent requests.")
def generate_batch(group):
missing = [label for label in group if label not in present]
print(f"Generating missing rubric (Persp): {', '.join(missing)}...")
group_content = (
"Contexte général de l’examen, fourni uniquement pour comprendre les questions :\n"
+ context + "\n\nProduis des barèmes UNIQUEMENT pour les labels suivants : "
+ ", ".join(missing) + "\n\n" + "\n\n".join(questions[label] for label in group)
)
rubrics = generate_rubrics(
return generate_rubrics(
api_client, group_content, workspace=workspace, expected_labels=missing,
)
with staged_files(workspace.root / "Persp") as staging:
for label, rubric in rubrics.items():
(staging / label.replace("/", "_")).write_text(
f"{label}\n{rubric}", encoding="utf-8")
generated += len(rubrics)
generated = 0
first_error = None
with ThreadPoolExecutor(max_workers=4) as executor:
futures = [executor.submit(generate_batch, group) for group in batches]
for future in as_completed(futures):
try:
rubrics = future.result()
# Commit on this thread so directory transactions never overlap.
with staged_files(workspace.root / "Persp") as staging:
for label, rubric in rubrics.items():
(staging / label.replace("/", "_")).write_text(
f"{label}\n{rubric}", encoding="utf-8")
generated += len(rubrics)
except Exception as error:
# Save other successful requests even when one request fails.
if first_error is None:
first_error = error
if first_error is not None:
raise first_error
print(f"Generated {generated} missing Persp rubrics; all {len(labels)} are now present.")
else:
raise ValueError(f"Unknown statement refinement: {mode}")
+5 -3
View File
@@ -31,11 +31,13 @@ Choisissez **Analyse avec Gemini**.
**Ignorer le cache** recommence l’analyse Gemini : à réserver à une
analyse devenue incorrecte ou obsolète.
### Relire les barèmes — facultatif
### Générer les barèmes avec Gemini — facultatif, profil personnel
**Effet :** permet de vérifier et d’ajuster les instructions qui guideront la correction.
**Effet :** régénère les barèmes dans `Persp`. L’option **Delete current Persp** est cochée par défaut ; décochez-la pour conserver les barèmes présents et compléter uniquement les manquants, notamment après un échec.
Ouvrez les barèmes dans `Persp`. Vérifiez les réponses acceptables, les raisonnements alternatifs et les exigences de justification ; corrigez les consignes trop sévères, ambiguës ou erronées. Enregistrez vos modifications avant de marquer l’étape terminée.
Les groupes existants sont réunis dans leur ordre en requêtes de six questions au maximum, sans jamais couper un groupe. Un groupe de plus de six questions est envoyé seul. Quatre requêtes peuvent être traitées simultanément. Ces valeurs sont fixes et le fichier `label_groups` reste inchangé. Les résultats des requêtes réussies sont conservés même si une autre échoue.
L’étape reste ouverte après la génération. Cliquez sur **Ouvrir le dossier Persp**, sous les autres contrôles, pour vérifier les réponses acceptables, les raisonnements alternatifs et les exigences de justification. Enregistrez vos modifications, puis passez à l’étape suivante.
## Prétraitement des copies
+100 -17
View File
@@ -2,6 +2,7 @@ import io
import json
import os
import tempfile
import threading
import unittest
from pathlib import Path
from tkinter import ttk
@@ -95,6 +96,25 @@ class PersonalStatementTests(unittest.TestCase):
class SelectiveGeminiTests(unittest.TestCase):
def test_rubric_batches_preserve_groups_and_six_question_limit(self):
for sizes, expected in (
([2, 2, 2], [6]),
([4, 3, 2, 1], [4, 6]),
([2, 7, 1, 5], [2, 7, 6]),
([], []),
):
with self.subTest(sizes=sizes):
groups = [[f'{index}:{question}' for question in range(size)]
for index, size in enumerate(sizes)]
original = [group[:] for group in groups]
batches = gemini.batch_rubric_groups(groups)
self.assertEqual([len(batch) for batch in batches], expected)
self.assertEqual([label for batch in batches for label in batch],
[label for group in groups for label in group])
for group in groups:
self.assertTrue(any(all(label in batch for label in group) for batch in batches))
self.assertEqual(groups, original)
def setUp(self):
self.temp = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
@@ -152,15 +172,14 @@ class SelectiveGeminiTests(unittest.TestCase):
def test_rubrics_replace_only_persp_using_normal_prompt(self):
before = self.snapshot()
self.client.models.generate_content.side_effect = [self.rubric_response(self.labels[:2]),
self.rubric_response(self.labels[2:])]
self.client.models.generate_content.return_value = self.rubric_response(self.labels)
self.assertEqual(gemini.refine_existing(self.workspace, "persp", api_client=self.client), ExitCode.SUCCESS)
after = self.snapshot()
self.assertEqual({key: value for key, value in before.items() if not key.startswith("Persp/")},
{key: value for key, value in after.items()
if not key.startswith(("Persp/", "Cache/"))})
responses = list((self.root / 'Cache' / 'rubric_responses').glob('*.json'))
self.assertEqual(len(responses), 2)
self.assertEqual(len(responses), 1)
self.assertEqual((self.root / "Persp" / "Ex 1").read_bytes(), before['Persp/Ex 1'])
for label in self.labels:
self.assertIn("Barème Gemini", (self.root / "Persp" / label).read_text())
@@ -173,12 +192,34 @@ class SelectiveGeminiTests(unittest.TestCase):
self.assertIn("caractères mathématiques Unicode", gemini.PROMPT_4)
self.assertIn(r"$\lfloor \sqrt{k} \rfloor$", gemini.PROMPT_4)
def separate_requests(self, last):
# A full six-question group cannot be combined with the final group.
extra = [f'Context {index}' for index in range(4)]
self.workspace.labels_file.write_text('\n'.join(self.labels + extra) + '\n')
self.workspace.label_groups_file.write_text(
', '.join(self.labels[:2] + extra) + '\n' + self.labels[2] + '\n')
for label in extra:
for directory in ('Text2', 'Sol2'):
(self.root / directory / f'{label}.tex').write_text(label)
(self.root / 'Persp' / label).write_text(label + '\nReviewed rubric')
def response_for_request(**kwargs):
text = kwargs['contents'][0].parts[1].text
if 'Question [Ex 2]' in text:
if isinstance(last, Exception):
raise last
return last
return self.rubric_response(self.labels[:2])
self.client.models.generate_content.side_effect = response_for_request
def test_incomplete_or_failed_rubrics_keep_completed_groups(self):
before = self.snapshot()
for last in (self.response({"rubrics": []}), RuntimeError("API unavailable")):
for label in self.labels[:2]:
(self.root / 'Persp' / label).write_bytes(before[f'Persp/{label}'])
self.client.models.generate_content.side_effect = [self.rubric_response(self.labels[:2]), last]
self.separate_requests(last)
before = self.snapshot()
with self.assertRaises((CliError, RuntimeError)):
gemini.refine_existing(self.workspace, "persp", api_client=self.client)
after = self.snapshot()
@@ -186,7 +227,8 @@ class SelectiveGeminiTests(unittest.TestCase):
self.assertIn(b'Bar\xc3\xa8me Gemini', after[f'Persp/{label}'])
self.assertEqual(after['Persp/Ex 2'], before['Persp/Ex 2'])
self.assertEqual(after['Persp/Ex 1'], before['Persp/Ex 1'])
self.assertEqual({key: value for key, value in before.items() if not key.startswith('Persp/')},
self.assertEqual({key: value for key, value in before.items()
if not key.startswith(('Persp/', 'Cache/'))},
{key: value for key, value in after.items()
if not key.startswith(('Persp/', 'Cache/'))})
@@ -198,7 +240,7 @@ class SelectiveGeminiTests(unittest.TestCase):
)
self.client.models.generate_content.side_effect = [
self.response({'rubrics': [{'label': label, 'rubric_content': expected} for label in group]})
for group in (self.labels[:2], self.labels[2:])
for group in (self.labels,)
]
gemini.refine_existing(self.workspace, 'persp', api_client=self.client)
for label in self.labels:
@@ -220,8 +262,8 @@ class SelectiveGeminiTests(unittest.TestCase):
def test_control_character_keeps_progress_and_saves_diagnostics(self):
before = self.snapshot()
broken = self.response({'rubrics': [{'label': 'Ex 2', 'rubric_content': 'invalid \x01 control'}]})
self.client.models.generate_content.side_effect = [
self.rubric_response(self.labels[:2]), broken]
self.separate_requests(broken)
before = self.snapshot()
output = io.StringIO()
with patch('sys.stdout', output), self.assertRaisesRegex(ValueError, 'control characters'):
gemini.refine_existing(self.workspace, 'persp', api_client=self.client)
@@ -258,19 +300,17 @@ class SelectiveGeminiTests(unittest.TestCase):
before = kept.read_bytes()
(self.root / 'Persp' / self.labels[1]).write_text('')
(self.root / 'Persp' / self.labels[2]).unlink()
self.client.models.generate_content.side_effect = [
self.rubric_response([self.labels[1]]), self.rubric_response([self.labels[2]])]
self.client.models.generate_content.return_value = self.rubric_response(self.labels[1:])
gemini.refine_existing(self.workspace, 'persp', api_client=self.client)
self.assertEqual(kept.read_bytes(), before)
call = self.client.models.generate_content.call_args_list[0]
text = call.kwargs['contents'][0].parts[1].text
self.assertIn('UNIQUEMENT pour les labels suivants : ' + self.labels[1] + '\n', text)
self.assertIn('UNIQUEMENT pour les labels suivants : ' + ', '.join(self.labels[1:]) + '\n', text)
self.assertIn('Question [' + self.labels[0] + ']', text)
self.assertEqual(self.client.models.generate_content.call_count, 2)
self.assertEqual(self.client.models.generate_content.call_count, 1)
def test_retry_requests_only_failed_group(self):
self.client.models.generate_content.side_effect = [
self.rubric_response(self.labels[:2]), RuntimeError('API unavailable')]
self.separate_requests(RuntimeError('API unavailable'))
with self.assertRaises(RuntimeError):
gemini.refine_existing(self.workspace, 'persp', api_client=self.client)
before = {label: (self.root / 'Persp' / label).read_bytes() for label in self.labels[:2]}
@@ -290,13 +330,56 @@ class SelectiveGeminiTests(unittest.TestCase):
client.assert_not_called()
self.assertEqual(self.snapshot(), before)
def test_four_concurrent_requests_save_successes_and_retry_only_failure(self):
labels = [f'Q{index}' for index in range(20)]
groups = [labels[index:index + 4] for index in range(0, 20, 4)]
self.workspace.labels_file.write_text('\n'.join(labels) + '\n')
group_text = ''.join(', '.join(group) + '\n' for group in groups)
self.workspace.label_groups_file.write_text(group_text)
for label in labels:
for directory in ('Text2', 'Sol2'):
(self.root / directory / f'{label}.tex').write_text(label)
barrier = threading.Barrier(4, timeout=10)
lock = threading.Lock()
active = 0
peak = 0
def generate(client, context, *, workspace, expected_labels):
nonlocal active, peak
self.assertIn(expected_labels, groups)
if expected_labels == groups[-1]:
raise RuntimeError('API unavailable')
with lock:
active += 1
peak = max(peak, active)
try:
barrier.wait()
return {label: 'Generated rubric' for label in expected_labels}
finally:
with lock:
active -= 1
with patch.object(gemini, 'generate_rubrics', side_effect=generate) as requests:
with self.assertRaisesRegex(RuntimeError, 'API unavailable'):
gemini.refine_existing(self.workspace, 'persp', api_client=self.client)
self.assertEqual(requests.call_count, 5)
self.assertEqual(peak, 4)
for label in labels[:16]:
self.assertEqual((self.root / 'Persp' / label).read_text(), label + '\nGenerated rubric')
self.assertFalse((self.root / 'Persp' / labels[-1]).exists())
self.client.models.generate_content.return_value = self.rubric_response(groups[-1])
gemini.refine_existing(self.workspace, 'persp', api_client=self.client)
self.assertEqual(self.client.models.generate_content.call_count, 1)
self.assertEqual(self.workspace.label_groups_file.read_text(), group_text)
for label in labels:
self.assertTrue((self.root / 'Persp' / label).is_file())
def test_delete_persp_forces_regeneration_and_removes_old_files(self):
for label in self.labels:
(self.root / 'Persp' / label).write_text(label + '\nReviewed rubric')
self.client.models.generate_content.side_effect = [
self.rubric_response(self.labels[:2]), self.rubric_response(self.labels[2:])]
self.client.models.generate_content.return_value = self.rubric_response(self.labels)
gemini.refine_existing(self.workspace, 'persp', api_client=self.client, delete_persp=True)
self.assertEqual(self.client.models.generate_content.call_count, 2)
self.assertEqual(self.client.models.generate_content.call_count, 1)
self.assertEqual({path.name for path in (self.root / 'Persp').iterdir()}, set(self.labels))
def test_cli_delete_persp_dispatch(self):