Files
FamilyMealPlanner/MealMood/Services/IngredientGenerator.swift
T
alexandrev-tibco a5c304e209 2.0: Dish gains optional ingredients + photo; on-device ingredient generation
- Dish: ingredients [String] (default [], never required) and photoData
  (externalStorage). Additive SwiftData migration — existing stores unaffected.
- ICloudSyncService: sync ingredients (optional in payload so pre-2.0 snapshots
  still decode); photos deliberately excluded from KV sync (1MB limit).
- IngredientGenerator: Foundation Models (iOS 26+) generates localized
  ingredient lines from the dish name, fully on-device. isAvailable gates the
  UI affordance on older OS/devices; failures record to Crashlytics and
  return [].

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BkanrydYtrme8wipTzWssG
2026-07-12 17:41:46 +02:00

69 lines
2.9 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
#if canImport(FoundationModels)
import FoundationModels
#endif
/// On-device ingredient generation from a dish name using Apple's Foundation
/// Models (iOS 26+, Apple Intelligence). Fully local no network, no API key,
/// private. On older OS versions or unsupported devices it simply reports
/// `isAvailable == false` and callers hide the "Generate" affordance.
enum IngredientGenerator {
/// Whether on-device generation can run right now on this device.
static var isAvailable: Bool {
#if canImport(FoundationModels)
if #available(iOS 26.0, *) {
if case .available = SystemLanguageModel.default.availability { return true }
}
#endif
return false
}
/// Generates ingredient lines for a dish, localized to `language`.
/// Returns an empty array when unavailable or on failure (never throws).
static func generate(dishName: String, language: AppLanguage) async -> [String] {
let trimmed = dishName.trimmingCharacters(in: .whitespacesAndNewlines)
guard !trimmed.isEmpty else { return [] }
#if canImport(FoundationModels)
if #available(iOS 26.0, *), case .available = SystemLanguageModel.default.availability {
let langName = language.resolved().displayName
let instructions = """
You are a helpful cooking assistant. Given a dish name, list the \
typical grocery ingredients needed to cook it for a family of four. \
Output one ingredient per line with a rough quantity (e.g. \
"500 g minced beef"). No numbering, no bullet symbols, no headings, \
no extra commentary. Write the ingredients in \(langName).
"""
do {
let session = LanguageModelSession(instructions: instructions)
let response = try await session.respond(to: "Dish: \(trimmed)")
return parseLines(response.content)
} catch {
CrashlyticsService.record(error, context: "ingredient_generation")
return []
}
}
#endif
return []
}
/// Normalizes raw model output into clean ingredient lines, stripping any
/// stray bullets or numbering the model may still emit.
static func parseLines(_ text: String) -> [String] {
text
.split(whereSeparator: \.isNewline)
.map { line -> String in
var s = line.trimmingCharacters(in: .whitespaces)
for bullet in ["- ", "", "* ", " ", ""] where s.hasPrefix(bullet) {
s.removeFirst(bullet.count)
}
if let range = s.range(of: #"^\d+[\.\)]\s+"#, options: .regularExpression) {
s.removeSubrange(range)
}
return s.trimmingCharacters(in: .whitespaces)
}
.filter { !$0.isEmpty }
}
}