mana-swift-llm/Sources/ManaLLM/AppleFMBackend.swift
till fd376bbdce L-1+L-2+L-3: mana-swift-llm Initial — lift aus memoro-native
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>
2026-05-18 22:55:32 +02:00

192 lines
6 KiB
Swift
Raw Permalink 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.

#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 12 "
+ "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 12 deutschen Sätzen, die den Kern der Sprachmemo zusammenfasst.")
var intro: String
}
#endif