Require label lists only on success.
This commit is contained in:
1 parent
28e3b5bfb9
commit
9d0e40cc4f
3 files changed
+62
-8
No files matched your search
@@ -12,7 +12,7 @@ from pathlib import Path
|
|||||||
|
|
||||||
from google import genai
|
from google import genai
|
||||||
from google.genai import types
|
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 import configuration as config
|
||||||
from copienator.gemini_config import fresh_seed, thinking_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])")
|
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")
|
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):
|
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(
|
upside_down_pages: typing.List[typing.Annotated[int, Field(strict=True, ge=1)]] = Field(
|
||||||
default_factory=list,
|
default_factory=list,
|
||||||
description='1-based column numbers in this image that appear upside down (180 degrees)',
|
description='1-based column numbers in this image that appear upside down (180 degrees)',
|
||||||
|
|||||||
@@ -303,16 +303,9 @@ def build_workflow(show_personal_steps: bool) -> list[StepDefinition]:
|
|||||||
"Découper une partie à gauche pour détection des labels",
|
"Découper une partie à gauche pour détection des labels",
|
||||||
"Produit les images de la partie gauche des copies. "
|
"Produit les images de la partie gauche des copies. "
|
||||||
"La position est estimée automatiquement sur plusieurs pages de même largeur ; "
|
"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 "
|
"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. "
|
"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. "
|
"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} "
|
f"{personnal_binding} "
|
||||||
"Une copie précise peut être ciblée.",
|
"Une copie précise peut être ciblée.",
|
||||||
(python("default", "Découpe", "crop-labels"),),
|
(python("default", "Découpe", "crop-labels"),),
|
||||||
|
|||||||
@@ -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)
|
||||||
Reference in new issue
Block a user