Faster Persp generation
This commit is contained in:
1 parent
213576f83d
commit
28e3b5bfb9
3 files changed
+156
-31
No files matched your search
@@ -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}")
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user