NutriScanAI.swift — Apple FoundationModels (iOS 26)

IA on-device · Privacidad total · Sin nube · NutriScan — cliente ficticio CULTIVA IA

Swift 6 iOS 26+ FoundationModels @Generable Tool Calling Snapshot Streaming On-Device · 🔒 Privado
NutriScanAI.swift
// 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")
        }
    }
}
Patrones implementados
Availability Check
Comprueba model.availability antes de crear la sesión. Gestiona los 4 casos: available, deviceNotEligible, notEnabled, modelNotReady.
case .available → NutriScanHomeView() case .deviceNotEligible → ContentUnavailableView(…) case .appleIntelligenceNotEnabled → guía settings case .modelNotReady → progreso descarga
🧩
@Generable + @Guide
Genera PlanComida como tipo Swift estructurado con constraints: .range, .count, .options. Seguridad en tiempo de compilación.
@Generable struct PlanComida { @Guide(.range(1200...5000)) var caloriasTotal: Int @Guide(.count(4)) var comidas: [String] }
🔧
Tool Calling on-device
BuscarAlimentoTool consulta la SQLite local (~8.000 alimentos). El LLM invoca la tool sin salir del dispositivo.
struct BuscarAlimentoTool: Tool { let name = "buscar_alimento" @Generable struct Arguments { … } func call(…) → ToolOutput { … } }
Snapshot Streaming
Streaming via streamResponse. Cada snapshot es un estado parcial completo (PartiallyGenerated), no un delta — ideal para SwiftUI.
for try await parcial in stream { planParcial = parcial // snapshot } plan = resp.content // ✅ .content
UI preview (SwiftUI)
NutriScan IA
Planes de comida personalizados
🔒 On-device · 100% privado
Ciclista amateur, 75 kg, objetivo rendimiento, sin gluten…
✨ Generar mi plan
Plan de rendimiento ciclista
rendimiento
2800
kcal/día
5 comidas al día
1
Avena + platano + whey — 620 kcal
2
Batata + huevo + espinacas — 380 kcal
3
Arroz integral + pollo + aguacate — 750 kcal
4
Platano + shake whey — 430 kcal
⚡ Consume CH complejos 2h antes de cada sesión. Hidrata con 500 ml/h en ruta.
Decisiones de diseño
DecisiónRationale
On-devicePrivacidad total; funciona offline
@GenerableSeguridad en compile-time; genera PartiallyGenerated auto
SnapshotsEstado completo en cada iteración → SwiftUI friendly
.contentAPI correcta; nunca .output
4096 tokensLímite on-device; chunking si hace falta