Neues Swift-Package mit lokalen LLM-Backends für alle nativen mana- e.V.-Apps. Lift der bisher Memoro-eigenen Files in `memoro-native/Sources/Core/AI/` plus zwei neue Layer: ManaSharedModels (App-Group-Container-Helper) und ManaLLM-Facade. Library-Products: - ManaLLM — Backend-Abstraktion (FoundationModels, Gemma 4 E2B/E4B, NoOp), Router mit Priority-Liste, High-Level-Facade `ManaLLM.summarize/generate/classify` mit fast/creative/deep Level. - ManaLLMShared — App-Group `group.ev.mana.models` Container, HF_HUB_CACHE-Setup, Legacy-Fallback wenn Group fehlt. Lift-Anpassungen ggü. memoro: - public-Marker auf protocol + types + actors - generischer `generate(prompt:instructions:maxTokens:)` zu LLMBackend-Protocol hinzu; `summarize` als Default-Impl auf Basis von generate - AppleFMBackend behält optimierten @Generable-Summary-Path - GemmaBackend nutzt ManaSharedModels.effectiveCacheURL() statt eigenen Application-Support-Pfad; allowsCellular kommt jetzt als Initializer-Param statt App-Settings-Lookup - LLMRouter: Memoro-spezifische User-Pref-Store-Logic durch Priority-Liste-API ersetzt - LLMLog-Subsystem `ev.mana.llm` statt App-eigenes `Log.ai` Build: `swift build` clean (76s, MLX-Toolchain-Resolution beim ersten Lauf). 4/4 Parser-Tests grün. Doku: ../mana/docs/MANA_LLM.md (Plattform-SOT), CLAUDE.md (Konventionen + Lift-Tabelle). Folge: L-4 Memoro auf ManaLLM umstellen, L-5 pageta-Pilot. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
186 lines
5.9 KiB
Swift
186 lines
5.9 KiB
Swift
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?
|
|
}
|
|
|
|
public extension LLMBackend {
|
|
/// 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: <max 60 Zeichen, prägnant, ohne Punkt>
|
|
INTRO: <ein vollständiger Satz, max 200 Zeichen>
|
|
"""
|
|
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
|
|
}
|
|
}
|