Skip to content

Latest commit

 

History

History
350 lines (262 loc) · 17.1 KB

File metadata and controls

350 lines (262 loc) · 17.1 KB

Integraciones Externas

DucodeApp se integra con tres sistemas externos: la API pública de Whoop, los Foundation Models de Apple Intelligence (on-device) y HealthKit. No hay backend propio, ni servicios de terceros tipo Firebase / Sentry / analytics.

1. Whoop API v2

Whoop es un wearable de fitness que mide recovery, strain, sueño, HRV y workouts. La app consume la API v2 con OAuth 2.0.

Base URL: https://api.prod.whoop.com/developer/v2 Documentación oficial: developer.whoop.com

Datos consumidos

Métrica Descripción Uso en la app
Recovery Score Recuperación 0-100% Ajustar intensidad y calorías del día
Strain Esfuerzo acumulado 0-21 Estimar gasto adicional
HRV (RMSSD ms) Variabilidad cardíaca Indicador de estado físico
Resting Heart Rate Frecuencia cardíaca en reposo Indicador de recuperación
SpO2 / Skin Temp Saturación O2 / temperatura corporal Contexto adicional
Sleep stages Inbed / awake / light / deep / REM / debt Ajustar plan, alertar deuda de sueño
Sleep performance Performance / efficiency / consistency Contexto para AI
Workouts Sport, strain, kilojoule, HR, distancia Tipos top semanales y kcal quemadas
Profile Nombre y email Saludo personalizado

OAuth 2.0

┌─────────────┐                                    ┌─────────────┐
│   iOS App   │                                    │   Whoop     │
└──────┬──────┘                                    └──────┬──────┘
       │                                                  │
       │ 1. User taps "Conectar Whoop"                    │
       │ → UIApplication.shared.open(authorizationURL)    │
       │                                                  │
       │ 2. GET /oauth/oauth2/auth                        │
       │    ?client_id=…&redirect_uri=ducode://oauth/…    │
       │    &response_type=code                           │
       │    &scope=offline read:recovery read:cycles …    │
       │                                                  │
       │ 3. User logs in & authorizes                     │
       │ ◄──────────────────────────────────────────────┐ │
       │ 302 ducode://oauth/callback?code=AUTH_CODE       │
       │                                                  │
       │ 4. .onOpenURL → handleOAuthCallback(url:)        │
       │                                                  │
       │ 5. POST /oauth/oauth2/token                      │
       │    grant_type=authorization_code                 │
       │    code=AUTH_CODE & client_id & client_secret    │
       │ ────────────────────────────────────────────────►│
       │                                                  │
       │ 6. { access_token, refresh_token, expires_in }   │
       │ ◄────────────────────────────────────────────────│
       │   Tokens guardados en Keychain.                  │
       │   isAuthenticated = true.                        │
       │   fetchAllData()                                 │

Scopes solicitados (definidos en AppConfig.whoopScopes):

offline           → necesario para refresh_token
read:recovery
read:cycles
read:workout
read:sleep
read:profile
read:body_measurement

offline es obligatorio si quieres mantener la sesión más allá de 1 hora.

Refresh token automático

makeAuthenticatedRequest adjunta Authorization: Bearer <token>. Si recibe 401 Unauthorized, intenta refreshAccessToken() con el refresh token guardado y reintenta la request una vez. Si el refresh falla, lanza WhoopError.notAuthenticated y la UI invita al usuario a reconectarse.

Endpoints utilizados

Endpoint Método Scope Uso
/user/profile/basic GET read:profile Nombre y email
/recovery GET read:recovery Score más reciente / paginado
/cycle GET read:cycles Strain del ciclo actual / paginado
/activity/sleep GET read:sleep Sesiones de sueño / paginado
/activity/workout GET read:workout Workouts / paginado

Todos soportan paginación con next_token. WhoopSyncService.fetchPaginated(...) itera hasta agotar la última página (con un safety limit de 10 páginas).

Sincronización histórica

WhoopSyncService.syncHistoricalData(days:) (default 7) lanza las 4 queries en paralelo con async let, parsea las fechas en UTC y persiste un WhoopDailySnapshot por día. Si ya existe el snapshot del día, lo actualiza en lugar de duplicar.

onAppLaunch() decide si re-sincroniza basándose en lastSyncDate: si han pasado más de 4 horas, sincroniza; sino, usa los snapshots cacheados.

Almacenamiento de tokens

// WhoopService.swift
private var accessToken: String? {
    get { KeychainHelper.get(key: "whoop_access_token") }
    set { /* save / delete según value */ }
}

Implementación de KeychainHelper con kSecClassGenericPassword. Tokens nunca se persisten a disco fuera del Keychain.

Configuración

Secrets.xcconfig define dos variables:

WHOOP_CLIENT_ID = ...
WHOOP_CLIENT_SECRET = ...

project.yml las pasa al Info.plist y AppConfig las lee desde Bundle.main.infoDictionary. Ver ./SETUP.md.


2. Foundation Models (Apple Intelligence)

La generación de planes y el chat con el Coach usan los modelos on-device de Apple Intelligence. No hay API key, no hay costo por token y no hay datos saliendo del dispositivo.

Frameworks: FoundationModels (importado en NutritionAIService.swift y CoachChatService.swift).

Requisitos

Requisito Valor
Hardware iPhone 15 Pro / Pro Max o iPhone 16 (todos)
iOS 26.0 o superior
Apple Intelligence Activado en Ajustes → Apple Intelligence
Modelo descargado Sí (la primera activación tarda minutos)

Detección de disponibilidad

Antes de cada llamada se consulta:

availability = SystemLanguageModel.default.availability

Estados manejados:

Estado Mensaje al usuario
.available (vacío, todo OK)
.unavailable(.deviceNotEligible) "Tu dispositivo no soporta Apple Intelligence. Se requiere iPhone 15 Pro+"
.unavailable(.appleIntelligenceNotEnabled) "Activa Apple Intelligence en Ajustes → Apple Intelligence y Siri."
.unavailable(.modelNotReady) "El modelo se está descargando. Intenta de nuevo en unos minutos."
.unavailable(otro) "Apple Intelligence no está disponible en este momento."

Tanto NutritionAIService como CoachChatService exponen isAvailable: Bool y availabilityMessage: String. El Dashboard muestra una card "Apple Intelligence no disponible" con botón Reintentar cuando aplica. El OnboardingGeneratingStep ofrece un fallback "Continuar al inicio" si AI no está listo durante el primer plan.

LanguageModelSession

Cada operación crea o reutiliza una sesión:

let session = LanguageModelSession(instructions: systemPrompt)

// Generación estructurada (plan completo)
let response = try await session.respond(to: userPrompt, generating: PlanGenerado.self)
let plan: PlanGenerado = response.content

// Generación de UNA sola comida
let response = try await session.respond(to: userPrompt, generating: ComidaGenerada.self)

// Chat con streaming
for try await partial in session.streamResponse(to: userMessage) {
    streamingText = partial.content
}

@Generable y @Guide

Las structs marcadas con @Generable garantizan que el modelo respete la estructura. @Guide(description:) orienta semánticamente cada campo.

@Generable
private struct PlanGenerado {
    @Guide(description: "Análisis personalizado en 1-2 frases")
    let resumen: String
    @Guide(description: "Comidas en orden cronológico")
    let comidas: [ComidaGenerada]
    // …
}

Definiciones completas en ./DATA_MODELS.md.

Streaming en chat

CoachChatService.send(_:context:) consume streamResponse(to:). Cada partial.content es el texto acumulado hasta ese punto, no un delta. La UI lo asigna directamente a streamingText y ChatBubbleView lo renderiza con MarkdownText. Cuando el stream termina, el último partial.content es la respuesta final que se persiste como ChatMessage.

Si el stream lanza un error, se hace fallback a session.respond(to:) (no streaming).

System instructions construidas dinámicamente

Tanto NutritionAI como Coach construyen las instrucciones desde código a partir del estado actual del usuario (objetivo, preferencias, Whoop, HealthSummary, plan, etc). No hay system prompt hardcodeado.

Ver detalle del Coach en ./COACH.md.


3. HealthKit

Integración nativa con Apple Health para complementar los datos de Whoop. La app es read-only por ahora.

Entitlement

DucodeApp/DucodeApp.entitlements:

<key>com.apple.developer.healthkit</key>
<true/>
<key>com.apple.developer.healthkit.access</key>
<array/>

Usage descriptions

Info.plist (vía project.yml):

NSHealthShareUsageDescription:
  "Ducode usa tus datos de salud (peso, pasos, sueño y entrenamientos) para
   personalizar tus planes de comida según tu estado físico real."
NSHealthUpdateUsageDescription:
  "Ducode guarda las comidas que registres en Apple Health para que tu
   información nutricional esté centralizada."

Tipos leídos

Organizados por categoría (todos los HKQuantityType y HKCategoryType se declaran en HealthKitService.readTypes).

Categoría Tipos
Body composition bodyMass, bodyMassIndex, bodyFatPercentage, leanBodyMass, height
Activity stepCount, distanceWalkingRunning, activeEnergyBurned, basalEnergyBurned, flightsClimbed, appleExerciseTime, appleStandTime, vo2Max
Vitals heartRate, restingHeartRate, heartRateVariabilitySDNN, oxygenSaturation, respiratoryRate
Nutrition dietaryEnergyConsumed, dietaryProtein, dietaryCarbohydrates, dietaryFatTotal, dietaryWater
Sleep sleepAnalysis (incluye inBed, awake, asleepCore/Deep/REM)
Workouts workoutType() (últimos 7 días, agrega kcal y top 3 tipos)
Mindfulness mindfulSession

Flujo de autorización

First launch
    │
    ▼
OnboardingHealthStep / Dashboard banner
    │ user taps "Conectar Apple Health"
    ▼
HealthKitService.requestAuthorization()
    │ store.requestAuthorization(toShare: [], read: readTypes)
    ▼
iOS muestra sheet nativa por categoría (el usuario elige qué compartir)
    │
    ▼
hasRequestedAuthorization = true (persistido en UserDefaults)
    │
    ▼
HealthKitService.refreshAll()
    │ ~30 queries en paralelo con async let
    ▼
HealthSummary publicado en @Published var summary
    │
    ▼
Health360Card visible en Dashboard
HealthSummary inyectado a NutritionAI y Coach

hasRequestedAuthorization se guarda en UserDefaults con la clave healthKitHasRequestedAuthorization. Esto evita re-pedir permiso en cada arranque y permite a la UI saber si debe mostrar el banner "Conectar Apple Health" o no.

HealthSummary.generatePromptContext()

Convierte el snapshot a un bloque de texto compacto que se inyecta directamente al prompt del LLM (NutritionAI y Coach). Incluye solo los campos con datos.

Ejemplo de output:

DATOS DE APPLE HEALTH:
- Peso actual: 76.4kg, grasa 18.2%, masa magra 62.5kg
- Hoy: 8421 pasos, 412 kcal activas, 6.1km, 32 min ejercicio
- Vitales: RHR 54bpm, HRV 68ms, VO2max 48.1
- Sueño anoche: 7.4h (88% eficiencia)
- Workouts 7 días: 5 sesiones, 2840 kcal (Strength, Running, Cycling)
- Ya consumido hoy: 1240 kcal, P92g, C140g, G34g
- Agua hoy: 1800ml

Comportamiento si el usuario deniega

  • Las queries devuelven nil y summary.hasAnyData == false.
  • Health360Card se oculta en el Dashboard.
  • El banner "Conectar Apple Health" no se muestra (porque hasRequestedAuthorization == true).
  • NutritionAI y Coach simplemente no inyectan el bloque de Apple Health al prompt.

El usuario puede revocar permisos en cualquier momento desde Ajustes → Privacidad y seguridad → Salud → Ducode.


4. Apple Intelligence Coach Chat

Aplicación específica de Foundation Models para el chat. Se documenta brevemente aquí; ver ./COACH.md para el detalle completo.

Componentes

┌─────────────────────────────────────────────────────────────┐
│ CoachView                                                    │
│  • @Query messages: [ChatMessage]                            │
│  • streaming bubble en vivo                                  │
└──────────┬──────────────────────────────────────────────────┘
           │ buildContext() → CoachContext
           ▼
┌─────────────────────────────────────────────────────────────┐
│ CoachChatService                                             │
│  • ensureSession(context:) → recrea si cambió el system prompt│
│  • send(text, context) → streamResponse                      │
│  • resetConversation(context:)                               │
└──────────┬──────────────────────────────────────────────────┘
           │ system instructions = perfil + prefs + Whoop +
           │                       weekly + HealthSummary +
           │                       plan + últimas comidas
           ▼
┌─────────────────────────────────────────────────────────────┐
│ LanguageModelSession (on-device)                             │
└─────────────────────────────────────────────────────────────┘

Multi-turn

La LanguageModelSession mantiene el historial conversacional internamente. La app solo recrea la sesión cuando cambian las instrucciones (es decir, cuando cambia el contexto de fondo del usuario), preservando el chat mientras el contexto sea estable.

Persistencia

Cada mensaje se guarda como ChatMessage(role:content:timestamp:) en SwiftData. Al borrar la conversación se borran todos los mensajes y se crea una sesión nueva.


Documentos relacionados