Add post-validation of LLM structured responses against JSON Schema contract#23
Conversation
…ontract Strip extra fields the LLM invents outside the response_format schema using Pydantic models with extra='ignore'. Adds schema_utils.py for JSON Schema to Pydantic model conversion, validates in DeepAgentRunner._build_response(), and logs warnings for stripped fields.
Kaiohz
left a comment
There was a problem hiding this comment.
Code Review — Post-validation of LLM structured responses
Score : 7/10
✅ Ce qui est bien
- Problème réel bien identifié : les LLMs inventent des champs hors schema, la post-validation est une nécessité
- Approche
extra=ignoreest la bonne — simple, efficace, pas de rejet sur des extras - Logging des champs stripped (top-level + nested) — utile pour le debug et le monitoring en prod
- Hash du model name — évite les collisions de noms Pydantic
- Couverture de tests : 26 nouveaux tests, bien structurés, CI verte ✅
- Propagation propre du
response_format_modeldans la chaînefactory → registry → runner
⚠️ Problèmes à adresser
1. 🐛 Bug silencieux : _sanitize casse le round-trip des clés JSON Schema
Cest le problème le plus grave. Quand le schema a des clés avec hyphènes (my-field) ou chiffres initiaux (2ndField), _sanitize les convertit (my_field, field_2ndField). Mais model_dump() retourne les clés Python, pas les clés originales.
Conséquence : le consumer de la réponse validée reçoit my_field au lieu de my-field. Cest un breaking change silencieux.
Fix : utiliser Field(alias="my-field") + model_dump(by_alias=True) ou ajouter un mapping reverse dans _sanitize.
# Exemple de fix
field_defs[python_name] = (
python_type | None,
Field(default=None, alias=prop_name, description=...)
)
# + ConfigDict(populate_by_name=True)
# + model_dump(by_alias=True)2. 🐛 _sanitize pour les chiffres initiaux — préfixe redondant
_sanitize("1field") → field_1field. Le 1 est dupliqué. Devrait probablement être field_1 (préfixe + chiffre) ou n1_field.
3. ⚠️ Bare except Exception dans _validate_structured_response
Si la validation Pydantic lève une ValidationError (type incorrect sur un champ required), on avale l erreur et on retourne les données brutes non validées. Le warning est logué, mais la réponse non validée passe à travers.
Suggestion : distinguer ValidationError des autres erreurs. Pour ValidationError, logger les détails et idéalement filtrer les champs invalides plutôt que de tout passer.
from pydantic import ValidationError
try:
validated = self._response_format_model.model_validate(data)
...
except ValidationError as e:
logger.warning("Schema validation failed: %s", e)
# Attempt partial validation or return cleaned data
...
except Exception:
logger.warning("Unexpected validation error, returning raw data")
return data4. 📏 _log_extra_fields ne gère pas plus de 2 niveaux de profondeur
Seuls les champs top-level et nested-1 sont logués. Pour des schemas profondément imbriqués, les extras en profondeur ne seront pas signalés.
Suggestion : rendre la fonction récursive, ou documenter la limitation.
@staticmethod
def _log_extra_fields(original: dict, cleaned: dict, prefix: str = "") -> None:
for key in original:
full_key = f"{prefix}.{key}" if prefix else key
if key not in cleaned:
logger.warning("Stripped extra field: %s", full_key)
elif isinstance(original[key], dict) and isinstance(cleaned[key], dict):
DeepAgentRunner._log_extra_fields(original[key], cleaned[key], prefix=full_key)5. 📏 schema_to_pydantic_model retourne type au lieu de type[BaseModel] pour les primitives
La signature dit type[BaseModel] mais pour type=string elle retourne str. Cest incohérent et fragile si un jour on appelle issubclass(model, BaseModel) sur le résultat.
Fix : soit wrapper les primitives dans un BaseModel, soit changer la signature en type[BaseModel] | type.
💡 Suggestions damélioration
- Support
Field.aliaspour préserver les clés originales dansmodel_dump()— cest le fix prioritaire - Ajouter
$ref/allOf/oneOfdans le convertisseur (ou documenter labsence de support) - Rendre
_log_extra_fieldsrécursif - Ajouter un test : vérifier que les clés du
model_dump()matchent les clés du schema original (cas hyphénés) - Ajouter un metric/counter en plus du log warning pour le monitoring en prod (combien de fois des extras sont strippés ?)
Résumé
La fonctionnalité est solide et répond à un vrai besoin. Le principal point bloquant est le round-trip des clés sanitizées — sans fix, les consumers reçoivent des clés différentes du contrat schema. Le reste est améliorable mais pas bloquant.
Summary
schema_utils.pywithschema_to_pydantic_model()andmake_validation_model()to convert JSON Schema → Pydantic BaseModel withextra='ignore'DeepAgentRunnerto validatestructured_responseagainst the response_format model, stripping extra fields the LLM invents outside the contractcreate_agent_from_configto return(graph, response_format_model)tuple; propagate throughPersistentAgentRegistry_log_extra_fields(copy-paste from_try_parse_json)_sanitizeto usefield_prefix instead of_for leading-digit names (Pydantic rejects leading underscores)create_agent_from_configreturns tuple instead of single value)Test plan
test_schema_utils.pyfor schema conversion, sanitization, and validation model creationtest_deep_agent_runner.pyfor validation stripping, logging, tool-call validation path