Files
hyungi_document_server/Sources/AI/AIRouter.swift
T
Hyungi 17f8830d37 feat(ds-app): freeze S1 contract + S2 AIProvider interface baseline
S1 = contract/CONTRACT.md + 14 fixtures + README + AI-ROUTING.
S2 = Sources/AI/{AIProvider,AIRouter,MockAIProvider} + Providers skeletons.
Baseline before S3 (device app) scaffold work begins.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 15:27:24 +09:00

100 lines
4.1 KiB
Swift

// AIRouter.swift provider (3 fallback: explicit > rule > error)
//
// = feedback_task_routing_hybrid + feedback_no_silent_fallback_explicit_opt_in:
// 1) provider , ****( fallback ).
// 2) (rule), fallback routingNote .
// 3) noProviderAvailable.
import Foundation
public struct AIRoutingPolicy: Sendable {
/// provider . , / ( fallback).
public var preference: [AITask: [AIProviderID]]
public init(preference: [AITask: [AIProviderID]]) {
self.preference = preference
}
public func chain(for task: AITask) -> [AIProviderID] {
preference[task] ?? [.onDevice, .localMLX, .remoteDS]
}
/// .
/// / = , = , = DS, = GPU .
public static let `default` = AIRoutingPolicy(preference: [
.quickSummarize: [.onDevice, .localMLX],
.memoAssist: [.onDevice, .localMLX],
.askSelection: [.onDevice, .localMLX, .remoteDS],
.corpusAsk: [.remoteDS], //
.classify: [.localMLX, .remoteDS, .onDevice],
.vision: [.specialized, .onDevice],
])
}
public enum AIRoutingError: Error, Sendable {
case providerNotConfigured(AIProviderID)
case explicitProviderUnavailable(AIProviderID) // fallback
case noProviderAvailable(AITask)
}
public struct AIRouter: Sendable {
public let providers: [AIProviderID: any AIProvider]
public let policy: AIRoutingPolicy
/// (silent skip fallback). .
public let log: @Sendable (String) -> Void
public init(
providers: [AIProviderID: any AIProvider],
policy: AIRoutingPolicy = .default,
log: @escaping @Sendable (String) -> Void = { _ in }
) {
self.providers = providers
self.policy = policy
self.log = log
}
public func route(_ request: AICompletionRequest) async throws -> AICompletionResponse {
// 1) opt-in fallback .
if let explicit = request.explicitProvider {
guard let provider = providers[explicit] else {
throw AIRoutingError.providerNotConfigured(explicit)
}
guard await provider.isAvailable else {
log("explicit provider \(explicit.rawValue) unavailable — no fallback (opt-in)")
throw AIRoutingError.explicitProviderUnavailable(explicit)
}
return try await provider.complete(request)
}
// 2) rule-based fallback routingNote .
let chain = policy.chain(for: request.task)
var attempted: [AIProviderID] = []
var lastError: Error?
for id in chain {
guard let provider = providers[id] else { continue }
guard await provider.isAvailable else {
log("provider \(id.rawValue) unavailable for \(request.task.rawValue) — trying next")
attempted.append(id)
continue
}
do {
var response = try await provider.complete(request)
if !attempted.isEmpty {
response.routingNote = "fallback from \(attempted.map(\.rawValue).joined(separator: ","))\(id.rawValue)"
}
return response
} catch {
log("provider \(id.rawValue) failed for \(request.task.rawValue): \(error) — trying next")
attempted.append(id)
lastError = error
continue
}
}
// 3) .
if let lastError { throw lastError }
throw AIRoutingError.noProviderAvailable(request.task)
}
}