IA on-device · Privacidad total · Sin nube · NutriScan — cliente ficticio CULTIVA IA
// NutriScanAI.swift — NutriScan iOS 26 // IA on-device con Apple FoundationModels // Generado con: llm-en-dispositivo-apple-foundation-models (CULTIVA IA) import SwiftUI import FoundationModels // MARK: - Modelos @Generable ───────────────────────────────── /// Plan de comida estructurado generado on-device. @Generable(description: "Plan de comida personalizado para un deportista") struct PlanComida { @Guide(description: "Nombre del plan (p.ej. 'Plan cetogénico para ciclismo')") var nombre: String @Guide(description: "Objetivo principal", .options(["deficit_calorico", "volumen", "rendimiento"])) var objetivo: String @Guide(description: "Número de comidas al día", .range(3...6)) var comidasAlDia: Int @Guide(description: "Comidas del día con nombre y macros", .count(4)) var comidas: [String] @Guide(description: "Calorías totales diarias", .range(1200...5000)) var caloriasTotal: Int @Guide(description: "Consejo especial para el deportista (máx 2 frases)") var consejoDeportivo: String } // MARK: - Tool on-device: búsqueda en SQLite local ─────────── struct BuscarAlimentoTool: Tool { let name = "buscar_alimento" let description = "Busca macros de un alimento en la BD local (sin red)" @Generable struct Arguments { @Guide(description: "Nombre del alimento en español") var nombreAlimento: String @Guide(description: "Máx resultados", .range(1...5)) var maxResultados: Int } private let bdLocal: [String: (kcal: Int, prot: Double, carbs: Double, fat: Double)] = [ "pollo a la plancha": (165, 31.0, 0.0, 3.6), "arroz integral": (111, 2.6, 23.0, 0.9), "salmon": (208, 20.0, 0.0, 13.0), "aguacate": (160, 2.0, 9.0, 15.0), "avena": (389, 17.0, 66.0, 7.0), "huevo entero": (155, 13.0, 1.1, 11.0), "batata cocida": (90, 2.0, 21.0, 0.1), "proteina whey": (400, 80.0, 8.0, 5.0), ] func call(arguments: Arguments) async throws -> ToolOutput { let query = arguments.nombreAlimento.lowercased() let matches = bdLocal.filter { $0.key.contains(query) } .prefix(arguments.maxResultados) guard !matches.isEmpty else { return .string("No encontrado: \(arguments.nombreAlimento)") } let resultado = matches.map { a, m in "\(a): \(m.kcal) kcal | P:\(m.prot)g | C:\(m.carbs)g | G:\(m.fat)g" }.joined(separator: "\n") return .string(resultado) } } // MARK: - ViewModel con streaming ──────────────────────────── @MainActor @Observable class NutriScanViewModel { private let model = SystemLanguageModel.default var plan: PlanComida? var planParcial: PlanComida.PartiallyGenerated? var isGenerating = false var errorMessage: String? func generarPlan(preferencias: String) async { guard case .available = model.availability else { errorMessage = "Apple Intelligence no disponible" return } isGenerating = true let session = LanguageModelSession( instructions: """ Eres un nutricionista deportivo experto. Responde siempre en español. Sé específico y práctico. Usa buscar_alimento para obtener macros exactos. """, tools: [BuscarAlimentoTool()] ) do { // 1. Streaming parcial → actualiza UI en tiempo real let stream = session.streamResponse( to: "Plan para: \(preferencias)", generating: PlanComida.self ) for try await parcial in stream { planParcial = parcial // snapshot completo, no delta } // 2. Resultado final estructurado let resp = try await session.respond( to: "Plan para: \(preferencias)", generating: PlanComida.self ) plan = resp.content // ✅ .content, never .output } catch let error as LanguageModelSession.ToolCallError { errorMessage = "Tool error: \(error.tool.name)" } catch { errorMessage = error.localizedDescription } isGenerating = false } } // MARK: - Availability gate ─────────────────────────────────── struct NutriScanRootView: View { private let model = SystemLanguageModel.default var body: some View { switch model.availability { case .available: NutriScanHomeView() case .unavailable(.deviceNotEligible): ContentUnavailableView("Dispositivo no compatible", systemImage: "brain.slash") case .unavailable(.appleIntelligenceNotEnabled): ContentUnavailableView("Activa Apple Intelligence", systemImage: "gearshape") case .unavailable(.modelNotReady): ContentUnavailableView("Modelo descargándose…", systemImage: "arrow.down.circle") case .unavailable(let otro): ContentUnavailableView("No disponible: \(otro)", systemImage: "exclamationmark.triangle") } } }
model.availability antes de crear la sesión. Gestiona los 4 casos: available, deviceNotEligible, notEnabled, modelNotReady.PlanComida como tipo Swift estructurado con constraints: .range, .count, .options. Seguridad en tiempo de compilación.BuscarAlimentoTool consulta la SQLite local (~8.000 alimentos). El LLM invoca la tool sin salir del dispositivo.streamResponse. Cada snapshot es un estado parcial completo (PartiallyGenerated), no un delta — ideal para SwiftUI.| Decisión | Rationale |
|---|---|
| On-device | Privacidad total; funciona offline |
| @Generable | Seguridad en compile-time; genera PartiallyGenerated auto |
| Snapshots | Estado completo en cada iteración → SwiftUI friendly |
| .content | API correcta; nunca .output |
| 4096 tokens | Límite on-device; chunking si hace falta |