diff --git a/copienator/commands/gemini_for_labels.py b/copienator/commands/gemini_for_labels.py index 6c17e5b..3896c77 100644 --- a/copienator/commands/gemini_for_labels.py +++ b/copienator/commands/gemini_for_labels.py @@ -12,7 +12,7 @@ from pathlib import Path from google import genai from google.genai import types -from pydantic import BaseModel, Field, model_validator +from pydantic import BaseModel, ConfigDict, Field, model_validator from copienator import configuration as config from copienator.gemini_config import fresh_seed, thinking_config @@ -166,7 +166,23 @@ class BoxItem(BaseModel): box_2d: list[int] = Field(description="Bounding box coordinates (e.g., [ymin, xmin, ymax, xmax])") label: str = Field(description="The label associated with the specific box") +def _require_success_fields(schema: dict) -> None: + """Mirror the conditional payload validators in the schema sent to Gemini.""" + required = ["list"] + if "name" in schema["properties"]: + required.append("name") + schema["anyOf"] = [ + {"required": required}, + {"required": ["error"], + "properties": {"error": {"type": "string", "enum": ["insufficient_crop"]}}}, + {"required": ["upside_down_pages"], + "properties": {"upside_down_pages": {"type": "array", "minItems": 1}}}, + ] + + class LabelData(BaseModel): + model_config = ConfigDict(json_schema_extra=_require_success_fields) + upside_down_pages: typing.List[typing.Annotated[int, Field(strict=True, ge=1)]] = Field( default_factory=list, description='1-based column numbers in this image that appear upside down (180 degrees)', diff --git a/copienator_gui/workflow.py b/copienator_gui/workflow.py index 209d88d..5dcd15f 100644 --- a/copienator_gui/workflow.py +++ b/copienator_gui/workflow.py @@ -303,16 +303,9 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]: "Découper une partie à gauche pour détection des labels", "Produit les images de la partie gauche des copies. " "La position est estimée automatiquement sur plusieurs pages de même largeur ; " - "Les 10 % de bandes d’écriture les plus à gauche sont ignorées ; " - "la découpe retire le maximum entre la moitié de la marge et la marge moins 2 cm. " - "Si la marge est inférieure à 20 mm, elle commence au bord gauche. " - "Deux pages consécutives sans marge imposent le bord gauche pour la suite. " - "La bande est élargie de 10 %. " - "Pour les sous-pages étroites, la marge commence au bord gauche et est limitée à la largeur disponible. " "Dans la fenêtre de découpe, utilisez les flèches : ←/→ déplacent la zone " "de 50 px vers la gauche/droite, ↑ l’élargit de 50 px et ↓ la réduit de 50 px. " "1 utilise les pages entières, Entrée valide, P/p revient à la copie précédente et ! signale une erreur. " - "La validation efface uniquement le signalement de découpe, sans effacer celui des labels." f"{personnal_binding} " "Une copie précise peut être ciblée.", (python("default", "Découpe", "crop-labels"),), diff --git a/tests/test_label_schema.py b/tests/test_label_schema.py new file mode 100644 index 0000000..3f1947a --- /dev/null +++ b/tests/test_label_schema.py @@ -0,0 +1,45 @@ +import tempfile +import unittest +from pathlib import Path + +from jsonschema import Draft202012Validator +from pydantic import ValidationError + +from copienator.commands import gemini_for_labels as labels + + +class LabelSchemaTests(unittest.TestCase): + def test_request_schema_and_parser_agree_on_success_and_recovery(self): + cases = [ + ({}, False), + ({'name': 'Student'}, False), + ({'name': 'Student', 'error': None, 'upside_down_pages': []}, False), + ({'name': 'Student', 'list': []}, True), + ({'name': 'Student', 'list': [ + {'box_2d': [1, 2, 3, 4], 'label': 'Ex 1'}]}, True), + ({'error': 'insufficient_crop'}, True), + ({'upside_down_pages': [1]}, True), + ({'error': 'insufficient_crop', 'upside_down_pages': [1]}, True), + ({'error': None}, False), + ({'upside_down_pages': []}, False), + ({'upside_down_pages': [0]}, False), + ({'error': 'other'}, False), + ({'name': 'Student', 'list': None}, False), + ] + with tempfile.TemporaryDirectory() as directory: + image = Path(directory) / 'Copie01_01.jpg' + image.write_bytes(b'image') + for known_name, model in ((None, labels.AnnotationData), ('Student', labels.LabelData)): + _, config = labels.generate_request( + image, 'Ex 1', 'Student', [], [], known_name=known_name) + schema = config.response_json_schema + Draft202012Validator.check_schema(schema) + validator = Draft202012Validator(schema) + for payload, expected in cases + [({'list': []}, bool(known_name))]: + with self.subTest(known_name=known_name, payload=payload): + self.assertEqual(validator.is_valid(payload), expected) + if expected: + model.model_validate(payload) + else: + with self.assertRaises(ValidationError): + model.model_validate(payload)