La demostración parece terminada: enviamos una consulta a un modelo y devuelve RENEWAL. Entonces alguien pregunta qué pasa si responde RENEW, tarda demasiado o asegura que ya ha renovado el préstamo. Nuestra aplicación solo quería sugerir un equipo de atención. El modelo se ha venido arriba.
Integrar una capacidad de IA consiste en darle un lugar concreto dentro de una funcionalidad. Vamos a seguir una consulta de biblioteca, desde el texto de una persona hasta la cola que propone la aplicación. Haremos una sola llamada al modelo: suficiente para entender la frontera antes de añadir herramientas o ciclos más largos.
Primero, una promesa que podamos cumplir
La operación recibirá un mensaje y sugerirá una de estas salidas: equipo de préstamos, equipo de catálogo o revisión manual. Si el servicio de interpretación no está disponible o su respuesta es inválida, devolverá un resultado técnico diferente. No renovará préstamos ni contestará a la persona en nombre de la biblioteca.
La consulta del ejemplo es «¿Puedo quedarme el libro una semana más?». Esperamos que el modelo identifique una intención de renovación. Después será la aplicación quien traduzca esa intención al equipo de préstamos.
Aunque parezcan el mismo dato, separar intención y destino tiene sentido. Una etiqueta describe lo que creemos que pide alguien; una decisión de aplicación determina qué hacemos con esa interpretación. Si mañana cambia la organización de equipos, no debería hacer falta enseñar al modelo un organigrama nuevo.
Qué enviamos realmente al modelo
Un modelo de lenguaje no recibe el proyecto Java entero ni conoce nuestras clases porque estén en el mismo repositorio. Recibe la entrada que prepara el adaptador: instrucciones, mensaje y formato esperado, además de cualquier otra información que decidamos aportar.
Podríamos expresar las instrucciones así; el bloque es una descripción de la tarea, no una sintaxis obligatoria de ningún proveedor:
Classify the library request.
RENEWAL: extending an existing loan.
CATALOG: asking about books, authors or availability.
UNKNOWN: another request or insufficient information.
Return only the intent field using the agreed schema.
Do not claim that any library operation has been performed.
El mensaje de la persona se envía como entrada diferenciada de esas instrucciones. Si contiene «ignora las categorías», sigue siendo el contenido que debemos clasificar. Separarlo ayuda a expresar su función, aunque no garantiza por sí solo que el modelo obedezca siempre. Por eso la aplicación validará el resultado y tendrá una autoridad limitada.
En este ejemplo cada consulta se clasifica por separado. No enviamos historial, datos de préstamos ni identidad del usuario: no hacen falta para sugerir una cola. Si añadiéramos conversación, tendríamos que decidir qué mensajes aportan contexto y qué datos estamos autorizados a transmitir.
El contexto tiene un límite y se representa mediante tokens. Un token no equivale siempre a una palabra, y las instrucciones también ocupan espacio; la documentación de tokens de Gemini muestra cómo se cuenta la entrada. Una aplicación real debe comprobar el presupuesto pertinente para el modelo, no deducirlo de la longitud visual del mensaje.
Pedir un formato reduce ambigüedades
Si permitimos cualquier texto, podríamos recibir «parece una renovación, aunque habría que revisarlo». Es razonable para una conversación, pero incómodo para un programa que necesita reconocer un resultado.
Definimos esta estructura:
{
"type": "object",
"properties": {
"intent": {
"type": "string",
"enum": ["RENEWAL", "CATALOG", "UNKNOWN"]
}
},
"required": ["intent"],
"additionalProperties": false
}
El esquema permite un objeto con un campo obligatorio y tres valores. additionalProperties: false excluye campos nuevos. Una respuesta como esta respeta el contrato:
{"intent":"RENEWAL"}
Las APIs que admiten salida estructurada pueden restringir la generación a un esquema compatible; la documentación de salidas estructuradas de Gemini presenta ese mecanismo. Aun así, nuestro adaptador debe tratar fallos, respuestas incompletas y resultados no utilizables según el contrato del proveedor.
Hay tres comprobaciones diferentes. Primero, si podemos leer la respuesta. Después, si su estructura y valores son válidos. Finalmente, si la clasificación es correcta para el mensaje. El esquema ayuda con las dos primeras; una respuesta perfectamente válida puede clasificar mal.
El contrato pertenece a nuestra aplicación
El caso de uso necesita interpretar una consulta, no conocer la respuesta HTTP de una biblioteca concreta. Lo expresamos con tipos propios:
enum RequestIntent {
RENEWAL, CATALOG, UNKNOWN
}
enum RoutingOutcome {
LOAN_DESK, CATALOG_DESK, MANUAL_REVIEW,
TEMPORARILY_UNAVAILABLE, INVALID_RESPONSE
}
interface RequestInterpreter {
RequestIntent interpret(String message);
}
class InterpreterUnavailable extends RuntimeException {
InterpreterUnavailable(String message) {
super(message);
}
}
class InvalidModelResponse extends RuntimeException {
InvalidModelResponse(String message) {
super(message);
}
}
RequestInterpreter es un puerto: declara la capacidad que requiere el caso de uso. Una implementación que conecte con un proveedor será un adaptador. Preparará mensajes, realizará la llamada, validará la respuesta y devolverá un RequestIntent.
El contrato distingue tres situaciones: una intención reconocida, un servicio temporalmente indisponible y una respuesta externa que no se puede interpretar conforme a lo acordado. UNKNOWN es una clasificación válida para una consulta fuera de alcance; no es una papelera donde esconder errores de red o valores inventados.
Por ejemplo, RENEW no se transforma silenciosamente en UNKNOWN. Es una respuesta inválida. Si la biblioteca quiere admitir una nueva intención, debemos cambiar el contrato y su tratamiento de forma explícita.
La separación es la misma que desarrolla la arquitectura hexagonal. Aquí basta con entender su consecuencia: podemos sustituir el mecanismo de interpretación sin introducir sus formatos en la política de enrutamiento.
La política de aplicación, sin una API de IA dentro
Este caso de uso valida la entrada, pide una interpretación y decide qué resultado devolver:
final class RouteLibraryRequest {
private final RequestInterpreter interpreter;
RouteLibraryRequest(RequestInterpreter interpreter) {
this.interpreter = java.util.Objects.requireNonNull(interpreter);
}
RoutingOutcome route(String message) {
if (message == null || message.isBlank() || message.length() > 2_000) {
throw new IllegalArgumentException("Unsupported message.");
}
try {
RequestIntent intent = interpreter.interpret(message);
if (intent == null) {
throw new IllegalStateException("Interpreter returned null.");
}
if (intent == RequestIntent.RENEWAL) {
return RoutingOutcome.LOAN_DESK;
}
if (intent == RequestIntent.CATALOG) {
return RoutingOutcome.CATALOG_DESK;
}
return RoutingOutcome.MANUAL_REVIEW;
} catch (InterpreterUnavailable exception) {
return RoutingOutcome.TEMPORARILY_UNAVAILABLE;
} catch (InvalidModelResponse exception) {
return RoutingOutcome.INVALID_RESPONSE;
}
}
}
El límite ilustrativo son 2.000 unidades UTF-16, lo que cuenta String.length(). Sirve para acotar la entrada del caso de uso; no equivale a 2.000 tokens ni sustituye el cálculo del contexto del proveedor.
La captura de excepciones es específica. Una indisponibilidad prevista tiene un resultado previsto. Una dependencia que devuelve null incumple el contrato y provoca un error de programación visible; no se disfraza de consulta ambigua.
La clase tampoco renueva el libro. Si el modelo devuelve un objeto como {"intent":"RENEWAL","renewed":true}, el adaptador lo rechazará por incorporar un campo no permitido. Y aunque un texto diga «préstamo renovado», ese texto no habrá modificado el catálogo.
Sigamos la consulta de principio a fin

Las flechas representan llamadas y transformaciones durante la ejecución. El puerto separa el caso de uso de los detalles del proveedor; no es un servicio de red adicional.
La consulta sobre ampliar una semana el préstamo atraviesa estos pasos:
- La entrada existe, no está en blanco y no supera el límite. Puede pasar al intérprete.
- El adaptador prepara las instrucciones y el mensaje, solicita el esquema y realiza una llamada con tiempo máximo de espera.
- El proveedor devuelve
{"intent":"RENEWAL"}. - El adaptador lee y valida ese objeto y obtiene
RequestIntent.RENEWAL. RouteLibraryRequestdevuelveLOAN_DESK.- La interfaz presenta una sugerencia de atención. Ningún préstamo ha cambiado de fecha.
Con «Gracias por la ayuda» podríamos obtener UNKNOWN y revisión manual. Con un tiempo de espera agotado obtendríamos TEMPORARILY_UNAVAILABLE; con {"intent":"RENEW"}, INVALID_RESPONSE. Tres recorridos distintos que merecen diagnósticos distintos.
El fallo también forma parte de la funcionalidad
Una red puede interrumpirse, el proveedor puede rechazar la petición y la cuota puede agotarse. El adaptador debe traducir los fallos previstos a nuestro contrato y conservar suficiente información técnica para investigarlos sin volcar datos privados innecesarios en los registros.
Tampoco deberíamos reintentar indefinidamente. Aunque aquí no modificamos préstamos, cada intento puede consumir tiempo y cuota. El tiempo máximo de una llamada y el presupuesto total de la operación son límites diferentes. Repetir una petición de diez segundos tres veces puede superar ampliamente la espera que habíamos prometido.
La política de producto debe decidir qué ve la persona ante cada resultado. Por ejemplo, conservar su mensaje y ofrecer atención general cuando el servicio está indisponible. Eso exige un recorrido real de atención; devolver una etiqueta de revisión no crea mágicamente personal disponible.
Qué podemos comprobar sin un modelo real
Para probar el caso de uso utilizamos un sustituto de RequestInterpreter que devuelve un resultado controlado. Es un doble de prueba, no una clasificación real. Permite comprobar que RENEWAL llega a LOAN_DESK, que UNKNOWN llega a revisión y que cada fallo conserva su significado.
Otras pruebas verifican el adaptador: lectura de JSON, campos inesperados, categorías desconocidas, errores HTTP y espera agotada. Ninguna demuestra que el modelo entienda bien las consultas. Para eso necesitamos mensajes revisados que incluyan paráfrasis, ambigüedades y peticiones fuera de alcance.
Podríamos comparar una regla que busca «renovar» con el modelo sobre los mismos casos. Después revisaríamos qué mejora, qué errores introduce y cuánto tarda. Las cifras serían resultados de esa evaluación, no propiedades permanentes del proveedor.
Cambiar de proveedor conserva una frontera, no la calidad
Otro adaptador puede implementar RequestInterpreter con una API diferente o con un modelo servido localmente. El caso de uso seguirá recibiendo los mismos tipos.
Eso no hace equivalentes las implementaciones. Pueden diferir en categorías acertadas, tratamiento de mensajes ambiguos, latencia o consumo. Cambiar una configuración exige repetir las comprobaciones relevantes y registrar qué modelo, instrucciones y versión del adaptador produjeron cada resultado.
La integración empieza a estar bien delimitada cuando podemos seguir la consulta hasta su consecuencia y explicar también sus fallos. En nuestro ejemplo, el sistema propone un destino y reconoce cuándo no puede hacerlo. La decisión de ampliar una fecha de devolución sigue esperando a una operación que compruebe el préstamo y sus reglas.