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>
123 lines
3.6 KiB
Swift
123 lines
3.6 KiB
Swift
import Foundation
|
|
import ManaLLMShared
|
|
|
|
/// High-Level-Facade für lokale LLM-Aufrufe in mana-Apps.
|
|
///
|
|
/// Apps konsumieren typischerweise nur diese drei statischen Methoden:
|
|
///
|
|
/// ```swift
|
|
/// import ManaLLM
|
|
///
|
|
/// // Im App-Boot (z.B. @main App init):
|
|
/// ManaLLM.configure()
|
|
///
|
|
/// // Irgendwo später:
|
|
/// let summary = await ManaLLM.summarize(longText)
|
|
/// let tags = await ManaLLM.classify(text, into: ["#sport", "#politik"])
|
|
/// let story = await ManaLLM.generate(
|
|
/// prompt: "Schreib eine kurze Reise-Story über Konstanz.",
|
|
/// level: .creative
|
|
/// )
|
|
/// ```
|
|
///
|
|
/// **Level-Mapping zu Backends:**
|
|
/// - `.fast` → AppleFM erst, dann Gemma E2B
|
|
/// - `.creative` → Gemma E2B erst, dann AppleFM
|
|
/// - `.deep` → Gemma E4B erst, dann Gemma E2B, dann AppleFM
|
|
///
|
|
/// Niemals throw — bei Fehler `nil` (oder leeres Set). Apps rendern
|
|
/// dann eine Fallback-Heuristik.
|
|
public enum ManaLLM {
|
|
/// Zentraler Router mit Default-Backend-Priority. Apps können
|
|
/// das vor dem ersten Call konfigurieren:
|
|
/// ```swift
|
|
/// await ManaLLM.router.setPreferred([.gemmaE2B, .appleFM])
|
|
/// ```
|
|
public static let router = LLMRouter.shared
|
|
|
|
/// Boot-Side-Effects: HF_HUB_CACHE auf den Shared-Container
|
|
/// setzen. Möglichst früh aufrufen (z.B. im `@main`-`init()`).
|
|
@discardableResult
|
|
public static func configure() -> URL? {
|
|
ManaSharedModels.configureHuggingFaceCacheEnv()
|
|
}
|
|
|
|
/// Quality-Level für Routing.
|
|
public enum Level: Sendable {
|
|
case fast // AppleFM zuerst — Standard-Tasks
|
|
case creative // Gemma E2B zuerst — Story/Mood/Caption
|
|
case deep // Gemma E4B zuerst — Long-Context/Q&A
|
|
}
|
|
|
|
// MARK: - High-Level Operations
|
|
|
|
/// Freie Generation mit optionalem System-Prompt.
|
|
public static func generate(
|
|
prompt: String,
|
|
instructions: String? = nil,
|
|
level: Level = .fast,
|
|
maxTokens: Int = 500
|
|
) async -> String? {
|
|
let preferred = backendPriority(for: level)
|
|
let router = LLMRouter(preferred: preferred)
|
|
return await router.generate(
|
|
prompt: prompt,
|
|
instructions: instructions,
|
|
maxTokens: maxTokens
|
|
)
|
|
}
|
|
|
|
/// Memoro-Erbe: Headline + Intro für ein langes Transkript.
|
|
public static func summarize(
|
|
_ text: String,
|
|
level: Level = .fast
|
|
) async -> LLMSummary? {
|
|
let preferred = backendPriority(for: level)
|
|
let router = LLMRouter(preferred: preferred)
|
|
return await router.summarize(transcript: text)
|
|
}
|
|
|
|
/// Klassifikation in vordefinierte Labels. Returnt die Subset-
|
|
/// Labels, die laut LLM passen. Bei Parse-Fehler: leeres Set.
|
|
public static func classify(
|
|
_ text: String,
|
|
into labels: [String],
|
|
level: Level = .fast
|
|
) async -> Set<String> {
|
|
guard !labels.isEmpty else { return [] }
|
|
let labelList = labels.joined(separator: ", ")
|
|
let instructions = """
|
|
Du bist ein Klassifikator. Gegeben ein Text, wähle aus der Label-
|
|
Liste GENAU die Labels, die zum Text passen. Antworte
|
|
ausschließlich mit den passenden Labels, durch Komma getrennt,
|
|
ohne Erklärung, ohne Markdown.
|
|
|
|
Labels: \(labelList)
|
|
"""
|
|
guard let output = await generate(
|
|
prompt: "Text:\n\n\(text)",
|
|
instructions: instructions,
|
|
level: level,
|
|
maxTokens: 100
|
|
) else { return [] }
|
|
let valid = Set(labels)
|
|
let picked = output
|
|
.split(separator: ",")
|
|
.map { $0.trimmingCharacters(in: .whitespacesAndNewlines) }
|
|
.filter { valid.contains($0) }
|
|
return Set(picked)
|
|
}
|
|
|
|
// MARK: - Internal
|
|
|
|
private static func backendPriority(for level: Level) -> [LLMBackendID] {
|
|
switch level {
|
|
case .fast:
|
|
return [.appleFM, .gemmaE2B, .gemmaE4B, .noOp]
|
|
case .creative:
|
|
return [.gemmaE2B, .appleFM, .gemmaE4B, .noOp]
|
|
case .deep:
|
|
return [.gemmaE4B, .gemmaE2B, .appleFM, .noOp]
|
|
}
|
|
}
|
|
}
|