Modelo de Datos: Notificaciones Inteligentes¶
Identificador: MDL-NTF-001 Version: 1.0.0 Fecha: 2025-12-07 Autor: SpecQueen Technical Division Modulo Funcional: MTS-NTF-001
1. Resumen¶
Este documento define el modelo de datos para el sistema de notificaciones inteligentes de MedTime. Este modulo es 100% OFFLINE - todo el procesamiento ocurre en el dispositivo del usuario.
Principio Fundamental: Los datos de patrones de comportamiento NUNCA salen del dispositivo. El servidor solo almacena blobs cifrados para backup/sync entre dispositivos del mismo usuario.
2. Arquitectura de Datos¶
┌─────────────────────────────────────────────────────────────────┐
│ ARQUITECTURA 100% OFFLINE │
├─────────────────────────────────────────────────────────────────┤
│ │
│ CLIENTE (100% del procesamiento) │
│ ├── cli_pattern_data → Eventos de respuesta a alertas │
│ ├── cli_detected_patterns → Patrones identificados │
│ ├── cli_suggestions → Sugerencias generadas │
│ ├── cli_ml_model_state → Estado del modelo ML local │
│ └── cli_insights_cache → Cache de insights calculados │
│ │
│ ML LOCAL │
│ ├── iOS: CoreML (Random Forest, ~500KB) │
│ └── Android: TensorFlow Lite (Random Forest, ~500KB) │
│ │
│ SERVIDOR (Solo backup E2E - opcional) │
│ └── srv_encrypted_blobs → Blobs opacos para sync │
│ entity_type: 'notification_patterns' │
│ │
│ GARANTIA DE PRIVACIDAD │
│ - Servidor NUNCA ve datos de comportamiento │
│ - Todo analisis es local │
│ - Sync es opcional (Pro/Perfect) │
│ │
└─────────────────────────────────────────────────────────────────┘
3. Clasificacion de Datos¶
| Entidad | Clasificacion | PHI/PII | Sync | Notas |
|---|---|---|---|---|
| cli_pattern_data | LOCAL_ONLY | No (anonimizado) | No | NUNCA sale del dispositivo |
| cli_detected_patterns | LOCAL_ONLY | No | No | NUNCA sale del dispositivo |
| cli_suggestions | SYNCED_E2E | No | Si | Solo config, no datos |
| cli_ml_model_state | LOCAL_ONLY | No | No | Modelo entrenado localmente |
| cli_insights_cache | LOCAL_ONLY | No | No | Cache temporal |
IMPORTANTE: A diferencia de otros modulos, las entidades de patrones son LOCAL_ONLY y no tienen contraparte en servidor. El servidor solo ve blobs opacos si el usuario decide hacer backup.
4. Modelos del Cliente¶
4.1. cli_pattern_data (iOS - Swift/Realm)¶
Almacena eventos de respuesta para analisis de patrones.
4.1.1. Especificacion de medication_hash¶
El campo medication_hash se utiliza para agrupar patrones por medicamento sin revelar el ID real. Para prevenir ataques de rainbow table, se usa HMAC con salt unico por usuario.
Algoritmo:
medication_hash = HMAC-SHA256(user_salt, medication_id)
Donde:
- user_salt: Salt de 256 bits unico por usuario, generado al crear la cuenta
- medication_id: UUID del medicamento
- Resultado: String hexadecimal de 64 caracteres
Generacion del Salt:
// Android
val userSalt = ByteArray(32).also { SecureRandom().nextBytes(it) }
// Almacenar en EncryptedSharedPreferences
Razon de Seguridad:
Sin salt, un atacante con acceso a la base de datos podria:
- Generar hashes de todos los medication_id conocidos
- Comparar con medication_hash almacenados
- Determinar que medicamentos usa el usuario
Con salt unico por usuario, el atacante necesitaria el salt (protegido en Keychain/Keystore) para correlacionar los hashes.
// ============================================================
// MODELO: PatternData
// Descripcion: Eventos de respuesta a alertas para analisis
// Almacenamiento: Realm cifrado
// Sync: NUNCA - LOCAL_ONLY
// Retencion: 90 dias
// ============================================================
import RealmSwift
class PatternData: Object, Identifiable {
// Identificadores
@Persisted(primaryKey: true) var id: String = UUID().uuidString
@Persisted var alertId: String = "" // Referencia a la alerta
// Contexto temporal
@Persisted var dayOfWeek: Int = 0 // 0=Dom, 1=Lun, ... 6=Sab
@Persisted var hourScheduled: Int = 0 // 0-23
@Persisted var hourActual: Int? // Hora real de respuesta
// Respuesta del usuario
@Persisted var responseType: String = "" // taken, snoozed, skipped
@Persisted var responseTimeSeconds: Int? // Tiempo desde alerta hasta accion
@Persisted var snoozeCount: Int = 0 // Veces que pospuso
// Contexto del dispositivo
@Persisted var deviceState: String? // active, locked, background
@Persisted var wasOffline: Bool = false
// Medicamento (ID anonimizado, no nombre)
@Persisted var medicationHash: String = "" // SHA-256 del medication_id
// Timestamp
@Persisted var timestamp: Date = Date()
// Indices para queries eficientes
// dayOfWeek, hourScheduled, responseType
// Computed: Fue respuesta rapida?
var wasQuickResponse: Bool {
guard let seconds = responseTimeSeconds else { return false }
return seconds < 300 // Menos de 5 minutos
}
}
// Extension para limpieza de datos antiguos
extension PatternData {
static func cleanOldData(in realm: Realm, olderThan days: Int = 90) {
let cutoff = Calendar.current.date(byAdding: .day, value: -days, to: Date())!
let oldData = realm.objects(PatternData.self).filter("timestamp < %@", cutoff)
try? realm.write {
realm.delete(oldData)
}
}
}
4.2. cli_pattern_data (Android - Kotlin/Room)¶
// ============================================================
// MODELO: PatternData
// Descripcion: Eventos de respuesta a alertas para analisis
// Almacenamiento: Room cifrado (SQLCipher)
// Sync: NUNCA - LOCAL_ONLY
// Retencion: 90 dias
// ============================================================
package com.medtime.data.entities
import androidx.room.*
import java.time.Instant
import java.util.UUID
@Entity(
tableName = "cli_pattern_data",
indices = [
Index(value = ["day_of_week", "hour_scheduled"]),
Index(value = ["medication_hash"]),
Index(value = ["timestamp"]),
Index(value = ["response_type"])
]
)
data class PatternData(
@PrimaryKey
@ColumnInfo(name = "id")
val id: String = UUID.randomUUID().toString(),
@ColumnInfo(name = "alert_id")
val alertId: String = "",
// Contexto temporal
@ColumnInfo(name = "day_of_week")
val dayOfWeek: Int = 0, // 0=Dom ... 6=Sab
@ColumnInfo(name = "hour_scheduled")
val hourScheduled: Int = 0,
@ColumnInfo(name = "hour_actual")
val hourActual: Int? = null,
// Respuesta
@ColumnInfo(name = "response_type")
val responseType: ResponseType = ResponseType.TAKEN,
@ColumnInfo(name = "response_time_seconds")
val responseTimeSeconds: Int? = null,
@ColumnInfo(name = "snooze_count")
val snoozeCount: Int = 0,
// Contexto dispositivo
@ColumnInfo(name = "device_state")
val deviceState: DeviceState? = null,
@ColumnInfo(name = "was_offline")
val wasOffline: Boolean = false,
// Medicamento anonimizado (con salt por usuario)
// Hash = HMAC-SHA256(user_salt, medication_id)
// Salt unico por usuario previene rainbow table attacks
// Ver seccion 4.1.1 para especificacion de hashing
@ColumnInfo(name = "medication_hash")
val medicationHash: String = "",
// Timestamp
@ColumnInfo(name = "timestamp")
val timestamp: Instant = Instant.now()
) {
val wasQuickResponse: Boolean
get() = responseTimeSeconds?.let { it < 300 } ?: false
}
enum class ResponseType {
TAKEN, SNOOZED, SKIPPED
}
enum class DeviceState {
ACTIVE, LOCKED, BACKGROUND
}
// DAO para PatternData
@Dao
interface PatternDataDao {
@Query("SELECT * FROM pattern_data WHERE timestamp > :since ORDER BY timestamp DESC")
suspend fun getDataSince(since: Instant): List<PatternData>
@Query("SELECT * FROM pattern_data WHERE medication_hash = :hash ORDER BY timestamp DESC LIMIT :limit")
suspend fun getByMedication(hash: String, limit: Int = 100): List<PatternData>
@Query("SELECT day_of_week, COUNT(*) as total, " +
"SUM(CASE WHEN response_type = 'SKIPPED' THEN 1 ELSE 0 END) as skipped " +
"FROM pattern_data WHERE timestamp > :since GROUP BY day_of_week")
suspend fun getDayStats(since: Instant): List<DayStatResult>
@Query("DELETE FROM pattern_data WHERE timestamp < :cutoff")
suspend fun deleteOlderThan(cutoff: Instant): Int
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insert(data: PatternData)
}
data class DayStatResult(
@ColumnInfo(name = "day_of_week") val dayOfWeek: Int,
@ColumnInfo(name = "total") val total: Int,
@ColumnInfo(name = "skipped") val skipped: Int
)
4.3. cli_detected_patterns¶
// iOS - Swift/Realm
// ============================================================
// MODELO: DetectedPattern
// Descripcion: Patrones identificados por el analizador
// Almacenamiento: Realm cifrado
// Sync: NUNCA - LOCAL_ONLY
// ============================================================
class DetectedPattern: Object, Identifiable {
@Persisted(primaryKey: true) var id: String = UUID().uuidString
// Medicamento (anonimizado)
@Persisted var medicationHash: String = ""
// Tipo de patron
@Persisted var patternType: String = ""
// Tipos: schedule_consistent, schedule_variable, problematic_day,
// slow_responder, serial_snoozer, irregular
// Datos del patron
@Persisted var patternData: PatternDetails?
// Confianza (0.0 - 1.0)
@Persisted var confidence: Double = 0.0
// Accion sugerida
@Persisted var suggestedAction: String?
// Acciones: adjust_time, add_pre_alert, add_reminder, special_day_alert
@Persisted var suggestedValue: String? // ej: "07:30" para adjust_time
// Estado
@Persisted var isActive: Bool = true
@Persisted var userResponse: String? // accepted, rejected, pending
// Timestamps
@Persisted var detectedAt: Date = Date()
@Persisted var respondedAt: Date?
@Persisted var expiresAt: Date? // Patron expira si no se usa
// Recalculo
@Persisted var lastRecalculatedAt: Date = Date()
@Persisted var dataPointsUsed: Int = 0
}
// Embedded: Detalles del patron
class PatternDetails: EmbeddedObject {
// Para SCHEDULE_CONSISTENT
@Persisted var averageHour: Int?
@Persisted var averageMinute: Int?
@Persisted var standardDeviationMinutes: Double?
// Para SCHEDULE_VARIABLE
@Persisted var rangeStartHour: Int?
@Persisted var rangeEndHour: Int?
// Para PROBLEMATIC_DAY
@Persisted var problematicDays: List<Int> // 0=Dom...6=Sab
@Persisted var skipRateNormal: Double?
@Persisted var skipRateProblematic: Double?
// Para SLOW_RESPONDER
@Persisted var averageResponseSeconds: Int?
@Persisted var p90ResponseSeconds: Int?
// Para SERIAL_SNOOZER
@Persisted var snoozeRate: Double?
@Persisted var averageSnoozeCount: Double?
}
4.4. cli_suggestions¶
// iOS - Swift/Realm
// ============================================================
// MODELO: Suggestion
// Descripcion: Sugerencias generadas para el usuario
// Almacenamiento: Realm cifrado
// Sync: E2E (solo config de sugerencias aceptadas)
// ============================================================
class Suggestion: Object, Identifiable {
@Persisted(primaryKey: true) var id: String = UUID().uuidString
// Patron que genero la sugerencia
@Persisted var patternId: String = ""
// Tipo de sugerencia
@Persisted var suggestionType: String = ""
// Tipos: adjust_schedule, add_pre_alert, add_reinforcement,
// special_day_reminder, change_sound, enable_escalation
// Contenido
@Persisted var title: String = ""
@Persisted var body: String = ""
@Persisted var detailedExplanation: String?
// Datos de la sugerencia
@Persisted var currentValue: String? // ej: "08:00"
@Persisted var suggestedValue: String? // ej: "07:30"
@Persisted var affectedMedicationHash: String?
// Estadisticas mostradas al usuario
@Persisted var statsShown: SuggestionStats?
// Confianza
@Persisted var confidence: Double = 0.0
// Estado
@Persisted var status: String = "pending"
// Estados: pending, shown, accepted, rejected, expired, applied
// Timing
@Persisted var createdAt: Date = Date()
@Persisted var showAfter: Date? // No mostrar antes de esta fecha
@Persisted var shownAt: Date?
@Persisted var respondedAt: Date?
@Persisted var appliedAt: Date?
@Persisted var expiresAt: Date?
// Cooldown (no repetir si rechazada)
@Persisted var cooldownDays: Int = 30
// Sync (solo sugerencias aplicadas)
@Persisted var syncStatus: String = "local" // local, synced
@Persisted var version: Int = 1
}
// Embedded: Stats mostradas en la sugerencia
class SuggestionStats: EmbeddedObject {
@Persisted var dataPointsAnalyzed: Int = 0
@Persisted var periodDays: Int = 0
@Persisted var averageValue: String?
@Persisted var improvementEstimate: String?
}
// Reglas de negocio:
// - Max 1 sugerencia por dia
// - Confianza minima 80%
// - Cooldown 30 dias si rechazada
// - Expira en 7 dias si no responde
4.5. cli_ml_model_state¶
// iOS - Swift/Realm
// ============================================================
// MODELO: MLModelState
// Descripcion: Estado del modelo de ML local
// Almacenamiento: Realm cifrado
// Sync: NUNCA - LOCAL_ONLY
// ============================================================
class MLModelState: Object {
@Persisted(primaryKey: true) var id: String = "ml_state"
// Version del modelo
@Persisted var modelVersion: String = "1.0.0"
@Persisted var frameworkVersion: String = "" // CoreML/TFLite version
// Estado de entrenamiento
@Persisted var isReadyForPrediction: Bool = false
@Persisted var minDataPointsRequired: Int = 140 // ~14 dias * 10 tomas
@Persisted var currentDataPoints: Int = 0
// Metricas del modelo
@Persisted var lastTrainedAt: Date?
@Persisted var trainingDataPoints: Int = 0
@Persisted var validationAccuracy: Double?
@Persisted var precision: Double?
@Persisted var recall: Double?
@Persisted var f1Score: Double?
// Features usadas
@Persisted var featuresUsed: List<String>
// day_of_week (7), hour_of_day (1), days_since_last_skip (1),
// current_streak (1), historical_skip_rate (1), avg_response_time (1)
// Total: 12 features
// Predicciones recientes
@Persisted var predictionsLast7Days: Int = 0
@Persisted var correctPredictions: Int = 0
@Persisted var falsePositives: Int = 0
@Persisted var falseNegatives: Int = 0
// Proximo entrenamiento
@Persisted var nextTrainingScheduled: Date?
@Persisted var retrainingIntervalDays: Int = 7
// Timestamps
@Persisted var createdAt: Date = Date()
@Persisted var updatedAt: Date = Date()
// Computed
var predictionAccuracy: Double {
guard predictionsLast7Days > 0 else { return 0 }
return Double(correctPredictions) / Double(predictionsLast7Days)
}
}
4.6. cli_insights_cache¶
// iOS - Swift/Realm
// ============================================================
// MODELO: InsightsCache
// Descripcion: Cache de insights calculados para dashboard
// Almacenamiento: Realm cifrado
// Sync: NUNCA - LOCAL_ONLY
// TTL: Se recalcula diariamente
// ============================================================
class InsightsCache: Object {
@Persisted(primaryKey: true) var id: String = "insights"
// Periodo de datos
@Persisted var periodStart: Date = Date()
@Persisted var periodEnd: Date = Date()
@Persisted var periodDays: Int = 7
// Resumen general
@Persisted var totalAlerts: Int = 0
@Persisted var takenOnTime: Int = 0
@Persisted var takenLate: Int = 0
@Persisted var snoozed: Int = 0
@Persisted var skipped: Int = 0
// Adherencia
@Persisted var adherenceRate: Double = 0.0
@Persisted var onTimeRate: Double = 0.0
// Tiempo de respuesta
@Persisted var avgResponseSeconds: Int = 0
@Persisted var medianResponseSeconds: Int = 0
// Por dia de la semana
@Persisted var dayStats: List<DayInsight>
// Mejor y peor dia
@Persisted var bestDayOfWeek: Int?
@Persisted var bestDayRate: Double?
@Persisted var worstDayOfWeek: Int?
@Persisted var worstDayRate: Double?
// Rachas
@Persisted var currentStreak: Int = 0
@Persisted var longestStreak: Int = 0
@Persisted var streakStartDate: Date?
// Prediccion para manana
@Persisted var tomorrowRiskScore: Double?
@Persisted var tomorrowRiskFactors: List<String>
// Cache validity
@Persisted var calculatedAt: Date = Date()
@Persisted var expiresAt: Date = Date()
var isExpired: Bool {
return Date() > expiresAt
}
}
// Embedded: Stats por dia de la semana
class DayInsight: EmbeddedObject {
@Persisted var dayOfWeek: Int = 0 // 0=Dom...6=Sab
@Persisted var totalAlerts: Int = 0
@Persisted var takenCount: Int = 0
@Persisted var skippedCount: Int = 0
@Persisted var adherenceRate: Double = 0.0
@Persisted var avgResponseSeconds: Int = 0
@Persisted var isProblematic: Bool = false
}
4.7. cli_prediction_log¶
// iOS - Swift/Realm
// ============================================================
// MODELO: PredictionLog
// Descripcion: Log de predicciones para evaluar modelo
// Almacenamiento: Realm cifrado
// Sync: NUNCA - LOCAL_ONLY
// Retencion: 30 dias
// ============================================================
class PredictionLog: Object, Identifiable {
@Persisted(primaryKey: true) var id: String = UUID().uuidString
// Prediccion
@Persisted var alertId: String = ""
@Persisted var medicationHash: String = ""
@Persisted var predictedSkipProbability: Double = 0.0
@Persisted var predictionThreshold: Double = 0.6
// Fue clasificado como riesgo?
@Persisted var wasHighRisk: Bool = false
// Alerta preventiva enviada?
@Persisted var preventiveAlertSent: Bool = false
@Persisted var preventiveAlertTime: Date?
// Resultado real
@Persisted var actualOutcome: String? // taken, skipped, snoozed
@Persisted var outcomeRecordedAt: Date?
// Evaluacion
@Persisted var wasCorrectPrediction: Bool?
// true si: (wasHighRisk && skipped) || (!wasHighRisk && taken)
// Features usadas (para debug)
@Persisted var featuresSnapshot: String? // JSON de features
// Timestamp
@Persisted var predictedAt: Date = Date()
}
// Extension para metricas
extension PredictionLog {
static func calculateMetrics(in realm: Realm, lastDays: Int = 7) -> PredictionMetrics {
let cutoff = Calendar.current.date(byAdding: .day, value: -lastDays, to: Date())!
let logs = realm.objects(PredictionLog.self)
.filter("predictedAt >= %@ AND actualOutcome != nil", cutoff)
let total = logs.count
let correct = logs.filter("wasCorrectPrediction == true").count
let truePositives = logs.filter("wasHighRisk == true AND actualOutcome == 'skipped'").count
let falsePositives = logs.filter("wasHighRisk == true AND actualOutcome != 'skipped'").count
let falseNegatives = logs.filter("wasHighRisk == false AND actualOutcome == 'skipped'").count
let precision = truePositives > 0 ?
Double(truePositives) / Double(truePositives + falsePositives) : 0
let recall = truePositives > 0 ?
Double(truePositives) / Double(truePositives + falseNegatives) : 0
return PredictionMetrics(
total: total,
correct: correct,
accuracy: total > 0 ? Double(correct) / Double(total) : 0,
precision: precision,
recall: recall,
f1Score: precision + recall > 0 ?
2 * precision * recall / (precision + recall) : 0
)
}
}
struct PredictionMetrics {
let total: Int
let correct: Int
let accuracy: Double
let precision: Double
let recall: Double
let f1Score: Double
}
5. Android Room Equivalents¶
5.1. DetectedPattern¶
@Entity(
tableName = "cli_detected_patterns",
indices = [
Index(value = ["medication_hash"]),
Index(value = ["pattern_type"]),
Index(value = ["is_active"])
]
)
data class DetectedPattern(
@PrimaryKey
val id: String = UUID.randomUUID().toString(),
@ColumnInfo(name = "medication_hash")
val medicationHash: String = "",
@ColumnInfo(name = "pattern_type")
val patternType: PatternType = PatternType.IRREGULAR,
@Embedded(prefix = "details_")
val patternDetails: PatternDetails? = null,
@ColumnInfo(name = "confidence")
val confidence: Double = 0.0,
@ColumnInfo(name = "suggested_action")
val suggestedAction: SuggestedAction? = null,
@ColumnInfo(name = "suggested_value")
val suggestedValue: String? = null,
@ColumnInfo(name = "is_active")
val isActive: Boolean = true,
@ColumnInfo(name = "user_response")
val userResponse: UserResponse? = null,
@ColumnInfo(name = "detected_at")
val detectedAt: Instant = Instant.now(),
@ColumnInfo(name = "responded_at")
val respondedAt: Instant? = null,
@ColumnInfo(name = "last_recalculated_at")
val lastRecalculatedAt: Instant = Instant.now(),
@ColumnInfo(name = "data_points_used")
val dataPointsUsed: Int = 0
)
enum class PatternType {
SCHEDULE_CONSISTENT,
SCHEDULE_VARIABLE,
PROBLEMATIC_DAY,
SLOW_RESPONDER,
SERIAL_SNOOZER,
IRREGULAR
}
enum class SuggestedAction {
ADJUST_TIME,
ADD_PRE_ALERT,
ADD_REMINDER,
SPECIAL_DAY_ALERT
}
enum class UserResponse {
ACCEPTED, REJECTED, PENDING
}
data class PatternDetails(
// Schedule patterns
val averageHour: Int? = null,
val averageMinute: Int? = null,
val standardDeviationMinutes: Double? = null,
val rangeStartHour: Int? = null,
val rangeEndHour: Int? = null,
// Problematic day
val problematicDays: List<Int> = emptyList(),
val skipRateNormal: Double? = null,
val skipRateProblematic: Double? = null,
// Response patterns
val averageResponseSeconds: Int? = null,
val p90ResponseSeconds: Int? = null,
val snoozeRate: Double? = null,
val averageSnoozeCount: Double? = null
)
6. Algoritmos de Analisis¶
6.1. Detector de Patrones de Horario¶
// PatternAnalyzer.swift
class PatternAnalyzer {
private let realm: Realm
private let minimumDataPoints = 10
func analyzeSchedulePattern(for medicationHash: String) -> DetectedPattern? {
// Obtener datos de los ultimos 30 dias
let cutoff = Calendar.current.date(byAdding: .day, value: -30, to: Date())!
let data = realm.objects(PatternData.self)
.filter("medicationHash == %@ AND timestamp >= %@ AND responseType == 'taken'",
medicationHash, cutoff)
guard data.count >= minimumDataPoints else { return nil }
// Extraer horas reales de respuesta
let hours = data.compactMap { $0.hourActual }
guard !hours.isEmpty else { return nil }
// Calcular estadisticas
let mean = Double(hours.reduce(0, +)) / Double(hours.count)
let variance = hours.map { pow(Double($0) - mean, 2) }.reduce(0, +) / Double(hours.count)
let stdDev = sqrt(variance)
// Convertir a minutos para mayor precision
let stdDevMinutes = stdDev * 60
let pattern = DetectedPattern()
pattern.medicationHash = medicationHash
pattern.dataPointsUsed = data.count
if stdDevMinutes < 10 {
// Patron consistente (< 10 min desviacion)
pattern.patternType = "schedule_consistent"
pattern.confidence = min(0.95, 0.7 + (Double(data.count) / 100.0))
let details = PatternDetails()
details.averageHour = Int(mean)
details.averageMinute = Int((mean - Double(Int(mean))) * 60)
details.standardDeviationMinutes = stdDevMinutes
pattern.patternData = details
pattern.suggestedAction = "adjust_time"
pattern.suggestedValue = String(format: "%02d:%02d",
details.averageHour!, details.averageMinute!)
} else if stdDevMinutes < 30 {
// Patron variable pero dentro de rango
pattern.patternType = "schedule_variable"
pattern.confidence = min(0.80, 0.6 + (Double(data.count) / 150.0))
let details = PatternDetails()
details.rangeStartHour = Int(mean - stdDev)
details.rangeEndHour = Int(mean + stdDev)
pattern.patternData = details
} else {
// Irregular
pattern.patternType = "irregular"
pattern.confidence = 0.5
}
return pattern
}
func analyzeProblematicDays(for medicationHash: String) -> DetectedPattern? {
let cutoff = Calendar.current.date(byAdding: .day, value: -30, to: Date())!
let data = realm.objects(PatternData.self)
.filter("medicationHash == %@ AND timestamp >= %@", medicationHash, cutoff)
guard data.count >= minimumDataPoints else { return nil }
// Agrupar por dia de la semana
var dayStats: [Int: (total: Int, skipped: Int)] = [:]
for record in data {
let day = record.dayOfWeek
var stats = dayStats[day] ?? (0, 0)
stats.total += 1
if record.responseType == "skipped" {
stats.skipped += 1
}
dayStats[day] = stats
}
// Calcular tasas de omision
var skipRates: [Int: Double] = [:]
for (day, stats) in dayStats {
if stats.total >= 3 { // Minimo 3 puntos por dia
skipRates[day] = Double(stats.skipped) / Double(stats.total)
}
}
guard !skipRates.isEmpty else { return nil }
let overallSkipRate = Double(data.filter { $0.responseType == "skipped" }.count) / Double(data.count)
// Encontrar dias problematicos (>= 2x la tasa promedio)
let problematicDays = skipRates.filter { $0.value >= overallSkipRate * 2 && $0.value > 0.2 }
guard !problematicDays.isEmpty else { return nil }
let pattern = DetectedPattern()
pattern.medicationHash = medicationHash
pattern.patternType = "problematic_day"
pattern.confidence = min(0.85, 0.6 + (Double(data.count) / 200.0))
pattern.dataPointsUsed = data.count
let details = PatternDetails()
details.problematicDays.append(objectsIn: problematicDays.keys.sorted())
details.skipRateNormal = overallSkipRate
details.skipRateProblematic = problematicDays.values.max()
pattern.patternData = details
pattern.suggestedAction = "special_day_alert"
return pattern
}
}
6.2. Predictor de Omision (ML Local)¶
// OmissionPredictor.swift
class OmissionPredictor {
private var model: MLModel?
private let featureExtractor = FeatureExtractor()
init() {
loadModel()
}
private func loadModel() {
// iOS: CoreML
guard let modelURL = Bundle.main.url(forResource: "OmissionPredictor",
withExtension: "mlmodelc") else {
return
}
model = try? MLModel(contentsOf: modelURL)
}
func predictOmissionRisk(
dayOfWeek: Int,
hourOfDay: Int,
daysSinceLastSkip: Int,
currentStreak: Int,
historicalSkipRate: Double,
avgResponseTimeSeconds: Int
) -> Double {
guard let model = model else {
// Fallback a heuristicas simples si no hay modelo
return fallbackPrediction(
dayOfWeek: dayOfWeek,
historicalSkipRate: historicalSkipRate
)
}
// Preparar features
let features = featureExtractor.extract(
dayOfWeek: dayOfWeek,
hourOfDay: hourOfDay,
daysSinceLastSkip: daysSinceLastSkip,
currentStreak: currentStreak,
historicalSkipRate: historicalSkipRate,
avgResponseTimeSeconds: avgResponseTimeSeconds
)
// Ejecutar prediccion
guard let prediction = try? model.prediction(from: features) else {
return fallbackPrediction(dayOfWeek: dayOfWeek, historicalSkipRate: historicalSkipRate)
}
// Extraer probabilidad de omision
return prediction.featureValue(for: "skipProbability")?.doubleValue ?? 0.0
}
private func fallbackPrediction(dayOfWeek: Int, historicalSkipRate: Double) -> Double {
// Heuristica simple cuando no hay modelo ML
var risk = historicalSkipRate
// Fines de semana suelen tener mas omisiones
if dayOfWeek == 0 || dayOfWeek == 6 {
risk *= 1.3
}
return min(1.0, risk)
}
}
class FeatureExtractor {
func extract(
dayOfWeek: Int,
hourOfDay: Int,
daysSinceLastSkip: Int,
currentStreak: Int,
historicalSkipRate: Double,
avgResponseTimeSeconds: Int
) -> MLFeatureProvider {
// One-hot encoding para dia de semana (7 features)
var dayVector = [Double](repeating: 0.0, count: 7)
dayVector[dayOfWeek] = 1.0
// Normalizar hora (0-1)
let normalizedHour = Double(hourOfDay) / 24.0
// Normalizar dias desde ultimo skip (cap en 30)
let normalizedDaysSince = min(Double(daysSinceLastSkip), 30.0) / 30.0
// Normalizar racha (cap en 100)
let normalizedStreak = min(Double(currentStreak), 100.0) / 100.0
// Skip rate ya esta normalizado (0-1)
// Normalizar tiempo de respuesta (cap en 1 hora)
let normalizedResponse = min(Double(avgResponseTimeSeconds), 3600.0) / 3600.0
// Crear feature provider
return OmissionFeatures(
dayOfWeek: dayVector,
hourOfDay: normalizedHour,
daysSinceLastSkip: normalizedDaysSince,
currentStreak: normalizedStreak,
historicalSkipRate: historicalSkipRate,
avgResponseTime: normalizedResponse
)
}
}
7. Disponibilidad por Tier¶
| Feature | Free | Pro | Perfect |
|---|---|---|---|
| Alertas basicas | Si | Si | Si |
| Recoleccion de datos | No | Si | Si |
| Deteccion patrones horario | No | Si | Si |
| Deteccion dias problematicos | No | No | Si |
| Sugerencias de optimizacion | No | Si | Si |
| Alertas predictivas (ML) | No | No | Si |
| Dashboard insights basico | No | Si | Si |
| Dashboard insights completo | No | No | Si |
| Sync de preferencias | No | Si | Si |
8. Retencion de Datos¶
| Tipo de Dato | Retencion | Razon |
|---|---|---|
| PatternData | 90 dias | Calculo de patrones |
| DetectedPattern | Indefinido | Personalizacion activa |
| Suggestion (aceptada) | Indefinido | Configuracion |
| Suggestion (rechazada) | 30 dias | Cooldown |
| MLModelState | Indefinido | Estado del modelo |
| InsightsCache | 24 horas | Cache temporal |
| PredictionLog | 30 dias | Evaluacion del modelo |
9. Validaciones¶
// Validaciones de negocio
extension PatternAnalyzer {
// RN-NTF-001: Minimo 14 dias de datos
static let minimumDaysForPatterns = 14
// RN-NTF-002: Minimo 10 tomas por medicamento
static let minimumDataPointsPerMedication = 10
// RN-NTF-010: Confianza minima para sugerencias
static let minimumConfidenceForSuggestion = 0.80
// RN-NTF-020: Riesgo minimo para alerta predictiva
static let minimumRiskForPredictiveAlert = 0.60
}
extension Suggestion {
// RN-NTF-011: Max 1 sugerencia por dia
static func canShowNewSuggestion(in realm: Realm) -> Bool {
let today = Calendar.current.startOfDay(for: Date())
let shown = realm.objects(Suggestion.self)
.filter("shownAt >= %@", today)
return shown.isEmpty
}
// RN-NTF-012: Cooldown de 30 dias si rechazada
static func isInCooldown(type: String, medicationHash: String, in realm: Realm) -> Bool {
let cutoff = Calendar.current.date(byAdding: .day, value: -30, to: Date())!
let rejected = realm.objects(Suggestion.self)
.filter("suggestionType == %@ AND affectedMedicationHash == %@ AND status == 'rejected' AND respondedAt >= %@",
type, medicationHash, cutoff)
return !rejected.isEmpty
}
}
10. Referencias¶
| Documento | Relacion |
|---|---|
| MTS-NTF-001 | Especificacion funcional |
| MDL-ALT-001 | Modelo alertas base |
| MDL-MED-001 | Modelo medicamentos |
| 04-seguridad-cliente.md | Cifrado local |
Modelo generado por SpecQueen Technical Division - IT-05