Try to fix badly encoded json tex commands from the flash-lite calls
This commit is contained in:
1 parent
32764c101b
commit
6a6ed178ef
4 files changed
+74
-7
No files matched your search
@@ -22,6 +22,7 @@ from copienator import (
|
|||||||
workspace_from_args,
|
workspace_from_args,
|
||||||
)
|
)
|
||||||
from copienator.platform import validate_windows_labels
|
from copienator.platform import validate_windows_labels
|
||||||
|
from copienator.latex_json import repair_latex_json
|
||||||
from copienator.filesystem import staged_directory
|
from copienator.filesystem import staged_directory
|
||||||
from copienator.utils import compile_to_pdf
|
from copienator.utils import compile_to_pdf
|
||||||
|
|
||||||
@@ -69,7 +70,7 @@ class ExamContext(BaseModel):
|
|||||||
# --- Modèles pour la Requête 4 (Barèmes) ---
|
# --- Modèles pour la Requête 4 (Barèmes) ---
|
||||||
class RubricItem(BaseModel):
|
class RubricItem(BaseModel):
|
||||||
label: str = Field(description="Label exact de la question, à conserver sans traduction.")
|
label: str = Field(description="Label exact de la question, à conserver sans traduction.")
|
||||||
rubric_content: str = Field(description="Barème détaillé sur 4 points : toutes les consignes, explications et justifications doivent être rédigées en français.")
|
rubric_content: str = Field(description="Barème détaillé sur 4 points, en français, avec formules LaTeX. Échapper chaque antislash LaTeX conformément à la syntaxe JSON.")
|
||||||
|
|
||||||
class GroupRubrics(BaseModel):
|
class GroupRubrics(BaseModel):
|
||||||
rubrics: list[RubricItem]
|
rubrics: list[RubricItem]
|
||||||
@@ -101,6 +102,27 @@ question fournie, cette liste contient un objet avec son `label` exact et
|
|||||||
son barème en français dans `rubric_content`.
|
son barème en français dans `rubric_content`.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
LATEX_JSON_INSTRUCTION = r"""
|
||||||
|
IMPORTANT — Échappement JSON du LaTeX :
|
||||||
|
Dans les chaînes JSON, chaque antislash LaTeX doit être doublé.
|
||||||
|
Exemple d’échappement dans un barème (adapte les clés au schéma demandé) :
|
||||||
|
{"rubrics":[{"label":"1)","rubric_content":"1 point : $\\frac{1}{2}$ et $f \\circ g$.\n1 point : $x \\rightarrow 0$ et $x \\not=0$."}]}
|
||||||
|
Ne confonds pas les commandes LaTeX avec les échappements JSON :
|
||||||
|
\\frac, \\rightarrow, \\not, \\theta et \\begin sont des commandes LaTeX ;
|
||||||
|
\n est réservé à un véritable saut de ligne du texte.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def validate_rubric_content(content: str) -> str:
|
||||||
|
"""Catch decoded control escapes; ordinary JSON newlines remain valid.
|
||||||
|
|
||||||
|
A misescaped LaTeX command starting with n is indistinguishable from
|
||||||
|
an intentional newline. Do not guess or rewrite those after decoding.
|
||||||
|
"""
|
||||||
|
if any((ord(char) < 32 and char != "\n") or ord(char) == 127 for char in content):
|
||||||
|
raise ValueError("Rubric contains control characters, likely from unescaped LaTeX")
|
||||||
|
return content
|
||||||
|
|
||||||
# --- Modèle fusionné (pour le reste du script) ---
|
# --- Modèle fusionné (pour le reste du script) ---
|
||||||
class QuestionItem(BaseModel):
|
class QuestionItem(BaseModel):
|
||||||
label: str
|
label: str
|
||||||
@@ -178,6 +200,12 @@ traduire, et conserve le LaTeX ainsi que les labels exacts.
|
|||||||
Renvoie le résultat dans la liste `contexts` de l'objet JSON attendu.
|
Renvoie le résultat dans la liste `contexts` de l'objet JSON attendu.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
PROMPT_1 += LATEX_JSON_INSTRUCTION
|
||||||
|
PROMPT_2 += LATEX_JSON_INSTRUCTION
|
||||||
|
PROMPT_3 += LATEX_JSON_INSTRUCTION
|
||||||
|
PROMPT_4 += LATEX_JSON_INSTRUCTION
|
||||||
|
|
||||||
|
|
||||||
def find_file(folder: Path, base_name: str) -> Path | None:
|
def find_file(folder: Path, base_name: str) -> Path | None:
|
||||||
for ext in [".org", ".tex"]:
|
for ext in [".org", ".tex"]:
|
||||||
path = folder / f"{base_name}{ext}"
|
path = folder / f"{base_name}{ext}"
|
||||||
@@ -202,10 +230,10 @@ def generate_rubrics(client, group_context_text: str) -> dict[str, str]:
|
|||||||
response_json_schema=GroupRubrics.model_json_schema(),
|
response_json_schema=GroupRubrics.model_json_schema(),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
rubrics = GroupRubrics.model_validate_json(response.text).rubrics
|
rubrics = GroupRubrics.model_validate_json(repair_latex_json(response.text)).rubrics
|
||||||
if len({item.label for item in rubrics}) != len(rubrics):
|
if len({item.label for item in rubrics}) != len(rubrics):
|
||||||
raise ValueError("Gemini returned duplicate rubric labels")
|
raise ValueError("Gemini returned duplicate rubric labels")
|
||||||
return {item.label: item.rubric_content for item in rubrics}
|
return {item.label: validate_rubric_content(item.rubric_content) for item in rubrics}
|
||||||
|
|
||||||
|
|
||||||
def validate_groups(groups: list[list[str]], labels: list[str]) -> None:
|
def validate_groups(groups: list[list[str]], labels: list[str]) -> None:
|
||||||
@@ -364,7 +392,7 @@ def process_exam(
|
|||||||
print("Saving questions to cache...")
|
print("Saving questions to cache...")
|
||||||
atomic_write_text(cache_q_file, response_q_text)
|
atomic_write_text(cache_q_file, response_q_text)
|
||||||
|
|
||||||
questions_data = ExamQuestions.model_validate_json(response_q_text)
|
questions_data = ExamQuestions.model_validate_json(repair_latex_json(response_q_text))
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# REQUÊTE 2 : Extraction des Corrections
|
# REQUÊTE 2 : Extraction des Corrections
|
||||||
@@ -403,7 +431,7 @@ def process_exam(
|
|||||||
print("Saving solutions to cache...")
|
print("Saving solutions to cache...")
|
||||||
atomic_write_text(cache_s_file, response_s_text)
|
atomic_write_text(cache_s_file, response_s_text)
|
||||||
|
|
||||||
solutions_data = ExamSolutions.model_validate_json(response_s_text)
|
solutions_data = ExamSolutions.model_validate_json(repair_latex_json(response_s_text))
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# REQUÊTE 3 : Extraction du Contexte (Notations, etc.)
|
# REQUÊTE 3 : Extraction du Contexte (Notations, etc.)
|
||||||
@@ -440,7 +468,7 @@ def process_exam(
|
|||||||
print("Saving context to cache...")
|
print("Saving context to cache...")
|
||||||
atomic_write_text(cache_c_file, response_c_text)
|
atomic_write_text(cache_c_file, response_c_text)
|
||||||
|
|
||||||
context_data = ExamContext.model_validate_json(response_c_text)
|
context_data = ExamContext.model_validate_json(repair_latex_json(response_c_text))
|
||||||
|
|
||||||
# ==========================================
|
# ==========================================
|
||||||
# FUSION des trois résultats
|
# FUSION des trois résultats
|
||||||
|
|||||||
@@ -1551,7 +1551,6 @@ class CopienatorApp(tk.Tk):
|
|||||||
if self.current_step and self.current_step.optional:
|
if self.current_step and self.current_step.optional:
|
||||||
step_id = self.current_step.id
|
step_id = self.current_step.id
|
||||||
self._mark_step("skipped")
|
self._mark_step("skipped")
|
||||||
if step_id.startswith("refaire_"):
|
|
||||||
self._move_selection_from(step_id, 1)
|
self._move_selection_from(step_id, 1)
|
||||||
|
|
||||||
def _poll_runner(self) -> None:
|
def _poll_runner(self) -> None:
|
||||||
|
|||||||
@@ -190,7 +190,9 @@ class GuiConvenienceTests(unittest.TestCase):
|
|||||||
command = self.app._make_command()
|
command = self.app._make_command()
|
||||||
self.assertEqual(command[-4:], ["crop-margins", str(source), "--workers", "5"])
|
self.assertEqual(command[-4:], ["crop-margins", str(source), "--workers", "5"])
|
||||||
self.app._skip_step()
|
self.app._skip_step()
|
||||||
|
self.app.update()
|
||||||
self.assertEqual(self.app.state_store.step("crop_blank_margins")["status"], "skipped")
|
self.assertEqual(self.app.state_store.step("crop_blank_margins")["status"], "skipped")
|
||||||
|
self.assertNotEqual(self.app.current_step.id, "crop_blank_margins")
|
||||||
|
|
||||||
def test_optional_answer_bottom_crop_uses_parallel_workers_and_can_be_skipped(self):
|
def test_optional_answer_bottom_crop_uses_parallel_workers_and_can_be_skipped(self):
|
||||||
answers = self.evaluation / "Copies" / "Copie01"
|
answers = self.evaluation / "Copies" / "Copie01"
|
||||||
@@ -207,9 +209,11 @@ class GuiConvenienceTests(unittest.TestCase):
|
|||||||
["crop-answer-bottoms", self.app._evaluation_arg(), "--workers", "5"],
|
["crop-answer-bottoms", self.app._evaluation_arg(), "--workers", "5"],
|
||||||
)
|
)
|
||||||
self.app._skip_step()
|
self.app._skip_step()
|
||||||
|
self.app.update()
|
||||||
self.assertEqual(
|
self.assertEqual(
|
||||||
self.app.state_store.step("crop_exercise_bottoms")["status"], "skipped"
|
self.app.state_store.step("crop_exercise_bottoms")["status"], "skipped"
|
||||||
)
|
)
|
||||||
|
self.assertNotEqual(self.app.current_step.id, "crop_exercise_bottoms")
|
||||||
|
|
||||||
def test_redo_targets_selected_copy_and_copies_runnable_command(self):
|
def test_redo_targets_selected_copy_and_copies_runnable_command(self):
|
||||||
for folder in ("Copies", "Copies Originales"):
|
for folder in ("Copies", "Copies Originales"):
|
||||||
|
|||||||
@@ -134,6 +134,42 @@ class SelectiveGeminiTests(unittest.TestCase):
|
|||||||
gemini.refine_existing(self.workspace, "persp", api_client=self.client)
|
gemini.refine_existing(self.workspace, "persp", api_client=self.client)
|
||||||
self.assertEqual(self.snapshot(), before)
|
self.assertEqual(self.snapshot(), before)
|
||||||
|
|
||||||
|
def test_rubric_latex_and_line_breaks_survive_json_and_file_writes(self):
|
||||||
|
expected = (
|
||||||
|
r'Barème : $f \circ g \rightarrow \frac{1}{2} \not= 0$.'
|
||||||
|
'\n' + r'$\begin{matrix}a & b \\ c & d\end{matrix}$, $\theta$, $\beta$, $\nu$.'
|
||||||
|
'\nUne "justification" vaut 1 point.'
|
||||||
|
)
|
||||||
|
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:])
|
||||||
|
]
|
||||||
|
gemini.refine_existing(self.workspace, 'persp', api_client=self.client)
|
||||||
|
for label in self.labels:
|
||||||
|
self.assertEqual((self.root / 'Persp' / label).read_bytes(),
|
||||||
|
(label + '\n' + expected).encode('utf-8'))
|
||||||
|
|
||||||
|
def test_invalid_latex_escapes_are_repaired_without_retry(self):
|
||||||
|
for command in (r'\circ', r'\rightarrow', r'\frac', r'\not', r'\theta', r'\beta'):
|
||||||
|
with self.subTest(command=command):
|
||||||
|
# Deliberately malformed wire JSON: do not json.dumps the command.
|
||||||
|
broken = SimpleNamespace(text='{"rubrics":[{"label":"Ex 2","rubric_content":"$'
|
||||||
|
+ command + '$"}]}')
|
||||||
|
self.client.models.generate_content.reset_mock()
|
||||||
|
self.client.models.generate_content.side_effect = [broken]
|
||||||
|
self.assertEqual(gemini.generate_rubrics(self.client, 'context'),
|
||||||
|
{'Ex 2': '$' + command + '$'})
|
||||||
|
self.assertEqual(self.client.models.generate_content.call_count, 1)
|
||||||
|
|
||||||
|
def test_unrepairable_control_character_preserves_all_existing_rubrics(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]
|
||||||
|
with self.assertRaisesRegex(ValueError, 'control characters'):
|
||||||
|
gemini.refine_existing(self.workspace, 'persp', api_client=self.client)
|
||||||
|
self.assertEqual(self.snapshot(), before)
|
||||||
|
|
||||||
def test_cli_dispatches_selective_modes_without_full_extraction(self):
|
def test_cli_dispatches_selective_modes_without_full_extraction(self):
|
||||||
with patch.object(gemini, "refine_existing", return_value=ExitCode.SUCCESS) as refine, patch.object(
|
with patch.object(gemini, "refine_existing", return_value=ExitCode.SUCCESS) as refine, patch.object(
|
||||||
gemini, "process_exam"
|
gemini, "process_exam"
|
||||||
|
|||||||
Reference in new issue
Block a user