mana-swift-llm/Sources/ManaLLM/ManaLLM.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

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]
}
}
}