Skip to content

Add post-validation of LLM structured responses against JSON Schema contract#23

Merged
Kaiohz merged 1 commit into
mainfrom
BRIC-18/post-validate-structured-response
May 20, 2026
Merged

Add post-validation of LLM structured responses against JSON Schema contract#23
Kaiohz merged 1 commit into
mainfrom
BRIC-18/post-validate-structured-response

Conversation

@Kaiohz
Copy link
Copy Markdown
Contributor

@Kaiohz Kaiohz commented May 20, 2026

Summary

  • Add schema_utils.py with schema_to_pydantic_model() and make_validation_model() to convert JSON Schema → Pydantic BaseModel with extra='ignore'
  • Modify DeepAgentRunner to validate structured_response against the response_format model, stripping extra fields the LLM invents outside the contract
  • Modify create_agent_from_config to return (graph, response_format_model) tuple; propagate through PersistentAgentRegistry
  • Fix duplicate code bug in _log_extra_fields (copy-paste from _try_parse_json)
  • Fix _sanitize to use field_ prefix instead of _ for leading-digit names (Pydantic rejects leading underscores)
  • Update test mocks (create_agent_from_config returns tuple instead of single value)

Test plan

  • 19 new tests in test_schema_utils.py for schema conversion, sanitization, and validation model creation
  • 7 new tests in test_deep_agent_runner.py for validation stripping, logging, tool-call validation path
  • All 266 unit tests passing

…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 Kaiohz merged commit 3aa884c into main May 20, 2026
1 check passed
@Kaiohz Kaiohz deleted the BRIC-18/post-validate-structured-response branch May 20, 2026 15:22
Copy link
Copy Markdown
Contributor Author

@Kaiohz Kaiohz left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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=ignore est 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_model dans la chaîne factory → 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 data

4. 📏 _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

  1. Support Field.alias pour préserver les clés originales dans model_dump() — cest le fix prioritaire
  2. Ajouter $ref / allOf / oneOf dans le convertisseur (ou documenter labsence de support)
  3. Rendre _log_extra_fields récursif
  4. Ajouter un test : vérifier que les clés du model_dump() matchent les clés du schema original (cas hyphénés)
  5. 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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant