Cómo Apple Intelligence aprendió a hablar ruso#
Foundation Models es el framework que, desde iOS 26, da a los desarrolladores acceso directo al mismo modelo on-device que impulsa Apple Intelligence. Un solo tipo Swift, LanguageModelSession, y la app genera texto sin ida y vuelta a la nube, sin clave de API y sin que ningún dato del usuario salga del dispositivo. Suena como la respuesta a casi cualquier función de texto.
Excepto por un detalle: el modelo no habla ruso. No es que "lo hable mal" — no lo habla en absoluto, oficialmente, al nivel de supportedLanguages.
Me encontré con esto construyendo Lanternly, una app de diario con una compañera de IA llamada Luna, actualmente en desarrollo activo. La premisa de la app es que la IA funciona completamente en el dispositivo: sin inicio de sesión, sin servidor, sin que ninguna entrada salga jamás del teléfono. En esa arquitectura, el ruso no es un idioma secundario — es la diferencia entre que el producto funcione para un usuario de habla rusa o no funcione en absoluto. Así lo resolví a nivel de arquitectura, con código real.
El problema: Foundation Models no tiene ruso#
El modelo detrás de Apple Intelligence fue entrenado como multilingüe — las propias publicaciones de investigación de Apple lo dejan claro. Pero que un idioma haya estado presente en los datos de entrenamiento y que ese idioma se ofrezca oficialmente como compatible en el framework de cara al consumidor son dos cosas distintas. A mediados de 2026, la lista de idiomas declarada públicamente por Apple Intelligence cubre inglés, francés, alemán, italiano, portugués (Brasil), español, japonés, coreano y chino simplificado, además de un puñado de locales adicionales como neerlandés, sueco o turco — poco más de veinte en total. El ruso no está en la lista.
No se trata de la calidad de generación — se trata de que SystemLanguageModel no declara oficialmente el ruso como compatible, por lo que cualquier llamada que asuma generación directa en ruso puede degradarse silenciosamente o simplemente no tener garantía de funcionar. Es un tema recurrente en los foros de desarrolladores: la gente pregunta periódicamente en Apple Discussions si el ruso está previsto, y recibe la respuesta habitual de cualquier foro comunitario — nada oficial, más especulación (incluida la teoría de que la salida de Apple del mercado minorista ruso pudo bajar la prioridad de ese idioma). Ninguna de las dos respuestas cambia el requisito práctico: la app igual necesita responder al usuario en ruso.
Un detalle importa para la ingeniería: esta lista no es una constante. Apple indica explícitamente comprobarla en tiempo de ejecución en lugar de fijarla en el código, porque el conjunto de idiomas soportados puede cambiar de una versión a otra. Eso dio forma directamente a la arquitectura de Lanternly — el código está escrito para que, el día que Apple añada el ruso, la app cambie a la ruta de generación directa sin tocar una sola línea.
No adivinar: comprobar supportedLanguages en tiempo de ejecución#
La primera decisión de arquitectura es no fijar nunca en la app una lista de idiomas "soportados". En su lugar, se consulta el modelo directamente:
@available(iOS 26, macOS 26, *)
private static func fmSupports(_ lang: Locale.Language) -> Bool {
guard let code = lang.languageCode else { return false }
return SystemLanguageModel.default.supportedLanguages
.contains { $0.languageCode == code }
}Es una función pequeña, pero toda la bifurcación de la arquitectura descansa en ella: decide si una respuesta toma el camino corto (generación directa) o el largo (traducir de ida y vuelta). Justo al lado aparece un segundo problema práctico: detectar el idioma del usuario a partir de texto corto. NLLanguageRecognizer, de NaturalLanguage, no es fiable con frases cortas: no es raro que confunda el cirílico con búlgaro, ucraniano o macedonio. La solución es reducir el conjunto de candidatos a los idiomas que realmente se pueden manejar y fijar prioridades:
private static func languageOf(_ text: String) -> Locale.Language {
let recognizer = NLLanguageRecognizer()
recognizer.languageConstraints = candidateLanguages() // soportados por el modelo + ru + idiomas del dispositivo
recognizer.languageHints = [.russian: 0.5, .english: 0.35]
recognizer.processString(text)
if let lang = recognizer.dominantLanguage {
return Locale.Language(identifier: lang.rawValue)
}
return Locale.current.language
}Sin esa restricción, un saludo corto en ruso tiene una probabilidad real de clasificarse como búlgaro — y toda la lógica posterior seguiría la rama equivocada.
Ruta A: cuando el idioma sí está soportado directamente#
Si fmSupports devuelve true, el flujo es simple: una sesión LanguageModelSession con instrucciones en el idioma de respuesta, una llamada a respond(to:). Para inglés, español, japonés y cualquier otro idioma oficialmente soportado, Lanternly usa exactamente esta ruta — sin traducción, con latencia mínima.
El ruso merece una nota aparte: aunque no esté oficialmente soportado, el código de Lanternly conserva un prompt de sistema completo en ruso, textual, como fuente de verdad. No porque se invoque directamente hoy — sino porque el día que Apple añada el ruso a supportedLanguages, la app no tendrá que reescribir la lógica de tono y personalidad de la compañera. Ya está lista, esperando su rama de la condición.
Ruta B: pivot-translation — sortear el límite en el dispositivo#
Cuando el idioma no está soportado directamente, entra en juego una segunda ruta: la generación sigue ocurriendo on-device, pero en inglés en lugar del idioma del usuario, con traducción a la entrada y a la salida.
@available(iOS 26, macOS 26, *)
private static func pivotReply(userText: String, userLang: Locale.Language,
history: [ChatMessage]) async -> String? {
let english = Locale.Language(identifier: "en")
// 1. Traducir la entrada al inglés — requiere un paquete de idioma descargado.
guard let userEN = await Translator.translate(userText, from: userLang, to: english) else {
return nil // paquete no descargado — no lo simulamos, devolvemos nil honestamente
}
// 2. Generar en inglés — dentro del rango oficialmente soportado por el modelo.
guard let replyEN = await runFM(instructions: systemPromptEN, prompt: userEN) else {
return nil
}
// 3. Traducir la respuesta de vuelta al idioma del usuario.
return await Translator.translate(replyEN, from: english, to: userLang)
}Tres etapas, tres llamadas on-device en lugar de una — y es notablemente más lento que la generación directa. Pero para un idioma fuera de la lista soportada, es la única forma de dar una respuesta real y generada en vez de una respuesta enlatada, sin que ningún paso salga del dispositivo.
El código en producción de Lanternly es algo más resiliente que la versión simplificada de arriba: si traducir el mensaje en sí tiene éxito pero traducir el contexto que lo acompaña (el historial del chat) falla, la app no descarta toda la respuesta hacia el banco de reservas — responde con el modelo, sin historial. Un fallo parcial de traducción no mata la conversación; es una decisión deliberada entre contexto completo y una respuesta viva.
Translation framework: on-device, pero no sin condiciones#
Un detalle fácil de pasar por alto: Translation también es completamente on-device (se introdujo en iOS 18, en la WWDC 2024), pero no viene con todos los pares de idiomas incluidos de fábrica. Traducir un par concreto de idiomas solo funciona si el paquete de idioma correspondiente ya está descargado en el dispositivo — un recurso a nivel de sistema, compartido por todas las apps. Si el usuario nunca abrió el traductor del sistema ni descargó el paquete ru↔en, la primera llamada a TranslationSession inicia una descarga (en SwiftUI, .translationTask hace que el sistema la ofrezca) o simplemente falla, si la app no está en posición de solicitarla.
De ahí se sigue una regla práctica para el pipeline: un pivot-translation nunca puede asumir el éxito en silencio. Si el paquete no está descargado, la traducción falla — y ese es un resultado normal y esperado, no un caso extremo poco frecuente.
Diagnóstico honesto en lugar de un fallback silencioso#
El error más costoso en este tipo de arquitectura no es tener un fallback — es que ese fallback sea opaco. Una versión anterior de un código similar en Lanternly envolvía la llamada al modelo en try? — cómodo, pero significa que, ante un fallo (el modelo se negó, la traducción no está disponible, falta el paquete de idioma), la razón desaparece en silencio, dejando solo una señal: "la respuesta vino del banco de reservas". No hay forma de diagnosticar eso en el dispositivo real de un usuario.
La versión actual registra la razón real en cada paso:
do {
let response = try await session.respond(to: prompt)
return response.content
} catch {
// NO try? — registramos la razón real en vez de tragárnosla.
Diagnostics.shared.lastGenerationError = String(describing: error)
return nil
}Además, una capa de diagnóstico dedicada que puede responder, en cualquier momento, a "por qué Luna está respondiendo con frases enlatadas en lugar de generar una ella misma ahora mismo": si el modelo está disponible, si el idioma está soportado, si esto es un simulador o un dispositivo real, cuál fue el último error de generación. Esto no es para el usuario final en producción — es la diferencia entre "no está claro por qué no funciona" y "está claro qué hay que arreglar" durante el desarrollo y el soporte.
Un detalle de privacidad importa aquí: la capa de diagnóstico nunca registra el contenido de los mensajes — solo metadatos técnicos (código de idioma, tipo de error, origen de la respuesta). Y se desactiva explícitamente para cualquier mensaje capturado por el protocolo de detección de crisis — bajo ninguna circunstancia esas interacciones deben dejar rastro, ni siquiera en un registro de depuración del dispositivo.
Así se comparan las tres rutas, lado a lado:
| Ruta A (directa) | Ruta B (pivot) | Banco (fallback) | |
|---|---|---|---|
| Se activa cuando | El idioma está en supportedLanguages | El idioma no está soportado, pero la traducción sí está disponible | El modelo no está disponible, falta el paquete de idioma, o falló algún paso |
| Llamadas on-device | 1 (generación) | hasta 3 (traducir → generar → traducir) | 0 |
| Requisitos | Apple Intelligence activo, iOS 26+ | + paquete de traducción ru↔en descargado | ninguno |
| Latencia | mínima | notablemente mayor (dos saltos de traducción sobre la generación) | instantánea |
| Privacidad | 100% on-device | 100% on-device | on-device (texto estático) |
| Si falla | pasa al banco | pasa al banco | nunca falla — siempre devuelve una respuesta |
Qué llevarse de este patrón#
Este enfoque no es específico de una app de diario con compañera de IA — es un patrón general para cualquier función de LLM on-device que atienda a una audiencia fuera de los idiomas oficialmente soportados por el modelo: detectar el idioma con un conjunto de candidatos reducido y prioridades, comprobar el soporte en tiempo de ejecución en lugar de fijarlo en el código, usar un paquete de idioma local del sistema como puente temporal y — lo más importante — no tragarse nunca los errores en silencio justo donde un fallback enmascararía la causa real.
Es probable que el ruso acabe apareciendo en supportedLanguages tarde o temprano — Apple sigue ampliando la lista, y el ruso ya figura entre los idiomas con los que se entrena el modelo subyacente, según la propia investigación de la compañía. Hasta entonces, el pivot-translation no es un parche — es una arquitectura funcional que mantiene la app honesta con sus usuarios: o responde con sentido on-device, o dice, a través del diagnóstico, exactamente por qué hoy no lo hizo.



