import Foundation /// Uniformes Protocol für alle LLM-Backends in `ManaLLM`. /// /// Implementierungen: /// - `NoOpBackend` — kein LLM, erste-Sätze-Fallback. /// - `AppleFMBackend` — Apple Foundation Models (iOS 26+, /// Apple-Intelligence-Geräte). /// - `GemmaBackend` — Gemma 4 E2B/E4B via MLX-Swift, lokal /// heruntergeladen (oder aus `ManaSharedModels`-Container /// geladen, wenn die App in der `group.ev.mana.models`-Group ist). /// /// **API-Design:** `generate(...)` ist die generische Methode für /// freie Prompts. `summarize(...)` ist eine Memoro-Erbe-Convenience /// und hat eine Default-Implementation auf Basis von `generate`, /// damit alle Backends sie automatisch unterstützen. /// /// `LLMRouter` ist der typische Aufrufer und wählt das Backend nach /// Capability + Availability + App-Wunsch. public protocol LLMBackend: Sendable { var identifier: LLMBackendID { get } func availability() async -> LLMAvailability /// Idempotenter Prepare-Schritt: System-Modell-Check (Apple FM), /// Modell-Download (Gemma), No-Op (NoOpBackend). func prepare(onProgress: @Sendable @escaping (LLMPrepareUpdate) -> Void) async throws /// Generische Generation: nimmt einen Prompt, gibt einen String /// zurück. `instructions` ist optional ein vorangestellter /// System-Prompt (FoundationModels: `Instructions { ... }`, /// Gemma: vorne an den User-Prompt geheftet). `maxTokens` /// limitiert die Output-Länge (Backends können das kappen). /// /// Niemals throw — bei Fehler `nil`. UI rendert dann /// "Backend nicht verfügbar". func generate( prompt: String, instructions: String?, maxTokens: Int ) async -> String? /// Memoro-Erbe-Convenience: liefert Headline + Intro für ein /// Transkript. Default-Impl ruft `generate` mit einem /// Standard-Summary-Prompt — Backends können das überschreiben /// für Modell-spezifische Optimierungen. func summarize(transcript: String) async -> LLMSummary? /// Entfernt ggf. gecachtes Modell von Disk. Default-Impl ist /// No-Op (für Apple FM, NoOp — die haben keinen /// Caller-kontrollierten Cache). Gemma überschreibt und löscht /// den HF-Repo-Pfad im App-Group-Container. func removeCachedModel() async throws } public extension LLMBackend { /// Default: kein Cache zu löschen. Backends mit gemanagtem Disk- /// Cache (Gemma) überschreiben. func removeCachedModel() async throws {} /// Default-Implementation für `summarize` auf Basis von /// `generate`. Backends mit optimiertem Summary-Pfad /// (z.B. AppleFMBackend mit FoundationModels-Schema) überschreiben. func summarize(transcript: String) async -> LLMSummary? { let instructions = """ Du bist ein Assistent, der Audio-Transkripte in eine prägnante Headline und einen kurzen Intro-Satz auf Deutsch destilliert. Antworte im exakten Format: HEADLINE: INTRO: """ let prompt = "Transkript:\n\n\(transcript)" guard let output = await generate( prompt: prompt, instructions: instructions, maxTokens: 200 ) else { return nil } return LLMSummary.parse(output) } } /// Stabile IDs für `UserDefaults`-Persistenz (rawValue) und /// SwiftUI-Picker. public enum LLMBackendID: String, CaseIterable, Sendable { case noOp case appleFM case gemmaE2B case gemmaE4B public var displayName: String { switch self { case .noOp: "Kein LLM (Fallback)" case .appleFM: "Apple Foundation Models (3 B)" case .gemmaE2B: "Gemma 4 E2B (2 B, ~1.3 GB)" case .gemmaE4B: "Gemma 4 E4B (4 B, ~2.5 GB)" } } public var isOnDeviceLLM: Bool { self != .noOp } } public struct LLMSummary: Equatable, Sendable { public let headline: String public let intro: String public init(headline: String, intro: String) { self.headline = headline self.intro = intro } /// Parst Default-Impl-Output ("HEADLINE: ...\nINTRO: ..."). Bei /// kaputtem Format `nil` (Aufrufer fällt auf `firstSentence` /// zurück). public static func parse(_ output: String) -> LLMSummary? { var headline: String? var intro: String? for rawLine in output.split(separator: "\n", omittingEmptySubsequences: true) { let line = rawLine.trimmingCharacters(in: .whitespaces) if line.uppercased().hasPrefix("HEADLINE:") { headline = line.dropPrefix(caseInsensitive: "HEADLINE:") .trimmingCharacters(in: .whitespaces) } else if line.uppercased().hasPrefix("INTRO:") { intro = line.dropPrefix(caseInsensitive: "INTRO:") .trimmingCharacters(in: .whitespaces) } } guard let h = headline, !h.isEmpty, let i = intro, !i.isEmpty else { return nil } return LLMSummary(headline: h, intro: i) } } private extension String { func dropPrefix(caseInsensitive prefix: String) -> String { let lower = self.lowercased() let prefixLower = prefix.lowercased() guard lower.hasPrefix(prefixLower) else { return self } return String(self.dropFirst(prefix.count)) } } public enum LLMAvailability: Equatable, Sendable { case available case requiresDownload(estimatedBytes: Int64) case downloading(fractionCompleted: Double) case unavailableDeviceNotEligible case unavailableModelNotReady case unavailableAppleIntelligenceNotEnabled case unavailableOSTooOld case unavailableMissingDependency(String) case unknown(String) /// Soll der Toggle in Settings auswählbar sein? public var isSelectable: Bool { switch self { case .available, .requiresDownload, .downloading: true default: false } } } public enum LLMPrepareStage: Equatable, Sendable { case checking case downloading case initializing case ready } /// Reichhaltigeres Progress-Event als nur `Double`: enthält optional /// Byte-Werte. UI kann sinnvolle Anzeige machen auch wenn /// `fractionCompleted` zu Anfang noch 0 ist (passiert bei HF-LFS- /// Downloads bis das erste URLSession-Callback kommt). Bytes können /// `nil` sein, wenn die Quelle keine kennt (Apple FM, NoOp). public struct LLMPrepareUpdate: Equatable, Sendable { public let stage: LLMPrepareStage public let fractionCompleted: Double public let bytesCompleted: Int64? public let bytesTotal: Int64? public init( stage: LLMPrepareStage, fractionCompleted: Double, bytesCompleted: Int64? = nil, bytesTotal: Int64? = nil ) { self.stage = stage self.fractionCompleted = fractionCompleted self.bytesCompleted = bytesCompleted self.bytesTotal = bytesTotal } }