Ir al contenido principal
Laravel, thinking fast.

El arreglo no fue otra comprobación. Fue darse cuenta de que la estructura del contenido se describía por separado en tres sitios, con tres vocabularios:

  • el constructor del prompt, que le decía al modelo qué producir;
  • la validación de salida, que decidía si lo había hecho;
  • el DTO y el resource, que le daban forma para los consumidores.

Tres descripciones de un mismo contrato, editadas en momentos distintos por personas distintas. Naturalmente se separaron. Una sección añadida al prompt no aparecía en el resource. Una clave que el resource exponía no se pedía nunca.

Así que la estructura se movió a una clase que leen todos:

final class ContentSchema
{
    public const BUSINESS = 'business';
    public const EVENT = 'event';

    /** Sections shared by every category, in output order. */
    private const SHARED_TAIL = ['faqs', 'map', 'contact', 'brand', 'navigation', 'seo'];

    private static function leadSections(string $category): array
    {
        return $category === self::EVENT
            ? ['hero', 'schedule', 'highlights', 'gallery']
            : ['hero', 'services', 'testimonials'];
    }

    /** @return list<string> */
    public static function sections(string $category): array
    {
        return [...self::leadSections(self::normalize($category)), ...self::SHARED_TAIL];
    }
}

Dos propiedades hacen que esto funcione.

No depende del framework. Constantes puras y métodos estáticos, sin contenedor, sin configuración, sin service provider. Eso es lo que permite que la misma clase viva en un paquete compartido y la lean todos los servicios que tocan contenido generado —el que lo genera y el que lo confirma— en lugar de que cada uno guarde una copia que poco a poco deja de coincidir.

Lleva versión. Cuando las secciones o las rutas obligatorias cambian de forma relevante, se sube la versión, de modo que un consumidor puede registrar contra qué forma se generó un contenido concreto. El contenido generado sobrevive al prompt que lo produjo. Poder responder «¿bajo qué contrato se escribió esto?» seis meses después vale el método que cuesta.

Las rutas obligatorias son un contrato: manténlas cortas

Con un esquema único, validar se reduce a un bucle:

public function __invoke(array $content, string $category): void
{
    foreach (ContentSchema::requiredPaths($category) as $path) {
        $value = self::get($content, $path);

        if ($value === null || $value === '' || $value === []) {
            throw new RuntimeException("AI site content response missing required key {$path}.");
        }
    }
}

Ahora mira lo que devuelve requiredPaths() en realidad:

public static function requiredPaths(string $category): array
{
    return ['hero.title'];
}

Una ruta. Después de todo eso, una.

Es deliberado, y es la parte que la gente falla cuando descubre la validación por esquema e inmediatamente exige cuarenta claves. Obligatorio significa el llamante debería tirar la respuesta e intentarlo otra vez. Ese listón está mucho más alto que «estaría bien tenerlo».

hero.title está en la lista porque su ausencia confirma un fichero roto en un repositorio. brand no está, pese a ser esencial, porque el llamante lo rellena a partir de los datos de la petición: la versión del modelo no es la fuente de verdad, así que su ausencia no es un fallo. Un testimonio que falte tampoco está: tres testimonios en lugar de tres y una foto es un sitio ligeramente peor, no uno corrupto.

Cada ruta que añadas a esa lista es un reintento que pagarás y un modo de fallo que has elegido crear. Añade aquellas cuya alternativa es la corrupción. Deja fuera el resto.