5383a93f98
risk-first 채움(RemoteDS→LocalMLX→OnDevice→Specialized) + makeDefaultRouter 컴포지션 루트. 동결 인터페이스(AIProvider/AIRouter/MockAIProvider) 무변경. SPM AIFabric 단독 빌드·테스트(46 PASS). - RemoteDS: DSAskClient seam + AskResponse(ask.json) 매핑 + backend exhaustive switch(qwen/cloud TODO) - LocalMLX: GET /v1/models probe + OpenAI /v1/chat/completions system/user call-shape + non-200 backendError - OnDevice: FoundationModels 라이브(M5 Max) availability + respond() + GenerationError 9-case 매핑 + stateless/prewarm - Specialized: scaffold-only(명시 unavailable, vision 폴백 가시화), cloud='claude-cloud' 503 - config 단일소스(env override) + 타임아웃/취소(URLSession 자동 honor, OnDevice 협조적) 실측 동결(S2-3a, M5 Max): availability=available · 취소=COOPERATIVE(~33ms) · 오버플로=exceededContextWindowSize · GenerationError 9-case(refusal·concurrentRequests 추가 발견, plan 정정). 한계: LocalMLX fixture=PROVISIONAL_SYNTHETIC(맥미니 offline → 라이브 재캡처 S2-Ff 대기). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
156 lines
6.6 KiB
Swift
156 lines
6.6 KiB
Swift
// RemoteDSProvider.swift — S2 구현 (원격 DS 코퍼스 RAG).
|
|
//
|
|
// S1 계약과 만나는 다리 (CONTRACT.md §4 / AI-ROUTING.md §4):
|
|
// complete(corpusAsk) → DSAskClient.ask(query:backend:) → AskResponse → AICompletionResponse
|
|
// AskResponse.ai_answer → text
|
|
// AskResponse.citations[] → [AICitation]
|
|
// AskResponse.synthesis_status → AIFinishReason
|
|
// AskResponse.confidence → AIConfidence
|
|
// AskResponse.backend_used → routingNote (어느 LLM 이 응답했는지)
|
|
//
|
|
// HTTP 는 S3 의 구체 client(LiveDSClient)가 소유 — S2 는 DSAskClient 프로토콜 seam + 매핑만.
|
|
// 인터페이스 동결: AIProvider 프로토콜은 불변. RemoteDSProvider.init(client:) 은 S2 가 채우는 구현부.
|
|
import Foundation
|
|
|
|
// MARK: - S2 가 소유하는 DS ask seam (구체 impl = S3)
|
|
|
|
/// DS `GET /search/ask?q=&backend=` 호출 추상화. S3 의 LiveDSClient 가 conform,
|
|
/// S2 는 mock 으로 단위테스트(라이브 네트워크 0). HTTP 실패는 conformer 가 throw
|
|
/// (권장: `AIProviderError.backendError(.remoteDS, status:, reason:)`) — 침묵 폴백 금지.
|
|
public protocol DSAskClient: Sendable {
|
|
func ask(query: String, backend: String) async throws -> AskResponse
|
|
}
|
|
|
|
// MARK: - DS /search/ask 응답 (부분 미러, 디코딩 전용)
|
|
//
|
|
// 명시 CodingKeys — convertFromSnakeCase 금지(S3 모델 규약과 일관). fixture: contract/fixtures/ask.json.
|
|
|
|
public struct AskResponse: Decodable, Sendable {
|
|
public let aiAnswer: String
|
|
public let citations: [AskCitation]
|
|
public let synthesisStatus: String
|
|
public let synthesisMs: Double?
|
|
public let confidence: String?
|
|
public let backendUsed: String?
|
|
public let refused: Bool?
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case aiAnswer = "ai_answer"
|
|
case citations
|
|
case synthesisStatus = "synthesis_status"
|
|
case synthesisMs = "synthesis_ms"
|
|
case confidence
|
|
case backendUsed = "backend_used"
|
|
case refused
|
|
}
|
|
|
|
public init(aiAnswer: String, citations: [AskCitation], synthesisStatus: String,
|
|
synthesisMs: Double? = nil, confidence: String? = nil,
|
|
backendUsed: String? = nil, refused: Bool? = nil) {
|
|
self.aiAnswer = aiAnswer
|
|
self.citations = citations
|
|
self.synthesisStatus = synthesisStatus
|
|
self.synthesisMs = synthesisMs
|
|
self.confidence = confidence
|
|
self.backendUsed = backendUsed
|
|
self.refused = refused
|
|
}
|
|
}
|
|
|
|
public struct AskCitation: Decodable, Sendable {
|
|
public let n: Int
|
|
public let docId: Int
|
|
public let title: String?
|
|
public let sectionTitle: String?
|
|
public let spanText: String
|
|
|
|
enum CodingKeys: String, CodingKey {
|
|
case n
|
|
case docId = "doc_id"
|
|
case title
|
|
case sectionTitle = "section_title"
|
|
case spanText = "span_text"
|
|
}
|
|
|
|
public init(n: Int, docId: Int, title: String?, sectionTitle: String?, spanText: String) {
|
|
self.n = n
|
|
self.docId = docId
|
|
self.title = title
|
|
self.sectionTitle = sectionTitle
|
|
self.spanText = spanText
|
|
}
|
|
}
|
|
|
|
// MARK: - Provider
|
|
|
|
public struct RemoteDSProvider: AIProvider {
|
|
public let id: AIProviderID = .remoteDS
|
|
private let client: DSAskClient
|
|
|
|
public init(client: DSAskClient) {
|
|
self.client = client
|
|
}
|
|
|
|
/// 원격 코퍼스는 항상 후보(라우팅 시맨틱). 실제 도달 실패는 complete 에서 표면화.
|
|
public var isAvailable: Bool {
|
|
get async { true }
|
|
}
|
|
|
|
public func complete(_ request: AICompletionRequest) async throws -> AICompletionResponse {
|
|
// corpusAsk 외 태스크는 이 provider 의 책임 아님(라우터가 거름).
|
|
guard request.task == .corpusAsk else {
|
|
throw AIProviderError.notImplemented(id)
|
|
}
|
|
try Task.checkCancellation()
|
|
let backend = Self.dsBackend(for: request.explicitProvider)
|
|
// HTTP 실패(503 등)는 client 가 throw → 그대로 전파(자동 로컬 폴백 금지).
|
|
let response = try await client.ask(query: request.prompt, backend: backend)
|
|
return Self.map(response)
|
|
}
|
|
|
|
// MARK: 매핑 (AI-ROUTING.md §4, 고정)
|
|
|
|
static func map(_ r: AskResponse) -> AICompletionResponse {
|
|
let citations = r.citations.map {
|
|
AICitation(n: $0.n, docId: $0.docId, title: $0.title,
|
|
sectionTitle: $0.sectionTitle, spanText: $0.spanText)
|
|
}
|
|
return AICompletionResponse(
|
|
text: r.aiAnswer,
|
|
providerUsed: .remoteDS,
|
|
finishReason: finishReason(fromSynthesisStatus: r.synthesisStatus),
|
|
citations: citations,
|
|
confidence: r.confidence.flatMap(AIConfidence.init(rawValue:)),
|
|
latencyMs: r.synthesisMs, // latency 는 synthesis_ms 만 기록(하드 게이트 없음)
|
|
routingNote: r.backendUsed // 어느 LLM 이 응답했는지
|
|
)
|
|
}
|
|
|
|
static func finishReason(fromSynthesisStatus status: String) -> AIFinishReason {
|
|
switch status {
|
|
case "completed": return .completed
|
|
case "timeout": return .timeout
|
|
case "no_evidence", "skipped": return .noEvidence
|
|
case "backend_unavailable": return .unavailable
|
|
default: return .refused
|
|
}
|
|
}
|
|
|
|
/// explicitProvider → DS 합성 backend (AI-ROUTING.md §4, 고정).
|
|
/// **dict 아닌 exhaustive switch** — 미래 AIProviderID 추가 시 컴파일러가 backend 결정을 강제
|
|
/// (미매핑 provider → nil → 미정의 backend → 404 침묵실패를 컴파일 타임에 차단).
|
|
static func dsBackend(for explicit: AIProviderID?) -> String {
|
|
guard let explicit else { return "mac-mini-default" } // 미지정 → DS 기본
|
|
switch explicit {
|
|
case .localMLX: return "gemma-macmini"
|
|
case .remoteDS: return "mac-mini-default" // 명시 remoteDS = DS 기본 합성
|
|
case .onDevice: return "mac-mini-default" // onDevice 는 코퍼스 합성 불가 → DS 기본
|
|
case .specialized: return "mac-mini-default" // specialized 코퍼스 backend 없음 → DS 기본
|
|
}
|
|
// TODO(qwen-macbook): 현재 어떤 AIProviderID 도 'qwen-macbook'(M5 Max Qwen VLM) 로 매핑 안 됨.
|
|
// 해당 provider case 가 생기면 위 exhaustive switch 가 컴파일 실패 → backend 결정 강제(S2-1b 게이트 b).
|
|
// TODO(claude-cloud): cloud backend = 'claude-cloud' 는 DS 가 503(scaffold, S2-4b). 매핑하는 case 없음.
|
|
// 503 은 client 가 backendError(.remoteDS, status:503, …) 로 표면화 — 절대 로컬 침묵 폴백 X(과금 버킷 분리).
|
|
}
|
|
}
|