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>
192 lines
6 KiB
Swift
192 lines
6 KiB
Swift
#if canImport(FoundationModels)
|
||
import FoundationModels
|
||
#endif
|
||
import Foundation
|
||
import OSLog
|
||
|
||
/// `LLMBackend` über Apples `FoundationModels` Framework (iOS 26+).
|
||
///
|
||
/// Läuft auf demselben ~3 B-Modell, das auch Apple Intelligence
|
||
/// antreibt. ANE-beschleunigt, kein Modell-Download — Apple liefert
|
||
/// das Modell mit dem System aus. **System-shared:** alle Apps auf
|
||
/// demselben Gerät nutzen dieselbe Modell-Instanz, kein Cross-App-
|
||
/// Setup nötig (anders als Gemma — siehe `ManaSharedModels`).
|
||
///
|
||
/// **Token-Window:** 4096 (Instructions + Prompt + Response). Bei
|
||
/// längeren Inputs hart auf ~3000 chars geklippt. Map-Reduce über
|
||
/// längere Inputs liegt im Aufrufer-Pfad.
|
||
public actor AppleFMBackend: LLMBackend {
|
||
public let identifier: LLMBackendID = .appleFM
|
||
|
||
public init() {}
|
||
|
||
public func availability() async -> LLMAvailability {
|
||
#if canImport(FoundationModels)
|
||
if #available(iOS 26.0, macOS 26.0, *) {
|
||
let model = SystemLanguageModel.default
|
||
switch model.availability {
|
||
case .available:
|
||
return .available
|
||
case let .unavailable(reason):
|
||
switch reason {
|
||
case .deviceNotEligible:
|
||
return .unavailableDeviceNotEligible
|
||
case .modelNotReady:
|
||
return .unavailableModelNotReady
|
||
case .appleIntelligenceNotEnabled:
|
||
return .unavailableAppleIntelligenceNotEnabled
|
||
@unknown default:
|
||
return .unknown(String(describing: reason))
|
||
}
|
||
}
|
||
}
|
||
return .unavailableOSTooOld
|
||
#else
|
||
return .unavailableOSTooOld
|
||
#endif
|
||
}
|
||
|
||
public func prepare(
|
||
onProgress: @Sendable @escaping (LLMPrepareUpdate) -> Void
|
||
) async throws {
|
||
onProgress(LLMPrepareUpdate(stage: .checking, fractionCompleted: 0))
|
||
_ = await availability()
|
||
// Kein expliziter prepare-Pfad — Apple managt das. "ready"
|
||
// unabhängig vom Availability-Wert; der UI-Text kommt
|
||
// separat aus `LLMRouter.availabilityMap()`.
|
||
onProgress(LLMPrepareUpdate(stage: .ready, fractionCompleted: 1.0))
|
||
}
|
||
|
||
// MARK: - Generic generate
|
||
|
||
public func generate(
|
||
prompt: String,
|
||
instructions: String?,
|
||
maxTokens _: Int
|
||
) async -> String? {
|
||
let trimmed = prompt.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !trimmed.isEmpty else { return nil }
|
||
#if canImport(FoundationModels)
|
||
if #available(iOS 26.0, macOS 26.0, *) {
|
||
return await runFoundationModelsGenerate(
|
||
prompt: clip(trimmed),
|
||
instructions: instructions
|
||
)
|
||
}
|
||
return nil
|
||
#else
|
||
return nil
|
||
#endif
|
||
}
|
||
|
||
// MARK: - Summary (Memoro-optimierter Pfad mit @Generable)
|
||
|
||
public func summarize(transcript: String) async -> LLMSummary? {
|
||
let trimmed = transcript.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
guard !trimmed.isEmpty else { return nil }
|
||
#if canImport(FoundationModels)
|
||
if #available(iOS 26.0, macOS 26.0, *) {
|
||
return await runFoundationModelsSummary(transcript: clip(trimmed))
|
||
}
|
||
return nil
|
||
#else
|
||
return nil
|
||
#endif
|
||
}
|
||
|
||
/// Token-Window-Heuristik: ~4 chars / Token bei Deutsch, wir behalten
|
||
/// ~3000 chars (~750 Tokens Prompt) damit Instructions + Response
|
||
/// Platz haben.
|
||
private func clip(_ text: String) -> String {
|
||
let max = 3000
|
||
guard text.count > max else { return text }
|
||
return String(text.prefix(max))
|
||
}
|
||
|
||
#if canImport(FoundationModels)
|
||
@available(iOS 26.0, macOS 26.0, *)
|
||
private func runFoundationModelsGenerate(
|
||
prompt: String,
|
||
instructions: String?
|
||
) async -> String? {
|
||
let session: LanguageModelSession
|
||
if let instructions, !instructions.isEmpty {
|
||
session = LanguageModelSession(instructions: Instructions(instructions))
|
||
} else {
|
||
session = LanguageModelSession()
|
||
}
|
||
do {
|
||
let response = try await session.respond(to: Prompt(prompt))
|
||
let text = response.content.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
LLMLog.backend.notice(
|
||
"AppleFM generate OK (\(text.count, privacy: .public) chars)"
|
||
)
|
||
return text
|
||
} catch {
|
||
let message = String(describing: error)
|
||
LLMLog.backend.error(
|
||
"AppleFM generate failed: \(message, privacy: .public)"
|
||
)
|
||
return nil
|
||
}
|
||
}
|
||
|
||
@available(iOS 26.0, macOS 26.0, *)
|
||
private func runFoundationModelsSummary(transcript: String) async -> LLMSummary? {
|
||
let instructions = Instructions(
|
||
"Du bist ein deutscher Assistent, der gesprochene Sprachmemos kurz "
|
||
+ "zusammenfasst. Antworte auf Deutsch, ohne Floskeln, ohne Anrede."
|
||
)
|
||
let session = LanguageModelSession(instructions: instructions)
|
||
let prompt = Prompt(
|
||
"Hier ist das Transkript einer Sprachmemo. Erzeuge eine prägnante "
|
||
+ "Überschrift (maximal 80 Zeichen, kein Punkt am Ende, "
|
||
+ "keine Anführungszeichen) und ein einleitendes Intro von 1–2 "
|
||
+ "Sätzen, das den Kern der Memo wiedergibt. Antworte ausschließlich "
|
||
+ "im geforderten Schema.\n\nTranskript:\n\(transcript)"
|
||
)
|
||
do {
|
||
let response = try await session.respond(
|
||
to: prompt,
|
||
generating: GeneratedSummary.self
|
||
)
|
||
let summary = response.content
|
||
let trimSet = CharacterSet(
|
||
charactersIn: "\"\u{201E}\u{201C}\u{201D}.\u{00BB}\u{00AB}"
|
||
)
|
||
let cleanHeadline = summary.headline
|
||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
.trimmingCharacters(in: trimSet)
|
||
let cleanIntro = summary.intro
|
||
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||
LLMLog.backend.notice(
|
||
"AppleFM summary OK (headline=\(cleanHeadline.count, privacy: .public)c, intro=\(cleanIntro.count, privacy: .public)c)"
|
||
)
|
||
return LLMSummary(
|
||
headline: String(cleanHeadline.prefix(80)),
|
||
intro: cleanIntro
|
||
)
|
||
} catch {
|
||
let message = String(describing: error)
|
||
LLMLog.backend.error(
|
||
"AppleFM summary failed: \(message, privacy: .public)"
|
||
)
|
||
return nil
|
||
}
|
||
}
|
||
#endif
|
||
}
|
||
|
||
#if canImport(FoundationModels)
|
||
@available(iOS 26.0, macOS 26.0, *)
|
||
@Generable
|
||
private struct GeneratedSummary {
|
||
@Guide(
|
||
description: "Prägnante Überschrift auf Deutsch, maximal 80 Zeichen, ohne Punkt am Ende, ohne Anführungszeichen."
|
||
)
|
||
var headline: String
|
||
|
||
@Guide(description: "Einleitung in 1–2 deutschen Sätzen, die den Kern der Sprachmemo zusammenfasst.")
|
||
var intro: String
|
||
}
|
||
#endif
|