diff --git a/Readme.org b/Readme.org index 1aeb92e..99ec4c8 100644 --- a/Readme.org +++ b/Readme.org @@ -1,7 +1,7 @@ #+title: Script #+author: Sébastien Miquel #+date: 14-03-2026 -# Time-stamp: <22-08-26 12:05> +# Time-stamp: <22-08-26 12:22> #+OPTIONS: * Méta @@ -407,11 +407,6 @@ OU _Needs_ : label_groups file (made automatically by this function), qui dit quelles questions regrouper. -Dans ces trois modes, les métadonnées JSON sont écrites atomiquement. -Lors d'une régénération, les nouvelles sorties sont préparées dans un -dossier temporaire voisin. Une erreur de rendu conserve donc la sortie -précédente ; pour =BGnot --overwrite=, le dossier complet n'est remplacé -que si tous les groupes ont été produits. 3. =python export.py Interro BGnot= (gestion perso) Cela déplace les groupes dans le dossier configuré par =EXPORT_DIR= (par défaut =Export=). diff --git a/annotating_by_label.py b/annotating_by_label.py index 216f686..5dc2077 100644 --- a/annotating_by_label.py +++ b/annotating_by_label.py @@ -125,11 +125,80 @@ def _initial_label_groups(labels: list[str]) -> str: return "".join(",".join(items) + "\n" for items in groups.values()) +def _gemini_label_groups( + workspace: EvaluationWorkspace, labels: list[str] +) -> list[list[str]] | None: + source = workspace.gemini_exam_items_file + if not source.is_file(): + return None + + groups: list[list[str]] = [] + current: list[str] = [] + try: + source_lines = source.read_text(encoding="utf-8").splitlines() + except (OSError, UnicodeError) as exc: + print(f"Warning: could not read Gemini question groups from {source}: {exc}") + return None + for raw_line in source_lines: + line = raw_line.strip() + if not line or line.startswith("#"): + continue + if line == "---": + if current: + groups.append(current) + current = [] + continue + if " ### " not in line: + continue + label = line.split(" ### ", 1)[0].strip() + if label and label != "CONTEXT": + current.append(label) + if current: + groups.append(current) + + flattened = [label for group in groups for label in group] + known = set(labels) + if ( + not flattened + or len(flattened) != len(set(flattened)) + or set(flattened) != known + ): + missing = sorted(known.difference(flattened), key=natural_key) + unknown = sorted(set(flattened).difference(known), key=natural_key) + details = [] + if missing: + details.append("missing: " + ", ".join(missing)) + if unknown: + details.append("unknown: " + ", ".join(unknown)) + if len(flattened) != len(set(flattened)): + details.append("duplicate labels") + print( + f"Warning: ignoring incompatible Gemini question groups in {source}" + + (f" ({'; '.join(details)})" if details else "") + ) + return None + return groups + + +def _serialize_label_groups(groups: list[list[str]]) -> str: + return "".join(",".join(group) + "\n" for group in groups) + + def _load_label_groups(workspace: EvaluationWorkspace, labels: list[str]) -> list[list[str]]: - label_groups = workspace.root / "label_groups" + label_groups = workspace.label_groups_file if not label_groups.exists(): - atomic_write_text(label_groups, _initial_label_groups(labels)) - print(f"Created {label_groups}; review the groups before continuing.") + gemini_groups = _gemini_label_groups(workspace, labels) + if gemini_groups is not None: + initial_content = _serialize_label_groups(gemini_groups) + source_description = "the groups selected in gemini_for_enonce.py" + else: + initial_content = _initial_label_groups(labels) + source_description = "the label-prefix fallback" + atomic_write_text(label_groups, initial_content) + print( + f"Created {label_groups} from {source_description}; " + "review the groups before continuing." + ) utils.edit_file_and_enter(label_groups) known_labels = set(labels) groups: list[list[str]] = [] diff --git a/copienator/workspace.py b/copienator/workspace.py index 7e61d17..f9199a0 100644 --- a/copienator/workspace.py +++ b/copienator/workspace.py @@ -93,6 +93,14 @@ class EvaluationWorkspace: def labels_file(self) -> Path: return self.root / "labels" + @property + def label_groups_file(self) -> Path: + return self.root / "label_groups" + + @property + def gemini_exam_items_file(self) -> Path: + return self.root / "Tmp" / "exam_items.txt" + @property def correction_file(self) -> Path: return self.root / "correction.json" diff --git a/tests/test_gui_core.py b/tests/test_gui_core.py index 5ee363f..b6765ac 100644 --- a/tests/test_gui_core.py +++ b/tests/test_gui_core.py @@ -80,6 +80,10 @@ class WorkspaceTests(unittest.TestCase): self.assertFalse(workspace.metadata_dir.exists()) self.assertEqual(workspace.labels_file, root / "labels") + self.assertEqual(workspace.label_groups_file, root / "label_groups") + self.assertEqual( + workspace.gemini_exam_items_file, root / "Tmp" / "exam_items.txt" + ) self.assertEqual(workspace.copies_dir, root / "Copies") self.assertEqual(workspace.annotation_dir("grouped"), root / "BGnot") self.assertEqual(workspace.state_database, root / ".copienator" / "state.sqlite3") @@ -1400,6 +1404,65 @@ class StandardCliTests(unittest.TestCase): (previous / "sentinel.txt").read_text(encoding="utf-8"), "old" ) + def test_grouped_annotations_default_to_gemini_question_groups(self) -> None: + module = self.modules["annotating_by_label"] + with tempfile.TemporaryDirectory() as directory: + evaluation = Path(directory) / "Exam" + items = evaluation / "Tmp" / "exam_items.txt" + items.parent.mkdir(parents=True) + items.write_text( + "# edited Gemini groups\n" + "Ex 1 ### First question\n" + "CONTEXT ### Shared context\n" + "Ex 2 ### Second question\n" + "\n---\n\n" + "Ex 3 ### Third question\n", + encoding="utf-8", + ) + workspace = EvaluationWorkspace(evaluation) + + with patch.object(module.utils, "edit_file_and_enter") as editor: + groups = module._load_label_groups( + workspace, ["Ex 1", "Ex 2", "Ex 3"] + ) + + self.assertEqual(groups, [["Ex 1", "Ex 2"], ["Ex 3"]]) + self.assertEqual( + workspace.label_groups_file.read_text(encoding="utf-8"), + "Ex 1,Ex 2\nEx 3\n", + ) + editor.assert_called_once_with(workspace.label_groups_file) + + def test_grouped_annotations_fall_back_when_gemini_was_not_run(self) -> None: + module = self.modules["annotating_by_label"] + with tempfile.TemporaryDirectory() as directory: + workspace = EvaluationWorkspace(Path(directory) / "Exam") + workspace.root.mkdir() + labels = ["Ex 1 : a", "Ex 1 : b", "Ex 2"] + + with patch.object(module.utils, "edit_file_and_enter"): + groups = module._load_label_groups(workspace, labels) + + self.assertEqual(groups, [["Ex 1 : a", "Ex 1 : b"], ["Ex 2"]]) + + def test_existing_label_groups_override_gemini_defaults(self) -> None: + module = self.modules["annotating_by_label"] + with tempfile.TemporaryDirectory() as directory: + workspace = EvaluationWorkspace(Path(directory) / "Exam") + workspace.gemini_exam_items_file.parent.mkdir(parents=True) + workspace.gemini_exam_items_file.write_text( + "Ex 1 ### First\nEx 2 ### Second\n", encoding="utf-8" + ) + workspace.label_groups_file.write_text( + "Ex 1\nEx 2\n", encoding="utf-8" + ) + + with patch.object(module.utils, "edit_file_and_enter") as editor: + groups = module._load_label_groups(workspace, ["Ex 1", "Ex 2"]) + + self.assertEqual(groups, [["Ex 1"], ["Ex 2"]]) + editor.assert_not_called() + def test_grouped_batching_does_not_split_one_student(self) -> None: module = self.modules["annotating_by_label"] image = Image.new("RGB", (10, 60), "white")