Files
FamilyMealPlanner/MealMood/Services/AutocompleteEngine.swift
T
alexandrev-tibco 6fe16d8e29 huecos sin planificar y exclusiones en la lista de la compra
- MealSlot.isSkipped: opcion "No planificar esta comida" en el picker de
  hueco vacio — cuenta como resuelto (semana completa, notificaciones,
  stats) y el autocompletado lo respeta; vista gris con guion, tap para
  desmarcar; viaja en undo, snapshot KV (junto con isEatingOut, que
  faltaba en el payload) y exports (— en imagen, omitido en texto)
- ShoppingItem.isDismissed: "Ya lo tengo" por ingrediente (swipe
  izquierdo) y "Ya esta hecho" por plato entero (boton en la cabecera de
  su seccion); van a la seccion "Ya en casa" con restauracion de un tap,
  y quedan fuera del export de texto. No se borran porque reconcile los
  recrearia
- Esquema CloudKit Development: CD_isSkipped, CD_isDismissed y el record
  type CD_ShoppingItem entero, que no existia — la lista de la compra no
  estaba sincronizando entre dispositivos (mismo bug que photoData)

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013H6bXqGX1ygwib1Dm3n3UG
2026-09-01 16:16:34 +02:00

320 lines
13 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import Foundation
struct AutocompleteEngine {
struct AutocompleteResult {
let filledSlots: [(slotId: UUID, dishId: UUID)]
let unfilledCount: Int
}
struct RuleViolation {
let slotId: UUID
let dayOfWeek: Int
let mealType: String
let dishId: UUID
let dishName: String
}
// recentPlans: up to 12 previous weeks for period detection (weekly/bi-weekly/monthly cycles).
static func autocomplete(
emptySlots: [MealSlot],
currentPlan: WeekPlan,
allDishes: [Dish],
allTags: [Tag],
recentPlans: [WeekPlan] = [],
rejectionCounts: [UUID: Int] = [:]
) -> AutocompleteResult {
let tagMap = Dictionary(allTags.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
let dishMap = Dictionary(allDishes.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
let historyScores = periodScores(dishes: allDishes, recentPlans: recentPlans, currentWeekStart: currentPlan.weekStartDate)
let ratingScores = Self.ratingScores(dishes: allDishes, recentPlans: recentPlans)
var filledSlots: [(slotId: UUID, dishId: UUID)] = []
var unfilledCount = 0
var pendingSlots = emptySlots.filter { !$0.isEatingOut && !$0.isSkipped }
// Fixed-slot rules win outright: a dish pinned to a weekday+meal fills
// that slot before any scoring. Runs before the MRV loop so the pinned
// dish also counts against no-repeat/tag rules for the rest of the week.
for slot in pendingSlots {
guard let fixed = allDishes.first(where: {
$0.fixedDayOfWeek == slot.dayOfWeek && $0.fixedMealType == slot.mealType
}) else { continue }
slot.dishId = fixed.id
filledSlots.append((slotId: slot.id, dishId: fixed.id))
}
pendingSlots.removeAll { $0.dishId != nil }
// MRV: at each step pick the slot with fewest valid candidates first.
// Assignments update currentPlan.slotList in-place, so subsequent candidate
// counts automatically reflect the growing set of committed dishes.
while !pendingSlots.isEmpty {
// Score each remaining slot by strict candidate count
let ranked = pendingSlots
.map { slot -> (slot: MealSlot, strict: [Dish]) in
let strict = allDishes.filter {
!violatesRules(dish: $0, slot: slot, plan: currentPlan, tagMap: tagMap, dishMap: dishMap)
}
return (slot, strict)
}
.sorted { $0.strict.count < $1.strict.count }
let (slot, strictCandidates) = ranked[0]
pendingSlots.removeAll { $0.id == slot.id }
// Fallback: relax the implicit no-repeat rule if strict set is empty
let candidates: [Dish]
if strictCandidates.isEmpty {
candidates = allDishes.filter {
!violatesExplicitRules(dish: $0, slot: slot, plan: currentPlan, tagMap: tagMap, dishMap: dishMap)
}
} else {
candidates = strictCandidates
}
if candidates.isEmpty {
unfilledCount += 1
continue
}
if let picked = weightedRandomPick(candidates: candidates, plan: currentPlan, historyScores: historyScores, ratingScores: ratingScores, rejectionCounts: rejectionCounts) {
slot.dishId = picked.id
filledSlots.append((slotId: slot.id, dishId: picked.id))
} else {
unfilledCount += 1
}
}
return AutocompleteResult(filledSlots: filledSlots, unfilledCount: unfilledCount)
}
static func findViolations(plan: WeekPlan, allDishes: [Dish], allTags: [Tag]) -> [RuleViolation] {
let dishMap = Dictionary(allDishes.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
return plan.slotList.compactMap { slot in
guard let dishId = slot.dishId,
let dish = dishMap[dishId],
slot.isRuleOverridden,
!slot.isEatingOut else { return nil }
return RuleViolation(
slotId: slot.id,
dayOfWeek: slot.dayOfWeek,
mealType: slot.mealType,
dishId: dishId,
dishName: dish.name
)
}
}
static func validateDrop(
dish: Dish,
slot: MealSlot,
plan: WeekPlan,
allTags: [Tag],
allDishes: [Dish]
) -> Bool {
let tagMap = Dictionary(allTags.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
let dishMap = Dictionary(allDishes.map { ($0.id, $0) }, uniquingKeysWith: { first, _ in first })
return !violatesRules(dish: dish, slot: slot, plan: plan, tagMap: tagMap, dishMap: dishMap)
}
// MARK: - Rule checks
private static func violatesRules(
dish: Dish,
slot: MealSlot,
plan: WeekPlan,
tagMap: [UUID: Tag],
dishMap: [UUID: Dish]
) -> Bool {
violatesDefaultNoRepeatRule(dish: dish, slot: slot, plan: plan) ||
violatesExplicitRules(dish: dish, slot: slot, plan: plan, tagMap: tagMap, dishMap: dishMap)
}
private static func violatesDefaultNoRepeatRule(dish: Dish, slot: MealSlot, plan: WeekPlan) -> Bool {
plan.slotList.contains { $0.id != slot.id && $0.dishId == dish.id }
}
private static func violatesExplicitRules(
dish: Dish,
slot: MealSlot,
plan: WeekPlan,
tagMap: [UUID: Tag],
dishMap: [UUID: Dish]
) -> Bool {
for tagId in dish.tagIds {
guard let tag = tagMap[tagId] else { continue }
if let maxPerWeek = tag.maxPerWeek {
var count = 0
for s in plan.slotList {
guard let did = s.dishId, let d = dishMap[did] else { continue }
if d.tagIds.contains(tagId) { count += 1 }
}
if count >= maxPerWeek { return true }
}
if tag.noConsecutive {
for adjSlot in adjacentSlots(of: slot, in: plan) {
guard let did = adjSlot.dishId, let d = dishMap[did] else { continue }
if d.tagIds.contains(tagId) { return true }
}
}
if tag.noDuplicateInDay {
for sdSlot in plan.slotList where sdSlot.dayOfWeek == slot.dayOfWeek && sdSlot.id != slot.id {
guard let did = sdSlot.dishId, let d = dishMap[did] else { continue }
if d.tagIds.contains(tagId) { return true }
}
}
if let restriction = tag.mealTypeRestriction, slot.mealType != restriction {
return true
}
if let dayRestriction = tag.dayRestriction {
switch dayRestriction {
case "weekdays" where slot.dayOfWeek > 4: return true
case "weekend" where slot.dayOfWeek < 5: return true
default: break
}
}
}
return false
}
private static func adjacentSlots(of slot: MealSlot, in plan: WeekPlan) -> [MealSlot] {
var result: [MealSlot] = []
if slot.dayOfWeek > 0,
let prev = plan.slotList.first(where: { $0.dayOfWeek == slot.dayOfWeek - 1 && $0.mealType == slot.mealType }) {
result.append(prev)
}
if slot.dayOfWeek < 6,
let next = plan.slotList.first(where: { $0.dayOfWeek == slot.dayOfWeek + 1 && $0.mealType == slot.mealType }) {
result.append(next)
}
return result
}
// MARK: - Period-based scoring
// Weight multiplier per dish based on its detected recurrence cycle.
// Values > 1.0 boost a dish that is "due"; < 1.0 suppress one used too recently.
// Requires recentPlans covering 812 weeks to reliably detect monthly cycles.
private static func periodScores(
dishes: [Dish],
recentPlans: [WeekPlan],
currentWeekStart: Date
) -> [UUID: Double] {
guard !recentPlans.isEmpty else { return [:] }
let calendar = Calendar.current
var scores: [UUID: Double] = [:]
for dish in dishes {
// Week offsets: how many weeks ago did this dish appear?
var offsets: [Int] = []
for plan in recentPlans {
guard plan.slotList.contains(where: { $0.dishId == dish.id }) else { continue }
let diff = calendar.dateComponents([.weekOfYear], from: plan.weekStartDate, to: currentWeekStart).weekOfYear ?? 0
if diff > 0 { offsets.append(diff) }
}
guard !offsets.isEmpty else { continue }
let weeksSinceLast = offsets.min()!
guard offsets.count >= 2 else {
// Single sighting: simple recency penalty, no cycle to detect
switch weeksSinceLast {
case 1: scores[dish.id] = 0.20
case 2: scores[dish.id] = 0.50
case 3: scores[dish.id] = 0.75
default: break // 4 weeks ago neutral (1.0)
}
continue
}
// Estimate recurrence period via median of consecutive intervals
let sorted = offsets.sorted()
var intervals: [Int] = []
for i in 0..<sorted.count - 1 {
intervals.append(sorted[i + 1] - sorted[i])
}
let period = medianDouble(intervals)
// Score by how well weeksSinceLast aligns with the detected period
let ratio = Double(weeksSinceLast) / period
let score: Double
switch ratio {
case ..<0.50: score = 0.10 // too soon strongly suppress
case 0.50..<0.75: score = 0.30 // a bit early
case 0.75..<1.25: score = 3.00 // right on schedule boost
case 1.25..<1.75: score = 2.00 // slightly overdue
case 1.75..<2.50: score = 1.50 // overdue
default: score = 1.00 // very overdue or pattern changed
}
scores[dish.id] = score
}
return scores
}
private static func medianDouble(_ values: [Int]) -> Double {
let s = values.sorted()
let n = s.count
return n % 2 == 1 ? Double(s[n / 2]) : Double(s[n / 2 - 1] + s[n / 2]) / 2.0
}
// MARK: - Rating-based scoring
// Boost dishes that appeared in liked weeks; penalise those from disliked weeks.
// Signal is intentionally modest (±0.5 max) so period scoring dominates.
private static func ratingScores(dishes: [Dish], recentPlans: [WeekPlan]) -> [UUID: Double] {
let ratedPlans = recentPlans.filter { $0.userRating != 0 }
guard !ratedPlans.isEmpty else { return [:] }
var scores: [UUID: Double] = [:]
for dish in dishes {
var liked = 0, disliked = 0
for plan in ratedPlans {
guard plan.slotList.contains(where: { $0.dishId == dish.id }) else { continue }
if plan.userRating > 0 { liked += 1 } else { disliked += 1 }
}
guard liked + disliked > 0 else { continue }
let boost = min(0.50, Double(liked) * 0.20)
let penalty = min(0.50, Double(disliked) * 0.25)
scores[dish.id] = max(0.50, 1.0 + boost - penalty)
}
return scores
}
// MARK: - Weighted pick
private static func weightedRandomPick(
candidates: [Dish],
plan: WeekPlan,
historyScores: [UUID: Double] = [:],
ratingScores: [UUID: Double] = [:],
rejectionCounts: [UUID: Int] = [:]
) -> Dish? {
guard !candidates.isEmpty else { return nil }
let weights: [Double] = candidates.map { dish in
let usage = plan.slotList.filter { $0.dishId == dish.id }.count
let priorityBoost: Double = dish.isPriority ? 2.5 : 1.0
let patternScore = historyScores[dish.id] ?? 1.0
let ratingScore = ratingScores[dish.id] ?? 1.0
let rejectionScore = rejectionCounts[dish.id].map { max(0.30, 1.0 - 0.25 * Double($0)) } ?? 1.0
let baseWeight: Double
switch usage {
case 0: baseWeight = 3.0
case 1: baseWeight = 2.0
default: baseWeight = 1.0
}
return baseWeight * priorityBoost * patternScore * ratingScore * rejectionScore
}
let total = weights.reduce(0, +)
var r = Double.random(in: 0..<total)
for (i, w) in weights.enumerated() {
r -= w
if r <= 0 { return candidates[i] }
}
return candidates.last
}
}