diff --git a/clients/ds-app/.gitignore b/clients/ds-app/.gitignore new file mode 100644 index 0000000..405c589 --- /dev/null +++ b/clients/ds-app/.gitignore @@ -0,0 +1,28 @@ +# macOS +.DS_Store + +# Swift / SwiftPM +.build/ +.swiftpm/ +Package.resolved + +# Xcode +DerivedData/ +build/ +# A-6: generated by `xcodegen generate` from project.yml (the source of truth). Regenerate, don't commit. +*.xcodeproj/ +Support/Info.plist +Support/DSApp.entitlements +*.xcodeproj/project.xcworkspace/xcuserdata/ +*.xcodeproj/xcuserdata/ +*.xcworkspace/xcuserdata/ +*.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +xcuserdata/ +*.moved-aside +*.hmap +*.ipa +*.dSYM.zip +*.dSYM + +# Misc +*.swp diff --git a/clients/ds-app/App/DSApp.swift b/clients/ds-app/App/DSApp.swift new file mode 100644 index 0000000..fbba2c8 --- /dev/null +++ b/clients/ds-app/App/DSApp.swift @@ -0,0 +1,24 @@ +import SwiftUI +import AppFeature + +/// Thin @main entry: window + DI only. Injects AppModel (FixtureDSClient + AIRouter(MockAIProvider)) +/// so the whole pipeline renders with zero real backend / zero real LLM. Feature logic lives in +/// AppFeature, keeping the seam to a future Xcode/iPhone target trivial. +@main +struct DSApp: App { + @State private var model: AppModel + + @MainActor + init() { + _model = State(initialValue: AppModel.preview) + } + + var body: some Scene { + WindowGroup { + RootView() + .environment(model) + .frame(minWidth: 980, minHeight: 640) + } + .windowStyle(.automatic) + } +} diff --git a/clients/ds-app/Package.swift b/clients/ds-app/Package.swift new file mode 100644 index 0000000..f73f6cd --- /dev/null +++ b/clients/ds-app/Package.swift @@ -0,0 +1,57 @@ +// swift-tools-version: 6.2 +// +// DS multidevice app — unified package (S2 AIFabric + S3 app), one repo / one manifest. +// +// AIFabric (Sources/AI, S2-owned) is compiled exactly once as a single target here, so the +// "소비모델 b" duplicate-symbol concern (two packages each compiling Sources/AI) does not arise. +// It is also exposed as a library product so a future separate app package could depend on it. +// +// Ownership boundary unchanged: S2 owns Sources/AI/** + Tests/AITests/**; S3 owns DSKit / AppFeature +// / Tests/DSKitTests. S3 consumes AIFabric read-only. +// +// A-6 (Xcode .app target via xcodegen): the runnable macOS app shell lives in App/DSApp.swift, owned +// by the xcodegen "DSApp" application target (project.yml), NOT by SPM. SPM here builds only the +// libraries + tests, so `swift build` / `swift test` stay backend-free and the executable lives in +// the real .app bundle (Info.plist + entitlements) where Cmd+R actually opens a window. The App +// target depends on the AppFeature library product below. +import PackageDescription + +let package = Package( + name: "DSApp", + platforms: [ + .macOS(.v26), // FoundationModels (OnDeviceProvider) + 3-column NavigationSplitView + ], + products: [ + .library(name: "AIFabric", targets: ["AIFabric"]), + // Consumed by the xcodegen "DSApp" .app target (App/DSApp.swift). + .library(name: "AppFeature", targets: ["AppFeature"]), + ], + targets: [ + .target( + name: "AIFabric", + path: "Sources/AI", + swiftSettings: [.swiftLanguageMode(.v6)] + ), + .target( + name: "DSKit", + resources: [.process("Resources")], + swiftSettings: [.swiftLanguageMode(.v6)] + ), + .target( + name: "AppFeature", + dependencies: ["DSKit", "AIFabric"], + swiftSettings: [.swiftLanguageMode(.v6)] + ), + .testTarget( + name: "DSKitTests", + dependencies: ["DSKit", "AIFabric"], + swiftSettings: [.swiftLanguageMode(.v6)] + ), + .testTarget( + name: "AITests", + dependencies: ["AIFabric"], + path: "Tests/AITests", + swiftSettings: [.swiftLanguageMode(.v6)] + ), + ] +) diff --git a/clients/ds-app/Sources/AI/AIProvider.swift b/clients/ds-app/Sources/AI/AIProvider.swift new file mode 100644 index 0000000..c99db10 --- /dev/null +++ b/clients/ds-app/Sources/AI/AIProvider.swift @@ -0,0 +1,143 @@ +// AIProvider.swift — S2 인터페이스 동결 (멀티디바이스 LLM 라우팅 패브릭) +// +// 이 파일이 앱(S3)이 코딩하는 "AI 호출 모양"을 동결한다. +// S3 는 이 프로토콜 + MockAIProvider 로 빌드하고, S2 가 실제 provider +// (OnDevice / LocalMLX / RemoteDS / Specialized)를 그 뒤에서 채운다. +// +// Foundation 외 의존 0 → 표준 출발선. 실제 구현(FoundationModels / MLX / URLSession)은 +// Providers/ 의 각 스켈레톤에서 S2 가 결선. + +import Foundation + +// MARK: - Provider 식별 (디바이스 역할 = 인프라 패브릭과 1:1) + +public enum AIProviderID: String, Codable, Sendable, CaseIterable { + /// 맥북·아이폰 온디바이스 (Apple Foundation Models). 즉답·오프라인·프라이버시. + case onDevice + /// 맥미니 메인 로컬 LLM 허브 (Gemma 4 26B, MLX :8801 / llm-router :8890). 무거운 생성. + case localMLX + /// 원격 DS 코퍼스 RAG (`GET /search/ask`). 전체 지식베이스 질의 — 온디바이스 불가 영역. + case remoteDS + /// GPU 특화 통로 (rerank / embed / vision / OCR). 온디맨드. + case specialized +} + +// MARK: - 태스크 분류 (앱이 AI 에 요청하는 일의 종류 → 라우팅 기준) + +public enum AITask: String, Codable, Sendable { + /// 선택 텍스트/문서 빠른 요약. + case quickSummarize + /// 메모 정리·제목 생성·태그 제안. + case memoAssist + /// 현재 문서/선택에 대한 질문 (로컬 컨텍스트, 코퍼스 불필요). + case askSelection + /// 전체 지식베이스 RAG 질의 (코퍼스 필요 → 원격 DS). + case corpusAsk + /// 문서 분류 (도메인/카테고리 제안). + case classify + /// 이미지/스캔 PDF 이해 (비전). + case vision +} + +public enum AIConfidence: String, Codable, Sendable { + case high, medium, low +} + +public enum AIFinishReason: String, Codable, Sendable { + case completed // 정상 완료 + case refused // 모델이 답변 거부 (근거 부족 등) + case timeout // 생성 지연으로 생략 + case unavailable // provider 사용 불가 (503 계열) + case noEvidence // (corpusAsk) 관련 근거 없음 +} + +// MARK: - 요청 / 응답 + +public struct AICompletionRequest: Sendable { + public var task: AITask + public var prompt: String + /// 선택 텍스트·문서 본문 등 로컬 컨텍스트 (corpusAsk 는 보통 nil — 코퍼스가 컨텍스트). + public var context: String? + public var systemPrompt: String? + public var maxTokens: Int? + /// 사용자가 명시적으로 고른 provider. 지정 시 **자동 fallback 금지**(명시 opt-in). + public var explicitProvider: AIProviderID? + + public init( + task: AITask, + prompt: String, + context: String? = nil, + systemPrompt: String? = nil, + maxTokens: Int? = nil, + explicitProvider: AIProviderID? = nil + ) { + self.task = task + self.prompt = prompt + self.context = context + self.systemPrompt = systemPrompt + self.maxTokens = maxTokens + self.explicitProvider = explicitProvider + } +} + +/// corpusAsk 응답의 근거 — DS `Citation`(CONTRACT.md §4)에서 매핑. +public struct AICitation: Codable, Sendable, Identifiable { + public var id: Int { n } + public var n: Int + public var docId: Int + public var title: String? + public var sectionTitle: String? + public var spanText: String + + 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 + } +} + +public struct AICompletionResponse: Sendable { + public var text: String + public var providerUsed: AIProviderID + public var finishReason: AIFinishReason + public var citations: [AICitation] + public var confidence: AIConfidence? + public var latencyMs: Double? + /// 라우팅 가시성 — rule-based fallback 으로 1차 선택이 빗나갔을 때 채워짐(silent 금지). + public var routingNote: String? + + public init( + text: String, + providerUsed: AIProviderID, + finishReason: AIFinishReason = .completed, + citations: [AICitation] = [], + confidence: AIConfidence? = nil, + latencyMs: Double? = nil, + routingNote: String? = nil + ) { + self.text = text + self.providerUsed = providerUsed + self.finishReason = finishReason + self.citations = citations + self.confidence = confidence + self.latencyMs = latencyMs + self.routingNote = routingNote + } +} + +// MARK: - 프로토콜 (S2 가 구현, S3 가 소비) + +public protocol AIProvider: Sendable { + var id: AIProviderID { get } + /// 능력 프로브 (온디바이스 Apple Intelligence 가용? 맥미니 도달 가능? 등). + var isAvailable: Bool { get async } + func complete(_ request: AICompletionRequest) async throws -> AICompletionResponse +} + +public enum AIProviderError: Error, Sendable { + case notImplemented(AIProviderID) + case unavailable(AIProviderID) + case backendError(AIProviderID, status: Int, reason: String?) +} diff --git a/clients/ds-app/Sources/AI/AIRouter.swift b/clients/ds-app/Sources/AI/AIRouter.swift new file mode 100644 index 0000000..75a5688 --- /dev/null +++ b/clients/ds-app/Sources/AI/AIRouter.swift @@ -0,0 +1,99 @@ +// 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) + } +} diff --git a/clients/ds-app/Sources/AI/Composition.swift b/clients/ds-app/Sources/AI/Composition.swift new file mode 100644 index 0000000..7296206 --- /dev/null +++ b/clients/ds-app/Sources/AI/Composition.swift @@ -0,0 +1,82 @@ +// Composition.swift — S2 → S3 통합 진입점 (소비모델 b) + 엔드포인트 단일소스 config. +// +// INTEGRATION (소비모델 b): 앱(S3)은 이 패키지의 `AIFabric` product 에 **로컬 SwiftPM 의존**으로 붙고, +// Sources/AI 를 앱 타깃에 직접 포함하지 않는다(소스 이중소유/중복심볼 방지). S3 는 makeDefaultRouter(...) +// 하나로 실 라우터를 와이어링하고 MockAIProvider 를 대체한다. 구체 DSAskClient(HTTP) = S3 소유. +// +// 엔드포인트 단일소스(S2-Fa): raw URL 산재 금지 — 부주의한 편집의 침묵 엔드포인트 swap 방지 +// (2026-05-17 Hermes incident 선례). env override → 검증된 기본값. ([[feedback_hermes_config_single_source_envvar]]) +import Foundation +import os + +public struct AIProviderConfiguration: Sendable { + /// 맥미니 llm-router base (trailing slash 없는 base; 경로는 provider 가 append). + public var localMLXBaseURL: URL + /// llm-router 모델 별칭(라이브 확정 2026-06-05: 'mac-mini-default' → gemma-4-26b resolve). + public var localMLXModel: String + /// DS API base — S3 의 DSAskClient 가 사용. 공인 https://document.hyungi.net/api · 내부 http://100.110.63.63:8000/api. + /// 주의: DS `/search/ask` 는 **trailing slash 필수**(경로 결합 시 S3 client 가 보장). + public var dsBaseURL: URL + public var requestTimeout: TimeInterval + public var probeTimeout: TimeInterval + + public init( + localMLXBaseURL: URL, + localMLXModel: String = "mac-mini-default", + dsBaseURL: URL, + requestTimeout: TimeInterval = 60, + probeTimeout: TimeInterval = 2 + ) { + self.localMLXBaseURL = localMLXBaseURL + self.localMLXModel = localMLXModel + self.dsBaseURL = dsBaseURL + self.requestTimeout = requestTimeout + self.probeTimeout = probeTimeout + } + + /// 환경변수 override → 검증된 기본값(단일 source). 키 부재 시 기본값. + public static func resolved( + environment: [String: String] = ProcessInfo.processInfo.environment + ) -> AIProviderConfiguration { + let localMLX = environment["AIFABRIC_LOCALMLX_URL"].flatMap(URL.init(string:)) + ?? URL(string: "http://100.76.254.116:8890")! + let model = environment["AIFABRIC_LOCALMLX_MODEL"] ?? "mac-mini-default" + let ds = environment["AIFABRIC_DS_URL"].flatMap(URL.init(string:)) + ?? URL(string: "https://document.hyungi.net/api")! + return AIProviderConfiguration(localMLXBaseURL: localMLX, localMLXModel: model, dsBaseURL: ds) + } +} + +/// 기본 OSLog 라우팅 훅 — 폴백/스킵을 가시화(silent 금지). S3 도 참조 가능(public). +public enum AIFabricLog { + static let router = Logger(subsystem: "ds-app.AIFabric", category: "AIRouter") + public static let routerHook: @Sendable (String) -> Void = { msg in + router.info("\(msg, privacy: .public)") + } +} + +/// S3 → S2 단일 진입점. 4 provider 전부 등록(vision 체인 가시 폴백 보장) + 기본 정책 + log 훅. +/// - client: S3 가 주입하는 구체 DS ask client(HTTP). +/// - config: 엔드포인트 단일소스(기본 = env override → 검증 기본값). +/// - session: LocalMLX URLSession(기본 .shared; 테스트는 mock 주입). +public func makeDefaultRouter( + client: DSAskClient, + config: AIProviderConfiguration = .resolved(), + session: URLSession = .shared, + policy: AIRoutingPolicy = .default, + log: @escaping @Sendable (String) -> Void = AIFabricLog.routerHook +) -> AIRouter { + let providers: [AIProviderID: any AIProvider] = [ + .remoteDS: RemoteDSProvider(client: client), + .localMLX: LocalMLXProvider( + baseURL: config.localMLXBaseURL, + model: config.localMLXModel, + session: session, + requestTimeout: config.requestTimeout, + probeTimeout: config.probeTimeout + ), + .onDevice: OnDeviceProvider(), + .specialized: SpecializedProvider(), // scaffold(불가) — vision 폴백 가시화 + ] + return AIRouter(providers: providers, policy: policy, log: log) +} diff --git a/clients/ds-app/Sources/AI/MockAIProvider.swift b/clients/ds-app/Sources/AI/MockAIProvider.swift new file mode 100644 index 0000000..69459a7 --- /dev/null +++ b/clients/ds-app/Sources/AI/MockAIProvider.swift @@ -0,0 +1,67 @@ +// MockAIProvider.swift — S3 가 실 LLM 없이 빌드/프리뷰하기 위한 mock. +// +// 어떤 AIProviderID 로도 가장해 태스크별 canned 응답을 반환한다. +// S3 SwiftUI 프리뷰 / UI 테스트가 이걸 주입해 AI 흐름을 끝까지 그릴 수 있다. + +import Foundation + +public struct MockAIProvider: AIProvider { + public let id: AIProviderID + public let available: Bool + /// 인위적 지연(ms) — 로딩 UI 확인용. + public let simulatedLatencyMs: Double + + public init(id: AIProviderID = .onDevice, available: Bool = true, simulatedLatencyMs: Double = 120) { + self.id = id + self.available = available + self.simulatedLatencyMs = simulatedLatencyMs + } + + public var isAvailable: Bool { + get async { available } + } + + public func complete(_ request: AICompletionRequest) async throws -> AICompletionResponse { + guard available else { throw AIProviderError.unavailable(id) } + + switch request.task { + case .quickSummarize: + return AICompletionResponse( + text: "요약(mock): 엘보 내경 가공 시 공차 +0.1/-0.0 관리와 최종 치수 검사 기록이 핵심.", + providerUsed: id, confidence: .high, latencyMs: simulatedLatencyMs + ) + case .memoAssist: + return AICompletionResponse( + text: "제목 제안(mock): 엘보 발주 확인 / 태그: 발주, 엘보, 업무", + providerUsed: id, confidence: .medium, latencyMs: simulatedLatencyMs + ) + case .askSelection: + return AICompletionResponse( + text: "선택 구간 답변(mock): UCS-66 면제 곡선은 재료군과 두께로 MDMT 면제를 판정합니다.", + providerUsed: id, confidence: .high, latencyMs: simulatedLatencyMs + ) + case .corpusAsk: + return AICompletionResponse( + text: "충격시험 면제는 UCS-66 면제 곡선으로 판정합니다 [1]. 설계 응력비가 낮으면 UCS-66.1로 MDMT를 더 낮출 수 있습니다 [1].", + providerUsed: .remoteDS, + citations: [ + AICitation(n: 1, docId: 4912, + title: "ASME Section VIII Div 1 — Impact Test 요건", + sectionTitle: "2. UCS-66 면제 곡선", + spanText: "재료군과 두께에 따라 MDMT에서의 충격시험 면제 여부를 결정한다.") + ], + confidence: .high, latencyMs: 2840 + ) + case .classify: + return AICompletionResponse( + text: "분류 제안(mock): Engineering / 압력용기", + providerUsed: id, confidence: .medium, latencyMs: simulatedLatencyMs + ) + case .vision: + return AICompletionResponse( + text: "비전(mock): 스캔 도면에서 표제란·치수선·용접기호가 식별됨.", + providerUsed: .specialized, confidence: .medium, latencyMs: 900 + ) + } + } +} diff --git a/clients/ds-app/Sources/AI/Providers/LocalMLXProvider.swift b/clients/ds-app/Sources/AI/Providers/LocalMLXProvider.swift new file mode 100644 index 0000000..91c7e91 --- /dev/null +++ b/clients/ds-app/Sources/AI/Providers/LocalMLXProvider.swift @@ -0,0 +1,161 @@ +// LocalMLXProvider.swift — S2 구현 (맥미니 메인 로컬 LLM 허브). +// +// 맥미니 Gemma 4 26B, llm-router :8890 (OpenAI 호환, wake-on-call). 결정 #4: raw MLX :8801 폐기. +// - isAvailable = GET /v1/models 경량 probe(짧은 timeout, wake 미트리거 → '가용인데 콜드' 정상) +// - complete = POST /v1/chat/completions, messages system/user 분리(call-shape 고정) +// +// fixture(llm-router-chat.*.json) = CAPTURED_LIVE (2026-06-05, Tailscale 100.76.254.116:8890 실측). +// 요청 model='mac-mini-default'(별칭) → 응답 model='mlx-community/gemma-4-26b-a4b-it-8bit'. +import Foundation + +public struct LocalMLXProvider: AIProvider { + public let id: AIProviderID = .localMLX + + /// 맥미니 허브 베이스 URL (S2-Fa config 에서 주입). trailing slash 없는 base, 경로는 appendingPathComponent. + public let baseURL: URL + let model: String + let session: URLSession + let requestTimeout: TimeInterval + let probeTimeout: TimeInterval + + public init( + baseURL: URL, + model: String = "mac-mini-default", // llm-router 별칭(라이브 /v1/models 확인) → gemma-4-26b resolve + session: URLSession = .shared, + requestTimeout: TimeInterval = 60, + probeTimeout: TimeInterval = 2 + ) { + self.baseURL = baseURL + self.model = model + self.session = session + self.requestTimeout = requestTimeout + self.probeTimeout = probeTimeout + } + + // MARK: isAvailable — 경량 health probe (wake 미트리거) + + public var isAvailable: Bool { + get async { + var req = URLRequest(url: baseURL.appendingPathComponent("v1/models")) + req.httpMethod = "GET" + req.timeoutInterval = probeTimeout + do { + let (_, resp) = try await session.data(for: req) + guard let http = resp as? HTTPURLResponse else { return false } + return (200..<300).contains(http.statusCode) + } catch { + // timeout/연결오류 → false(throw 아님). probe 는 wake/모델로드 유발 X. + // '가용=응답가능 ≠ 즉답' — 콜드 모델은 첫 complete() 가 로드지연 흡수. + return false + } + } + } + + // MARK: complete — OpenAI 호환 chat/completions + + public func complete(_ request: AICompletionRequest) async throws -> AICompletionResponse { + try Task.checkCancellation() + + var req = URLRequest(url: baseURL.appendingPathComponent("v1/chat/completions")) + req.httpMethod = "POST" + req.setValue("application/json", forHTTPHeaderField: "Content-Type") + req.timeoutInterval = requestTimeout // S2-Fe: 행 걸린 생성 무한정지 방지 + req.httpBody = try Self.encodeRequest(request, model: model) + + let started = Date() + let data: Data + let resp: URLResponse + do { + // URLSession async 는 Task 취소를 자동 honor → CancellationError 전파(S2-Fe). + (data, resp) = try await session.data(for: req) + } catch let e as URLError where e.code == .timedOut { + throw AIProviderError.backendError(id, status: -1, reason: "request timed out after \(Int(requestTimeout))s") + } + + guard let http = resp as? HTTPURLResponse else { + throw AIProviderError.backendError(id, status: -1, reason: "non-HTTP response") + } + guard (200..<300).contains(http.statusCode) else { + // non-200 → backendError (빈 text 침묵 금지). + let reason = String(data: data, encoding: .utf8).map { String($0.prefix(300)) } + throw AIProviderError.backendError(id, status: http.statusCode, reason: reason) + } + + let decoded = try JSONDecoder().decode(OpenAIChatResponse.self, from: data) + guard let choice = decoded.choices.first else { + throw AIProviderError.backendError(id, status: http.statusCode, reason: "no choices in response") + } + return AICompletionResponse( + text: choice.message.content, + providerUsed: .localMLX, + finishReason: Self.finishReason(choice.finishReason), + citations: [], // 로컬 생성 — 코퍼스 인용 없음 + confidence: nil, + latencyMs: Date().timeIntervalSince(started) * 1000, + routingNote: nil // fallback note 는 라우터가 채움 + ) + } + + // MARK: 매핑 + + static func finishReason(_ openAI: String?) -> AIFinishReason { + switch openAI { + case "stop": return .completed + case "length": return .completed // max_tokens 절단 — 정상 완료로 취급 + default: return .completed + } + } + + /// AICompletionRequest → OpenAI chat/completions body. messages system/user 분리(fixture 와 단일 source-of-truth). + /// system.content = systemPrompt ?? "" (plan S2-2c). temperature 는 AICompletionRequest 에 없음(동결) → 미설정(서버 기본). + static func encodeRequest(_ request: AICompletionRequest, model: String) throws -> Data { + let body = OpenAIChatRequest( + model: model, + messages: [ + OpenAIChatRequest.Message(role: "system", content: request.systemPrompt ?? ""), + OpenAIChatRequest.Message(role: "user", content: request.prompt), + ], + maxTokens: request.maxTokens, + stream: false + ) + let enc = JSONEncoder() + enc.outputFormatting = [.sortedKeys] + return try enc.encode(body) + } +} + +// MARK: - OpenAI 호환 wire 타입 (내부) + +struct OpenAIChatRequest: Encodable, Sendable { + struct Message: Encodable, Sendable { + let role: String + let content: String + } + let model: String + let messages: [Message] + let maxTokens: Int? + let stream: Bool + + enum CodingKeys: String, CodingKey { + case model, messages, stream + case maxTokens = "max_tokens" + } +} + +struct OpenAIChatResponse: Decodable, Sendable { + struct Choice: Decodable, Sendable { + struct Message: Decodable, Sendable { + let role: String + let content: String + } + let index: Int? + let message: Message + let finishReason: String? + + enum CodingKeys: String, CodingKey { + case index, message + case finishReason = "finish_reason" + } + } + let choices: [Choice] +} diff --git a/clients/ds-app/Sources/AI/Providers/OnDeviceProvider.swift b/clients/ds-app/Sources/AI/Providers/OnDeviceProvider.swift new file mode 100644 index 0000000..5b21885 --- /dev/null +++ b/clients/ds-app/Sources/AI/Providers/OnDeviceProvider.swift @@ -0,0 +1,196 @@ +// OnDeviceProvider.swift — S2 구현 (맥북·아이폰 온디바이스, Apple FoundationModels). +// +// 라이브 경로(결정 #3): SystemLanguageModel.default.availability(동기) + LanguageModelSession.respond(). +// 모델 ~3B/2-bit QAT → quickSummarize/classify 적합, corpusAsk 무리(라우팅이 corpusAsk 를 RemoteDS 로만 보냄). +// +// 테스트 가능성: 모델 backend 를 seam(OnDeviceModelBackend)으로 주입 → CI/비-AI Mac 은 mock 으로 +// 에러매핑/라우팅 단위테스트, M5 Max 는 라이브 통합테스트(아래 default = FoundationModels). +// +// 실측 동결(S2-3a, M5 Max 2026-06-04): availability=available · respond()→Response.content · +// GenerationError 9 case · 컨텍스트 오버플로→exceededContextWindowSize · 취소=COOPERATIVE(respond() 가 +// mid-flight Task.cancel 협조 → checkCancellation 은 belt-and-suspenders, streamResponse 불필요). +import Foundation +import os + +// MARK: - 중립 타입 (FoundationModels 비의존 — 매핑 로직을 HW 없이 단위테스트) + +public enum OnDeviceAvailability: Sendable, Equatable { + case available + case unavailable(reason: String) +} + +/// FoundationModels.LanguageModelSession.GenerationError 의 중립 미러(9 case + unknown). +/// 라이브 backend 가 SDK 에러를 이걸로 번역 → provider 가 finishReason/throw 로 매핑(순수·테스트 가능). +public enum OnDeviceGenerationError: Error, Sendable, Equatable { + case guardrailViolation + case refusal + case exceededContextWindowSize + case rateLimited + case concurrentRequests + case unsupportedLanguageOrLocale + case unsupportedGuide + case decodingFailure + case assetsUnavailable + case unknown(String) +} + +/// 온디바이스 모델 backend seam. 라이브 = FoundationModelsBackend, 테스트 = mock. +protocol OnDeviceModelBackend: Sendable { + var availability: OnDeviceAvailability { get } + /// 실패는 OnDeviceGenerationError 또는 CancellationError 로 throw. + func generate(prompt: String, systemPrompt: String?, maxTokens: Int?) async throws -> String +} + +// MARK: - Provider + +public struct OnDeviceProvider: AIProvider { + public let id: AIProviderID = .onDevice + private let backend: OnDeviceModelBackend + private let log: @Sendable (String) -> Void + + public init() { + self.backend = Self.makeLiveBackend() + let logger = Logger(subsystem: "ds-app.AIFabric", category: "OnDeviceProvider") + self.log = { msg in logger.warning("\(msg, privacy: .public)") } + } + + /// 테스트 seam — backend/log 주입(HW 비의존 단위테스트). + init(backend: OnDeviceModelBackend, log: @escaping @Sendable (String) -> Void = { _ in }) { + self.backend = backend + self.log = log + } + + public var isAvailable: Bool { + get async { backend.availability == .available } + } + + public func complete(_ request: AICompletionRequest) async throws -> AICompletionResponse { + // belt-and-suspenders: 실제 mid-flight 취소는 respond() 내부가 협조(S2-3a 실측). + try Task.checkCancellation() + let started = Date() + do { + let text = try await backend.generate( + prompt: request.prompt, + systemPrompt: request.systemPrompt, + maxTokens: request.maxTokens + ) + return AICompletionResponse( + text: text, + providerUsed: .onDevice, + finishReason: .completed, + latencyMs: Date().timeIntervalSince(started) * 1000 + ) + } catch let e as OnDeviceGenerationError { + return try mapError(e) + } + // CancellationError 등은 자연 전파. + } + + /// GenerationError 매핑(S2-3c). 거부 = 답변의 일종 → 반환(폴백 X). 그 외 = provider 불가 → throw(라우터가 가시 폴백). + private func mapError(_ e: OnDeviceGenerationError) throws -> AICompletionResponse { + switch e { + case .guardrailViolation, .refusal: + return AICompletionResponse( + text: "", + providerUsed: .onDevice, + finishReason: .refused, + routingNote: "on-device refused (guardrail/refusal)" + ) + case .rateLimited: + // stateless-per-request 인데 발생 = 세션 재사용 버그 신호(가림 금지 → loud + 불가). + log("UNEXPECTED onDevice rateLimited on stateless session — 세션 재사용 버그 의심") + throw AIProviderError.unavailable(id) + case .concurrentRequests: + log("UNEXPECTED onDevice concurrentRequests on stateless session — 세션 재사용 버그 의심") + throw AIProviderError.unavailable(id) + case .exceededContextWindowSize: + log("onDevice context window(4096) exceeded — 라우터가 localMLX 로 폴백") + throw AIProviderError.unavailable(id) + case .unsupportedLanguageOrLocale: + log("onDevice unsupported language/locale — 폴백") + throw AIProviderError.unavailable(id) + case .unsupportedGuide, .decodingFailure, .assetsUnavailable: + throw AIProviderError.unavailable(id) + case .unknown(let detail): + log("onDevice unknown generation error: \(detail)") + throw AIProviderError.unavailable(id) + } + } + + static func makeLiveBackend() -> OnDeviceModelBackend { + #if canImport(FoundationModels) + return FoundationModelsBackend() + #else + return UnavailableBackend(reason: "FoundationModels not importable on this platform") + #endif + } +} + +/// FoundationModels 미가용 플랫폼/SDK 폴백. +struct UnavailableBackend: OnDeviceModelBackend { + let reason: String + var availability: OnDeviceAvailability { .unavailable(reason: reason) } + func generate(prompt: String, systemPrompt: String?, maxTokens: Int?) async throws -> String { + throw OnDeviceGenerationError.unknown("backend unavailable: \(reason)") + } +} + +// MARK: - 라이브 FoundationModels backend (M5 Max / Apple Intelligence) + +#if canImport(FoundationModels) +import FoundationModels + +struct FoundationModelsBackend: OnDeviceModelBackend { + + var availability: OnDeviceAvailability { + switch SystemLanguageModel.default.availability { + case .available: + return .available + case .unavailable(let reason): + switch reason { + case .deviceNotEligible: return .unavailable(reason: "deviceNotEligible") + case .appleIntelligenceNotEnabled: return .unavailable(reason: "appleIntelligenceNotEnabled") + case .modelNotReady: return .unavailable(reason: "modelNotReady") + @unknown default: return .unavailable(reason: "unknownReason") + } + @unknown default: + return .unavailable(reason: "unknown") + } + } + + func generate(prompt: String, systemPrompt: String?, maxTokens: Int?) async throws -> String { + // instructions = init 시점. systemPrompt nil 이면 생략(빈 문자열 금지 — S2-3c, LocalMLX 의 ?? '' 와 구분). + let session: LanguageModelSession + if let systemPrompt { + session = LanguageModelSession(model: .default, instructions: systemPrompt) + } else { + session = LanguageModelSession(model: .default) + } + session.prewarm() // 동기 반환(~1.3ms), 백그라운드 워밍(S2-3a) + // temperature 는 AICompletionRequest 에 없음(동결) → 미설정(모델 기본). LocalMLX 와 동일 정책. + let options = GenerationOptions(maximumResponseTokens: maxTokens) + do { + let response = try await session.respond(to: prompt, options: options) + return response.content // Response.content : String + } catch let g as LanguageModelSession.GenerationError { + throw Self.translate(g) + } + } + + /// SDK GenerationError(9 case) → 중립 OnDeviceGenerationError. exhaustive + @unknown default. + static func translate(_ g: LanguageModelSession.GenerationError) -> OnDeviceGenerationError { + switch g { + case .guardrailViolation: return .guardrailViolation + case .refusal: return .refusal + case .exceededContextWindowSize: return .exceededContextWindowSize + case .rateLimited: return .rateLimited + case .concurrentRequests: return .concurrentRequests + case .unsupportedLanguageOrLocale: return .unsupportedLanguageOrLocale + case .unsupportedGuide: return .unsupportedGuide + case .decodingFailure: return .decodingFailure + case .assetsUnavailable: return .assetsUnavailable + @unknown default: return .unknown("\(g)") + } + } +} +#endif diff --git a/clients/ds-app/Sources/AI/Providers/RemoteDSProvider.swift b/clients/ds-app/Sources/AI/Providers/RemoteDSProvider.swift new file mode 100644 index 0000000..1744e8d --- /dev/null +++ b/clients/ds-app/Sources/AI/Providers/RemoteDSProvider.swift @@ -0,0 +1,155 @@ +// 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(과금 버킷 분리). + } +} diff --git a/clients/ds-app/Sources/AI/Providers/SpecializedProvider.swift b/clients/ds-app/Sources/AI/Providers/SpecializedProvider.swift new file mode 100644 index 0000000..0d11025 --- /dev/null +++ b/clients/ds-app/Sources/AI/Providers/SpecializedProvider.swift @@ -0,0 +1,27 @@ +// SpecializedProvider.swift — S2 scaffold (GPU 특화 통로: rerank / embed / vision / OCR). +// +// 이번 PR = **scaffold-only**: isAvailable=false, complete→notImplemented(.specialized). HTTP client/API key/cost = 0. +// 목적: 라우터 .vision 체인 [.specialized, .onDevice] 에서 specialized 가 **dict 에 부재 → 침묵 continue** 하던 것을, +// 등록된 명시-불가 provider 로 바꿔 폴백을 **가시화**(라우터가 'specialized unavailable → onDevice' log). +// +// 실 vision 통합 = backend 확정 + fixture 캡처 후 **별 PR**(결정 #1): +// 후보 = MacBook M5 Max Qwen VLM http://100.118.112.84:8810 (OpenAI 호환, wake-on-call) — 패브릭 내 유일 생성형 VLM. +// GPU Ollama 는 embedding(bge-m3) 전용이라 생성형 vision 아님. +import Foundation + +public struct SpecializedProvider: AIProvider { + public let id: AIProviderID = .specialized + + public init() {} + + /// scaffold — 미구현. 라우터가 가시 폴백하도록 정직하게 false. + public var isAvailable: Bool { + get async { false } + } + + public func complete(_ request: AICompletionRequest) async throws -> AICompletionResponse { + // 침묵 nil-skip 금지 — 명시 notImplemented 로 vision-chain 폴백을 가시화. + // TODO(vision): .specialized → M5 Max Qwen VLM(:8810, wake-on-call) 실 call-shape + fixture 후 별 PR. + throw AIProviderError.notImplemented(id) + } +} diff --git a/clients/ds-app/Sources/AppFeature/AI/AICompletionView.swift b/clients/ds-app/Sources/AppFeature/AI/AICompletionView.swift new file mode 100644 index 0000000..733e722 --- /dev/null +++ b/clients/ds-app/Sources/AppFeature/AI/AICompletionView.swift @@ -0,0 +1,87 @@ +import SwiftUI +import AIFabric + +public extension AIProviderID { + var displayName: String { + switch self { + case .onDevice: return "온디바이스" + case .localMLX: return "맥미니" + case .remoteDS: return "원격 DS" + case .specialized: return "GPU 특화" + } + } +} + +/// Reusable renderer for any AICompletionResponse: answer text, numbered citations (deep-link to doc), +/// an ALWAYS-visible provider/routing badge (the no-silent-fallback visibility rule), and confidence. +public struct AICompletionView: View { + public let response: AICompletionResponse + public var onOpenDoc: (Int) -> Void + + public init(response: AICompletionResponse, onOpenDoc: @escaping (Int) -> Void) { + self.response = response + self.onOpenDoc = onOpenDoc + } + + public var body: some View { + VStack(alignment: .leading, spacing: 12) { + // Routing badges (mandatory, always shown) + HStack(spacing: 8) { + Chip(response.providerUsed.displayName, Sage.brand) + if let note = response.routingNote { + Chip(note, Sage.amber) + } + if response.finishReason != .completed { + Chip(finishLabel(response.finishReason), Sage.danger) + } + if let c = response.confidence { + Chip("신뢰도 \(confidenceLabel(c))", Sage.muted) + } + if let ms = response.latencyMs { + Text("\(Int(ms)) ms").font(.caption2).foregroundStyle(Sage.muted) + } + } + + // Answer + Text(response.text) + .font(.body) + .foregroundStyle(Sage.ink) + .textSelection(.enabled) + + // Citations + if !response.citations.isEmpty { + VStack(alignment: .leading, spacing: 6) { + Text("출처").font(.caption.weight(.bold)).foregroundStyle(Sage.muted) + ForEach(response.citations) { c in + Button { onOpenDoc(c.docId) } label: { + HStack(alignment: .top, spacing: 8) { + Text("[\(c.n)]").font(.caption.weight(.bold)).foregroundStyle(Sage.brand) + VStack(alignment: .leading, spacing: 2) { + Text(c.title ?? c.sectionTitle ?? "문서 \(c.docId)") + .font(.callout.weight(.medium)).foregroundStyle(Sage.ink) + Text(c.spanText).font(.caption).foregroundStyle(Sage.muted).lineLimit(2) + } + } + } + .buttonStyle(.plain) + } + } + .padding(10) + .background(Sage.surface, in: RoundedRectangle(cornerRadius: 10)) + } + } + } + + private func finishLabel(_ r: AIFinishReason) -> String { + switch r { + case .completed: return "완료" + case .refused: return "거부" + case .timeout: return "시간 초과" + case .unavailable: return "사용 불가" + case .noEvidence: return "근거 없음" + } + } + private func confidenceLabel(_ c: AIConfidence) -> String { + switch c { case .high: return "높음"; case .medium: return "중간"; case .low: return "낮음" } + } +} diff --git a/clients/ds-app/Sources/AppFeature/AI/AIService.swift b/clients/ds-app/Sources/AppFeature/AI/AIService.swift new file mode 100644 index 0000000..cca6c7f --- /dev/null +++ b/clients/ds-app/Sources/AppFeature/AI/AIService.swift @@ -0,0 +1,59 @@ +import Foundation +import AIFabric + +/// Renderable failure (the UI never sees a raw AIRoutingError — that would break the +/// "visible error, not silent fallback" contract). +public enum AIServiceError: Error, Sendable { + case explicitUnavailable(AIProviderID) + case notConfigured(AIProviderID) + case noneAvailable(AITask) + case providerFailed(String) + case unknown(String) +} + +public enum AIResult: Sendable { + case success(AICompletionResponse) + case failure(AIServiceError) +} + +/// The single app-facing facade over the S2 fabric. Views call intent methods; AIService is the ONLY +/// place AICompletionRequest is built. An actor (not @Observable): routing is async work that should +/// serialize and not hop to MainActor. +public actor AIService { + private let router: AIRouter + + public init(router: AIRouter) { self.router = router } + + private func run(_ request: AICompletionRequest) async -> AIResult { + do { + return .success(try await router.route(request)) + } catch let e as AIRoutingError { + switch e { + case .explicitProviderUnavailable(let id): return .failure(.explicitUnavailable(id)) + case .providerNotConfigured(let id): return .failure(.notConfigured(id)) + case .noProviderAvailable(let t): return .failure(.noneAvailable(t)) + } + } catch let e as AIProviderError { + return .failure(.providerFailed("\(e)")) + } catch { + return .failure(.unknown("\(error)")) + } + } + + // Intent methods — UI gesture -> AITask. (vision is deferred: the frozen request is text-only.) + public func summarize(text: String) async -> AIResult { + await run(AICompletionRequest(task: .quickSummarize, prompt: "다음 문서를 요약", context: text)) + } + public func memoAssist(content: String) async -> AIResult { + await run(AICompletionRequest(task: .memoAssist, prompt: "제목과 태그 제안", context: content)) + } + public func askSelection(selection: String, question: String) async -> AIResult { + await run(AICompletionRequest(task: .askSelection, prompt: question, context: selection)) + } + public func corpusAsk(question: String, explicit: AIProviderID? = nil) async -> AIResult { + await run(AICompletionRequest(task: .corpusAsk, prompt: question, context: nil, explicitProvider: explicit)) + } + public func classify(documentText: String) async -> AIResult { + await run(AICompletionRequest(task: .classify, prompt: "도메인/카테고리 분류 제안", context: documentText)) + } +} diff --git a/clients/ds-app/Sources/AppFeature/AI/AppAIComposition.swift b/clients/ds-app/Sources/AppFeature/AI/AppAIComposition.swift new file mode 100644 index 0000000..b957a7d --- /dev/null +++ b/clients/ds-app/Sources/AppFeature/AI/AppAIComposition.swift @@ -0,0 +1,37 @@ +import Foundation +import AIFabric +import DSKit + +/// The ONE composition touch-point that names MockAIProvider. When S2 ships real providers, +/// only this file changes (mockProviders -> realProviders) — AIService, views, and intents stay put. +public enum AppAIComposition { + public static func mockProviders(unavailable: Set = []) -> [AIProviderID: any AIProvider] { + var providers: [AIProviderID: any AIProvider] = [:] + for id in AIProviderID.allCases { + providers[id] = MockAIProvider(id: id, available: !unavailable.contains(id)) + } + return providers + } + + public static func mockRouter(unavailable: Set = []) -> AIRouter { + AIRouter( + providers: mockProviders(unavailable: unavailable), + policy: .default, + log: { msg in + #if DEBUG + print("[route]", msg) + #endif + } + ) + } + + /// FU-B seam: the real S2 fabric (RemoteDS/LocalMLX/OnDevice/Specialized) via `makeDefaultRouter`, + /// fed S3's concrete `LiveDSAskClient`. The app default stays `mockRouter` (offline scaffold); + /// switching to live is THIS one call — AIService, views, and intents are unchanged. + public static func realRouter( + base: DSBaseURL = .publicTLS, + token: @escaping @Sendable () async -> String? = { nil } + ) -> AIRouter { + makeDefaultRouter(client: LiveDSAskClient(base: base, token: token)) + } +} diff --git a/clients/ds-app/Sources/AppFeature/AI/LiveDSAskClient.swift b/clients/ds-app/Sources/AppFeature/AI/LiveDSAskClient.swift new file mode 100644 index 0000000..f18ae9b --- /dev/null +++ b/clients/ds-app/Sources/AppFeature/AI/LiveDSAskClient.swift @@ -0,0 +1,50 @@ +import Foundation +import AIFabric +import DSKit + +/// S3-owned concrete `DSAskClient` (the HTTP seam S2's `RemoteDSProvider` calls). Hits DS +/// `GET /search/ask?q=&backend=` and decodes **AIFabric.AskResponse** (a decode-only type that +/// RemoteDSProvider maps to AICompletionResponse). This is the piece S2's plan assigns to S3 +/// ("구체 DSAskClient(HTTP) = S3 소유"). FU-A: exercised only when the real router is wired. +public struct LiveDSAskClient: DSAskClient { + private let base: DSBaseURL + private let session: URLSession + private let decoder: JSONDecoder + private let token: @Sendable () async -> String? + + public init( + base: DSBaseURL = .publicTLS, + session: URLSession = .shared, + token: @escaping @Sendable () async -> String? = { nil } + ) { + self.base = base + self.session = session + self.decoder = DSDecoder.make() + self.token = token + } + + public func ask(query: String, backend: String) async throws -> AIFabric.AskResponse { + let raw = base.url.absoluteString + "/search/ask" + guard var comps = URLComponents(string: raw) else { + throw DSError.transport(underlying: "bad ask URL") + } + comps.queryItems = [ + URLQueryItem(name: "q", value: query), + URLQueryItem(name: "backend", value: backend), + ] + guard let url = comps.url else { throw DSError.transport(underlying: "bad ask URL components") } + + var request = URLRequest(url: url) + if let token = await token() { + request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") + } + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse else { + throw DSError.transport(underlying: "no HTTP response") + } + guard (200..<300).contains(http.statusCode) else { + throw DSError.from(status: http.statusCode, data: data) + } + return try decoder.decode(AIFabric.AskResponse.self, from: data) + } +} diff --git a/clients/ds-app/Sources/AppFeature/Markdown/MarkdownView.swift b/clients/ds-app/Sources/AppFeature/Markdown/MarkdownView.swift new file mode 100644 index 0000000..dcf1a56 --- /dev/null +++ b/clients/ds-app/Sources/AppFeature/Markdown/MarkdownView.swift @@ -0,0 +1,187 @@ +import SwiftUI + +/// Lightweight block-aware Markdown renderer. SwiftUI's `AttributedString(markdown:)` is INLINE-only — +/// it silently drops block structure including GFM TABLES (the completed fixture's UCS-66 table would +/// vanish). This splits blocks by hand (headings, lists, pipe tables, fenced code, blockquote, +/// paragraphs) and renders each natively; inline emphasis/links use AttributedString per line. +/// Full CommonMark fidelity is a deferred dependency swap (swift-markdown / MarkdownUI). +public struct MarkdownView: View { + let markdown: String + public init(_ markdown: String) { self.markdown = markdown } + + public var body: some View { + VStack(alignment: .leading, spacing: 10) { + ForEach(Array(MarkdownBlock.parse(markdown).enumerated()), id: \.offset) { _, block in + view(for: block) + } + } + } + + @ViewBuilder + private func view(for block: MarkdownBlock) -> some View { + switch block { + case .heading(let level, let text): + inline(text) + .font(headingFont(level)) + .foregroundStyle(Sage.ink) + .padding(.top, level <= 2 ? 6 : 2) + case .paragraph(let text): + inline(text).font(.body).foregroundStyle(Sage.ink) + case .listItem(let text): + HStack(alignment: .top, spacing: 8) { + Text("•").foregroundStyle(Sage.brand) + inline(text).font(.body).foregroundStyle(Sage.ink) + } + case .quote(let text): + inline(text) + .font(.body).foregroundStyle(Sage.muted) + .padding(.leading, 10) + .overlay(Rectangle().fill(Sage.brand).frame(width: 3), alignment: .leading) + case .code(let text): + Text(text) + .font(.system(.callout, design: .monospaced)) + .foregroundStyle(Sage.ink) + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Sage.surface, in: RoundedRectangle(cornerRadius: 8)) + case .table(let header, let rows): + tableView(header: header, rows: rows) + } + } + + private func tableView(header: [String], rows: [[String]]) -> some View { + let columns = max(header.count, rows.map(\.count).max() ?? 0) + return VStack(spacing: 0) { + tableRow(cells: header, columns: columns, isHeader: true) + ForEach(Array(rows.enumerated()), id: \.offset) { _, row in + tableRow(cells: row, columns: columns, isHeader: false) + } + } + .background(Sage.card, in: RoundedRectangle(cornerRadius: 8)) + .overlay(RoundedRectangle(cornerRadius: 8).stroke(Sage.line)) + } + + private func tableRow(cells: [String], columns: Int, isHeader: Bool) -> some View { + HStack(spacing: 0) { + ForEach(0.. Text { + if let attr = try? AttributedString(markdown: s, options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)) { + return Text(attr) + } + return Text(s) + } + + private func headingFont(_ level: Int) -> Font { + switch level { + case 1: return .title2.weight(.bold) + case 2: return .title3.weight(.semibold) + case 3: return .headline + default: return .subheadline.weight(.semibold) + } + } +} + +enum MarkdownBlock { + case heading(Int, String) + case paragraph(String) + case listItem(String) + case quote(String) + case code(String) + case table([String], [[String]]) + + static func parse(_ md: String) -> [MarkdownBlock] { + var blocks: [MarkdownBlock] = [] + let lines = md.components(separatedBy: "\n") + var i = 0 + var para: [String] = [] + + func flushPara() { + if !para.isEmpty { + blocks.append(.paragraph(para.joined(separator: " "))) + para.removeAll() + } + } + + while i < lines.count { + let raw = lines[i] + let line = raw.trimmingCharacters(in: .whitespaces) + + // fenced code + if line.hasPrefix("```") { + flushPara() + var code: [String] = [] + i += 1 + while i < lines.count, !lines[i].trimmingCharacters(in: .whitespaces).hasPrefix("```") { + code.append(lines[i]); i += 1 + } + blocks.append(.code(code.joined(separator: "\n"))) + i += 1 + continue + } + + // GFM pipe table: a header row followed by an alignment separator row (| --- | :--: |) + if line.contains("|"), i + 1 < lines.count, isTableSeparator(lines[i + 1]) { + flushPara() + let header = splitRow(line) + var rows: [[String]] = [] + i += 2 // skip header + separator (the separator is NOT data) + while i < lines.count, lines[i].trimmingCharacters(in: .whitespaces).contains("|"), + !lines[i].trimmingCharacters(in: .whitespaces).isEmpty { + rows.append(splitRow(lines[i])); i += 1 + } + blocks.append(.table(header, rows)) + continue + } + + if line.isEmpty { flushPara(); i += 1; continue } + + if line.hasPrefix("#") { + flushPara() + let level = line.prefix(while: { $0 == "#" }).count + let text = line.drop(while: { $0 == "#" }).trimmingCharacters(in: .whitespaces) + blocks.append(.heading(min(level, 6), text)) + i += 1; continue + } + if line.hasPrefix("> ") { + flushPara() + blocks.append(.quote(String(line.dropFirst(2)))) + i += 1; continue + } + if line.hasPrefix("- ") || line.hasPrefix("* ") || line.hasPrefix("+ ") { + flushPara() + blocks.append(.listItem(String(line.dropFirst(2)))) + i += 1; continue + } + + para.append(line) + i += 1 + } + flushPara() + return blocks + } + + /// A separator row is all dashes/colons/pipes/spaces, e.g. `| --- | :--: |`. + private static func isTableSeparator(_ line: String) -> Bool { + let t = line.trimmingCharacters(in: .whitespaces) + guard t.contains("-"), t.contains("|") else { return false } + return t.allSatisfy { $0 == "-" || $0 == ":" || $0 == "|" || $0 == " " } + } + + private static func splitRow(_ line: String) -> [String] { + var t = line.trimmingCharacters(in: .whitespaces) + if t.hasPrefix("|") { t.removeFirst() } + if t.hasSuffix("|") { t.removeLast() } + return t.components(separatedBy: "|").map { $0.trimmingCharacters(in: .whitespaces) } + } +} diff --git a/clients/ds-app/Sources/AppFeature/Pages/AskView.swift b/clients/ds-app/Sources/AppFeature/Pages/AskView.swift new file mode 100644 index 0000000..b9d4acf --- /dev/null +++ b/clients/ds-app/Sources/AppFeature/Pages/AskView.swift @@ -0,0 +1,85 @@ +import SwiftUI +import AIFabric + +/// RAG proof page: routes corpusAsk through AIService (-> AIRouter -> MockAIProvider). Explicit backend +/// pick sets explicitProvider; an explicit-unavailable result renders a visible, non-retrying error. +struct AskView: View { + @Environment(AppModel.self) private var model + @State private var backend: BackendChoice = .auto + + var body: some View { + @Bindable var model = model + ScrollView { + VStack(alignment: .leading, spacing: 14) { + Picker("백엔드", selection: $backend) { + ForEach(BackendChoice.allCases) { Text($0.label).tag($0) } + } + .pickerStyle(.segmented) + + HStack(spacing: 8) { + TextField("코퍼스 전체에 질문", text: $model.askQuery) + .textFieldStyle(.roundedBorder) + .onSubmit { Task { await model.runAsk(backend: backend.provider) } } + Button("질문") { Task { await model.runAsk(backend: backend.provider) } } + .buttonStyle(.borderedProminent) + } + + if let result = model.askResult { + switch result { + case .success(let response): + AICompletionView(response: response) { docID in + model.section = .documents + Task { await model.openDocument(docID) } + } + if let meta = model.askMeta { + HStack(spacing: 6) { + Chip("완성도 \(meta.completeness)", Sage.muted) + if let aspects = meta.coveredAspects { + ForEach(aspects, id: \.self) { Chip($0, Sage.brand) } + } + } + } + case .failure(let err): + ErrorBanner(text: message(for: err)) + } + } else { + EmptyState(text: "질문을 입력하세요").frame(minHeight: 160) + } + } + .padding(16) + } + .background(Sage.surface) + } + + private func message(for error: AIServiceError) -> String { + switch error { + case .explicitUnavailable(let id): + return "\(id.displayName) 백엔드를 쓸 수 없습니다 — 다른 백엔드로 자동 전환하지 않았습니다. 다른 백엔드를 고르세요." + case .notConfigured(let id): return "\(id.displayName) 백엔드 미구성" + case .noneAvailable: return "응답 가능한 백엔드가 없습니다." + case .providerFailed(let s): return "응답 실패: \(s)" + case .unknown(let s): return "오류: \(s)" + } + } +} + +enum BackendChoice: String, CaseIterable, Identifiable { + case auto, onDevice, localMLX, remoteDS + var id: String { rawValue } + var label: String { + switch self { + case .auto: return "자동" + case .onDevice: return "온디바이스" + case .localMLX: return "맥미니" + case .remoteDS: return "원격 DS" + } + } + var provider: AIProviderID? { + switch self { + case .auto: return nil + case .onDevice: return .onDevice + case .localMLX: return .localMLX + case .remoteDS: return .remoteDS + } + } +} diff --git a/clients/ds-app/Sources/AppFeature/Pages/DashboardView.swift b/clients/ds-app/Sources/AppFeature/Pages/DashboardView.swift new file mode 100644 index 0000000..93901e9 --- /dev/null +++ b/clients/ds-app/Sources/AppFeature/Pages/DashboardView.swift @@ -0,0 +1,38 @@ +import SwiftUI + +/// Corpus-health overview (not a dumped table). Stat hero + domain distribution bars; tapping a +/// domain jumps to Documents (cross-page nav proof). +struct DashboardView: View { + @Environment(AppModel.self) private var model + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + if let s = model.stats { + LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: 12)], spacing: 12) { + StatCard(title: "전체", value: s.total, color: Sage.brand) + StatCard(title: "문서", value: s.documents, color: Sage.brand) + StatCard(title: "검토 대기", value: s.reviewPending, color: Sage.amber) + StatCard(title: "파이프라인 실패", value: s.pipelineFailed, color: Sage.danger) + } + + VStack(alignment: .leading, spacing: 10) { + Text("도메인 분포").font(.headline).foregroundStyle(Sage.ink) + ForEach(s.byDomain.sorted { $0.value > $1.value }, id: \.key) { key, value in + DomainBar(name: key, count: value, max: s.byDomain.values.max() ?? 1) + .contentShape(Rectangle()) + .onTapGesture { model.section = .documents } + } + } + .padding(16) + .background(Sage.card, in: RoundedRectangle(cornerRadius: 14)) + .overlay(RoundedRectangle(cornerRadius: 14).stroke(Sage.line)) + } else { + ProgressView().frame(maxWidth: .infinity, minHeight: 200) + } + } + .padding(20) + } + .background(Sage.surface) + } +} diff --git a/clients/ds-app/Sources/AppFeature/Pages/DigestView.swift b/clients/ds-app/Sources/AppFeature/Pages/DigestView.swift new file mode 100644 index 0000000..2c73bf1 --- /dev/null +++ b/clients/ds-app/Sources/AppFeature/Pages/DigestView.swift @@ -0,0 +1,52 @@ +import SwiftUI + +/// News-brief reading layout (its own treatment): per-country sections of rank-ordered topic cards. +/// date-only displayed from the raw string (no Date conversion → no timezone off-by-one). +struct DigestView: View { + @Environment(AppModel.self) private var model + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + if let digest = model.digest { + VStack(alignment: .leading, spacing: 2) { + Text("뉴스 다이제스트").font(.title2.weight(.bold)).foregroundStyle(Sage.ink) + Text("\(digest.digestDateDisplay) · 기사 \(digest.totalArticles ?? 0)건 · \(digest.totalTopics ?? 0)개 주제") + .font(.caption).foregroundStyle(Sage.muted) + } + + ForEach(digest.countries) { country in + VStack(alignment: .leading, spacing: 8) { + Chip(country.country, Sage.brand) + ForEach(country.topics) { topic in + VStack(alignment: .leading, spacing: 5) { + Text(topic.topicLabel).font(.headline).foregroundStyle(Sage.ink) + Text(topic.summary).font(.callout).foregroundStyle(Sage.muted) + HStack(spacing: 8) { + Text("기사 \(topic.articleCount ?? topic.articles.count)건") + .font(.caption2).foregroundStyle(Sage.muted) + if topic.llmFallbackUsed == true { + Text("fallback").font(.caption2).foregroundStyle(Sage.amber) + } + } + ForEach(topic.articles) { article in + Text("· \(article.title ?? "기사 \(article.id)")") + .font(.caption).foregroundStyle(Sage.ink) + } + } + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Sage.card, in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Sage.line)) + } + } + } + } else { + ProgressView().frame(maxWidth: .infinity, minHeight: 200) + } + } + .padding(20) + } + .background(Sage.surface) + } +} diff --git a/clients/ds-app/Sources/AppFeature/Pages/DocumentsView.swift b/clients/ds-app/Sources/AppFeature/Pages/DocumentsView.swift new file mode 100644 index 0000000..20eb96e --- /dev/null +++ b/clients/ds-app/Sources/AppFeature/Pages/DocumentsView.swift @@ -0,0 +1,91 @@ +import SwiftUI +import DSKit + +struct DocumentListView: View { + @Environment(AppModel.self) private var model + + var body: some View { + let selection = Binding( + get: { model.selectedDocumentID }, + set: { if let id = $0 { Task { await model.openDocument(id) } } } + ) + List(model.documentList, selection: selection) { doc in + DocumentRow(doc: doc) + } + .listStyle(.inset) + .background(Sage.surface) + } +} + +struct DocumentRow: View { + let doc: DocumentResponse + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 6) { + Chip(doc.displayFormat.uppercased(), Sage.formatColor(doc.displayFormat)) + Text(doc.title ?? doc.downloadLabel) + .font(.callout.weight(.medium)).foregroundStyle(Sage.ink).lineLimit(1) + Spacer() + if doc.pinned == true { Text("고정").font(.caption2).foregroundStyle(Sage.amber) } + } + HStack(spacing: 6) { + if let d = doc.aiDomain { Chip(d, Sage.domainColor(d)) } + if let r = doc.reviewStatus { + Text(r).font(.caption2).foregroundStyle(Sage.reviewStatusColor(r)) + } + Spacer() + Text(doc.updatedAtRaw.prefix(10)).font(.caption2.monospacedDigit()).foregroundStyle(Sage.muted) + } + } + .padding(.vertical, 4) + } +} + +/// MD-first detail: render md_content when renderable, else extracted_text fallback + 'MD 변환 대기' +/// badge + emphasized original-download button. (Download builds a real-shaped ?token= URL.) +struct DocumentDetailView: View { + @Environment(AppModel.self) private var model + let detail: DocumentDetailResponse + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 14) { + Text(detail.base.title ?? detail.base.downloadLabel) + .font(.title2.weight(.bold)).foregroundStyle(Sage.ink) + + HStack(spacing: 8) { + if let d = detail.base.aiDomain { Chip(d, Sage.domainColor(d)) } + Chip(detail.base.displayFormat.uppercased(), Sage.formatColor(detail.base.displayFormat)) + if let conf = detail.base.aiConfidence { + Chip("AI \(String(format: "%.0f%%", conf * 100))", Sage.muted) + } + Spacer() + if let url = model.downloadURL(for: detail.base) { + Link(detail.base.downloadLabel, destination: url).font(.callout.weight(.semibold)) + } + } + + if let tags = detail.base.aiTags, !tags.isEmpty { + HStack(spacing: 6) { ForEach(tags, id: \.self) { Chip($0, Sage.brand) } } + } + + Divider() + + if detail.mdIsRenderable, let md = detail.mdContent { + MarkdownView(md) + } else { + HStack { Chip("MD 변환 대기", Sage.amber); Spacer() } + Text(detail.extractedText ?? "본문 없음") + .font(.body).foregroundStyle(Sage.muted) + .frame(maxWidth: .infinity, alignment: .leading) + if let url = model.downloadURL(for: detail.base) { + Link("원본 다운로드 — \(detail.base.downloadLabel)", destination: url) + .font(.callout.weight(.semibold)) + } + } + } + .padding(20) + } + .background(Sage.surface) + } +} diff --git a/clients/ds-app/Sources/AppFeature/Pages/MemosView.swift b/clients/ds-app/Sources/AppFeature/Pages/MemosView.swift new file mode 100644 index 0000000..0c5bc04 --- /dev/null +++ b/clients/ds-app/Sources/AppFeature/Pages/MemosView.swift @@ -0,0 +1,76 @@ +import SwiftUI +import DSKit + +/// Capture-first treatment: quick-capture box + list distinguishing note vs audio. +struct MemoListView: View { + @Environment(AppModel.self) private var model + @State private var draft: String = "" + + var body: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 8) { + TextField("빠른 메모 캡처", text: $draft) + .textFieldStyle(.roundedBorder) + Button("저장") { + let content = draft + draft = "" + Task { _ = try? await model.client.createMemo(MemoCreate(content: content)) } + } + .buttonStyle(.bordered) + .disabled(draft.isEmpty) + } + .padding(12) + + let selection = Binding( + get: { model.selectedMemoID }, + set: { if let id = $0 { Task { await model.openMemo(id) } } } + ) + List(model.memoList, selection: selection) { memo in + VStack(alignment: .leading, spacing: 3) { + HStack(spacing: 6) { + if memo.isAudio { Chip("음성", Sage.formatColor("audio")) } + if memo.aiEventKind == "task" { Chip("할 일", Sage.amber) } + Text(memo.title ?? "메모 \(memo.id)") + .font(.callout.weight(.medium)).foregroundStyle(Sage.ink).lineLimit(1) + Spacer() + if memo.isPinned { Text("고정").font(.caption2).foregroundStyle(Sage.amber) } + } + Text(memo.content ?? "").font(.caption).foregroundStyle(Sage.muted).lineLimit(2) + } + .padding(.vertical, 3) + } + .listStyle(.inset) + } + .background(Sage.surface) + } +} + +struct MemoDetailView: View { + let memo: MemoResponse + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 12) { + Text(memo.title ?? "메모").font(.title2.weight(.bold)).foregroundStyle(Sage.ink) + + if memo.isAudio { + HStack(spacing: 8) { + Image(systemName: "waveform") + Text("원본 재생 (스캐폴드에서는 비활성)").foregroundStyle(Sage.muted) + } + .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Sage.surface, in: RoundedRectangle(cornerRadius: 8)) + } + + if let tags = memo.aiTags, !tags.isEmpty { + HStack(spacing: 6) { ForEach(tags, id: \.self) { Chip($0, Sage.brand) } } + } + + MarkdownView(memo.content ?? "") + } + .padding(20) + } + .background(Sage.surface) + } +} diff --git a/clients/ds-app/Sources/AppFeature/Pages/SearchView.swift b/clients/ds-app/Sources/AppFeature/Pages/SearchView.swift new file mode 100644 index 0000000..59fc7d0 --- /dev/null +++ b/clients/ds-app/Sources/AppFeature/Pages/SearchView.swift @@ -0,0 +1,50 @@ +import SwiftUI +import DSKit + +/// Distinct from the Documents table: relevance-forward result cards (score bar + match_reason). +struct SearchView: View { + @Environment(AppModel.self) private var model + + var body: some View { + @Bindable var model = model + VStack(alignment: .leading, spacing: 0) { + HStack(spacing: 8) { + TextField("검색어를 입력하세요", text: $model.searchQuery) + .textFieldStyle(.roundedBorder) + .onSubmit { Task { await model.runSearch() } } + Button("검색") { Task { await model.runSearch() } } + .buttonStyle(.borderedProminent) + } + .padding(12) + + if let response = model.searchResponse { + List(response.results) { result in + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 6) { + if let d = result.aiDomain { Chip(d, Sage.domainColor(d)) } + Text(result.title ?? "문서 \(result.id)") + .font(.callout.weight(.medium)).foregroundStyle(Sage.ink).lineLimit(1) + Spacer() + if let m = result.matchReason { + Text(m).font(.caption2).foregroundStyle(Sage.muted) + } + } + Text(result.snippet ?? result.aiSummary ?? "") + .font(.caption).foregroundStyle(Sage.muted).lineLimit(2) + if let score = result.score { ScoreBar(score: score) } + } + .padding(.vertical, 4) + .contentShape(Rectangle()) + .onTapGesture { + model.section = .documents + Task { await model.openDocument(result.id) } + } + } + .listStyle(.inset) + } else { + EmptyState(text: "검색어를 입력하세요") + } + } + .background(Sage.surface) + } +} diff --git a/clients/ds-app/Sources/AppFeature/Shell/Components.swift b/clients/ds-app/Sources/AppFeature/Shell/Components.swift new file mode 100644 index 0000000..df30ed4 --- /dev/null +++ b/clients/ds-app/Sources/AppFeature/Shell/Components.swift @@ -0,0 +1,71 @@ +import SwiftUI + +struct StatCard: View { + let title: String + let value: Int + let color: Color + var body: some View { + VStack(alignment: .leading, spacing: 4) { + Text(title).font(.caption).foregroundStyle(Sage.muted) + Text("\(value)").font(.title.weight(.bold)).foregroundStyle(color) + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(14) + .background(Sage.card, in: RoundedRectangle(cornerRadius: 12)) + .overlay(RoundedRectangle(cornerRadius: 12).stroke(Sage.line)) + } +} + +struct DomainBar: View { + let name: String + let count: Int + let max: Int + var body: some View { + HStack(spacing: 10) { + Text(name).font(.caption).foregroundStyle(Sage.ink) + .frame(width: 120, alignment: .leading).lineLimit(1) + GeometryReader { geo in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 4).fill(Sage.surface) + RoundedRectangle(cornerRadius: 4) + .fill(Sage.domainColor(name)) + .frame(width: geo.size.width * fraction) + } + } + .frame(height: 10) + Text("\(count)").font(.caption.monospacedDigit()).foregroundStyle(Sage.muted) + .frame(width: 44, alignment: .trailing) + } + } + private var fraction: CGFloat { max > 0 ? CGFloat(count) / CGFloat(max) : 0 } +} + +struct ScoreBar: View { + let score: Double + var body: some View { + HStack(spacing: 6) { + GeometryReader { geo in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 3).fill(Sage.surface) + RoundedRectangle(cornerRadius: 3).fill(Sage.brand) + .frame(width: geo.size.width * CGFloat(min(max(score, 0), 1))) + } + } + .frame(height: 6) + Text(String(format: "%.2f", score)).font(.caption2.monospacedDigit()).foregroundStyle(Sage.muted) + } + } +} + +struct ErrorBanner: View { + let text: String + var body: some View { + Text(text) + .font(.callout) + .foregroundStyle(Sage.danger) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(12) + .background(Sage.danger.opacity(0.08), in: RoundedRectangle(cornerRadius: 10)) + .overlay(RoundedRectangle(cornerRadius: 10).stroke(Sage.danger.opacity(0.3))) + } +} diff --git a/clients/ds-app/Sources/AppFeature/Shell/RootView.swift b/clients/ds-app/Sources/AppFeature/Shell/RootView.swift new file mode 100644 index 0000000..cecc501 --- /dev/null +++ b/clients/ds-app/Sources/AppFeature/Shell/RootView.swift @@ -0,0 +1,126 @@ +import SwiftUI +import DSKit + +/// DEVONthink-style 3-column shell. RootView only ROUTES; each page owns its own interior treatment +/// (no shell-level auto-inherit). macOS-only target. +public struct RootView: View { + @Environment(AppModel.self) private var model + @State private var columnVisibility: NavigationSplitViewVisibility = .all + + public init() {} + + public var body: some View { + NavigationSplitView(columnVisibility: $columnVisibility) { + Sidebar() + .navigationSplitViewColumnWidth(min: 220, ideal: 250) + } content: { + ContentColumn() + .navigationSplitViewColumnWidth(min: 300, ideal: 380) + } detail: { + DetailColumn() + } + .navigationSplitViewStyle(.balanced) + .tint(Sage.brand) + .task { await model.loadInitial() } + } +} + +struct Sidebar: View { + @Environment(AppModel.self) private var model + + var body: some View { + let selection = Binding( + get: { model.section }, + set: { if let v = $0 { model.section = v } } + ) + List(selection: selection) { + Section { + ForEach(AppModel.Section.allCases) { s in + Text(s.title).tag(s) + } + } + if model.section == .documents, !model.tree.isEmpty { + Section("도메인") { + ForEach(model.tree) { node in + DomainRow(node: node) + } + } + } + } + .listStyle(.sidebar) + .background(Sage.sidebar) + } +} + +struct DomainRow: View { + @Environment(AppModel.self) private var model + let node: DomainTreeNode + + var body: some View { + HStack(spacing: 8) { + Circle().fill(Sage.domainColor(node.name)).frame(width: 8, height: 8) + Text(node.name).font(.callout).foregroundStyle(Sage.ink) + Spacer() + Text("\(node.count)").font(.caption).foregroundStyle(Sage.muted) + } + .contentShape(Rectangle()) + .onTapGesture { model.section = .documents } + } +} + +struct ContentColumn: View { + @Environment(AppModel.self) private var model + + var body: some View { + Group { + switch model.section { + case .dashboard: DashboardView() + case .documents: DocumentListView() + case .search: SearchView() + case .ask: AskView() + case .memos: MemoListView() + case .digest: DigestView() + } + } + .navigationTitle(model.section.title) + } +} + +struct DetailColumn: View { + @Environment(AppModel.self) private var model + + var body: some View { + Group { + switch model.section { + case .documents: + if let d = model.documentDetail { DocumentDetailView(detail: d) } + else { EmptyState(text: "문서를 선택하세요") } + case .memos: + if let m = model.memoDetail { MemoDetailView(memo: m) } + else { EmptyState(text: "메모를 선택하세요") } + default: + EmptyState(text: model.section.title) + } + } + } +} + +struct EmptyState: View { + let text: String + var body: some View { + Text(text) + .foregroundStyle(Sage.muted) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Sage.surface) + } +} + +#if DEBUG +#Preview("DS App — full shell") { + @Previewable @State var model = AppModel.preview + RootView() + .environment(model) + .task { await model.loadInitial() } + .frame(minWidth: 1000, minHeight: 660) +} +#endif diff --git a/clients/ds-app/Sources/AppFeature/State/AppModel.swift b/clients/ds-app/Sources/AppFeature/State/AppModel.swift new file mode 100644 index 0000000..41d2242 --- /dev/null +++ b/clients/ds-app/Sources/AppFeature/State/AppModel.swift @@ -0,0 +1,98 @@ +import SwiftUI +import Observation +import DSKit +import AIFabric + +/// The single app-state store driving the 3-pane shell. @MainActor @Observable: mutations are +/// main-isolated; the DSClient returns Sendable models; AIService is an actor. +@MainActor +@Observable +public final class AppModel { + public enum Section: String, CaseIterable, Identifiable, Hashable { + case dashboard, documents, search, ask, memos, digest + public var id: String { rawValue } + public var title: String { + switch self { + case .dashboard: return "대시보드" + case .documents: return "문서" + case .search: return "검색" + case .ask: return "질문" + case .memos: return "메모" + case .digest: return "뉴스" + } + } + } + + public var section: Section = .dashboard + public var selectedDocumentID: Int? + public var selectedMemoID: Int? + + public var tree: [DomainTreeNode] = [] + public var stats: CategoryCounts? + public var documentList: [DocumentResponse] = [] + public var documentDetail: DocumentDetailResponse? + public var searchQuery: String = "" + public var searchResponse: SearchResponse? + public var askQuery: String = "" + public var askResult: AIResult? + public var askMeta: DSKit.AskResponse? // qualified: AIFabric also defines an AskResponse + public var memoList: [MemoResponse] = [] + public var memoDetail: MemoResponse? + public var digest: DigestResponse? + public var errorText: String? + + let client: any DSClient + let ai: AIService + /// Placeholder token from the auth fixture — builds a real-SHAPED download URL with no expectation it resolves offline. + public private(set) var accessToken: String = "" + + public init(client: any DSClient, ai: AIService) { + self.client = client + self.ai = ai + } + + @MainActor + public static var preview: AppModel { + AppModel(client: FixtureDSClient(), ai: AIService(router: AppAIComposition.mockRouter())) + } + + public func loadInitial() async { + await guarded { self.accessToken = (try? await self.client.login(username: "hyungi", password: "x", totpCode: nil).accessToken) ?? "" } + await guarded { self.tree = try await self.client.documentTree() } + await guarded { self.stats = try await self.client.categoryCounts() } + await guarded { self.documentList = try await self.client.documents(DocumentListQuery()).items } + await guarded { self.memoList = try await self.client.memos(MemoListQuery()).items } + await guarded { self.digest = try await self.client.digest(date: nil, country: nil) } + } + + public func openDocument(_ id: Int) async { + selectedDocumentID = id + await guarded { self.documentDetail = try await self.client.document(id: id) } + } + + public func runSearch() async { + guard !searchQuery.isEmpty else { return } + await guarded { self.searchResponse = try await self.client.search(q: self.searchQuery, mode: .hybrid, page: 1, debug: false) } + } + + public func runAsk(backend: AIProviderID?) async { + guard !askQuery.isEmpty else { return } + askResult = await ai.corpusAsk(question: askQuery, explicit: backend) + await guarded { self.askMeta = try await self.client.ask(q: self.askQuery, limit: nil, backend: nil, debug: false) } + } + + public func openMemo(_ id: Int) async { + selectedMemoID = id + await guarded { self.memoDetail = try await self.client.memo(id: id) } + } + + public func downloadURL(for doc: DocumentResponse) -> URL? { + guard doc.hasDownloadableOriginal, !accessToken.isEmpty else { return nil } + return DSDownload.fileURL(base: .publicTLS, documentID: doc.id, accessToken: accessToken) + } + + private func guarded(_ work: () async throws -> Void) async { + do { try await work() } + catch { errorText = (error as? LocalizedError)?.errorDescription ?? "\(error)" } + } +} diff --git a/clients/ds-app/Sources/AppFeature/Theme/SageTheme.swift b/clients/ds-app/Sources/AppFeature/Theme/SageTheme.swift new file mode 100644 index 0000000..b2277fb --- /dev/null +++ b/clients/ds-app/Sources/AppFeature/Theme/SageTheme.swift @@ -0,0 +1,85 @@ +import SwiftUI + +/// Single sage token source (web F1 @theme parity). The shell/tokens are fixed; each page's magnetic +/// treatment layers on top. No emoji — status/format are color chips + short text. Status helpers are +/// per-vocabulary (md/review) so a value from one vocabulary never mis-colors another. +public enum Sage { + public static let brand = Color(hex: 0x4f8a6b) + public static let brandDark = Color(hex: 0x3d7256) + public static let surface = Color(hex: 0xf1f4ee) + public static let card = Color.white + public static let sidebar = Color(hex: 0xf4f7f1) + public static let ink = Color(hex: 0x23291f) + public static let muted = Color(hex: 0x697061) + public static let line = Color(hex: 0xdde3d6) + public static let amber = Color(hex: 0xb5840a) + public static let danger = Color(hex: 0xc0564a) + + public static func domainColor(_ d: String?) -> Color { + switch d { + case "Engineering": return Color(hex: 0x2f7d8f) + case "Industrial_Safety": return Color(hex: 0xb5840a) + case "General": return Color(hex: 0x7a8b3f) + case "Programming": return Color(hex: 0x3d7256) + case "법령": return Color(hex: 0x8a6a3f) + case "Philosophy": return Color(hex: 0x7a6a9b) + default: return muted + } + } + + public static func formatColor(_ f: String?) -> Color { + switch f?.lowercased() { + case "md": return Color(hex: 0x5a8f7a) + case "pdf": return Color(hex: 0xc0564a) + case "m4a", "mp3", "wav", "audio": return Color(hex: 0x8a6aa5) + case "html": return Color(hex: 0xc2911f) + case "docx", "xlsx", "txt": return Color(hex: 0x6f7c8a) + default: return muted + } + } + + public static func mdStatusColor(_ s: String?) -> Color { + switch s { + case "completed": return brand + case "partial": return Color(hex: 0x7a9f86) + case "processing", "pending": return amber + case "failed": return danger + default: return muted + } + } + + public static func reviewStatusColor(_ s: String?) -> Color { + switch s { + case "approved": return brand + case "pending": return amber + case "rejected": return danger + default: return muted + } + } +} + +public extension Color { + init(hex: UInt) { + self.init( + .sRGB, + red: Double((hex >> 16) & 0xff) / 255.0, + green: Double((hex >> 8) & 0xff) / 255.0, + blue: Double(hex & 0xff) / 255.0, + opacity: 1.0 + ) + } +} + +/// A small color chip + short label (no emoji icons). +public struct Chip: View { + let text: String + let color: Color + public init(_ text: String, _ color: Color) { self.text = text; self.color = color } + public var body: some View { + Text(text) + .font(.caption2.weight(.semibold)) + .foregroundStyle(color) + .padding(.horizontal, 7).padding(.vertical, 2) + .background(color.opacity(0.13), in: Capsule()) + } +} diff --git a/clients/ds-app/Sources/DSKit/Auth/KeychainStore.swift b/clients/ds-app/Sources/DSKit/Auth/KeychainStore.swift new file mode 100644 index 0000000..45754e6 --- /dev/null +++ b/clients/ds-app/Sources/DSKit/Auth/KeychainStore.swift @@ -0,0 +1,71 @@ +import Foundation +import Security + +/// Access-token persistence. The scaffold/preview/unsigned-build path uses InMemoryTokenStore +/// (Keychain on an unsigned binary can prompt or return errSecMissingEntitlement); the signed app +/// uses KeychainStore. Only the ACCESS token is stored — refresh rides the HttpOnly cookie. +public protocol TokenPersistence: Sendable { + func read() -> String? + func save(_ token: String) throws + func delete() throws +} + +public final class InMemoryTokenStore: TokenPersistence, @unchecked Sendable { + private let lock = NSLock() + private var token: String? + public init() {} + public func read() -> String? { lock.withLock { token } } + public func save(_ token: String) throws { lock.withLock { self.token = token } } + public func delete() throws { lock.withLock { token = nil } } +} + +public struct KeychainStore: TokenPersistence, Sendable { + private let service: String + private let account: String + + public init(service: String = "net.hyungi.ds-app", account: String = "access_token") { + self.service = service + self.account = account + } + + public func read() -> String? { + let query: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + kSecReturnData as String: true, + kSecMatchLimit as String: kSecMatchLimitOne, + ] + var item: CFTypeRef? + guard SecItemCopyMatching(query as CFDictionary, &item) == errSecSuccess, + let data = item as? Data, + let token = String(data: data, encoding: .utf8) + else { return nil } + return token + } + + public func save(_ token: String) throws { + let base: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + SecItemDelete(base as CFDictionary) // upsert + var add = base + add[kSecValueData as String] = Data(token.utf8) + add[kSecAttrAccessible as String] = kSecAttrAccessibleAfterFirstUnlock + let status = SecItemAdd(add as CFDictionary, nil) + guard status == errSecSuccess else { + throw DSError.transport(underlying: "keychain save failed (\(status))") + } + } + + public func delete() throws { + let base: [String: Any] = [ + kSecClass as String: kSecClassGenericPassword, + kSecAttrService as String: service, + kSecAttrAccount as String: account, + ] + SecItemDelete(base as CFDictionary) + } +} diff --git a/clients/ds-app/Sources/DSKit/Auth/TokenProvider.swift b/clients/ds-app/Sources/DSKit/Auth/TokenProvider.swift new file mode 100644 index 0000000..63fdeb1 --- /dev/null +++ b/clients/ds-app/Sources/DSKit/Auth/TokenProvider.swift @@ -0,0 +1,43 @@ +import Foundation + +/// Concurrency-safe access-token holder with SINGLE-FLIGHT refresh: concurrent 401s must not each +/// fire `/auth/refresh`. The coalescing is via a stored `Task` handle (not a bool) — entrants await +/// the same in-flight task; it's cleared (success OR failure) when complete. Clearing too early lets a +/// follower re-refresh; never clearing means the next expiry won't refresh. +public actor TokenProvider { + private var cached: String? + private let persistence: TokenPersistence + private let refresh: @Sendable () async throws -> String + private var inFlightRefresh: Task? + + public init(persistence: TokenPersistence, refresh: @escaping @Sendable () async throws -> String) { + self.persistence = persistence + self.refresh = refresh + self.cached = persistence.read() + } + + public func current() -> String? { cached ?? persistence.read() } + + public func set(_ token: String) { + cached = token + try? persistence.save(token) + } + + public func clear() { + cached = nil + try? persistence.delete() + } + + public func refreshOnce() async throws -> String { + if let task = inFlightRefresh { + return try await task.value + } + let task = Task { try await self.refresh() } + inFlightRefresh = task + defer { inFlightRefresh = nil } + let token = try await task.value + cached = token + try? persistence.save(token) + return token + } +} diff --git a/clients/ds-app/Sources/DSKit/DSClient.swift b/clients/ds-app/Sources/DSKit/DSClient.swift new file mode 100644 index 0000000..6082ad8 --- /dev/null +++ b/clients/ds-app/Sources/DSKit/DSClient.swift @@ -0,0 +1,43 @@ +import Foundation + +/// The single networking seam the whole app codes against. The app builds entirely on +/// `FixtureDSClient` (zero backend); `LiveDSClient` (FU-A) drops in later unchanged. +/// +/// `ask(backend:)` keeps `backend` as `String?` (raw AI-ROUTING §4 values) — S2 maps AIProviderID +/// to that string. This shape is locked by AI-ROUTING.md §4 (a documented contract), verified at +/// integration by the FU-B call-shape regression. +public protocol DSClient: Sendable { + // Auth + func login(username: String, password: String, totpCode: String?) async throws -> AccessTokenResponse + func me() async throws -> UserResponse + func refresh() async throws -> AccessTokenResponse + func logout() async throws + + // Documents + func documents(_ query: DocumentListQuery) async throws -> DocumentListResponse + func document(id: Int) async throws -> DocumentDetailResponse + func documentContent(id: Int) async throws -> DocumentContentResponse + func documentTree() async throws -> [DomainTreeNode] + func categoryCounts() async throws -> CategoryCounts + func duplicates() async throws -> DuplicatesResponse + func patchDocument(id: Int, _ update: DocumentUpdate) async throws -> DocumentResponse + func putContent(id: Int, content: String) async throws + func deleteDocument(id: Int) async throws + + // Search / Ask + func search(q: String, mode: SearchMode?, page: Int?, debug: Bool?) async throws -> SearchResponse + func ask(q: String, limit: Int?, backend: String?, debug: Bool?) async throws -> AskResponse + + // Memos + func memos(_ query: MemoListQuery) async throws -> MemoListResponse + func memo(id: Int) async throws -> MemoResponse + func createMemo(_ create: MemoCreate) async throws -> MemoResponse + func patchMemo(id: Int, _ update: MemoUpdate) async throws -> MemoResponse + func pinMemo(id: Int, pinned: Bool) async throws -> MemoResponse + func archiveMemo(id: Int, archived: Bool) async throws -> MemoResponse + func toggleMemoTask(id: Int, taskIndex: Int, checked: Bool) async throws -> MemoResponse + func deleteMemo(id: Int) async throws + + // Digest + func digest(date: String?, country: String?) async throws -> DigestResponse +} diff --git a/clients/ds-app/Sources/DSKit/DSCoding.swift b/clients/ds-app/Sources/DSKit/DSCoding.swift new file mode 100644 index 0000000..44199ec --- /dev/null +++ b/clients/ds-app/Sources/DSKit/DSCoding.swift @@ -0,0 +1,18 @@ +import Foundation + +/// Single decoder/encoder factory shared by every fixture load and both DS clients +/// (call-shape single source of truth). NO `.convertFromSnakeCase` (every model has explicit +/// CodingKeys) and NO global date strategy (dates are String-raw + DSDate accessors). +public enum DSDecoder { + public static func make() -> JSONDecoder { + JSONDecoder() + } +} + +public enum DSEncoder { + public static func make() -> JSONEncoder { + let e = JSONEncoder() + e.outputFormatting = [.sortedKeys] + return e + } +} diff --git a/clients/ds-app/Sources/DSKit/DSConfig.swift b/clients/ds-app/Sources/DSKit/DSConfig.swift new file mode 100644 index 0000000..d699242 --- /dev/null +++ b/clients/ds-app/Sources/DSKit/DSConfig.swift @@ -0,0 +1,18 @@ +import Foundation + +/// Injectable base URL. Public TLS by default; Tailscale alternative uses a MagicDNS hostname +/// (NOT a hardcoded 100.x IP, which changes on node re-registration). Scaffold never makes a live +/// call, so the Tailscale host is a placeholder until FU-A. +public enum DSBaseURL: Sendable { + case publicTLS + case tailscale + case custom(URL) + + public var url: URL { + switch self { + case .publicTLS: return URL(string: "https://document.hyungi.net/api")! + case .tailscale: return URL(string: "http://ds-gpu.tailnet-name.ts.net:8000/api")! + case .custom(let u): return u + } + } +} diff --git a/clients/ds-app/Sources/DSKit/DSDate.swift b/clients/ds-app/Sources/DSKit/DSDate.swift new file mode 100644 index 0000000..1834452 --- /dev/null +++ b/clients/ds-app/Sources/DSKit/DSDate.swift @@ -0,0 +1,33 @@ +import Foundation + +/// Date parsing for the contract's mixed shapes. Two ISO-8601 datetime shapes (with / without +/// fractional seconds) coexist with date-only strings in the SAME response (digest), so a single +/// JSONDecoder.dateDecodingStrategy is impossible. Dates are decoded as `String?` raw and parsed here. +/// +/// Concurrency (B-1 review): uses value-type `Date.ISO8601FormatStyle` (Sendable) as static lets — +/// no shared reference formatter, so no `nonisolated(unsafe)`. +/// +/// Date-only (B-1 r2 review): `digest_date` should be DISPLAYED from its raw string (no Date conversion) +/// to avoid KST off-by-one; `parse` here fixes date-only to UTC and is for sort/window math only. +public enum DSDate { + private static let isoFractional = Date.ISO8601FormatStyle(includingFractionalSeconds: true) + private static let isoPlain = Date.ISO8601FormatStyle(includingFractionalSeconds: false) + + public static func parse(_ s: String?) -> Date? { + guard let s, !s.isEmpty else { return nil } + if let d = try? isoFractional.parse(s) { return d } + if let d = try? isoPlain.parse(s) { return d } + return parseDateOnly(s) + } + + /// Date-only "YYYY-MM-DD" fixed to UTC (avoids timezone off-by-one). Value-type Calendar, Sendable-safe. + private static func parseDateOnly(_ s: String) -> Date? { + let p = s.split(separator: "-") + guard p.count == 3, let y = Int(p[0]), let m = Int(p[1]), let d = Int(p[2]) else { return nil } + var comps = DateComponents() + comps.year = y; comps.month = m; comps.day = d + var cal = Calendar(identifier: .gregorian) + cal.timeZone = TimeZone(identifier: "UTC")! + return cal.date(from: comps) + } +} diff --git a/clients/ds-app/Sources/DSKit/DSError.swift b/clients/ds-app/Sources/DSKit/DSError.swift new file mode 100644 index 0000000..26633ba --- /dev/null +++ b/clients/ds-app/Sources/DSKit/DSError.swift @@ -0,0 +1,62 @@ +import Foundation + +/// Typed error decoded from the contract's two error body shapes ({detail: String} OR +/// {detail: {error_code, message}}) and keyed by HTTP status. +/// +/// Boundary note: an in-body synthesis failure (AskResponse.synthesis_status == "backend_unavailable") +/// is an HTTP success and is NOT a DSError — only a true HTTP 503 maps to .serviceUnavailable. +public enum DSError: Error, Sendable { + case unauthorized(message: String?) + case notFound(message: String?) + case validation(message: String?, errorCode: String?) + case serviceUnavailable(message: String?) + case server(status: Int, message: String?, errorCode: String?) + case transport(underlying: String) + case decoding(String) + + public var isAuthExpired: Bool { + if case .unauthorized = self { return true } + return false + } + + public static func from(status: Int, data: Data) -> DSError { + let body = DSErrorBody.parse(data) + switch status { + case 401: return .unauthorized(message: body?.message) + case 404: return .notFound(message: body?.message) + case 422: return .validation(message: body?.message, errorCode: body?.errorCode) + case 503: return .serviceUnavailable(message: body?.message) + default: return .server(status: status, message: body?.message, errorCode: body?.errorCode) + } + } +} + +struct DSErrorBody { + let message: String? + let errorCode: String? + + static func parse(_ data: Data) -> DSErrorBody? { + guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } + if let s = obj["detail"] as? String { + return DSErrorBody(message: s, errorCode: nil) + } + if let d = obj["detail"] as? [String: Any] { + return DSErrorBody(message: d["message"] as? String, errorCode: d["error_code"] as? String) + } + return nil + } +} + +extension DSError: LocalizedError { + public var errorDescription: String? { + switch self { + case .unauthorized(let m): return m ?? "인증이 만료되었습니다. 다시 로그인하세요." + case .notFound(let m): return m ?? "찾을 수 없습니다." + case .validation(let m, _): return m ?? "요청이 올바르지 않습니다." + case .serviceUnavailable(let m): return m ?? "서비스를 일시적으로 사용할 수 없습니다." + case .server(let s, let m, _): return m ?? "서버 오류 (\(s))" + case .transport(let u): return "네트워크 오류: \(u)" + case .decoding(let d): return "응답 해석 실패: \(d)" + } + } +} diff --git a/clients/ds-app/Sources/DSKit/DownloadURL.swift b/clients/ds-app/Sources/DSKit/DownloadURL.swift new file mode 100644 index 0000000..0c7c0be --- /dev/null +++ b/clients/ds-app/Sources/DSKit/DownloadURL.swift @@ -0,0 +1,21 @@ +import Foundation + +/// Original-file download URL builder. CRITICAL: this endpoint authenticates via the `?token=` QUERY +/// parameter, NOT an Authorization header (iframe/download compatibility). The token must live only in +/// the query. Callers must redact the token when logging the URL. +public enum DSDownload { + public static func fileURL( + base: DSBaseURL, + documentID id: Int, + accessToken: String, + download: Bool = true + ) -> URL? { + let endpoint = base.url.appendingPathComponent("documents/\(id)/file") + var comps = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) + comps?.queryItems = [ + URLQueryItem(name: "token", value: accessToken), + URLQueryItem(name: "download", value: download ? "true" : "false"), + ] + return comps?.url + } +} diff --git a/clients/ds-app/Sources/DSKit/Endpoint.swift b/clients/ds-app/Sources/DSKit/Endpoint.swift new file mode 100644 index 0000000..f2c895b --- /dev/null +++ b/clients/ds-app/Sources/DSKit/Endpoint.swift @@ -0,0 +1,135 @@ +import Foundation + +/// Single source of truth mapping each DSClient call to HTTP method + path + query + body. +/// Trailing slashes are significant (e.g. `documents/`, `search/`) and are preserved by building the +/// URL from the base string rather than appendingPathComponent (which strips them). +enum DSEndpoint { + case login(String, String, String?) + case me + case refresh + case logout + case documents(DocumentListQuery) + case document(Int) + case documentContent(Int) + case documentTree + case categoryCounts + case duplicates + case patchDocument(Int, DocumentUpdate) + case putContent(Int, String) + case deleteDocument(Int) + case search(String, SearchMode?, Int?, Bool?) + case ask(String, Int?, String?, Bool?) + case memos(MemoListQuery) + case memo(Int) + case createMemo(MemoCreate) + case patchMemo(Int, MemoUpdate) + case pinMemo(Int, Bool) + case archiveMemo(Int, Bool) + case toggleMemoTask(Int, Int, Bool) + case deleteMemo(Int) + case digest(String?, String?) + + var method: String { + switch self { + case .login, .refresh, .logout, .createMemo: return "POST" + case .patchDocument, .patchMemo, .pinMemo, .archiveMemo, .toggleMemoTask: return "PATCH" + case .putContent: return "PUT" + case .deleteDocument, .deleteMemo: return "DELETE" + default: return "GET" + } + } + + var path: String { + switch self { + case .login: return "auth/login" + case .me: return "auth/me" + case .refresh: return "auth/refresh" + case .logout: return "auth/logout" + case .documents: return "documents/" + case .document(let id): return "documents/\(id)" + case .documentContent(let id): return "documents/\(id)/content" + case .documentTree: return "documents/tree" + case .categoryCounts: return "documents/stats/category-counts" + case .duplicates: return "documents/duplicates" + case .patchDocument(let id, _): return "documents/\(id)" + case .putContent(let id, _): return "documents/\(id)/content" + case .deleteDocument(let id): return "documents/\(id)" + case .search: return "search/" + case .ask: return "search/ask" + case .memos: return "memos/" + case .memo(let id): return "memos/\(id)" + case .createMemo: return "memos/" + case .patchMemo(let id, _): return "memos/\(id)" + case .pinMemo(let id, _): return "memos/\(id)/pin" + case .archiveMemo(let id, _): return "memos/\(id)/archive" + case .toggleMemoTask(let id, let idx, _): return "memos/\(id)/tasks/\(idx)" + case .deleteMemo(let id): return "memos/\(id)" + case .digest: return "digest" + } + } + + /// Bearer header applies to everything except login/refresh (refresh rides the HttpOnly cookie). + var requiresBearer: Bool { + switch self { + case .login, .refresh: return false + default: return true + } + } + + var queryItems: [URLQueryItem] { + var items: [URLQueryItem] = [] + func add(_ name: String, _ value: String?) { if let value { items.append(URLQueryItem(name: name, value: value)) } } + switch self { + case .documents(let q): + add("page", String(q.page)); add("page_size", String(q.pageSize)) + add("domain", q.domain); add("sub_group", q.subGroup); add("source", q.source) + add("format", q.format); add("review_status", q.reviewStatus); add("category", q.category) + case .search(let qq, let mode, let page, let debug): + add("q", qq); add("mode", mode?.rawValue); add("page", page.map(String.init)); add("debug", debug.map(String.init)) + case .ask(let qq, let limit, let backend, let debug): + add("q", qq); add("limit", limit.map(String.init)); add("backend", backend); add("debug", debug.map(String.init)) + case .memos(let q): + add("page", String(q.page)); add("page_size", String(q.pageSize)) + add("pinned", q.pinned.map(String.init)); add("archived", q.archived.map(String.init)) + case .digest(let date, let country): + add("date", date); add("country", country) + default: + break + } + return items + } + + func httpBody(_ encoder: JSONEncoder) throws -> Data? { + switch self { + case .login(let u, let p, let totp): + return try encoder.encode(LoginBody(username: u, password: p, totpCode: totp)) + case .patchDocument(_, let update): + return try encoder.encode(update) + case .putContent(_, let content): + return try encoder.encode(ContentBody(content: content)) + case .createMemo(let create): + return try encoder.encode(create) + case .patchMemo(_, let update): + return try encoder.encode(update) + case .pinMemo(_, let pinned): + return try encoder.encode(PinnedBody(pinned: pinned)) + case .archiveMemo(_, let archived): + return try encoder.encode(ArchivedBody(archived: archived)) + case .toggleMemoTask(_, _, let checked): + return try encoder.encode(CheckedBody(checked: checked)) + default: + return nil + } + } +} + +private struct LoginBody: Encodable { + let username: String + let password: String + let totpCode: String? + enum CodingKeys: String, CodingKey { case username, password; case totpCode = "totp_code" } +} +private struct ContentBody: Encodable { let content: String } +private struct PinnedBody: Encodable { let pinned: Bool } +private struct ArchivedBody: Encodable { let archived: Bool } +private struct CheckedBody: Encodable { let checked: Bool } diff --git a/clients/ds-app/Sources/DSKit/FixtureDSClient.swift b/clients/ds-app/Sources/DSKit/FixtureDSClient.swift new file mode 100644 index 0000000..4cf9f23 --- /dev/null +++ b/clients/ds-app/Sources/DSKit/FixtureDSClient.swift @@ -0,0 +1,81 @@ +import Foundation + +/// Zero-backend DSClient: loads the 14 bundled fixtures (Bundle.module) with the SAME decoder the +/// live client will use, so "it previews" == "the DTOs decode the real shapes". Powers SwiftUI +/// previews and the contract acceptance test. Write-side methods return a plausible echo. +/// +/// Holds no non-Sendable stored state (uses Bundle.module locally) → Sendable. +public struct FixtureDSClient: DSClient { + public init() {} + + private func load(_ name: String, as type: T.Type) throws -> T { + guard let url = Bundle.module.url(forResource: name, withExtension: "json") else { + throw DSError.notFound(message: "fixture \(name).json") + } + let data = try Data(contentsOf: url) + do { + return try DSDecoder.make().decode(T.self, from: data) + } catch { + throw DSError.decoding("\(name): \(error)") + } + } + + // Auth + public func login(username: String, password: String, totpCode: String?) async throws -> AccessTokenResponse { + try load("auth_login", as: AccessTokenResponse.self) + } + public func me() async throws -> UserResponse { try load("auth_me", as: UserResponse.self) } + public func refresh() async throws -> AccessTokenResponse { try load("auth_login", as: AccessTokenResponse.self) } + public func logout() async throws {} + + // Documents + public func documents(_ query: DocumentListQuery) async throws -> DocumentListResponse { + try load("documents_list", as: DocumentListResponse.self) + } + public func document(id: Int) async throws -> DocumentDetailResponse { + // id 5301 = the pending-MD fixture (extracted_text fallback); otherwise the completed MD-first fixture. + try load(id == 5301 ? "document_detail_pending_md" : "document_detail", as: DocumentDetailResponse.self) + } + public func documentContent(id: Int) async throws -> DocumentContentResponse { + try load("document_content", as: DocumentContentResponse.self) + } + public func documentTree() async throws -> [DomainTreeNode] { + try load("documents_tree", as: [DomainTreeNode].self) + } + public func categoryCounts() async throws -> CategoryCounts { + try load("documents_stats", as: CategoryCounts.self) + } + public func duplicates() async throws -> DuplicatesResponse { + try load("documents_duplicates", as: DuplicatesResponse.self) + } + public func patchDocument(id: Int, _ update: DocumentUpdate) async throws -> DocumentResponse { + try load("document_detail", as: DocumentDetailResponse.self).base + } + public func putContent(id: Int, content: String) async throws {} + public func deleteDocument(id: Int) async throws {} + + // Search / Ask + public func search(q: String, mode: SearchMode?, page: Int?, debug: Bool?) async throws -> SearchResponse { + try load("search", as: SearchResponse.self) + } + public func ask(q: String, limit: Int?, backend: String?, debug: Bool?) async throws -> AskResponse { + try load("ask", as: AskResponse.self) + } + + // Memos + public func memos(_ query: MemoListQuery) async throws -> MemoListResponse { + try load("memos_list", as: MemoListResponse.self) + } + public func memo(id: Int) async throws -> MemoResponse { try load("memo_detail", as: MemoResponse.self) } + public func createMemo(_ create: MemoCreate) async throws -> MemoResponse { try load("memo_detail", as: MemoResponse.self) } + public func patchMemo(id: Int, _ update: MemoUpdate) async throws -> MemoResponse { try load("memo_detail", as: MemoResponse.self) } + public func pinMemo(id: Int, pinned: Bool) async throws -> MemoResponse { try load("memo_detail", as: MemoResponse.self) } + public func archiveMemo(id: Int, archived: Bool) async throws -> MemoResponse { try load("memo_detail", as: MemoResponse.self) } + public func toggleMemoTask(id: Int, taskIndex: Int, checked: Bool) async throws -> MemoResponse { try load("memo_detail", as: MemoResponse.self) } + public func deleteMemo(id: Int) async throws {} + + // Digest + public func digest(date: String?, country: String?) async throws -> DigestResponse { + try load("digest", as: DigestResponse.self) + } +} diff --git a/clients/ds-app/Sources/DSKit/JSONValue.swift b/clients/ds-app/Sources/DSKit/JSONValue.swift new file mode 100644 index 0000000..4ea67b7 --- /dev/null +++ b/clients/ds-app/Sources/DSKit/JSONValue.swift @@ -0,0 +1,71 @@ +import Foundation + +/// A Sendable, fully-Codable representation of arbitrary JSON, for the contract's open-shape +/// `[String: Any]?` fields (ai_suggestion, md_frontmatter, md_extraction_quality, memo_task_state, +/// source_metadata, freshness_debug, debug). Plain Codable cannot decode those; this can. +/// +/// Numeric note (B-1 review): on Foundation's JSONDecoder, a JSON boolean fails `Int` decode +/// (no silent 1/0 coercion), so Bool ordering is harmless. The real disambiguation is Int-before-Double +/// so integral numbers stay `.int`. BUT whole-valued floats like `1.0` can land as `.int` depending on +/// toolchain — so numeric meaning MUST be read through `doubleValue`/`intValue` (which cross-convert), +/// never by pattern-matching the raw case. +public enum JSONValue: Codable, Sendable, Hashable { + case string(String) + case int(Int) + case double(Double) + case bool(Bool) + case object([String: JSONValue]) + case array([JSONValue]) + case null + + public init(from decoder: Decoder) throws { + let c = try decoder.singleValueContainer() + if c.decodeNil() { self = .null; return } + if let b = try? c.decode(Bool.self) { self = .bool(b); return } + if let i = try? c.decode(Int.self) { self = .int(i); return } + if let d = try? c.decode(Double.self) { self = .double(d); return } + if let s = try? c.decode(String.self) { self = .string(s); return } + if let o = try? c.decode([String: JSONValue].self) { self = .object(o); return } + if let a = try? c.decode([JSONValue].self) { self = .array(a); return } + throw DecodingError.dataCorruptedError(in: c, debugDescription: "Unsupported JSON value") + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.singleValueContainer() + switch self { + case .string(let s): try c.encode(s) + case .int(let i): try c.encode(i) + case .double(let d): try c.encode(d) + case .bool(let b): try c.encode(b) + case .object(let o): try c.encode(o) + case .array(let a): try c.encode(a) + case .null: try c.encodeNil() + } + } + + // Accessors cross-convert so numeric reads are robust regardless of int/double storage. + public var stringValue: String? { if case .string(let s) = self { return s }; return nil } + public var intValue: Int? { + switch self { + case .int(let i): return i + case .double(let d): return Int(d) + default: return nil + } + } + public var doubleValue: Double? { + switch self { + case .double(let d): return d + case .int(let i): return Double(i) + default: return nil + } + } + public var boolValue: Bool? { if case .bool(let b) = self { return b }; return nil } + public var objectValue: [String: JSONValue]? { if case .object(let o) = self { return o }; return nil } + public var arrayValue: [JSONValue]? { if case .array(let a) = self { return a }; return nil } + + public subscript(key: String) -> JSONValue? { objectValue?[key] } + public subscript(index: Int) -> JSONValue? { + guard let a = arrayValue, index >= 0, index < a.count else { return nil } + return a[index] + } +} diff --git a/clients/ds-app/Sources/DSKit/LiveDSClient.swift b/clients/ds-app/Sources/DSKit/LiveDSClient.swift new file mode 100644 index 0000000..9af4531 --- /dev/null +++ b/clients/ds-app/Sources/DSKit/LiveDSClient.swift @@ -0,0 +1,135 @@ +import Foundation + +/// Real-network DSClient (FU-A). Same DTOs/decoder as FixtureDSClient, so swapping it in is +/// behavior-identical except for I/O. URLSession with shared cookie storage so the HttpOnly refresh +/// cookie is replayed on `/auth/refresh`. A 401 on a bearer request triggers a single-flight refresh +/// + ONE retry (never on login/refresh/logout, to avoid loops). +/// +/// Immutable stored props (URLSession/decoder shared but never mutated) → @unchecked Sendable. +public final class LiveDSClient: DSClient, @unchecked Sendable { + private let base: DSBaseURL + private let session: URLSession + private let decoder: JSONDecoder + private let encoder: JSONEncoder + private let tokens: TokenProvider + + public init(base: DSBaseURL = .publicTLS, persistence: TokenPersistence = InMemoryTokenStore()) { + let baseURL = base + let config = URLSessionConfiguration.default + config.httpCookieStorage = .shared + config.httpShouldSetCookies = true + let session = URLSession(configuration: config) + let decoder = DSDecoder.make() + + self.base = baseURL + self.session = session + self.decoder = decoder + self.encoder = DSEncoder.make() + // Refresh closure captures only Sendable values (no self): raw POST /auth/refresh via cookie. + self.tokens = TokenProvider(persistence: persistence) { + var request = URLRequest(url: baseURL.url.appendingPathComponent("auth/refresh")) + request.httpMethod = "POST" + let (data, response) = try await session.data(for: request) + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + throw DSError.unauthorized(message: "refresh failed") + } + return try decoder.decode(AccessTokenResponse.self, from: data).accessToken + } + } + + public func setAccessToken(_ token: String) async { await tokens.set(token) } + + // MARK: - Request building / sending + + private func makeRequest(_ endpoint: DSEndpoint, token: String?) throws -> URLRequest { + // Build URL from the base string to preserve trailing slashes; URLComponents percent-encodes. + let raw = base.url.absoluteString + "/" + endpoint.path + guard var comps = URLComponents(string: raw) else { + throw DSError.transport(underlying: "bad URL \(raw)") + } + if !endpoint.queryItems.isEmpty { comps.queryItems = endpoint.queryItems } + guard let url = comps.url else { throw DSError.transport(underlying: "bad URL components") } + + var request = URLRequest(url: url) + request.httpMethod = endpoint.method + if endpoint.requiresBearer, let token { request.setValue("Bearer \(token)", forHTTPHeaderField: "Authorization") } + if let body = try endpoint.httpBody(encoder) { + request.httpBody = body + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + } + return request + } + + private func perform(_ endpoint: DSEndpoint) async throws -> Data { + let request = try makeRequest(endpoint, token: await tokens.current()) + let (data, response) = try await dataOrTransport(request) + guard let http = response as? HTTPURLResponse else { + throw DSError.transport(underlying: "no HTTP response") + } + if http.statusCode == 401, endpoint.requiresBearer { + // Single-flight refresh + one retry. + let newToken = try await tokens.refreshOnce() + let retry = try makeRequest(endpoint, token: newToken) + let (data2, response2) = try await dataOrTransport(retry) + guard let http2 = response2 as? HTTPURLResponse else { + throw DSError.transport(underlying: "no HTTP response") + } + guard (200..<300).contains(http2.statusCode) else { throw DSError.from(status: http2.statusCode, data: data2) } + return data2 + } + guard (200..<300).contains(http.statusCode) else { throw DSError.from(status: http.statusCode, data: data) } + return data + } + + private func dataOrTransport(_ request: URLRequest) async throws -> (Data, URLResponse) { + do { return try await session.data(for: request) } + catch { throw DSError.transport(underlying: "\(error)") } + } + + private func send(_ endpoint: DSEndpoint, as type: T.Type) async throws -> T { + let data = try await perform(endpoint) + do { return try decoder.decode(T.self, from: data) } + catch { throw DSError.decoding("\(endpoint.path): \(error)") } + } + + private func sendVoid(_ endpoint: DSEndpoint) async throws { _ = try await perform(endpoint) } + + // MARK: - DSClient + + public func login(username: String, password: String, totpCode: String?) async throws -> AccessTokenResponse { + let token: AccessTokenResponse = try await send(.login(username, password, totpCode), as: AccessTokenResponse.self) + await tokens.set(token.accessToken) + return token + } + public func me() async throws -> UserResponse { try await send(.me, as: UserResponse.self) } + public func refresh() async throws -> AccessTokenResponse { + let token: AccessTokenResponse = try await send(.refresh, as: AccessTokenResponse.self) + await tokens.set(token.accessToken) + return token + } + public func logout() async throws { try await sendVoid(.logout); await tokens.clear() } + + public func documents(_ query: DocumentListQuery) async throws -> DocumentListResponse { try await send(.documents(query), as: DocumentListResponse.self) } + public func document(id: Int) async throws -> DocumentDetailResponse { try await send(.document(id), as: DocumentDetailResponse.self) } + public func documentContent(id: Int) async throws -> DocumentContentResponse { try await send(.documentContent(id), as: DocumentContentResponse.self) } + public func documentTree() async throws -> [DomainTreeNode] { try await send(.documentTree, as: [DomainTreeNode].self) } + public func categoryCounts() async throws -> CategoryCounts { try await send(.categoryCounts, as: CategoryCounts.self) } + public func duplicates() async throws -> DuplicatesResponse { try await send(.duplicates, as: DuplicatesResponse.self) } + public func patchDocument(id: Int, _ update: DocumentUpdate) async throws -> DocumentResponse { try await send(.patchDocument(id, update), as: DocumentResponse.self) } + public func putContent(id: Int, content: String) async throws { try await sendVoid(.putContent(id, content)) } + public func deleteDocument(id: Int) async throws { try await sendVoid(.deleteDocument(id)) } + + public func search(q: String, mode: SearchMode?, page: Int?, debug: Bool?) async throws -> SearchResponse { try await send(.search(q, mode, page, debug), as: SearchResponse.self) } + public func ask(q: String, limit: Int?, backend: String?, debug: Bool?) async throws -> AskResponse { try await send(.ask(q, limit, backend, debug), as: AskResponse.self) } + + public func memos(_ query: MemoListQuery) async throws -> MemoListResponse { try await send(.memos(query), as: MemoListResponse.self) } + public func memo(id: Int) async throws -> MemoResponse { try await send(.memo(id), as: MemoResponse.self) } + public func createMemo(_ create: MemoCreate) async throws -> MemoResponse { try await send(.createMemo(create), as: MemoResponse.self) } + public func patchMemo(id: Int, _ update: MemoUpdate) async throws -> MemoResponse { try await send(.patchMemo(id, update), as: MemoResponse.self) } + public func pinMemo(id: Int, pinned: Bool) async throws -> MemoResponse { try await send(.pinMemo(id, pinned), as: MemoResponse.self) } + public func archiveMemo(id: Int, archived: Bool) async throws -> MemoResponse { try await send(.archiveMemo(id, archived), as: MemoResponse.self) } + public func toggleMemoTask(id: Int, taskIndex: Int, checked: Bool) async throws -> MemoResponse { try await send(.toggleMemoTask(id, taskIndex, checked), as: MemoResponse.self) } + public func deleteMemo(id: Int) async throws { try await sendVoid(.deleteMemo(id)) } + + public func digest(date: String?, country: String?) async throws -> DigestResponse { try await send(.digest(date, country), as: DigestResponse.self) } +} diff --git a/clients/ds-app/Sources/DSKit/Models/Auth.swift b/clients/ds-app/Sources/DSKit/Models/Auth.swift new file mode 100644 index 0000000..70e7661 --- /dev/null +++ b/clients/ds-app/Sources/DSKit/Models/Auth.swift @@ -0,0 +1,28 @@ +import Foundation + +public struct AccessTokenResponse: Codable, Sendable, Equatable { + public let accessToken: String + public let tokenType: String + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case tokenType = "token_type" + } +} + +public struct UserResponse: Codable, Sendable, Equatable, Identifiable { + public let id: Int + public let username: String + public let isActive: Bool + public let totpEnabled: Bool + public let lastLoginAtRaw: String? + + public var lastLoginAt: Date? { DSDate.parse(lastLoginAtRaw) } + + enum CodingKeys: String, CodingKey { + case id, username + case isActive = "is_active" + case totpEnabled = "totp_enabled" + case lastLoginAtRaw = "last_login_at" + } +} diff --git a/clients/ds-app/Sources/DSKit/Models/Catalog.swift b/clients/ds-app/Sources/DSKit/Models/Catalog.swift new file mode 100644 index 0000000..272a2d8 --- /dev/null +++ b/clients/ds-app/Sources/DSKit/Models/Catalog.swift @@ -0,0 +1,54 @@ +import Foundation + +/// Recursive domain tree (documents_tree.json is a top-level ARRAY). `children` is optional so +/// SwiftUI OutlineGroup can treat leaves cleanly; `kids` is the non-optional accessor. +public struct DomainTreeNode: Codable, Sendable, Identifiable { + public let name: String + public let path: String + public let count: Int + public let children: [DomainTreeNode]? + + public var id: String { path } + public var kids: [DomainTreeNode] { children ?? [] } +} + +public struct CategoryCounts: Codable, Sendable { + public let total: Int + public let documents: Int + public let byDomain: [String: Int] + public let reviewPending: Int + public let pipelineFailed: Int + + enum CodingKeys: String, CodingKey { + case total, documents + case byDomain = "by_domain" + case reviewPending = "review_pending" + case pipelineFailed = "pipeline_failed" + } +} + +public struct DuplicateGroup: Codable, Sendable, Identifiable { + public let canonicalId: Int + public let members: [Int] + public let reason: String + public let detail: String? + + public var id: Int { canonicalId } + + enum CodingKeys: String, CodingKey { + case members, reason, detail + case canonicalId = "canonical_id" + } +} + +public struct DuplicatesResponse: Codable, Sendable { + public let groups: [DuplicateGroup] + public let totalGroups: Int + public let totalDuplicateDocs: Int + + enum CodingKeys: String, CodingKey { + case groups + case totalGroups = "total_groups" + case totalDuplicateDocs = "total_duplicate_docs" + } +} diff --git a/clients/ds-app/Sources/DSKit/Models/Digest.swift b/clients/ds-app/Sources/DSKit/Models/Digest.swift new file mode 100644 index 0000000..d4d5098 --- /dev/null +++ b/clients/ds-app/Sources/DSKit/Models/Digest.swift @@ -0,0 +1,88 @@ +import Foundation + +public struct ArticleRef: Codable, Sendable, Identifiable { + public let id: Int + public let title: String? +} + +public struct TopicResponse: Codable, Sendable, Identifiable { + public let topicRank: Int + public let topicLabel: String + public let summary: String + public let articleIds: [Int]? + public let articles: [ArticleRef] + public let articleCount: Int? + public let importanceScore: Double? + public let rawWeightSum: Double? + public let llmFallbackUsed: Bool? + + public var id: Int { topicRank } + + enum CodingKeys: String, CodingKey { + case summary, articles + case topicRank = "topic_rank" + case topicLabel = "topic_label" + case articleIds = "article_ids" + case articleCount = "article_count" + case importanceScore = "importance_score" + case rawWeightSum = "raw_weight_sum" + case llmFallbackUsed = "llm_fallback_used" + } +} + +public struct CountryGroup: Codable, Sendable, Identifiable { + public let country: String + public let topics: [TopicResponse] + public var id: String { country } +} + +public struct DigestResponse: Codable, Sendable { + public let digestDateRaw: String? + public let windowStartRaw: String? + public let windowEndRaw: String? + public let decayLambda: Double? + public let totalArticles: Int? + public let totalCountries: Int? + public let totalTopics: Int? + public let generationMs: Int? + public let llmCalls: Int? + public let llmFailures: Int? + public let status: String? + public let countries: [CountryGroup] + + /// date-only (B-1 r2): DISPLAY the raw string (no Date conversion → no KST off-by-one). + public var digestDateDisplay: String { digestDateRaw ?? "" } + /// Date conversions are for sort/window math only (UTC-fixed via DSDate). + public var windowStart: Date? { DSDate.parse(windowStartRaw) } + public var windowEnd: Date? { DSDate.parse(windowEndRaw) } + + enum CodingKeys: String, CodingKey { + case status, countries + case digestDateRaw = "digest_date" + case windowStartRaw = "window_start" + case windowEndRaw = "window_end" + case decayLambda = "decay_lambda" + case totalArticles = "total_articles" + case totalCountries = "total_countries" + case totalTopics = "total_topics" + case generationMs = "generation_ms" + case llmCalls = "llm_calls" + case llmFailures = "llm_failures" + } +} + +public struct DigestDateSummary: Codable, Sendable { + public let digestDateRaw: String? + public let totalTopics: Int? + public let totalCountries: Int? + public let totalArticles: Int? + public let status: String? + + enum CodingKeys: String, CodingKey { + case status + case digestDateRaw = "digest_date" + case totalTopics = "total_topics" + case totalCountries = "total_countries" + case totalArticles = "total_articles" + } +} diff --git a/clients/ds-app/Sources/DSKit/Models/Document.swift b/clients/ds-app/Sources/DSKit/Models/Document.swift new file mode 100644 index 0000000..d2cd79e --- /dev/null +++ b/clients/ds-app/Sources/DSKit/Models/Document.swift @@ -0,0 +1,201 @@ +import Foundation + +/// Document list row. Non-optional set is intentional (B-2 review): only structural/envelope-grade +/// fields whose absence is a contract violation are non-optional, so a malformed response throws +/// loudly rather than silently nil-ing. `file_format` and `read_count` are OPTIONAL + fallback +/// (a note has no format; a light projection may omit the counter — one production null must not +/// kill the whole list decode). Everything else is optional (all-optional discipline). +public struct DocumentResponse: Codable, Sendable, Identifiable, Equatable { + // Invariants (loud-throw on absence) + public let id: Int + public let fileType: String + public let createdAtRaw: String + public let updatedAtRaw: String + // Optional + fallback + public let fileFormat: String? + public let readCount: Int? + // Everything else optional + public let filePath: String? + public let fileSize: Int? + public let title: String? + public let aiDomain: String? + public let aiSubGroup: String? + public let aiTags: [String]? + public let aiSummary: String? + public let documentType: String? + public let importance: String? + public let aiConfidence: Double? + public let userNote: String? + public let userTags: [String]? + public let pinned: Bool? + public let askIncludable: Bool? + public let derivedPath: String? + public let originalFormat: String? + public let conversionStatus: String? + public let isRead: Bool? + public let reviewStatus: String? + public let editUrl: String? + public let previewStatus: String? + public let sourceChannel: String? + public let dataOrigin: String? + public let docPurpose: String? + public let facetCompany: String? + public let facetTopic: String? + public let facetYear: Int? + public let facetDoctype: String? + public let category: String? + public let aiSuggestion: JSONValue? + public let aiTldr: String? + public let aiBullets: [String]? + public let aiDetailSummary: String? + public let aiInconsistencies: [String]? + public let aiAnalysisTier: String? + public let extractedAtRaw: String? + public let aiProcessedAtRaw: String? + public let embeddedAtRaw: String? + public let lastReadAtRaw: String? + // [S1-ADD] + public let originalFilename: String? + public let duplicateOf: Int? + public let duplicateCount: Int? + + public var createdAt: Date? { DSDate.parse(createdAtRaw) } + public var updatedAt: Date? { DSDate.parse(updatedAtRaw) } + public var lastReadAt: Date? { DSDate.parse(lastReadAtRaw) } + public var reads: Int { readCount ?? 0 } + public var displayFormat: String { + fileFormat ?? filePath.map { ($0 as NSString).pathExtension }.flatMap { $0.isEmpty ? nil : $0 } ?? "?" + } + public var downloadLabel: String { + originalFilename ?? filePath.map { ($0 as NSString).lastPathComponent } ?? "원본" + } + public var hasDownloadableOriginal: Bool { fileType != "note" } + + enum CodingKeys: String, CodingKey { + case id, title, importance, pinned, category + case fileType = "file_type" + case createdAtRaw = "created_at" + case updatedAtRaw = "updated_at" + case fileFormat = "file_format" + case readCount = "read_count" + case filePath = "file_path" + case fileSize = "file_size" + case aiDomain = "ai_domain" + case aiSubGroup = "ai_sub_group" + case aiTags = "ai_tags" + case aiSummary = "ai_summary" + case documentType = "document_type" + case aiConfidence = "ai_confidence" + case userNote = "user_note" + case userTags = "user_tags" + case askIncludable = "ask_includable" + case derivedPath = "derived_path" + case originalFormat = "original_format" + case conversionStatus = "conversion_status" + case isRead = "is_read" + case reviewStatus = "review_status" + case editUrl = "edit_url" + case previewStatus = "preview_status" + case sourceChannel = "source_channel" + case dataOrigin = "data_origin" + case docPurpose = "doc_purpose" + case facetCompany = "facet_company" + case facetTopic = "facet_topic" + case facetYear = "facet_year" + case facetDoctype = "facet_doctype" + case aiSuggestion = "ai_suggestion" + case aiTldr = "ai_tldr" + case aiBullets = "ai_bullets" + case aiDetailSummary = "ai_detail_summary" + case aiInconsistencies = "ai_inconsistencies" + case aiAnalysisTier = "ai_analysis_tier" + case extractedAtRaw = "extracted_at" + case aiProcessedAtRaw = "ai_processed_at" + case embeddedAtRaw = "embedded_at" + case lastReadAtRaw = "last_read_at" + case originalFilename = "original_filename" + case duplicateOf = "duplicate_of" + case duplicateCount = "duplicate_count" + } +} + +/// Single-document detail = the flat DocumentResponse fields + markdown/body fields, decoded from the +/// same flat JSON object via composition (avoids duplicating ~45 field declarations). The pending-md +/// fixture decodes through the SAME struct. +public struct DocumentDetailResponse: Decodable, Sendable, Identifiable { + public let base: DocumentResponse + public let extractedText: String? + public let mdContent: String? + public let mdFrontmatter: JSONValue? + public let mdStatus: String? + public let mdExtractionQuality: JSONValue? + public let mdExtractionError: String? + public let mdExtractionEngine: String? + public let mdExtractionEngineVersion: String? + public let mdGeneratedAtRaw: String? + + public var id: Int { base.id } + public var mdGeneratedAt: Date? { DSDate.parse(mdGeneratedAtRaw) } + /// MD-first render rule: render md_content when status is completed/partial, else fall back. + public var mdIsRenderable: Bool { mdStatus == "completed" || mdStatus == "partial" } + + enum CodingKeys: String, CodingKey { + case extractedText = "extracted_text" + case mdContent = "md_content" + case mdFrontmatter = "md_frontmatter" + case mdStatus = "md_status" + case mdExtractionQuality = "md_extraction_quality" + case mdExtractionError = "md_extraction_error" + case mdExtractionEngine = "md_extraction_engine" + case mdExtractionEngineVersion = "md_extraction_engine_version" + case mdGeneratedAtRaw = "md_generated_at" + } + + public init(from decoder: Decoder) throws { + self.base = try DocumentResponse(from: decoder) + let c = try decoder.container(keyedBy: CodingKeys.self) + self.extractedText = try c.decodeIfPresent(String.self, forKey: .extractedText) + self.mdContent = try c.decodeIfPresent(String.self, forKey: .mdContent) + self.mdFrontmatter = try c.decodeIfPresent(JSONValue.self, forKey: .mdFrontmatter) + self.mdStatus = try c.decodeIfPresent(String.self, forKey: .mdStatus) + self.mdExtractionQuality = try c.decodeIfPresent(JSONValue.self, forKey: .mdExtractionQuality) + self.mdExtractionError = try c.decodeIfPresent(String.self, forKey: .mdExtractionError) + self.mdExtractionEngine = try c.decodeIfPresent(String.self, forKey: .mdExtractionEngine) + self.mdExtractionEngineVersion = try c.decodeIfPresent(String.self, forKey: .mdExtractionEngineVersion) + self.mdGeneratedAtRaw = try c.decodeIfPresent(String.self, forKey: .mdGeneratedAtRaw) + } +} + +public struct DocumentListResponse: Codable, Sendable { + public let items: [DocumentResponse] + public let total: Int + public let page: Int + public let pageSize: Int + + enum CodingKeys: String, CodingKey { + case items, total, page + case pageSize = "page_size" + } +} + +public struct DocumentContentResponse: Codable, Sendable { + public let id: Int + public let title: String? + public let domain: String? + public let subGroup: String? + public let documentType: String? + public let aiSummary: String? + public let aiTags: [String]? + public let content: String? + public let contentLength: Int? + public let truncated: Bool? + + enum CodingKeys: String, CodingKey { + case id, title, domain, content, truncated + case subGroup = "sub_group" + case documentType = "document_type" + case aiSummary = "ai_summary" + case aiTags = "ai_tags" + case contentLength = "content_length" + } +} diff --git a/clients/ds-app/Sources/DSKit/Models/Memo.swift b/clients/ds-app/Sources/DSKit/Models/Memo.swift new file mode 100644 index 0000000..a0c7d62 --- /dev/null +++ b/clients/ds-app/Sources/DSKit/Models/Memo.swift @@ -0,0 +1,71 @@ +import Foundation + +public struct MemoResponse: Codable, Sendable, Identifiable { + public let id: Int + public let title: String? + public let content: String? + public let fileFormat: String? + public let fileType: String? + public let filePath: String? + public let userTags: [String]? + public let aiTags: [String]? + public let aiDomain: String? + public let aiSubGroup: String? + public let aiSummary: String? + public let pinned: Bool? + public let archived: Bool? + public let askIncludable: Bool? + public let memoTaskState: JSONValue? + public let aiEventKind: String? + public let aiEventConfidence: Double? + public let sourceChannel: String? + public let sourceMetadata: JSONValue? + public let createdAtRaw: String? + public let updatedAtRaw: String? + + public var createdAt: Date? { DSDate.parse(createdAtRaw) } + public var updatedAt: Date? { DSDate.parse(updatedAtRaw) } + public var isPinned: Bool { pinned ?? false } + public var isArchived: Bool { archived ?? false } + public var isAudio: Bool { fileType == "audio" } + + /// Checked task indices derived from memo_task_state keys (index-keyed object). + public var checkedTaskIndices: Set { + guard let o = memoTaskState?.objectValue else { return [] } + var s = Set() + for k in o.keys { if let i = Int(k) { s.insert(i) } } + return s + } + + enum CodingKeys: String, CodingKey { + case id, title, content, pinned, archived + case fileFormat = "file_format" + case fileType = "file_type" + case filePath = "file_path" + case userTags = "user_tags" + case aiTags = "ai_tags" + case aiDomain = "ai_domain" + case aiSubGroup = "ai_sub_group" + case aiSummary = "ai_summary" + case askIncludable = "ask_includable" + case memoTaskState = "memo_task_state" + case aiEventKind = "ai_event_kind" + case aiEventConfidence = "ai_event_confidence" + case sourceChannel = "source_channel" + case sourceMetadata = "source_metadata" + case createdAtRaw = "created_at" + case updatedAtRaw = "updated_at" + } +} + +public struct MemoListResponse: Codable, Sendable { + public let items: [MemoResponse] + public let total: Int + public let page: Int + public let pageSize: Int + + enum CodingKeys: String, CodingKey { + case items, total, page + case pageSize = "page_size" + } +} diff --git a/clients/ds-app/Sources/DSKit/Models/Requests.swift b/clients/ds-app/Sources/DSKit/Models/Requests.swift new file mode 100644 index 0000000..9798499 --- /dev/null +++ b/clients/ds-app/Sources/DSKit/Models/Requests.swift @@ -0,0 +1,61 @@ +import Foundation + +public enum SearchMode: String, Sendable, CaseIterable { + case text, vector, hybrid +} + +public struct DocumentListQuery: Sendable { + public var page: Int = 1 + public var pageSize: Int = 20 + public var domain: String? + public var subGroup: String? + public var source: String? + public var format: String? + public var reviewStatus: String? + public var category: String? + public init() {} +} + +public struct MemoListQuery: Sendable { + public var page: Int = 1 + public var pageSize: Int = 20 + public var pinned: Bool? + public var archived: Bool? + public init() {} +} + +public struct DocumentUpdate: Codable, Sendable { + public var title: String? + public var userNote: String? + public var pinned: Bool? + public var reviewStatus: String? + public init(title: String? = nil, userNote: String? = nil, pinned: Bool? = nil, reviewStatus: String? = nil) { + self.title = title; self.userNote = userNote; self.pinned = pinned; self.reviewStatus = reviewStatus + } + enum CodingKeys: String, CodingKey { + case title, pinned + case userNote = "user_note" + case reviewStatus = "review_status" + } +} + +public struct MemoCreate: Codable, Sendable { + public var content: String + public var title: String? + public var askIncludable: Bool? + public var sourceChannel: String? + public init(content: String, title: String? = nil, askIncludable: Bool? = nil, sourceChannel: String? = nil) { + self.content = content; self.title = title; self.askIncludable = askIncludable; self.sourceChannel = sourceChannel + } + enum CodingKeys: String, CodingKey { + case content, title + case askIncludable = "ask_includable" + case sourceChannel = "source_channel" + } +} + +public struct MemoUpdate: Codable, Sendable { + public var content: String + public var title: String? + public init(content: String, title: String? = nil) { self.content = content; self.title = title } +} diff --git a/clients/ds-app/Sources/DSKit/Models/Search.swift b/clients/ds-app/Sources/DSKit/Models/Search.swift new file mode 100644 index 0000000..09222ad --- /dev/null +++ b/clients/ds-app/Sources/DSKit/Models/Search.swift @@ -0,0 +1,103 @@ +import Foundation + +public struct SearchResult: Codable, Sendable, Identifiable { + public let id: Int // doc_id + public let title: String? + public let aiDomain: String? + public let aiSummary: String? + public let fileFormat: String? + public let score: Double? + public let snippet: String? + public let matchReason: String? + public let chunkId: Int? + public let chunkIndex: Int? + public let sectionTitle: String? + public let rerankScore: Double? + public let freshnessDebug: JSONValue? + + enum CodingKeys: String, CodingKey { + case id, title, score, snippet + case aiDomain = "ai_domain" + case aiSummary = "ai_summary" + case fileFormat = "file_format" + case matchReason = "match_reason" + case chunkId = "chunk_id" + case chunkIndex = "chunk_index" + case sectionTitle = "section_title" + case rerankScore = "rerank_score" + case freshnessDebug = "freshness_debug" + } +} + +public struct SearchResponse: Codable, Sendable { + public let results: [SearchResult] + public let total: Int + public let query: String + public let mode: String + public let debug: JSONValue? +} + +/// Ask-response citation. DISTINCT from S2's `AICitation` (Sources/AI) — different field sets; +/// RemoteDSProvider maps this -> AICitation itself. Do not reuse the S2 type here. +public struct Citation: Codable, Sendable, Identifiable { + public let n: Int + public let chunkId: Int? + public let docId: Int + public let title: String? + public let sectionTitle: String? + public let spanText: String + public let fullSnippet: String + public let relevance: Double + public let rerankScore: Double + + public var id: Int { n } + + enum CodingKeys: String, CodingKey { + case n, title, relevance + case chunkId = "chunk_id" + case docId = "doc_id" + case sectionTitle = "section_title" + case spanText = "span_text" + case fullSnippet = "full_snippet" + case rerankScore = "rerank_score" + } +} + +public struct ConfirmedItem: Codable, Sendable { + public let aspect: String + public let text: String + public let citations: [Int] +} + +public struct AskResponse: Codable, Sendable { + public let results: [SearchResult] + public let aiAnswer: String? + public let citations: [Citation] + public let synthesisStatus: String + public let synthesisMs: Double + public let confidence: String? + public let refused: Bool + public let noResultsReason: String? + public let query: String + public let total: Int + public let completeness: String + public let coveredAspects: [String]? + public let missingAspects: [String]? + public let confirmedItems: [ConfirmedItem]? + public let backendRequested: String? + public let backendUsed: String? + public let debug: JSONValue? + + enum CodingKeys: String, CodingKey { + case results, citations, confidence, refused, query, total, completeness, debug + case aiAnswer = "ai_answer" + case synthesisStatus = "synthesis_status" + case synthesisMs = "synthesis_ms" + case noResultsReason = "no_results_reason" + case coveredAspects = "covered_aspects" + case missingAspects = "missing_aspects" + case confirmedItems = "confirmed_items" + case backendRequested = "backend_requested" + case backendUsed = "backend_used" + } +} diff --git a/clients/ds-app/Sources/DSKit/Resources/ask.json b/clients/ds-app/Sources/DSKit/Resources/ask.json new file mode 100644 index 0000000..a650751 --- /dev/null +++ b/clients/ds-app/Sources/DSKit/Resources/ask.json @@ -0,0 +1,47 @@ +{ + "results": [ + { + "id": 4912, + "title": "ASME Section VIII Div 1 — Impact Test 요건", + "ai_domain": "Engineering", + "ai_summary": "압력용기 재료의 충격시험 면제/요구 조건(UCS-66 등)을 정리.", + "file_format": "pdf", + "score": 0.8714, + "snippet": "...UCS-66 면제 곡선과 MDMT 적용...", + "match_reason": "vector+rerank", + "chunk_id": 88213, + "chunk_index": 3, + "section_title": "2. UCS-66 면제 곡선", + "rerank_score": 0.913, + "freshness_debug": null + } + ], + "ai_answer": "충격시험 면제는 UCS-66 면제 곡선으로 판정합니다 [1]. 재료군(Curve A~D)과 거버닝 두께에 따라 최소설계금속온도(MDMT)에서 면제 여부가 정해지며, 설계 응력비가 낮으면 UCS-66.1에 따라 MDMT를 추가로 낮출 수 있습니다 [1].", + "citations": [ + { + "n": 1, + "chunk_id": 88213, + "doc_id": 4912, + "title": "ASME Section VIII Div 1 — Impact Test 요건", + "section_title": "2. UCS-66 면제 곡선", + "span_text": "재료군(Curve A~D)과 거버닝 두께에 따라 최소설계금속온도(MDMT)에서의 충격시험 면제 여부를 결정한다.", + "full_snippet": "재료군(Curve A~D)과 거버닝 두께에 따라 최소설계금속온도(MDMT)에서의 충격시험 면제 여부를 결정한다. 설계 응력비가 낮으면 UCS-66.1에 따라 MDMT를 추가로 낮출 수 있다. 면제되지 않는 경우 UG-84에 따라 Charpy V-notch 시험을 수행한다.", + "relevance": 0.91, + "rerank_score": 0.913 + } + ], + "synthesis_status": "completed", + "synthesis_ms": 2841.5, + "confidence": "high", + "refused": false, + "no_results_reason": null, + "query": "충격시험은 언제 면제되나", + "total": 1, + "completeness": "full", + "covered_aspects": ["면제 곡선", "MDMT 적용"], + "missing_aspects": null, + "confirmed_items": null, + "backend_requested": "mac-mini-default", + "backend_used": "gemma-macmini", + "debug": null +} diff --git a/clients/ds-app/Sources/DSKit/Resources/auth_login.json b/clients/ds-app/Sources/DSKit/Resources/auth_login.json new file mode 100644 index 0000000..cea80a3 --- /dev/null +++ b/clients/ds-app/Sources/DSKit/Resources/auth_login.json @@ -0,0 +1,4 @@ +{ + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJoeXVuZ2kiLCJ0eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzQ5MDAwMDAwfQ.FIXTURE_SIGNATURE_NOT_REAL", + "token_type": "bearer" +} diff --git a/clients/ds-app/Sources/DSKit/Resources/auth_me.json b/clients/ds-app/Sources/DSKit/Resources/auth_me.json new file mode 100644 index 0000000..c92ea4f --- /dev/null +++ b/clients/ds-app/Sources/DSKit/Resources/auth_me.json @@ -0,0 +1,7 @@ +{ + "id": 1, + "username": "hyungi", + "is_active": true, + "totp_enabled": false, + "last_login_at": "2026-06-04T07:55:12.330Z" +} diff --git a/clients/ds-app/Sources/DSKit/Resources/digest.json b/clients/ds-app/Sources/DSKit/Resources/digest.json new file mode 100644 index 0000000..3c78d0e --- /dev/null +++ b/clients/ds-app/Sources/DSKit/Resources/digest.json @@ -0,0 +1,54 @@ +{ + "digest_date": "2026-06-03", + "window_start": "2026-05-27T00:00:00.000Z", + "window_end": "2026-06-03T00:00:00.000Z", + "decay_lambda": 0.18, + "total_articles": 312, + "total_countries": 4, + "total_topics": 9, + "generation_ms": 18420, + "llm_calls": 11, + "llm_failures": 0, + "status": "completed", + "countries": [ + { + "country": "KR", + "topics": [ + { + "topic_rank": 1, + "topic_label": "산업안전 규제 개정", + "summary": "중대재해처벌법 후속 시행령 개정 논의가 이어지며 제조업 현장 점검이 강화되는 흐름.", + "article_ids": [880123, 880140, 880155], + "articles": [ + { "id": 880123, "title": "고용부, 중대재해 시행령 개정안 입법예고" }, + { "id": 880140, "title": "제조 현장 안전점검 확대" }, + { "id": 880155, "title": "압력설비 검사 주기 단축 검토" } + ], + "article_count": 3, + "importance_score": 0.91, + "raw_weight_sum": 2.74, + "llm_fallback_used": false + } + ] + }, + { + "country": "US", + "topics": [ + { + "topic_rank": 1, + "topic_label": "ASME 코드 업데이트", + "summary": "ASME BPVC 2025 에디션 관련 산업계 적용 사례와 해설 자료가 늘어남.", + "article_ids": [880301, 880322], + "articles": [ + { "id": 880301, "title": "ASME BPVC 2025 adoption notes" }, + { "id": 880322, "title": "Impact test exemption clarifications" } + ], + "article_count": 2, + "importance_score": 0.77, + "raw_weight_sum": 1.62, + "llm_fallback_used": false + } + ] + } + ] +} diff --git a/clients/ds-app/Sources/DSKit/Resources/document_content.json b/clients/ds-app/Sources/DSKit/Resources/document_content.json new file mode 100644 index 0000000..b5430c3 --- /dev/null +++ b/clients/ds-app/Sources/DSKit/Resources/document_content.json @@ -0,0 +1,12 @@ +{ + "id": 4912, + "title": "ASME Section VIII Div 1 — Impact Test 요건", + "domain": "Engineering", + "sub_group": "압력용기", + "document_type": "standard", + "ai_summary": "압력용기 재료의 충격시험 면제/요구 조건(UCS-66 등)을 정리.", + "ai_tags": ["ASME", "Section VIII", "충격시험", "UCS-66"], + "content": "ASME Section VIII Division 1 Impact Test Requirements\nUCS-66 면제 곡선과 MDMT 적용 ... (최대 15000자) ...", + "content_length": 8421, + "truncated": false +} diff --git a/clients/ds-app/Sources/DSKit/Resources/document_detail.json b/clients/ds-app/Sources/DSKit/Resources/document_detail.json new file mode 100644 index 0000000..e285003 --- /dev/null +++ b/clients/ds-app/Sources/DSKit/Resources/document_detail.json @@ -0,0 +1,63 @@ +{ + "id": 4912, + "file_path": "Engineering/ASME/ASME_SecVIII_Div1_Impact_Test.pdf", + "file_format": "pdf", + "file_size": 1338920, + "file_type": "document", + "title": "ASME Section VIII Div 1 — Impact Test 요건", + "ai_domain": "Engineering", + "ai_sub_group": "압력용기", + "ai_tags": ["ASME", "Section VIII", "충격시험", "UCS-66"], + "ai_summary": "압력용기 재료의 충격시험 면제/요구 조건(UCS-66 등)을 정리.", + "document_type": "standard", + "importance": "high", + "ai_confidence": 0.93, + "user_note": "MDMT 판정 시 자주 참조", + "user_tags": ["자주봄"], + "pinned": true, + "ask_includable": true, + "derived_path": "Engineering/ASME/ASME_SecVIII_Div1_Impact_Test.md", + "original_format": "pdf", + "conversion_status": "completed", + "is_read": true, + "review_status": "approved", + "edit_url": null, + "preview_status": "ready", + "source_channel": "upload", + "data_origin": "external", + "doc_purpose": "reference", + "facet_company": "ASME", + "facet_topic": "압력용기", + "facet_year": 2023, + "facet_doctype": "standard", + "category": "library", + "ai_suggestion": null, + "ai_tldr": "충격시험 면제 곡선(UCS-66)과 MDMT 적용.", + "ai_bullets": ["UCS-66 면제 곡선", "UCS-66.1 감액", "UG-84 시험 요건"], + "ai_detail_summary": "본 표준 절은 탄소강·저합금강 압력용기 재료의 노치 인성(충격시험) 요구를 다룬다. UCS-66 면제 곡선은 재료군(A~D)과 두께에 따라 최소설계금속온도(MDMT)에서의 시험 면제 여부를 정한다.", + "ai_inconsistencies": [], + "ai_analysis_tier": "deep", + "extracted_at": "2026-05-22T05:00:11.000Z", + "ai_processed_at": "2026-05-22T05:04:40.000Z", + "embedded_at": "2026-05-22T05:06:02.000Z", + "created_at": "2026-05-22T04:59:50.000Z", + "updated_at": "2026-06-01T09:21:33.000Z", + "read_count": 11, + "last_read_at": "2026-06-03T18:02:10.000Z", + "original_filename": "ASME_SecVIII_Div1_Impact_Test.pdf", + "duplicate_of": null, + "duplicate_count": 1, + "extracted_text": "ASME Section VIII Division 1 Impact Test Requirements\nUCS-66 ... (원문 추출 텍스트, 폴백용) ...", + "md_content": "# ASME Section VIII Div 1 — 충격시험 요건\n\n## 1. 범위\n탄소강 및 저합금강 압력용기 재료의 노치 인성(충격시험) 요구를 규정한다.\n\n## 2. UCS-66 면제 곡선\n재료군(Curve A~D)과 거버닝 두께에 따라 **최소설계금속온도(MDMT)** 에서의 충격시험 면제 여부를 결정한다.\n\n| 곡선 | 대표 재료 | 비고 |\n|---|---|---|\n| A | SA-516 (비정규화) | 가장 보수적 |\n| B | SA-516 정규화 | |\n| C | SA-537 | |\n| D | 인성 우수 재료 | 가장 관대 |\n\n## 3. UCS-66.1 감액\n설계 응력비(stress ratio)가 낮으면 MDMT 를 추가로 낮출 수 있다.\n\n## 4. UG-84 시험 요건\n면제되지 않는 경우 Charpy V-notch 시험으로 흡수에너지/측면팽창 기준을 만족해야 한다.\n", + "md_frontmatter": { + "title": "ASME Section VIII Div 1 — 충격시험 요건", + "domain": "Engineering", + "source": "ASME_SecVIII_Div1_Impact_Test.pdf" + }, + "md_status": "completed", + "md_extraction_quality": { "page_count": 14, "table_count": 3, "ocr_used": false }, + "md_extraction_error": null, + "md_extraction_engine": "marker", + "md_extraction_engine_version": "1.10.2", + "md_generated_at": "2026-05-22T05:03:30.000Z" +} diff --git a/clients/ds-app/Sources/DSKit/Resources/document_detail_pending_md.json b/clients/ds-app/Sources/DSKit/Resources/document_detail_pending_md.json new file mode 100644 index 0000000..f664d71 --- /dev/null +++ b/clients/ds-app/Sources/DSKit/Resources/document_detail_pending_md.json @@ -0,0 +1,59 @@ +{ + "id": 5301, + "file_path": "General/매뉴얼/02_왕복압축기_운전매뉴얼.docx", + "file_format": "docx", + "file_size": 73402, + "file_type": "document", + "title": "02 왕복압축기 운전 매뉴얼", + "ai_domain": "General", + "ai_sub_group": "설비매뉴얼", + "ai_tags": ["왕복압축기", "운전", "매뉴얼"], + "ai_summary": "왕복동식 압축기 기동/정지/점검 절차 매뉴얼.", + "document_type": "manual", + "importance": "normal", + "ai_confidence": 0.81, + "user_note": null, + "user_tags": null, + "pinned": false, + "ask_includable": true, + "derived_path": null, + "original_format": "docx", + "conversion_status": "pending", + "is_read": false, + "review_status": "pending", + "edit_url": null, + "preview_status": "pending", + "source_channel": "upload", + "data_origin": "internal", + "doc_purpose": "reference", + "facet_company": null, + "facet_topic": "설비매뉴얼", + "facet_year": 2024, + "facet_doctype": "manual", + "category": "library", + "ai_suggestion": null, + "ai_tldr": null, + "ai_bullets": null, + "ai_detail_summary": null, + "ai_inconsistencies": null, + "ai_analysis_tier": "triage", + "extracted_at": "2026-06-03T01:20:00.000Z", + "ai_processed_at": "2026-06-03T01:22:14.000Z", + "embedded_at": null, + "created_at": "2026-06-03T01:19:55.000Z", + "updated_at": "2026-06-03T01:22:14.000Z", + "read_count": 0, + "last_read_at": null, + "original_filename": "02_왕복압축기_운전매뉴얼.docx", + "duplicate_of": null, + "duplicate_count": 0, + "extracted_text": "왕복압축기 운전 매뉴얼\n1. 기동 전 점검\n - 윤활유 레벨 확인\n - 흡입/토출 밸브 상태 확인\n2. 기동 절차 ...", + "md_content": null, + "md_frontmatter": null, + "md_status": "pending", + "md_extraction_quality": null, + "md_extraction_error": null, + "md_extraction_engine": null, + "md_extraction_engine_version": null, + "md_generated_at": null +} diff --git a/clients/ds-app/Sources/DSKit/Resources/documents_duplicates.json b/clients/ds-app/Sources/DSKit/Resources/documents_duplicates.json new file mode 100644 index 0000000..0095e2c --- /dev/null +++ b/clients/ds-app/Sources/DSKit/Resources/documents_duplicates.json @@ -0,0 +1,18 @@ +{ + "groups": [ + { + "canonical_id": 4912, + "members": [4912, 4977], + "reason": "content_hash", + "detail": "동일 본문 해시 (md_content normalized SHA-256 일치)" + }, + { + "canonical_id": 5120, + "members": [5120, 5121, 5260], + "reason": "near_duplicate", + "detail": "제목/본문 유사도 0.97 (cross-format: pdf + docx 동일 문서)" + } + ], + "total_groups": 2, + "total_duplicate_docs": 3 +} diff --git a/clients/ds-app/Sources/DSKit/Resources/documents_list.json b/clients/ds-app/Sources/DSKit/Resources/documents_list.json new file mode 100644 index 0000000..2b892f5 --- /dev/null +++ b/clients/ds-app/Sources/DSKit/Resources/documents_list.json @@ -0,0 +1,157 @@ +{ + "items": [ + { + "id": 5187, + "file_path": "Engineering/기계가공/엘보_내경가공_절차서.pdf", + "file_format": "pdf", + "file_size": 482113, + "file_type": "document", + "title": "엘보 내경가공 절차서", + "ai_domain": "Engineering", + "ai_sub_group": "기계가공", + "ai_tags": ["엘보", "내경가공", "절차서", "가공공차"], + "ai_summary": "엘보 내경 가공 시 공차 관리와 가공 순서를 정리한 사내 절차서.", + "document_type": "procedure", + "importance": "normal", + "ai_confidence": 0.86, + "user_note": null, + "user_tags": null, + "pinned": false, + "ask_includable": true, + "derived_path": "Engineering/기계가공/엘보_내경가공_절차서.md", + "original_format": "pdf", + "conversion_status": "completed", + "is_read": true, + "review_status": "approved", + "edit_url": null, + "preview_status": "ready", + "source_channel": "upload", + "data_origin": "internal", + "doc_purpose": "reference", + "facet_company": null, + "facet_topic": "기계가공", + "facet_year": 2025, + "facet_doctype": "procedure", + "category": "library", + "ai_suggestion": null, + "ai_tldr": "엘보 내경 가공 공차·순서 절차.", + "ai_bullets": ["가공 전 소재 검사", "내경 공차 +0.1/-0.0", "최종 치수 검사 기록"], + "ai_detail_summary": null, + "ai_inconsistencies": null, + "ai_analysis_tier": "triage", + "extracted_at": "2026-05-30T02:11:04.000Z", + "ai_processed_at": "2026-05-30T02:13:51.000Z", + "embedded_at": "2026-05-30T02:15:09.000Z", + "created_at": "2026-05-30T02:10:58.000Z", + "updated_at": "2026-05-30T02:15:09.000Z", + "read_count": 3, + "last_read_at": "2026-06-02T13:40:22.000Z", + "original_filename": "엘보_내경가공_절차서.pdf", + "duplicate_of": null, + "duplicate_count": 0 + }, + { + "id": 4912, + "file_path": "Engineering/ASME/ASME_SecVIII_Div1_Impact_Test.pdf", + "file_format": "pdf", + "file_size": 1338920, + "file_type": "document", + "title": "ASME Section VIII Div 1 — Impact Test 요건", + "ai_domain": "Engineering", + "ai_sub_group": "압력용기", + "ai_tags": ["ASME", "Section VIII", "충격시험", "UCS-66"], + "ai_summary": "압력용기 재료의 충격시험 면제/요구 조건(UCS-66 등)을 정리.", + "document_type": "standard", + "importance": "high", + "ai_confidence": 0.93, + "user_note": "MDMT 판정 시 자주 참조", + "user_tags": ["자주봄"], + "pinned": true, + "ask_includable": true, + "derived_path": "Engineering/ASME/ASME_SecVIII_Div1_Impact_Test.md", + "original_format": "pdf", + "conversion_status": "completed", + "is_read": true, + "review_status": "approved", + "edit_url": null, + "preview_status": "ready", + "source_channel": "upload", + "data_origin": "external", + "doc_purpose": "reference", + "facet_company": "ASME", + "facet_topic": "압력용기", + "facet_year": 2023, + "facet_doctype": "standard", + "category": "library", + "ai_suggestion": null, + "ai_tldr": "충격시험 면제 곡선(UCS-66)과 MDMT 적용.", + "ai_bullets": ["UCS-66 면제 곡선", "UCS-66.1 감액", "UG-84 시험 요건"], + "ai_detail_summary": null, + "ai_inconsistencies": null, + "ai_analysis_tier": "deep", + "extracted_at": "2026-05-22T05:00:11.000Z", + "ai_processed_at": "2026-05-22T05:04:40.000Z", + "embedded_at": "2026-05-22T05:06:02.000Z", + "created_at": "2026-05-22T04:59:50.000Z", + "updated_at": "2026-06-01T09:21:33.000Z", + "read_count": 11, + "last_read_at": "2026-06-03T18:02:10.000Z", + "original_filename": "ASME_SecVIII_Div1_Impact_Test.pdf", + "duplicate_of": null, + "duplicate_count": 1 + }, + { + "id": 5301, + "file_path": "General/매뉴얼/02_왕복압축기_운전매뉴얼.docx", + "file_format": "docx", + "file_size": 73402, + "file_type": "document", + "title": "02 왕복압축기 운전 매뉴얼", + "ai_domain": "General", + "ai_sub_group": "설비매뉴얼", + "ai_tags": ["왕복압축기", "운전", "매뉴얼"], + "ai_summary": "왕복동식 압축기 기동/정지/점검 절차 매뉴얼.", + "document_type": "manual", + "importance": "normal", + "ai_confidence": 0.81, + "user_note": null, + "user_tags": null, + "pinned": false, + "ask_includable": true, + "derived_path": null, + "original_format": "docx", + "conversion_status": "pending", + "is_read": false, + "review_status": "pending", + "edit_url": null, + "preview_status": "pending", + "source_channel": "upload", + "data_origin": "internal", + "doc_purpose": "reference", + "facet_company": null, + "facet_topic": "설비매뉴얼", + "facet_year": 2024, + "facet_doctype": "manual", + "category": "library", + "ai_suggestion": null, + "ai_tldr": null, + "ai_bullets": null, + "ai_detail_summary": null, + "ai_inconsistencies": null, + "ai_analysis_tier": "triage", + "extracted_at": "2026-06-03T01:20:00.000Z", + "ai_processed_at": "2026-06-03T01:22:14.000Z", + "embedded_at": null, + "created_at": "2026-06-03T01:19:55.000Z", + "updated_at": "2026-06-03T01:22:14.000Z", + "read_count": 0, + "last_read_at": null, + "original_filename": "02_왕복압축기_운전매뉴얼.docx", + "duplicate_of": null, + "duplicate_count": 0 + } + ], + "total": 783, + "page": 1, + "page_size": 20 +} diff --git a/clients/ds-app/Sources/DSKit/Resources/documents_stats.json b/clients/ds-app/Sources/DSKit/Resources/documents_stats.json new file mode 100644 index 0000000..f4e8edc --- /dev/null +++ b/clients/ds-app/Sources/DSKit/Resources/documents_stats.json @@ -0,0 +1,14 @@ +{ + "total": 1163, + "documents": 783, + "by_domain": { + "Industrial_Safety": 426, + "Engineering": 351, + "General": 189, + "Programming": 60, + "법령": 23, + "Philosophy": 12 + }, + "review_pending": 725, + "pipeline_failed": 19 +} diff --git a/clients/ds-app/Sources/DSKit/Resources/documents_tree.json b/clients/ds-app/Sources/DSKit/Resources/documents_tree.json new file mode 100644 index 0000000..d2fe617 --- /dev/null +++ b/clients/ds-app/Sources/DSKit/Resources/documents_tree.json @@ -0,0 +1,16 @@ +[ + { "name": "Industrial_Safety", "path": "Industrial_Safety", "count": 426, + "children": [ + { "name": "위험성평가", "path": "Industrial_Safety/위험성평가", "count": 118, "children": [] }, + { "name": "KGS", "path": "Industrial_Safety/KGS", "count": 73, "children": [] } + ] }, + { "name": "Engineering", "path": "Engineering", "count": 351, + "children": [ + { "name": "압력용기", "path": "Engineering/압력용기", "count": 96, "children": [] }, + { "name": "기계가공", "path": "Engineering/기계가공", "count": 54, "children": [] } + ] }, + { "name": "General", "path": "General", "count": 189, "children": [] }, + { "name": "Programming", "path": "Programming", "count": 60, "children": [] }, + { "name": "법령", "path": "법령", "count": 23, "children": [] }, + { "name": "Philosophy", "path": "Philosophy", "count": 12, "children": [] } +] diff --git a/clients/ds-app/Sources/DSKit/Resources/memo_detail.json b/clients/ds-app/Sources/DSKit/Resources/memo_detail.json new file mode 100644 index 0000000..0495a0e --- /dev/null +++ b/clients/ds-app/Sources/DSKit/Resources/memo_detail.json @@ -0,0 +1,23 @@ +{ + "id": 20238, + "title": "엘보 발주 확인 건", + "content": "엘보 내경가공 발주서 금요일까지 확인.\n- [ ] 도면 rev C 기준 공차 재확인\n- [ ] 발주처에 납기 회신", + "file_format": "txt", + "user_tags": ["업무"], + "ai_tags": ["발주", "엘보"], + "ai_domain": "General", + "ai_sub_group": "업무메모", + "ai_summary": "엘보 발주서 금요일까지 확인, 도면 rev C 공차 재확인.", + "pinned": true, + "archived": false, + "ask_includable": true, + "memo_task_state": { "0": { "checked_at": "2026-06-03T10:02:00.000Z" } }, + "ai_event_kind": "task", + "ai_event_confidence": 0.78, + "source_channel": "memo", + "source_metadata": {}, + "file_type": "note", + "file_path": null, + "created_at": "2026-06-03T09:40:00.000Z", + "updated_at": "2026-06-03T10:02:00.000Z" +} diff --git a/clients/ds-app/Sources/DSKit/Resources/memos_list.json b/clients/ds-app/Sources/DSKit/Resources/memos_list.json new file mode 100644 index 0000000..53ea0ea --- /dev/null +++ b/clients/ds-app/Sources/DSKit/Resources/memos_list.json @@ -0,0 +1,53 @@ +{ + "items": [ + { + "id": 20238, + "title": "엘보 발주 확인 건", + "content": "엘보 내경가공 발주서 금요일까지 확인. 도면 rev C 기준으로 공차 재확인 필요.", + "file_format": "txt", + "user_tags": ["업무"], + "ai_tags": ["발주", "엘보"], + "ai_domain": "General", + "ai_sub_group": "업무메모", + "ai_summary": "엘보 발주서 금요일까지 확인, 도면 rev C 공차 재확인.", + "pinned": true, + "archived": false, + "ask_includable": true, + "memo_task_state": { "0": { "checked_at": "2026-06-03T10:02:00.000Z" } }, + "ai_event_kind": "task", + "ai_event_confidence": 0.78, + "source_channel": "memo", + "source_metadata": {}, + "file_type": "note", + "file_path": null, + "created_at": "2026-06-03T09:40:00.000Z", + "updated_at": "2026-06-03T10:02:00.000Z" + }, + { + "id": 20251, + "title": "음성 메모 — 현장 점검", + "content": "3공장 압축기 베어링 소음. 다음 점검 때 진동 측정 추가하기로.", + "file_format": "m4a", + "user_tags": null, + "ai_tags": ["현장", "압축기", "점검"], + "ai_domain": "Industrial_Safety", + "ai_sub_group": "현장메모", + "ai_summary": "3공장 압축기 베어링 소음, 진동 측정 추가 예정.", + "pinned": false, + "archived": false, + "ask_includable": true, + "memo_task_state": {}, + "ai_event_kind": "note", + "ai_event_confidence": 0.64, + "source_channel": "voice", + "source_metadata": { "duration_s": 23, "device": "iPhone" }, + "file_type": "audio", + "file_path": "memos/voice/2026/06/test-voice-memo.m4a", + "created_at": "2026-06-02T17:11:00.000Z", + "updated_at": "2026-06-02T17:11:40.000Z" + } + ], + "total": 4807, + "page": 1, + "page_size": 20 +} diff --git a/clients/ds-app/Sources/DSKit/Resources/search.json b/clients/ds-app/Sources/DSKit/Resources/search.json new file mode 100644 index 0000000..f17e72e --- /dev/null +++ b/clients/ds-app/Sources/DSKit/Resources/search.json @@ -0,0 +1,38 @@ +{ + "results": [ + { + "id": 4912, + "title": "ASME Section VIII Div 1 — Impact Test 요건", + "ai_domain": "Engineering", + "ai_summary": "압력용기 재료의 충격시험 면제/요구 조건(UCS-66 등)을 정리.", + "file_format": "pdf", + "score": 0.8714, + "snippet": "...UCS-66 면제 곡선과 MDMT 적용. 충격시험 면제 여부는 재료군과 두께로 결정...", + "match_reason": "vector+rerank", + "chunk_id": 88213, + "chunk_index": 3, + "section_title": "2. UCS-66 면제 곡선", + "rerank_score": 0.913, + "freshness_debug": null + }, + { + "id": 5044, + "title": "KGS FU211 §2.5 — 가스설비 충격 관련 요건", + "ai_domain": "Industrial_Safety", + "ai_summary": "KGS FU211 가스 사용시설 기준 중 충격/내압 관련 조항.", + "file_format": "pdf", + "score": 0.7321, + "snippet": "...§2.5 충격에 의한 손상 방지... §2.8 내압 시험...", + "match_reason": "vector", + "chunk_id": 90122, + "chunk_index": 1, + "section_title": "2.5", + "rerank_score": 0.742, + "freshness_debug": null + } + ], + "total": 2, + "query": "충격시험 면제", + "mode": "hybrid", + "debug": null +} diff --git a/clients/ds-app/Tests/AITests/AIRouterSmokeTests.swift b/clients/ds-app/Tests/AITests/AIRouterSmokeTests.swift new file mode 100644 index 0000000..a65f6da --- /dev/null +++ b/clients/ds-app/Tests/AITests/AIRouterSmokeTests.swift @@ -0,0 +1,84 @@ +import XCTest +@testable import AIFabric + +/// S2-Fc — AI-ROUTING.md §3 스모크 매트릭스를 mock provider(라이브 net 0)로 검증. +/// 정성 게이트(올바른 라우팅 시맨틱 + 정확한 에러 enum + 폴백 가시성). latency 는 기록만. +final class AIRouterSmokeTests: XCTestCase { + + private func askFixture() throws -> AskResponse { + try Fixture.decode(AskResponse.self, from: "ask.json") + } + + /// 모든 태스크가 1순위 성공하는 정상 패브릭. + private func healthyRouter(log: @escaping @Sendable (String) -> Void = { _ in }) throws -> AIRouter { + AIRouter(providers: [ + .onDevice: EchoProvider(id: .onDevice), + .localMLX: EchoProvider(id: .localMLX), + .remoteDS: RemoteDSProvider(client: MockDSAskClient(response: try askFixture())), + .specialized: SpecializedProvider(), // 불가 scaffold + ], log: log) + } + + func testQuickSummarizeToOnDevice() async throws { + let resp = try await healthyRouter().route(AICompletionRequest(task: .quickSummarize, prompt: "p")) + XCTAssertEqual(resp.providerUsed, .onDevice) + XCTAssertNil(resp.routingNote) + } + + func testCorpusAskToRemoteDSWithCitations() async throws { + let resp = try await healthyRouter().route(AICompletionRequest(task: .corpusAsk, prompt: "p")) + XCTAssertEqual(resp.providerUsed, .remoteDS) + XCTAssertEqual(resp.citations.count, 1) + } + + func testClassifyToLocalMLX() async throws { + let resp = try await healthyRouter().route(AICompletionRequest(task: .classify, prompt: "p")) + XCTAssertEqual(resp.providerUsed, .localMLX) + XCTAssertNil(resp.routingNote) + } + + func testVisionSpecializedUnavailableFallsToOnDeviceVisibly() async throws { + let sink = LogSink() + let resp = try await healthyRouter(log: { sink.append($0) }) + .route(AICompletionRequest(task: .vision, prompt: "p")) + XCTAssertEqual(resp.providerUsed, .onDevice) + XCTAssertEqual(resp.routingNote, "fallback from specialized → onDevice") + XCTAssertTrue(sink.lines.contains { $0.contains("specialized") && $0.contains("unavailable") }) + } + + func testExplicitOnDeviceUnavailableErrorsNoFallback() async throws { + let router = AIRouter(providers: [ + .onDevice: EchoProvider(id: .onDevice, available: false), + .localMLX: EchoProvider(id: .localMLX, available: true), + ]) + do { + _ = try await router.route(AICompletionRequest(task: .quickSummarize, prompt: "p", explicitProvider: .onDevice)) + XCTFail("explicit onDevice 불가 → 에러(자동 fallback X)") + } catch let AIRoutingError.explicitProviderUnavailable(id) { + XCTAssertEqual(id, .onDevice) + } + } + + func testRuleFallbackOnDeviceDownToLocalMLXWithNote() async throws { + let router = AIRouter(providers: [ + .onDevice: EchoProvider(id: .onDevice, available: false), + .localMLX: EchoProvider(id: .localMLX, available: true), + ]) + let resp = try await router.route(AICompletionRequest(task: .quickSummarize, prompt: "p")) + XCTAssertEqual(resp.providerUsed, .localMLX) + XCTAssertEqual(resp.routingNote, "fallback from onDevice → localMLX") + } + + func testAllUnavailableYieldsNoProviderAvailable() async throws { + let router = AIRouter(providers: [ + .onDevice: EchoProvider(id: .onDevice, available: false), + .localMLX: EchoProvider(id: .localMLX, available: false), + ]) + do { + _ = try await router.route(AICompletionRequest(task: .quickSummarize, prompt: "p")) + XCTFail("전부 불가 → noProviderAvailable") + } catch let AIRoutingError.noProviderAvailable(task) { + XCTAssertEqual(task, .quickSummarize) + } + } +} diff --git a/clients/ds-app/Tests/AITests/CompositionTests.swift b/clients/ds-app/Tests/AITests/CompositionTests.swift new file mode 100644 index 0000000..12928a9 --- /dev/null +++ b/clients/ds-app/Tests/AITests/CompositionTests.swift @@ -0,0 +1,66 @@ +import XCTest +@testable import AIFabric + +/// S2-Fa(config 단일소스) + S2-Fb(컴포지션 루트) + S2-Fe(타임아웃/취소 전파). +final class CompositionTests: XCTestCase { + + // MARK: S2-Fa — 엔드포인트 단일소스 config + + func testConfigDefaults() { + let c = AIProviderConfiguration.resolved(environment: [:]) + XCTAssertEqual(c.localMLXBaseURL.absoluteString, "http://100.76.254.116:8890") + XCTAssertEqual(c.localMLXModel, "mac-mini-default") + XCTAssertEqual(c.dsBaseURL.absoluteString, "https://document.hyungi.net/api") + XCTAssertEqual(c.probeTimeout, 2) + } + + func testConfigEnvOverride() { + let c = AIProviderConfiguration.resolved(environment: [ + "AIFABRIC_LOCALMLX_URL": "http://127.0.0.1:9999", + "AIFABRIC_LOCALMLX_MODEL": "test-model", + "AIFABRIC_DS_URL": "http://100.110.63.63:8000/api", + ]) + XCTAssertEqual(c.localMLXBaseURL.absoluteString, "http://127.0.0.1:9999") + XCTAssertEqual(c.localMLXModel, "test-model") + XCTAssertEqual(c.dsBaseURL.absoluteString, "http://100.110.63.63:8000/api") + } + + // MARK: S2-Fb — 컴포지션 루트 (4 provider 전부 등록) + + func testMakeDefaultRouterRegistersAllFour() async throws { + let client = MockDSAskClient(response: try Fixture.decode(AskResponse.self, from: "ask.json")) + let router = makeDefaultRouter(client: client, session: MockURLProtocol.session(), log: { _ in }) + XCTAssertEqual(Set(router.providers.keys), Set(AIProviderID.allCases)) + // corpusAsk 는 와이어링된 RemoteDS 로 흘러 citations 매핑. + let resp = try await router.route(AICompletionRequest(task: .corpusAsk, prompt: "p")) + XCTAssertEqual(resp.providerUsed, .remoteDS) + XCTAssertEqual(resp.citations.count, 1) + } + + func testMakeDefaultRouterVisionFallbackVisible() async throws { + let client = MockDSAskClient(response: try Fixture.decode(AskResponse.self, from: "ask.json")) + let sink = LogSink() + let router = makeDefaultRouter(client: client, session: MockURLProtocol.session(), log: { sink.append($0) }) + // specialized scaffold(불가) → onDevice. (onDevice 가용 여부는 머신 의존 — 최소한 specialized 불가 log 는 떠야) + _ = try? await router.route(AICompletionRequest(task: .vision, prompt: "p")) + XCTAssertTrue(sink.lines.contains { $0.contains("specialized") && $0.contains("unavailable") }, + "specialized 불가가 침묵 아닌 log 로 가시화") + } + + // MARK: S2-Fe — 취소 전파 (URLSession 경로 아날로그) + + func testCancellationPropagatesThroughRouter() async throws { + let router = AIRouter(providers: [.localMLX: SleepingProvider(id: .localMLX)]) + let task = Task { + try await router.route(AICompletionRequest(task: .classify, prompt: "p")) + } + try? await Task.sleep(nanoseconds: 100_000_000) + task.cancel() + do { + _ = try await task.value + XCTFail("취소된 생성은 CancellationError 전파") + } catch is CancellationError { + // 기대: URLSession async/Task.sleep 둘 다 취소 자동 honor. OnDevice respond() 도 협조적(S2-3a). + } + } +} diff --git a/clients/ds-app/Tests/AITests/FixtureSupport.swift b/clients/ds-app/Tests/AITests/FixtureSupport.swift new file mode 100644 index 0000000..6bae563 --- /dev/null +++ b/clients/ds-app/Tests/AITests/FixtureSupport.swift @@ -0,0 +1,26 @@ +// FixtureSupport.swift — canonical fixture 로더 (contract/fixtures/ 를 #filePath 기준으로 직접 읽음). +// +// 픽스처는 repo 루트의 `contract/fixtures/` 에 단일 소유(S1 ask.json 등 + S2 가 추가하는 +// foundationmodels-respond / llm-router-chat). 테스트 타깃 안에 복제하면 드리프트가 생기므로 +// 복제 대신 #filePath 에서 repo 루트를 계산해 canonical 파일을 직접 로드한다. +import Foundation + +enum Fixture { + /// repo 루트(.../ds-app-s2) — 이 파일은 /Tests/AITests/FixtureSupport.swift. + static let repoRoot: URL = URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() // Tests/AITests + .deletingLastPathComponent() // Tests + .deletingLastPathComponent() // + + static func url(_ name: String) -> URL { + repoRoot.appendingPathComponent("contract/fixtures").appendingPathComponent(name) + } + + static func data(_ name: String) throws -> Data { + try Data(contentsOf: url(name)) + } + + static func decode(_ type: T.Type, from name: String, using decoder: JSONDecoder = JSONDecoder()) throws -> T { + try decoder.decode(type, from: data(name)) + } +} diff --git a/clients/ds-app/Tests/AITests/HarnessSmokeTests.swift b/clients/ds-app/Tests/AITests/HarnessSmokeTests.swift new file mode 100644 index 0000000..d592970 --- /dev/null +++ b/clients/ds-app/Tests/AITests/HarnessSmokeTests.swift @@ -0,0 +1,18 @@ +import XCTest +@testable import AIFabric + +/// Phase 0 하니스 스모크 — Sources/AI 단독 빌드/테스트가 도는지, 동결 타입이 보이는지만 확인. +final class HarnessSmokeTests: XCTestCase { + + func testFrozenTypesVisible() { + // 동결 인터페이스 심볼이 AIFabric product 에서 보인다. + XCTAssertEqual(AIProviderID.allCases.count, 4) + XCTAssertEqual(AIRoutingPolicy.default.chain(for: .corpusAsk), [.remoteDS]) + } + + func testAskFixtureReadable() throws { + // canonical contract/fixtures/ask.json 이 로드된다 (FixtureSupport 경로 확인). + let data = try Fixture.data("ask.json") + XCTAssertGreaterThan(data.count, 0) + } +} diff --git a/clients/ds-app/Tests/AITests/LocalMLXProviderTests.swift b/clients/ds-app/Tests/AITests/LocalMLXProviderTests.swift new file mode 100644 index 0000000..d60a9ee --- /dev/null +++ b/clients/ds-app/Tests/AITests/LocalMLXProviderTests.swift @@ -0,0 +1,169 @@ +import XCTest +@testable import AIFabric + +final class LocalMLXProviderTests: XCTestCase { + + private let baseURL = URL(string: "http://100.76.254.116:8890")! + + override func tearDown() { + MockURLProtocol.reset() + super.tearDown() + } + + private func provider() -> LocalMLXProvider { + LocalMLXProvider(baseURL: baseURL, model: "mac-mini-default", session: MockURLProtocol.session()) + } + + // MARK: isAvailable probe (wake 미트리거) + + func testProbeAvailable() async throws { + MockURLProtocol.handler = { req in + MockURLProtocol.ok(req.url!, json: Data(#"{"data":[{"id":"gemma-macmini"}]}"#.utf8)) + } + let available = await provider().isAvailable + XCTAssertTrue(available) + // probe 는 GET /v1/models + XCTAssertEqual(MockURLProtocol.recorder.lastURL?.path, "/v1/models") + XCTAssertEqual(MockURLProtocol.recorder.lastMethod, "GET") + } + + func testProbeUnavailableOnError() async throws { + MockURLProtocol.handler = { _ in throw URLError(.cannotConnectToHost) } + let available = await provider().isAvailable + XCTAssertFalse(available) // 연결오류 → false(throw 아님) + } + + func testProbeUnavailableOn500() async throws { + MockURLProtocol.handler = { req in MockURLProtocol.status(req.url!, 500) } + let available = await provider().isAvailable + XCTAssertFalse(available) + } + + // MARK: complete — 응답 매핑 + 요청 call-shape 락 + + func testCompleteMapsResponseFixture() async throws { + let body = try Fixture.data("llm-router-chat.response.json") + MockURLProtocol.handler = { req in MockURLProtocol.ok(req.url!, json: body) } + + let resp = try await provider().complete( + AICompletionRequest(task: .quickSummarize, prompt: "충격시험 면제 기준을 한 문장으로 요약해줘.", + systemPrompt: "You are a concise technical assistant.", maxTokens: 512) + ) + XCTAssertEqual(resp.providerUsed, .localMLX) + XCTAssertEqual(resp.finishReason, .completed) + XCTAssertTrue(resp.citations.isEmpty) + XCTAssertNotNil(resp.latencyMs) + XCTAssertTrue(resp.text.contains("면제")) // 실측 응답 본문 + } + + func testCompleteRequestCallShape() async throws { + let body = try Fixture.data("llm-router-chat.response.json") + MockURLProtocol.handler = { req in MockURLProtocol.ok(req.url!, json: body) } + + _ = try await provider().complete( + AICompletionRequest(task: .quickSummarize, prompt: "PROMPT_X", + systemPrompt: "SYS_Y", maxTokens: 512) + ) + // POST /v1/chat/completions + XCTAssertEqual(MockURLProtocol.recorder.lastURL?.path, "/v1/chat/completions") + XCTAssertEqual(MockURLProtocol.recorder.lastMethod, "POST") + + // messages system/user 분리 call-shape (load-bearing) + let sent = try XCTUnwrap(MockURLProtocol.recorder.lastBody) + let decoded = try JSONDecoder().decode(SentRequest.self, from: sent) + XCTAssertEqual(decoded.model, "mac-mini-default") + XCTAssertEqual(decoded.maxTokens, 512) + XCTAssertEqual(decoded.stream, false) + XCTAssertEqual(decoded.messages.count, 2) + XCTAssertEqual(decoded.messages[0].role, "system") + XCTAssertEqual(decoded.messages[0].content, "SYS_Y") + XCTAssertEqual(decoded.messages[1].role, "user") + XCTAssertEqual(decoded.messages[1].content, "PROMPT_X") + } + + func testNilSystemPromptSendsEmptySystemMessage() async throws { + let body = try Fixture.data("llm-router-chat.response.json") + MockURLProtocol.handler = { req in MockURLProtocol.ok(req.url!, json: body) } + _ = try await provider().complete(AICompletionRequest(task: .quickSummarize, prompt: "P")) + let sent = try XCTUnwrap(MockURLProtocol.recorder.lastBody) + let decoded = try JSONDecoder().decode(SentRequest.self, from: sent) + XCTAssertEqual(decoded.messages[0].role, "system") + XCTAssertEqual(decoded.messages[0].content, "") // plan S2-2c: systemPrompt ?? "" + } + + func testNon200BackendError() async throws { + MockURLProtocol.handler = { req in MockURLProtocol.status(req.url!, 503, body: "model loading") } + do { + _ = try await provider().complete(AICompletionRequest(task: .quickSummarize, prompt: "P")) + XCTFail("non-200 must throw backendError, not silent empty text") + } catch let AIProviderError.backendError(id, status, reason) { + XCTAssertEqual(id, .localMLX) + XCTAssertEqual(status, 503) + XCTAssertEqual(reason, "model loading") + } + } + + func testRequestFixtureMatchesEncoder() throws { + // 동결 request fixture 의 call-shape 가 encodeRequest 산출과 일치(템플릿 placeholder 회귀 방지). + let fixtureData = try Fixture.data("llm-router-chat.request.json") + let fixture = try JSONDecoder().decode(SentRequest.self, from: fixtureData) + XCTAssertEqual(fixture.messages.count, 2) + XCTAssertEqual(fixture.messages[0].role, "system") + XCTAssertEqual(fixture.messages[1].role, "user") + XCTAssertEqual(fixture.stream, false) + } + + // MARK: rule-fallback 라우팅 (S2-2d) — onDevice 불가 → localMLX + + func testFallbackFromOnDeviceToLocalMLX() async throws { + let body = try Fixture.data("llm-router-chat.response.json") + MockURLProtocol.handler = { req in MockURLProtocol.ok(req.url!, json: body) } + let router = AIRouter(providers: [ + .onDevice: MockAIProvider(id: .onDevice, available: false), // 불가 + .localMLX: provider(), + ]) + let resp = try await router.route(AICompletionRequest(task: .quickSummarize, prompt: "P")) + XCTAssertEqual(resp.providerUsed, .localMLX) + XCTAssertEqual(resp.routingNote, "fallback from onDevice → localMLX") + } + + func testNoFallbackNoteOnFirstChoiceSuccess() async throws { + let body = try Fixture.data("llm-router-chat.response.json") + MockURLProtocol.handler = { req in MockURLProtocol.ok(req.url!, json: body) } + // classify 체인 = [.localMLX, .remoteDS, .onDevice] → 1순위 localMLX 성공 → note nil + let router = AIRouter(providers: [.localMLX: provider()]) + let resp = try await router.route(AICompletionRequest(task: .classify, prompt: "P")) + XCTAssertEqual(resp.providerUsed, .localMLX) + XCTAssertNil(resp.routingNote) + } + + // MARK: 라이브 통합 (맥미니 llm-router :8890 — offline 시 skip) + + func testLiveLocalMLXIfReachable() async throws { + let live = LocalMLXProvider(baseURL: URL(string: "http://100.76.254.116:8890")!) // 실 URLSession, Tailscale + let reachable = await live.isAvailable + guard reachable else { + throw XCTSkip("llm-router :8890 도달 불가(맥미니 offline) — 라이브 테스트 skip") + } + let resp = try await live.complete( + AICompletionRequest(task: .quickSummarize, + prompt: "엘보 내경 가공 핵심을 한 문장으로 요약해줘.", + systemPrompt: "You are a concise technical assistant.", + maxTokens: 200) + ) + XCTAssertEqual(resp.providerUsed, .localMLX) + XCTAssertEqual(resp.finishReason, .completed) + XCTAssertFalse(resp.text.isEmpty, "라이브 응답은 비어있지 않아야") + XCTAssertNotNil(resp.latencyMs) + } + + /// 나간 요청 디코딩용(테스트 전용 미러). + struct SentRequest: Decodable { + struct Message: Decodable { let role: String; let content: String } + let model: String + let messages: [Message] + let maxTokens: Int? + let stream: Bool + enum CodingKeys: String, CodingKey { case model, messages, stream; case maxTokens = "max_tokens" } + } +} diff --git a/clients/ds-app/Tests/AITests/MockURLProtocol.swift b/clients/ds-app/Tests/AITests/MockURLProtocol.swift new file mode 100644 index 0000000..7c3d08b --- /dev/null +++ b/clients/ds-app/Tests/AITests/MockURLProtocol.swift @@ -0,0 +1,86 @@ +import Foundation + +/// 테스트용 URLProtocol — URLSession 을 가로채 canned 응답/에러를 돌려주고, 나간 요청을 기록. +/// 라이브 네트워크 0 으로 LocalMLX 의 probe/complete call-shape 를 검증. +final class MockURLProtocol: URLProtocol { + /// (request) -> (response, body). throw 하면 URLSession 에러 경로. + nonisolated(unsafe) static var handler: (@Sendable (URLRequest) throws -> (HTTPURLResponse, Data))? + /// 마지막으로 가로챈 요청(body 포함) 기록. + nonisolated(unsafe) static var recorder = RequestRecorder() + + static func reset() { + handler = nil + recorder = RequestRecorder() + } + + override class func canInit(with request: URLRequest) -> Bool { true } + override class func canonicalRequest(for request: URLRequest) -> URLRequest { request } + + override func startLoading() { + Self.recorder.record(request) + guard let handler = Self.handler else { + client?.urlProtocol(self, didFailWithError: URLError(.unsupportedURL)) + return + } + do { + let (response, data) = try handler(request) + client?.urlProtocol(self, didReceive: response, cacheStoragePolicy: .notAllowed) + client?.urlProtocol(self, didLoad: data) + client?.urlProtocolDidFinishLoading(self) + } catch { + client?.urlProtocol(self, didFailWithError: error) + } + } + + override func stopLoading() {} + + // MARK: helpers + + static func session() -> URLSession { + let config = URLSessionConfiguration.ephemeral + config.protocolClasses = [MockURLProtocol.self] + return URLSession(configuration: config) + } + + static func ok(_ url: URL, json: Data) -> (HTTPURLResponse, Data) { + (HTTPURLResponse(url: url, statusCode: 200, httpVersion: nil, headerFields: nil)!, json) + } + + static func status(_ url: URL, _ code: Int, body: String = "") -> (HTTPURLResponse, Data) { + (HTTPURLResponse(url: url, statusCode: code, httpVersion: nil, headerFields: nil)!, Data(body.utf8)) + } +} + +/// 나간 요청 기록(body 는 httpBody 또는 httpBodyStream 에서 추출 — URLProtocol 은 보통 stream 으로 전달). +final class RequestRecorder: @unchecked Sendable { + private(set) var lastURL: URL? + private(set) var lastMethod: String? + private(set) var lastBody: Data? + private(set) var callCount = 0 + + func record(_ request: URLRequest) { + callCount += 1 + lastURL = request.url + lastMethod = request.httpMethod + lastBody = request.bodyData + } +} + +extension URLRequest { + /// URLProtocol 단계에서 body 추출 — httpBody 가 nil 이면 httpBodyStream 에서 읽음. + var bodyData: Data? { + if let httpBody { return httpBody } + guard let stream = httpBodyStream else { return nil } + stream.open() + defer { stream.close() } + var data = Data() + let bufSize = 8192 + var buffer = [UInt8](repeating: 0, count: bufSize) + while stream.hasBytesAvailable { + let read = stream.read(&buffer, maxLength: bufSize) + if read <= 0 { break } + data.append(buffer, count: read) + } + return data + } +} diff --git a/clients/ds-app/Tests/AITests/OnDeviceProviderTests.swift b/clients/ds-app/Tests/AITests/OnDeviceProviderTests.swift new file mode 100644 index 0000000..88676ce --- /dev/null +++ b/clients/ds-app/Tests/AITests/OnDeviceProviderTests.swift @@ -0,0 +1,201 @@ +import XCTest +@testable import AIFabric +#if canImport(FoundationModels) +import FoundationModels +#endif + +/// HW 비의존 mock backend — availability + generate 결과를 주입. +struct MockOnDeviceBackend: OnDeviceModelBackend { + let avail: OnDeviceAvailability + let outcome: Result + + init(avail: OnDeviceAvailability = .available, + outcome: Result = .success("on-device ok")) { + self.avail = avail + self.outcome = outcome + } + var availability: OnDeviceAvailability { avail } + func generate(prompt: String, systemPrompt: String?, maxTokens: Int?) async throws -> String { + switch outcome { + case .success(let s): return s + case .failure(let e): throw e + } + } +} + +/// complete() 호출 횟수를 세는 provider — 무폴백 단언용. +actor CountingProvider: AIProvider { + nonisolated let id: AIProviderID + let available: Bool + private(set) var completeCalls = 0 + + init(id: AIProviderID, available: Bool) { + self.id = id + self.available = available + } + var isAvailable: Bool { get async { available } } + func complete(_ request: AICompletionRequest) async throws -> AICompletionResponse { + completeCalls += 1 + return AICompletionResponse(text: "should-not-be-called", providerUsed: id) + } +} + +final class LogSink: @unchecked Sendable { + private let lock = NSLock() + private var storage: [String] = [] + func append(_ s: String) { lock.lock(); storage.append(s); lock.unlock() } + var lines: [String] { lock.lock(); defer { lock.unlock() }; return storage } +} + +final class OnDeviceProviderTests: XCTestCase { + + // MARK: 가용성 + happy path (주입 backend) + + func testAvailableReturnsText() async throws { + let p = OnDeviceProvider(backend: MockOnDeviceBackend(avail: .available, outcome: .success("요약 결과"))) + let available = await p.isAvailable + XCTAssertTrue(available) + let resp = try await p.complete(AICompletionRequest(task: .quickSummarize, prompt: "p")) + XCTAssertEqual(resp.providerUsed, .onDevice) + XCTAssertEqual(resp.finishReason, .completed) + XCTAssertEqual(resp.text, "요약 결과") + XCTAssertNotNil(resp.latencyMs) + } + + func testUnavailableReportsFalse() async throws { + let p = OnDeviceProvider(backend: MockOnDeviceBackend(avail: .unavailable(reason: "appleIntelligenceNotEnabled"))) + let available = await p.isAvailable + XCTAssertFalse(available) + } + + // MARK: GenerationError 매핑 (S2-3c) + + func testGuardrailAndRefusalReturnRefused() async throws { + for err in [OnDeviceGenerationError.guardrailViolation, .refusal] { + let p = OnDeviceProvider(backend: MockOnDeviceBackend(outcome: .failure(err))) + let resp = try await p.complete(AICompletionRequest(task: .quickSummarize, prompt: "p")) + XCTAssertEqual(resp.finishReason, .refused, "\(err) → .refused (답변의 일종, 폴백 X)") + XCTAssertEqual(resp.providerUsed, .onDevice) + } + } + + func testRateLimitedThrowsUnavailableAndLoudLogs() async throws { + let sink = LogSink() + let p = OnDeviceProvider(backend: MockOnDeviceBackend(outcome: .failure(.rateLimited)), + log: { sink.append($0) }) + do { + _ = try await p.complete(AICompletionRequest(task: .quickSummarize, prompt: "p")) + XCTFail("rateLimited → throw unavailable") + } catch let AIProviderError.unavailable(id) { + XCTAssertEqual(id, .onDevice) + } + XCTAssertTrue(sink.lines.contains { $0.contains("rateLimited") }, "stateless 위반은 loud log") + } + + func testConcurrentRequestsThrowsUnavailableAndLoudLogs() async throws { + let sink = LogSink() + let p = OnDeviceProvider(backend: MockOnDeviceBackend(outcome: .failure(.concurrentRequests)), + log: { sink.append($0) }) + do { + _ = try await p.complete(AICompletionRequest(task: .quickSummarize, prompt: "p")) + XCTFail("concurrentRequests → throw unavailable") + } catch let AIProviderError.unavailable(id) { + XCTAssertEqual(id, .onDevice) + } + XCTAssertTrue(sink.lines.contains { $0.contains("concurrentRequests") }) + } + + func testContextOverflowThrowsUnavailable() async throws { + let p = OnDeviceProvider(backend: MockOnDeviceBackend(outcome: .failure(.exceededContextWindowSize))) + do { + _ = try await p.complete(AICompletionRequest(task: .quickSummarize, prompt: "p")) + XCTFail("exceededContextWindowSize → throw unavailable (폴백 유도)") + } catch let AIProviderError.unavailable(id) { + XCTAssertEqual(id, .onDevice) + } + } + + // MARK: 라우팅 (S2-3d) + + func testRouterFallsBackOnDeviceOverflowToLocalMLX() async throws { + let router = AIRouter(providers: [ + .onDevice: OnDeviceProvider(backend: MockOnDeviceBackend(outcome: .failure(.exceededContextWindowSize))), + .localMLX: MockAIProvider(id: .localMLX, available: true), + ]) + let resp = try await router.route(AICompletionRequest(task: .quickSummarize, prompt: "p")) + XCTAssertEqual(resp.providerUsed, .localMLX) + XCTAssertEqual(resp.routingNote, "fallback from onDevice → localMLX") + } + + func testExplicitOnDeviceUnavailableNoFallback() async throws { + let counting = CountingProvider(id: .localMLX, available: true) + let router = AIRouter(providers: [ + .onDevice: OnDeviceProvider(backend: MockOnDeviceBackend(avail: .unavailable(reason: "deviceNotEligible"))), + .localMLX: counting, + ]) + do { + _ = try await router.route(AICompletionRequest(task: .quickSummarize, prompt: "p", explicitProvider: .onDevice)) + XCTFail("explicit onDevice unavailable → explicitProviderUnavailable, 자동 폴백 금지") + } catch let AIRoutingError.explicitProviderUnavailable(id) { + XCTAssertEqual(id, .onDevice) + } + let calls = await counting.completeCalls + XCTAssertEqual(calls, 0, "명시 불가 시 타 provider complete() 호출 0") + } + + // MARK: SDK GenerationError → 중립 매핑 lock (구성 가능한 케이스) + + #if canImport(FoundationModels) + func testTranslateGenerationErrorCases() { + let ctx = LanguageModelSession.GenerationError.Context(debugDescription: "test") + XCTAssertEqual(FoundationModelsBackend.translate(.exceededContextWindowSize(ctx)), .exceededContextWindowSize) + XCTAssertEqual(FoundationModelsBackend.translate(.guardrailViolation(ctx)), .guardrailViolation) + XCTAssertEqual(FoundationModelsBackend.translate(.rateLimited(ctx)), .rateLimited) + XCTAssertEqual(FoundationModelsBackend.translate(.concurrentRequests(ctx)), .concurrentRequests) + XCTAssertEqual(FoundationModelsBackend.translate(.unsupportedLanguageOrLocale(ctx)), .unsupportedLanguageOrLocale) + XCTAssertEqual(FoundationModelsBackend.translate(.assetsUnavailable(ctx)), .assetsUnavailable) + } + #endif + + // MARK: 라이브 통합 (M5 Max — 비-AI Mac 은 skip) + + func testLiveOnDeviceIntegration() async throws { + let p = OnDeviceProvider() // 라이브 FoundationModels backend + guard await p.isAvailable else { + throw XCTSkip("FoundationModels not available on this machine — live test skipped") + } + let resp = try await p.complete( + AICompletionRequest(task: .quickSummarize, + prompt: "엘보 내경 가공의 핵심 관리 포인트를 한 문장으로 요약해줘.", + maxTokens: 120) + ) + XCTAssertEqual(resp.providerUsed, .onDevice) + XCTAssertEqual(resp.finishReason, .completed) + XCTAssertFalse(resp.text.isEmpty, "라이브 응답은 비어있지 않아야") + } + + func testLiveCancellationCooperative() async throws { + let p = OnDeviceProvider() + guard await p.isAvailable else { + throw XCTSkip("FoundationModels not available — cancellation live test skipped") + } + let started = Date() + let task = Task { () -> AIFinishReason in + let r = try await p.complete( + AICompletionRequest(task: .quickSummarize, + prompt: "대한민국 압력용기 산업과 ASME 표준 채택 역사를 아주 길고 자세하게 여러 단락으로 서술해줘.", + maxTokens: 4000) + ) + return r.finishReason + } + try? await Task.sleep(nanoseconds: 500_000_000) + task.cancel() + do { + _ = try await task.value + // 협조적이지 않으면 완주 — 정보로만(테스트 실패시키지 않음, 환경 의존). + } catch is CancellationError { + let elapsed = Date().timeIntervalSince(started) + XCTAssertLessThan(elapsed, 8.0, "협조적 취소면 빠르게 중단(S2-3a: ~33ms 후)") + } + } +} diff --git a/clients/ds-app/Tests/AITests/RemoteDSProviderTests.swift b/clients/ds-app/Tests/AITests/RemoteDSProviderTests.swift new file mode 100644 index 0000000..992a51c --- /dev/null +++ b/clients/ds-app/Tests/AITests/RemoteDSProviderTests.swift @@ -0,0 +1,151 @@ +import XCTest +@testable import AIFabric + +/// 테스트용 DS client — ask.json 등 fixture 를 반환하거나 에러를 던지고, 받은 call-shape 를 기록. +actor MockDSAskClient: DSAskClient { + let response: AskResponse? + let error: Error? + private(set) var lastBackend: String? + private(set) var lastQuery: String? + private(set) var callCount = 0 + + init(response: AskResponse? = nil, error: Error? = nil) { + self.response = response + self.error = error + } + + func ask(query: String, backend: String) async throws -> AskResponse { + callCount += 1 + lastBackend = backend + lastQuery = query + if let error { throw error } + return response! + } +} + +final class RemoteDSProviderTests: XCTestCase { + + private func askFixture() throws -> AskResponse { + try Fixture.decode(AskResponse.self, from: "ask.json") + } + + // MARK: ask.json 디코딩 + 매핑 (call-shape 회귀) + + func testAskJsonDecodeAndMap() throws { + let r = try askFixture() + XCTAssertEqual(r.synthesisStatus, "completed") + XCTAssertEqual(r.confidence, "high") + XCTAssertEqual(r.backendUsed, "gemma-macmini") + XCTAssertEqual(r.citations.count, 1) + XCTAssertEqual(r.citations[0].docId, 4912) + XCTAssertEqual(r.citations[0].n, 1) + XCTAssertEqual(r.citations[0].sectionTitle, "2. UCS-66 면제 곡선") + + let mapped = RemoteDSProvider.map(r) + XCTAssertEqual(mapped.providerUsed, .remoteDS) + XCTAssertEqual(mapped.finishReason, .completed) + XCTAssertEqual(mapped.citations.count, 1) + XCTAssertEqual(mapped.citations[0].docId, 4912) + XCTAssertEqual(mapped.confidence, .high) + XCTAssertEqual(mapped.routingNote, "gemma-macmini") + XCTAssertEqual(mapped.latencyMs, 2841.5) + XCTAssertEqual(mapped.text, r.aiAnswer) + } + + func testCompleteMapsFixture() async throws { + let mock = MockDSAskClient(response: try askFixture()) + let provider = RemoteDSProvider(client: mock) + let resp = try await provider.complete( + AICompletionRequest(task: .corpusAsk, prompt: "충격시험은 언제 면제되나") + ) + XCTAssertEqual(resp.providerUsed, .remoteDS) + XCTAssertEqual(resp.citations.count, 1) + XCTAssertEqual(resp.finishReason, .completed) + XCTAssertEqual(resp.routingNote, "gemma-macmini") + } + + // MARK: backend exhaustive switch call-shape (쿼리 파라미터 락) + + func testBackendCallShape_nilExplicit() async throws { + let mock = MockDSAskClient(response: try askFixture()) + let provider = RemoteDSProvider(client: mock) + _ = try await provider.complete(AICompletionRequest(task: .corpusAsk, prompt: "q")) + let backend = await mock.lastBackend + XCTAssertEqual(backend, "mac-mini-default") // 미지정 → DS 기본 + } + + func testBackendCallShape_localMLXExplicit() async throws { + let mock = MockDSAskClient(response: try askFixture()) + let provider = RemoteDSProvider(client: mock) + _ = try await provider.complete( + AICompletionRequest(task: .corpusAsk, prompt: "q", explicitProvider: .localMLX) + ) + let backend = await mock.lastBackend + XCTAssertEqual(backend, "gemma-macmini") + } + + func testBackendMapPure() { + XCTAssertEqual(RemoteDSProvider.dsBackend(for: nil), "mac-mini-default") + XCTAssertEqual(RemoteDSProvider.dsBackend(for: .localMLX), "gemma-macmini") + XCTAssertEqual(RemoteDSProvider.dsBackend(for: .remoteDS), "mac-mini-default") + XCTAssertEqual(RemoteDSProvider.dsBackend(for: .onDevice), "mac-mini-default") + XCTAssertEqual(RemoteDSProvider.dsBackend(for: .specialized), "mac-mini-default") + } + + func testNonCorpusTaskNotImplemented() async throws { + let mock = MockDSAskClient(response: try askFixture()) + let provider = RemoteDSProvider(client: mock) + do { + _ = try await provider.complete(AICompletionRequest(task: .quickSummarize, prompt: "q")) + XCTFail("non-corpus task should not be served by RemoteDS") + } catch let AIProviderError.notImplemented(id) { + XCTAssertEqual(id, .remoteDS) + } + } + + // MARK: 라우팅 — corpusAsk 무폴백 (오프라인에서도 정답) + + func testCorpusAskRoutesToRemoteDSOnly() async throws { + let router = AIRouter(providers: [ + .remoteDS: RemoteDSProvider(client: MockDSAskClient(response: try askFixture())), + .onDevice: MockAIProvider(id: .onDevice, available: true), // available 이어도 corpusAsk 폴백 금지 + ]) + let resp = try await router.route(AICompletionRequest(task: .corpusAsk, prompt: "q")) + XCTAssertEqual(resp.providerUsed, .remoteDS) + XCTAssertEqual(resp.citations.count, 1) + } + + func testCorpusAskRemoteDSDown_NoLocalFallback() async throws { + // remoteDS 불가 → 엉뚱한 온디바이스 코퍼스 답변 대신 명확한 실패(무폴백). + struct Net: Error {} + let router = AIRouter(providers: [ + .remoteDS: RemoteDSProvider(client: MockDSAskClient(error: Net())), + .onDevice: MockAIProvider(id: .onDevice, available: true), + ]) + do { + _ = try await router.route(AICompletionRequest(task: .corpusAsk, prompt: "q")) + XCTFail("corpusAsk must not fall back to onDevice") + } catch is Net { + // 기대: remoteDS 의 실패가 전파(코퍼스 체인 = [.remoteDS] only) + } + } + + // MARK: S2-4b — cloud 'claude-cloud' = 명시 503 (로컬 대체 응답 금지) + + func testCloud503Surfaces_NoSilentFallback() async throws { + let err = AIProviderError.backendError(.remoteDS, status: 503, reason: "cloud backend pending activation") + let router = AIRouter(providers: [ + .remoteDS: RemoteDSProvider(client: MockDSAskClient(error: err)), + .onDevice: MockAIProvider(id: .onDevice, available: true), + ]) + do { + _ = try await router.route( + AICompletionRequest(task: .corpusAsk, prompt: "q", explicitProvider: .remoteDS) + ) + XCTFail("503 must surface, not fall back") + } catch let AIProviderError.backendError(id, status, _) { + XCTAssertEqual(id, .remoteDS) + XCTAssertEqual(status, 503) + } + } +} diff --git a/clients/ds-app/Tests/AITests/SpecializedProviderTests.swift b/clients/ds-app/Tests/AITests/SpecializedProviderTests.swift new file mode 100644 index 0000000..7809f53 --- /dev/null +++ b/clients/ds-app/Tests/AITests/SpecializedProviderTests.swift @@ -0,0 +1,39 @@ +import XCTest +@testable import AIFabric + +final class SpecializedProviderTests: XCTestCase { + + func testScaffoldUnavailableAndNotImplemented() async throws { + let p = SpecializedProvider() + let available = await p.isAvailable + XCTAssertFalse(available) + do { + _ = try await p.complete(AICompletionRequest(task: .vision, prompt: "p")) + XCTFail("scaffold must throw notImplemented") + } catch let AIProviderError.notImplemented(id) { + XCTAssertEqual(id, .specialized) + } + } + + /// .vision 체인 [.specialized, .onDevice] — specialized 등록·불가 → onDevice 로 **가시** 폴백(라우터 log). + /// (onDevice 는 providerUsed=id 를 정직히 반환하는 CountingProvider 사용 — MockAIProvider 는 vision 케이스에서 + /// providerUsed 를 .specialized 로 하드코딩하므로 부적합.) + func testVisionFallsBackToOnDeviceVisibly() async throws { + let sink = LogSink() + let onDevice = CountingProvider(id: .onDevice, available: true) + let router = AIRouter( + providers: [ + .specialized: SpecializedProvider(), + .onDevice: onDevice, + ], + log: { sink.append($0) } + ) + let resp = try await router.route(AICompletionRequest(task: .vision, prompt: "도면 보기")) + XCTAssertEqual(resp.providerUsed, .onDevice) + XCTAssertEqual(resp.routingNote, "fallback from specialized → onDevice") + let calls = await onDevice.completeCalls + XCTAssertEqual(calls, 1) + XCTAssertTrue(sink.lines.contains { $0.contains("specialized") && $0.contains("unavailable") }, + "specialized 불가가 침묵 아닌 log 로 가시화") + } +} diff --git a/clients/ds-app/Tests/AITests/TestProviders.swift b/clients/ds-app/Tests/AITests/TestProviders.swift new file mode 100644 index 0000000..ae512bc --- /dev/null +++ b/clients/ds-app/Tests/AITests/TestProviders.swift @@ -0,0 +1,28 @@ +import Foundation +@testable import AIFabric + +/// providerUsed=id 를 정직히 반환하는 테스트 provider(MockAIProvider 는 일부 태스크에서 providerUsed 를 하드코딩). +struct EchoProvider: AIProvider { + let id: AIProviderID + let available: Bool + init(id: AIProviderID, available: Bool = true) { + self.id = id + self.available = available + } + var isAvailable: Bool { get async { available } } + func complete(_ request: AICompletionRequest) async throws -> AICompletionResponse { + AICompletionResponse(text: "echo:\(id.rawValue)", providerUsed: id) + } +} + +/// 취소 전파 테스트용 — sleep 중 Task 취소 시 CancellationError(S2-Fe URLSession 경로 아날로그). +struct SleepingProvider: AIProvider { + let id: AIProviderID + init(id: AIProviderID = .localMLX) { self.id = id } + var isAvailable: Bool { get async { true } } + func complete(_ request: AICompletionRequest) async throws -> AICompletionResponse { + try await Task.sleep(nanoseconds: 5_000_000_000) // 취소되면 CancellationError throw + try Task.checkCancellation() + return AICompletionResponse(text: "done", providerUsed: id) + } +} diff --git a/clients/ds-app/Tests/DSKitTests/ClientPlumbingTests.swift b/clients/ds-app/Tests/DSKitTests/ClientPlumbingTests.swift new file mode 100644 index 0000000..61693a6 --- /dev/null +++ b/clients/ds-app/Tests/DSKitTests/ClientPlumbingTests.swift @@ -0,0 +1,82 @@ +import XCTest +@testable import DSKit + +/// Offline tests for the live-client plumbing (the live HTTP path itself needs a real backend, FU-A). +final class ClientPlumbingTests: XCTestCase { + + func testEndpointPathsAndMethods() { + XCTAssertEqual(DSEndpoint.documents(DocumentListQuery()).path, "documents/") + XCTAssertEqual(DSEndpoint.documents(DocumentListQuery()).method, "GET") + XCTAssertEqual(DSEndpoint.document(4912).path, "documents/4912") + XCTAssertEqual(DSEndpoint.documentTree.path, "documents/tree") + XCTAssertEqual(DSEndpoint.ask("q", nil, nil, nil).path, "search/ask") + XCTAssertEqual(DSEndpoint.pinMemo(5, true).method, "PATCH") + XCTAssertEqual(DSEndpoint.pinMemo(5, true).path, "memos/5/pin") + XCTAssertEqual(DSEndpoint.toggleMemoTask(5, 2, true).path, "memos/5/tasks/2") + XCTAssertEqual(DSEndpoint.putContent(7, "x").method, "PUT") + XCTAssertEqual(DSEndpoint.deleteMemo(9).method, "DELETE") + } + + func testEndpointBearerRule() { + XCTAssertFalse(DSEndpoint.login("u", "p", nil).requiresBearer) + XCTAssertFalse(DSEndpoint.refresh.requiresBearer) + XCTAssertTrue(DSEndpoint.me.requiresBearer) + XCTAssertTrue(DSEndpoint.documents(DocumentListQuery()).requiresBearer) + } + + func testEndpointQueryItems() { + let items = DSEndpoint.ask("충격시험", 5, "gemma-macmini", nil).queryItems + let dict = Dictionary(uniqueKeysWithValues: items.map { ($0.name, $0.value) }) + XCTAssertEqual(dict["q"], "충격시험") + XCTAssertEqual(dict["backend"], "gemma-macmini") + XCTAssertEqual(dict["limit"], "5") + XCTAssertNil(dict["debug"]) // nil params are skipped, not sent as "nil" + } + + func testEndpointBodies() throws { + let encoder = DSEncoder.make() + let pinBody = try DSEndpoint.pinMemo(5, true).httpBody(encoder) + XCTAssertEqual(String(data: pinBody ?? Data(), encoding: .utf8), #"{"pinned":true}"#) + let loginBody = try XCTUnwrap(DSEndpoint.login("hyungi", "pw", nil).httpBody(encoder)) + let s = String(data: loginBody, encoding: .utf8) ?? "" + XCTAssertTrue(s.contains("\"username\":\"hyungi\"")) + XCTAssertFalse(s.contains("totp_code")) // nil optional omitted + XCTAssertNil(try DSEndpoint.me.httpBody(encoder)) // GET has no body + } + + /// Single-flight refresh: N concurrent refreshOnce() calls must fire the refresh closure exactly once. + func testSingleFlightRefresh() async throws { + let counter = CallCounter() + let provider = TokenProvider(persistence: InMemoryTokenStore()) { + try? await Task.sleep(nanoseconds: 50_000_000) + let n = await counter.inc() + return "token-\(n)" + } + let results = await withTaskGroup(of: String.self) { group -> [String] in + for _ in 0..<8 { group.addTask { (try? await provider.refreshOnce()) ?? "ERR" } } + var collected: [String] = [] + for await value in group { collected.append(value) } + return collected + } + let calls = await counter.value + XCTAssertEqual(calls, 1, "refresh closure must run exactly once under concurrency") + XCTAssertTrue(results.allSatisfy { $0 == "token-1" }) + } + + func testTokenProviderCacheAndPersistence() async { + let store = InMemoryTokenStore() + let provider = TokenProvider(persistence: store) { "x" } + await provider.set("abc") + let current = await provider.current() + XCTAssertEqual(current, "abc") + XCTAssertEqual(store.read(), "abc") + await provider.clear() + let cleared = await provider.current() + XCTAssertNil(cleared) + } +} + +actor CallCounter { + var value = 0 + func inc() -> Int { value += 1; return value } +} diff --git a/clients/ds-app/Tests/DSKitTests/FixtureDecodeTests.swift b/clients/ds-app/Tests/DSKitTests/FixtureDecodeTests.swift new file mode 100644 index 0000000..7bf0cee --- /dev/null +++ b/clients/ds-app/Tests/DSKitTests/FixtureDecodeTests.swift @@ -0,0 +1,137 @@ +import XCTest +@testable import DSKit + +/// The contract's "1차 수용 테스트": every fixture must decode through the app's models via the +/// shared decoder, with representative VALUE assertions (no-throw alone would miss a silently-missing +/// CodingKey). Driven through FixtureDSClient so it exercises client + decoder + models together. +final class FixtureDecodeTests: XCTestCase { + let client = FixtureDSClient() + + func testAuthLogin() async throws { + let r = try await client.login(username: "x", password: "y", totpCode: nil) + XCTAssertEqual(r.tokenType, "bearer") + XCTAssertFalse(r.accessToken.isEmpty) + } + + func testAuthMe() async throws { + let u = try await client.me() + XCTAssertEqual(u.id, 1) + XCTAssertEqual(u.username, "hyungi") + XCTAssertNotNil(u.lastLoginAt) // fractional-seconds ISO parse + } + + func testDocumentsList() async throws { + let r = try await client.documents(DocumentListQuery()) + XCTAssertEqual(r.total, 783) + XCTAssertEqual(r.items.count, 3) + XCTAssertEqual(r.items[2].conversionStatus, "pending") + XCTAssertEqual(r.items[0].aiTags?.count, 4) + XCTAssertEqual(r.items[1].duplicateCount, 1) + XCTAssertEqual(r.items[0].fileFormat, "pdf") + XCTAssertEqual(r.items[0].reads, 3) // read_count fallback accessor + } + + func testDocumentDetailCompleted() async throws { + let d = try await client.document(id: 4912) + XCTAssertEqual(d.mdStatus, "completed") + XCTAssertNotNil(d.mdContent) + XCTAssertTrue(d.mdIsRenderable) + XCTAssertEqual(d.mdExtractionQuality?["page_count"]?.intValue, 14) + XCTAssertEqual(d.base.aiInconsistencies?.isEmpty, true) + XCTAssertEqual(d.base.title, "ASME Section VIII Div 1 — Impact Test 요건") + } + + func testDocumentDetailPending() async throws { + let d = try await client.document(id: 5301) + XCTAssertEqual(d.mdStatus, "pending") + XCTAssertNil(d.mdContent) + XCTAssertFalse(d.mdIsRenderable) + XCTAssertNotNil(d.extractedText) + XCTAssertNil(d.mdFrontmatter) + } + + func testDocumentContent() async throws { + let c = try await client.documentContent(id: 4912) + XCTAssertEqual(c.contentLength, 8421) + XCTAssertEqual(c.truncated, false) + } + + func testDocumentTree() async throws { + let t = try await client.documentTree() + XCTAssertEqual(t.count, 6) + XCTAssertEqual(t[0].kids.count, 2) + XCTAssertEqual(t[0].kids[0].name, "위험성평가") + } + + func testStats() async throws { + let s = try await client.categoryCounts() + XCTAssertEqual(s.documents, 783) + XCTAssertEqual(s.byDomain["법령"], 23) // non-ASCII dict key + } + + func testDuplicates() async throws { + let d = try await client.duplicates() + XCTAssertEqual(d.totalGroups, 2) + XCTAssertEqual(d.groups[1].members.count, 3) + XCTAssertEqual(d.groups[0].reason, "content_hash") + } + + func testSearch() async throws { + let s = try await client.search(q: "충격시험", mode: .hybrid, page: 1, debug: false) + XCTAssertEqual(s.results.count, 2) + XCTAssertEqual(s.results[0].rerankScore, 0.913) + XCTAssertEqual(s.mode, "hybrid") + } + + func testAsk() async throws { + let a = try await client.ask(q: "충격시험은 언제 면제되나", limit: nil, backend: nil, debug: false) + XCTAssertEqual(a.citations.count, 1) + XCTAssertEqual(a.confidence, "high") + XCTAssertNil(a.confirmedItems) + XCTAssertEqual(a.coveredAspects?.count, 2) + XCTAssertEqual(a.backendUsed, "gemma-macmini") + } + + func testMemosList() async throws { + let m = try await client.memos(MemoListQuery()) + XCTAssertEqual(m.total, 4807) + XCTAssertEqual(m.items[0].checkedTaskIndices, [0]) + XCTAssertEqual(m.items[1].fileType, "audio") + XCTAssertTrue(m.items[1].isAudio) + } + + func testMemoDetail() async throws { + let m = try await client.memo(id: 20238) + XCTAssertTrue(m.isPinned) + XCTAssertNotNil(m.memoTaskState?["0"]?["checked_at"]?.stringValue) + } + + func testDigest() async throws { + let d = try await client.digest(date: nil, country: nil) + XCTAssertEqual(d.digestDateDisplay, "2026-06-03") // date-only raw display + XCTAssertNotNil(d.windowStart) // fractional ISO parse + XCTAssertEqual(d.countries.count, 2) + XCTAssertEqual(d.countries[0].topics[0].articles.count, 3) + } + + /// JSONValue numeric robustness (B-1 review): whole-valued floats and integers must read correctly + /// through the cross-converting accessors regardless of int/double storage. + func testJSONValueNumberTrap() throws { + let data = Data(#"{"a": 1.0, "b": 0.97, "c": 23, "d": true}"#.utf8) + let v = try DSDecoder.make().decode(JSONValue.self, from: data) + XCTAssertEqual(v["a"]?.doubleValue, 1.0) + XCTAssertEqual(v["b"]?.doubleValue, 0.97) + XCTAssertEqual(v["c"]?.intValue, 23) + XCTAssertEqual(v["d"]?.boolValue, true) + } + + /// Light round-trip encode (call-shape regression guard): decode -> encode -> decode, compare values. + func testAskRoundTrip() async throws { + let a = try await client.ask(q: "q", limit: nil, backend: nil, debug: false) + let data = try DSEncoder.make().encode(a) + let a2 = try DSDecoder.make().decode(AskResponse.self, from: data) + XCTAssertEqual(a2.citations.count, a.citations.count) + XCTAssertEqual(a2.backendUsed, a.backendUsed) + XCTAssertEqual(a2.confidence, a.confidence) + } +} diff --git a/clients/ds-app/Tests/DSKitTests/RouterFallbackTests.swift b/clients/ds-app/Tests/DSKitTests/RouterFallbackTests.swift new file mode 100644 index 0000000..97a5390 --- /dev/null +++ b/clients/ds-app/Tests/DSKitTests/RouterFallbackTests.swift @@ -0,0 +1,42 @@ +import XCTest +import AIFabric + +/// Gate 4 (AI flow): proves the S2 AIRouter produces a VISIBLE routingNote on a rule-based fallback, +/// and that an explicit-provider-unavailable pick throws (no silent fallback). Sources/AI is consumed +/// read-only; these tests never modify it. +final class RouterFallbackTests: XCTestCase { + + func testRuleFallbackIsVisible() async throws { + let router = AIRouter(providers: [ + .onDevice: MockAIProvider(id: .onDevice, available: false), + .localMLX: MockAIProvider(id: .localMLX, available: true), + ]) + let resp = try await router.route(AICompletionRequest(task: .quickSummarize, prompt: "x")) + XCTAssertEqual(resp.providerUsed, .localMLX) + XCTAssertEqual(resp.routingNote, "fallback from onDevice → localMLX") + } + + func testExplicitUnavailableThrowsNoFallback() async throws { + let router = AIRouter(providers: [ + .onDevice: MockAIProvider(id: .onDevice, available: false), + .localMLX: MockAIProvider(id: .localMLX, available: true), + ]) + do { + _ = try await router.route( + AICompletionRequest(task: .quickSummarize, prompt: "x", explicitProvider: .onDevice) + ) + XCTFail("expected explicitProviderUnavailable") + } catch let error as AIRoutingError { + guard case .explicitProviderUnavailable = error else { + return XCTFail("wrong error: \(error)") + } + } + } + + func testCorpusAskRoutesRemoteWithCitation() async throws { + let router = AIRouter(providers: [.remoteDS: MockAIProvider(id: .remoteDS)]) + let resp = try await router.route(AICompletionRequest(task: .corpusAsk, prompt: "q")) + XCTAssertEqual(resp.providerUsed, .remoteDS) + XCTAssertEqual(resp.citations.count, 1) + } +} diff --git a/clients/ds-app/contract/AI-ROUTING.md b/clients/ds-app/contract/AI-ROUTING.md new file mode 100644 index 0000000..60eb551 --- /dev/null +++ b/clients/ds-app/contract/AI-ROUTING.md @@ -0,0 +1,67 @@ +# DS App AI 라우팅 계약 (S2 인터페이스 동결) — v0.1 + +S1(데이터 fixture)이 앱의 **데이터 모양**을 동결했듯, 이 문서 + `Sources/AI/`는 앱의 **AI 호출 모양**을 동결한다. +S3 는 `AIProvider` 프로토콜 + `MockAIProvider` 로 AI 흐름을 끝까지 그리고, S2 가 실 provider 를 뒤에서 채운다. + +- **Frozen**: 2026-06-04 · **typecheck PASS** (swift 6 strict concurrency) · **router 스모크 PASS** +- **위치**: `Sources/AI/{AIProvider,AIRouter,MockAIProvider}.swift` + `Sources/AI/Providers/*.swift` +- **파일 경계**: 이 `AI/` 디렉토리 = **S2 소유**(앱 나머지 = S3). 같은 Xcode repo 디렉토리 단위 분담 → 충돌 0. + +## 1. Provider 티어 (= 디바이스 역할 표와 1:1) + +| `AIProviderID` | 노드 | 용도 | 구현 경로 | +|---|---|---|---| +| `.onDevice` | 맥북·아이폰 | 즉답·오프라인·프라이버시 | Apple **FoundationModels** (`SystemLanguageModel`/`LanguageModelSession`) | +| `.localMLX` | 맥미니 허브 | 무거운 로컬 생성 | Gemma 4 26B — llm-router `:8890` / MLX `:8801` (OpenAI 호환) | +| `.remoteDS` | GPU(원격) | **코퍼스 RAG** | `GET /search/ask?backend=` (CONTRACT.md §4) | +| `.specialized` | GPU | rerank·embed·**vision**·OCR | 특화 모델 통로(온디맨드) | + +## 2. 태스크 → 티어 정책 (`AIRoutingPolicy.default`) + +| `AITask` | 선호 체인 | 근거 | +|---|---|---| +| `quickSummarize` | onDevice → localMLX | 빠르고 사적 | +| `memoAssist` | onDevice → localMLX | 짧은 보조 | +| `askSelection` | onDevice → localMLX → remoteDS | 로컬 컨텍스트, 부족 시 승급 | +| `corpusAsk` | **remoteDS only** | 코퍼스 필요 — 온디바이스로 폴백 불가 | +| `classify` | localMLX → remoteDS → onDevice | 분류 품질 우선 | +| `vision` | specialized → onDevice | GPU VLM 우선 | + +## 3. 3단 fallback 규칙 (feedback_task_routing_hybrid + no_silent_fallback) + +1. **explicit > rule > error.** +2. **명시 opt-in**(`request.explicitProvider`) → 그 provider 만. 불가 시 **에러**(`explicitProviderUnavailable`) — 자동 다른 티어 호출 **금지**. +3. **미지정** → 태스크 선호 체인 순회. 불가/실패는 다음으로 넘기되 **`routingNote`로 가시화**(silent skip 금지) + `log` 훅. +4. 전부 불가 → `noProviderAvailable`. + +스모크 검증: +``` +quickSummarize → onDevice corpusAsk → remoteDS (citations=1) +vision → specialized classify → localMLX +explicit onDevice 불가 → 에러(자동 fallback X) +rule fallback: onDevice 불가 → localMLX note="fallback from onDevice → localMLX" +``` + +## 4. S1 계약과의 다리 (`RemoteDSProvider`) + +`corpusAsk` 만 `.remoteDS` 책임. 매핑(고정): +``` +GET /search/ask?q=&backend= → AskResponse (CONTRACT.md §4) +AskResponse.ai_answer → AICompletionResponse.text +AskResponse.citations[] → AICitation[] (n, doc_id, title, section_title, span_text) +AskResponse.synthesis_status → AIFinishReason (completed/timeout/no_evidence/backend_unavailable/…) +AskResponse.confidence → AIConfidence +AskResponse.backend_used → routingNote (어느 LLM 이 응답했는지) +``` +`backend` 매핑: `nil`→`mac-mini-default` · `.localMLX`→`gemma-macmini` · (M5 Max Qwen 경로)→`qwen-macbook` · cloud→`claude-cloud`(503, 별 PR). + +## 5. S2 가 다음에 채울 것 (인터페이스는 고정) +- `OnDeviceProvider`: `import FoundationModels` 가용성 프로브 + `LanguageModelSession` 호출. +- `LocalMLXProvider`: 맥미니 OpenAI 호환 호출(messages system/user 분리 call-shape 고정). +- `RemoteDSProvider`: S3 의 DS API client 주입 → 위 §4 매핑 결선. +- `SpecializedProvider`: GPU 비전/특화 통로(필요 태스크만). + +## 동시 출발선 (S1 + S2 둘 다 동결 완료) +- **S3** = `MockAIProvider` 주입 + S1 `fixtures/` 디코딩 → 실 백엔드·실 LLM 대기 0 으로 앱 전체 빌드. +- **S1** = `[S1-ADD]` 구현 + 응답 shape 유지. +- **S2** = 위 §5 provider 결선. 인터페이스(`AIProvider`/`AIRouter`) 변경 시만 합의. diff --git a/clients/ds-app/contract/CONTRACT.md b/clients/ds-app/contract/CONTRACT.md new file mode 100644 index 0000000..8d00920 --- /dev/null +++ b/clients/ds-app/contract/CONTRACT.md @@ -0,0 +1,224 @@ +# DS App ↔ Backend API 계약 (S1 인터페이스 동결) + +> **목적**: 멀티디바이스 DS 앱(S3)이 빌드/프리뷰 시 의존하는 **응답 shape를 동결**한다. +> 백엔드 구현(S1)·LLM 라우팅(S2)과 무관하게 이 계약 + `fixtures/*.json`만 보고 앱을 만든다. +> **이 계약이 곧 S1·S2·S3 동시 출발선이다.** 변경은 버전 bump + 합의로만. + +- **Contract version**: `v0.1` (frozen 2026-06-04) +- **Base URL**: `https://document.hyungi.net/api` (TLS, 공인) · 대안 Tailscale `http://100.110.63.63:8000/api` +- **출처**: 실제 GPU 백엔드 Pydantic 응답 모델에서 추출(지어내지 않음). 파일 = `app/api/{documents,search,memos,digest,auth}.py`. +- **표기**: `[EXISTING]` = 현재 백엔드가 이미 반환. `[S1-ADD]` = 신규 요구(MD-first 전포맷·중복검사·다운로드 편의)로 **S1이 추가**할 필드/엔드포인트 — 앱은 옵셔널로 디코딩(`?`), 없으면 폴백. + +--- + +## 0. 공통 규약 + +- **datetime**: ISO-8601 문자열 (`"2026-06-03T08:12:44.120Z"`). Swift `Date` 디코딩 시 ISO8601 + fractional seconds. +- **date**: `"2026-06-03"` (date-only). +- **null**: 필드 부재 가능 → 앱 모델 전부 옵셔널(`String?`). 위 모델의 `| None` = 옵셔널. +- **페이지네이션**: `{ items, total, page, page_size }`. 요청 `?page=1&page_size=20`. +- **에러 shape**: `{ "detail": "<메시지>" }` 또는 `{ "detail": { "error_code": "...", "message": "..." } }`. HTTP status로 분기(401/404/422/503). +- **인증**: 모든 `/api/*`(auth 제외)는 `Authorization: Bearer ` 헤더. + +### 인증 흐름 (네이티브) +- `POST /api/auth/login {username, password, totp_code?}` → `AccessTokenResponse {access_token, token_type}`. +- refresh는 **HttpOnly 쿠키**(`path=/api/auth`)로 내려옴 → `URLSession`의 `HTTPCookieStorage`가 자동 보관, `POST /api/auth/refresh`로 access 재발급 가능(네이티브에서도 동작). +- **권장**: access_token은 **Keychain** 보관. 만료 시 refresh → 실패하면 재로그인. (장수명 365d 토큰 옵션도 가능하나 v1은 정식 로그인.) +- 로그아웃 `POST /api/auth/logout`, 현재 사용자 `GET /api/auth/me` → `UserResponse`. + +--- + +## 1. Auth + +| Method | Path | 요청 | 응답 | fixture | +|---|---|---|---|---| +| POST | `/auth/login` | `{username, password, totp_code?}` | `AccessTokenResponse` | `auth_login.json` | +| POST | `/auth/refresh` | (쿠키) | `AccessTokenResponse` | `auth_login.json` | +| GET | `/auth/me` | — | `UserResponse` | `auth_me.json` | +| POST | `/auth/logout` | — | `{}` | — | + +``` +AccessTokenResponse { access_token: String, token_type: String("bearer") } [EXISTING] +UserResponse { id: Int, username: String, is_active: Bool, totp_enabled: Bool, last_login_at: Date? } [EXISTING] +``` + +--- + +## 2. Documents (MD-first 뷰 핵심) + +| Method | Path | 요청 | 응답 | fixture | +|---|---|---|---|---| +| GET | `/documents/` | `page, page_size, domain?, sub_group?, source?, format?, review_status?, category?, has_suggestion?, proposed_category?` | `DocumentListResponse` | `documents_list.json` | +| GET | `/documents/{id}` | — | `DocumentDetailResponse` (**md_content 동봉**) | `document_detail.json` | +| GET | `/documents/{id}/file` | `?token=&download=true` | **바이너리 원본** (PDF/이미지/오디오/원본) | — | +| GET | `/documents/{id}/content` | — | 경량 텍스트(`content` 15k cap) | `document_content.json` | +| GET | `/documents/tree` | — | 도메인 트리(사이드바) | `documents_tree.json` | +| GET | `/documents/stats/category-counts` | — | 카테고리 카운트 | `documents_stats.json` | +| POST | `/documents/` (multipart) | 파일 업로드 | `DocumentResponse` (201) | `document_detail.json` | +| PATCH | `/documents/{id}` | `DocumentUpdate` | `DocumentResponse` | — | +| PUT | `/documents/{id}/content` | `{content}` (md 편집 저장) | `{}` | — | +| POST | `/documents/{id}/accept-suggestion` | `{expected_source_updated_at}` | `DocumentResponse` | — | +| DELETE | `/documents/{id}/suggestion` | — | 204 | — | +| DELETE | `/documents/{id}` | — | 204 | — | + +### DocumentResponse (리스트 행 — 경량, md 본문 없음) `[EXISTING]` +``` +id: Int +file_path: String? file_format: String file_size: Int? file_type: String +title: String? +ai_domain: String? ai_sub_group: String? ai_tags: [String]? ai_summary: String? +document_type: String? importance: String? ai_confidence: Double? +user_note: String? user_tags: [String]? pinned: Bool? ask_includable: Bool? +derived_path: String? original_format: String? conversion_status: String? +is_read: Bool? review_status: String? edit_url: String? preview_status: String? +source_channel: String? data_origin: String? doc_purpose: String? +facet_company: String? facet_topic: String? facet_year: Int? facet_doctype: String? +category: String? ai_suggestion: [String:Any]? +ai_tldr: String? ai_bullets: [String]? ai_detail_summary: String? +ai_inconsistencies: [String]? ai_analysis_tier: String? // 'triage' | 'deep' | null +extracted_at: Date? ai_processed_at: Date? embedded_at: Date? +created_at: Date updated_at: Date +read_count: Int(=0) last_read_at: Date? +``` + +### DocumentDetailResponse (단건 — 위 전부 + 본문/canonical markdown) `[EXISTING]` +``` +…DocumentResponse 전 필드… +extracted_text: String? +md_content: String? // ← MD-first 뷰의 1차 렌더 소스 (canonical markdown) +md_frontmatter: [String:Any]? +md_status: String? // pending|processing|completed|partial|failed|skipped (enum은 S1 동결) +md_extraction_quality: [String:Any]? +md_extraction_error: String? +md_extraction_engine: String? md_extraction_engine_version: String? +md_generated_at: Date? +``` + +### `[S1-ADD]` (신규 요구 반영 — 앱은 옵셔널 디코딩, 없으면 폴백) +``` +DocumentResponse / Detail 에 추가 예정: + original_filename: String? // 다운로드 버튼 라벨용 (없으면 file_path basename) + duplicate_of: Int? // 중복검사 개선 — canonical doc id (자기 자신이 canonical이면 null) + duplicate_count: Int(=0) // 이 문서와 동일 판정된 사본 수 +신규 엔드포인트: + GET /documents/duplicates // 중복 그룹 목록 { groups: [{ canonical_id, members:[id], reason }] } +``` + +### MD-first 렌더 규칙 (앱 측 계약) +1. 본문 뷰 = **`md_content` 우선** (md_status ∈ {completed, partial}일 때). 일관성 = 모든 포맷을 markdown으로 본다. +2. md 없음(md_status ∈ {pending, processing, failed, skipped, null}) → `extracted_text` 폴백 + "원본 다운로드" 강조 + "MD 변환 대기" 배지. +3. **원본 접근 = 항상 다운로드 버튼** → `GET /documents/{id}/file?token=&download=true`. + - 주의: 이 엔드포인트는 **Authorization 헤더가 아니라 `?token=` 쿼리 파라미터**로 인증(iframe/다운로드 호환). 앱은 access_token을 쿼리로 붙인다. + - `note`(메모)는 물리 파일 없음 → 404. 다운로드 버튼 숨김. +4. 앱은 **절대 SMB를 보지 않는다.** 원본/스토리지 계층(맥미니 4TB ↔ NAS Docker)은 이 URL 뒤에서 S1이 추상화. 앱엔 단일 다운로드 URL만 노출. + +--- + +## 3. Search + +| Method | Path | 요청 | 응답 | fixture | +|---|---|---|---|---| +| GET | `/search/` | `q, mode?(text|vector|hybrid), page?, debug?` | `SearchResponse` | `search.json` | + +``` +SearchResponse { results: [SearchResult], total: Int, query: String, mode: String, debug: SearchDebug? } [EXISTING] +SearchResult { + id: Int // doc_id + title: String? ai_domain: String? ai_summary: String? file_format: String + score: Double snippet: String? match_reason: String? + chunk_id: Int? chunk_index: Int? section_title: String? + rerank_score: Double? freshness_debug: [String:Any]? +} +``` + +--- + +## 4. Ask (RAG — 원격 DS, S2 LLM 라우팅과 연결) + +| Method | Path | 요청 | 응답 | fixture | +|---|---|---|---|---| +| GET | `/search/ask` | `q, limit?, backend?, debug?` | `AskResponse` | `ask.json` | +| POST | `/search/ask/react` | `{...}` | `AskReactResponse` | — | + +``` +AskResponse { [EXISTING] + results: [SearchResult] + ai_answer: String? + citations: [Citation] + synthesis_status: "completed"|"timeout"|"skipped"|"no_evidence"|"parse_failed"|"llm_error"|"backend_unavailable" + synthesis_ms: Double + confidence: "high"|"medium"|"low" | null + refused: Bool no_results_reason: String? query: String total: Int + completeness: "full"|"partial"|"insufficient" // 기본 "full" + covered_aspects: [String]? missing_aspects: [String]? + confirmed_items: [ConfirmedItem]? + backend_requested: String? backend_used: String? // S2 라우팅 메타 + debug: AskDebug? +} +Citation { n: Int, chunk_id: Int?, doc_id: Int, title: String?, section_title: String?, + span_text: String, full_snippet: String, relevance: Double, rerank_score: Double } +ConfirmedItem { aspect: String, text: String, citations: [Int] } +``` + +- **`backend` 쿼리** = S2 인터페이스 접점: `qwen-macbook | gemma-macmini | mac-mini-default | claude-cloud | auto`. 미지정 = `mac-mini-default`(맥미니 26B). +- **앱 라우팅 규칙(S2 계약)**: 빠른 요약/선택문 ask/메모 보조 = **온디바이스(Apple FM)** 로컬 처리(이 엔드포인트 미호출). **전체 코퍼스 RAG** = 이 `/search/ask` 호출(backend로 맥미니/특화 선택). `[S1-ADD]` 없음 — backend 인자는 이미 존재. + +--- + +## 5. Memos (캡처/쓰기) + +| Method | Path | 요청 | 응답 | fixture | +|---|---|---|---|---| +| GET | `/memos/` | `page, page_size, pinned?, archived?` | `MemoListResponse` | `memos_list.json` | +| GET | `/memos/{id}` | — | `MemoResponse` | `memo_detail.json` | +| POST | `/memos/` | `MemoCreate {content, title?, ask_includable?, source_channel?, source_metadata?}` | `MemoResponse` (201) | `memo_detail.json` | +| PATCH | `/memos/{id}` | `MemoUpdate {content, title?}` | `MemoResponse` | — | +| PATCH | `/memos/{id}/pin` · `/archive` · `/ask-includable` | `{...}` | `MemoResponse` | — | +| PATCH | `/memos/{id}/tasks/{task_index}` | `{checked}` | `MemoResponse` | — | +| POST | `/memos/{id}/promote-to-event` | — | 201 | — | +| DELETE | `/memos/{id}` | — | 204 | — | + +``` +MemoResponse { [EXISTING] + id: Int title: String? content: String? // = extracted_text + file_format: String file_type: String? // "audio"(음성) | "note"(텍스트) + file_path: String? // 음성 메모 오디오 경로(있으면 재생 가능) + user_tags: [String]? ai_tags: [String]? + ai_domain: String? ai_sub_group: String? ai_summary: String? + pinned: Bool archived: Bool ask_includable: Bool + memo_task_state: [String:Any] // {"0": {"checked_at": "ISO"}} + ai_event_kind: String? ai_event_confidence: Double? + source_channel: String? source_metadata: [String:Any] + created_at: Date updated_at: Date +} +``` + +--- + +## 6. Digest (뉴스) + +| Method | Path | 요청 | 응답 | fixture | +|---|---|---|---|---| +| GET | `/digest` | `?date=YYYY-MM-DD&country?` | `DigestResponse` | `digest.json` | +| GET | `/digest/dates` | — | `[DigestDateSummary]` | — | + +``` +DigestResponse { [EXISTING] + digest_date: Date(date-only) window_start: Date window_end: Date + decay_lambda: Double total_articles: Int total_countries: Int total_topics: Int + generation_ms: Int? llm_calls: Int llm_failures: Int status: String + countries: [CountryGroup] +} +CountryGroup { country: String, topics: [TopicResponse] } +TopicResponse { topic_rank: Int, topic_label: String, summary: String, + article_ids: [Int], articles: [ArticleRef], article_count: Int, + importance_score: Double, raw_weight_sum: Double, llm_fallback_used: Bool } +ArticleRef { id: Int, title: String? } +DigestDateSummary { digest_date: Date, total_topics: Int, total_countries: Int, total_articles: Int, status: String } +``` + +--- + +## 변경 관리 +- 이 계약을 깨는 변경(필드 제거/타입 변경) = `version` bump + S1/S2/S3 합의. `[S1-ADD]`는 옵셔널이라 non-breaking. +- 통합 결선 시 fixture와 실 응답 **call-shape regression test**로 대조([[feedback_fixture_first_call_shape]]). diff --git a/clients/ds-app/contract/README.md b/clients/ds-app/contract/README.md new file mode 100644 index 0000000..cca56a2 --- /dev/null +++ b/clients/ds-app/contract/README.md @@ -0,0 +1,38 @@ +# DS App Contract (S1 인터페이스 동결) — v0.1 + +S1·S2·S3 **동시 출발선**. 앱(S3)은 실 백엔드/실 LLM 없이 **이 디렉토리만 보고** 빌드한다. + +``` +contract/ + CONTRACT.md ← 엔드포인트 + 요청/응답 shape 동결 스펙 (읽기 시작점) + fixtures/ ← 응답 JSON 박제 (앱 프리뷰/디코딩 테스트가 로드) + auth_login.json POST /auth/login + auth_me.json GET /auth/me + documents_list.json GET /documents/ (DocumentListResponse) + document_detail.json GET /documents/{id} (md_status=completed — MD-first 렌더) + document_detail_pending_md.json GET /documents/{id} (md_status=pending — extracted_text 폴백 케이스) + document_content.json GET /documents/{id}/content + documents_tree.json GET /documents/tree + documents_stats.json GET /documents/stats/category-counts + documents_duplicates.json GET /documents/duplicates [S1-ADD] 중복검사 + search.json GET /search/ + ask.json GET /search/ask + memos_list.json GET /memos/ + memo_detail.json GET /memos/{id} + digest.json GET /digest +``` + +## 동결 원칙 +- 모든 shape는 **실제 GPU 백엔드 Pydantic 모델에서 추출**(지어내지 않음). `[S1-ADD]` 필드만 신규. +- 앱 모델(Swift Codable)은 위 fixtures를 그대로 디코딩할 수 있어야 한다 = 1차 수용 테스트. +- 통합 시 fixture ↔ 실 응답 **call-shape regression**으로 대조. + +## `[S1-ADD]` (S1이 추가할 신규 — 앱은 옵셔널 디코딩) +- `original_filename`, `duplicate_of`, `duplicate_count` (DocumentResponse/Detail) +- `GET /documents/duplicates` (중복검사 개선 트랙) +- Word/Excel/이미지/오디오 → `md_content` 채우기 (MD-first 전포맷 — marker는 현재 PDF 전용) + +## 다음 +- S3: 이 fixtures를 디코딩하는 Swift `Codable` 모델 + API client(프로토콜) → macOS 앱. +- S1: `[S1-ADD]` 구현 + 위 shape 유지(깨면 version bump). +- S2: `/search/ask?backend=` + 온디바이스 provider 추상화(`AIProvider`). diff --git a/clients/ds-app/contract/contract-check.sh b/clients/ds-app/contract/contract-check.sh new file mode 100755 index 0000000..cd9b808 --- /dev/null +++ b/clients/ds-app/contract/contract-check.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# contract-check.sh — S2-Ff: 라이브 엔드포인트 ↔ 동결 fixture *모양* 드리프트 감지 (비차단 runbook). +# +# 라이브 재호출 → 키/타입 diff(LLM 값은 무시) → 드리프트면 비0 exit + fixture-update 안내. +# PR 게이트 아님 — 수동/Tailscale-CI 트리거([[feedback_pr_gate_vs_runbook_separation]]). +# fixture 는 동결·불변이며, 이 도구가 감지한 드리프트가 **재캡처 PR 의 유일 합법 트리거**(S2-0d). +# +# exit: 0 = 드리프트 없음(체크된 것 중) · 1 = breaking 드리프트 · 2 = 아무것도 체크 못함(전부 도달불가) +# env override: AIFABRIC_LOCALMLX_URL, AIFABRIC_DS_URL +# +# 스킵은 항상 *가시적*으로 출력(silent green 금지, [[feedback_silent_skip_accumulation]]). +set -uo pipefail # NOT -e: 개별 체크 실패를 모아 보고 + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +FIX="$SCRIPT_DIR/fixtures" +PY="$(command -v python3 || echo /usr/bin/python3)" +LLM_URL="${AIFABRIC_LOCALMLX_URL:-http://100.76.254.116:8890}" +DS_URL="${AIFABRIC_DS_URL:-https://document.hyungi.net/api}" +TMP="$(mktemp -d)"; trap 'rm -rf "$TMP"' EXIT + +breaking=0 +checked=0 + +echo "== S2-Ff contract drift check ==" +echo " llm-router: $LLM_URL" +echo " DS: $DS_URL" + +# --- 1) llm-router /v1/chat/completions (S2 fixture) --- +echo +echo "[1] llm-router /v1/chat/completions ↔ llm-router-chat.response.json" +# 동결 request fixture 에서 _meta 떼고 그대로 wire body 로 POST(call-shape 도 함께 검증). +"$PY" -c 'import json,sys; o=json.load(open(sys.argv[1],encoding="utf-8")); o.pop("_meta",None); json.dump(o,open(sys.argv[2],"w"))' \ + "$FIX/llm-router-chat.request.json" "$TMP/req.json" 2>"$TMP/strip_err" || { echo " SKIP — request fixture 처리 실패: $(head -c160 "$TMP/strip_err")"; } +if [ -s "$TMP/req.json" ]; then + code="$(curl -sS -m 60 -o "$TMP/llm_live.json" -w '%{http_code}' \ + -H 'Content-Type: application/json' --data @"$TMP/req.json" \ + "$LLM_URL/v1/chat/completions" 2>"$TMP/llm_err" || true)" + if [ "$code" = "200" ]; then + checked=$((checked + 1)) + if "$PY" "$SCRIPT_DIR/shape_diff.py" "$FIX/llm-router-chat.response.json" "$TMP/llm_live.json"; then + echo " PASS — shape 일치" + else + echo " >> 드리프트: llm-router-chat.{request,response}.json 재캡처 PR 필요" + breaking=1 + fi + else + echo " SKIP — 도달 불가/비200 (http=${code:-curlfail}; 맥미니 offline?) $(head -c120 "$TMP/llm_err" 2>/dev/null)" + fi +fi + +# --- 2) DS /search/ask (S1 fixture, best-effort: 인증 필요할 수 있음) --- +echo +echo "[2] DS /search/ask ↔ ask.json (best-effort)" +code="$(curl -sS -m 30 -G -o "$TMP/ds_live.json" -w '%{http_code}' \ + --data-urlencode 'q=충격시험은 언제 면제되나' --data-urlencode 'backend=mac-mini-default' \ + "$DS_URL/search/ask" 2>"$TMP/ds_err" || true)" +case "$code" in + 200) + checked=$((checked + 1)) + if "$PY" "$SCRIPT_DIR/shape_diff.py" "$FIX/ask.json" "$TMP/ds_live.json"; then + echo " PASS — shape 일치" + else + echo " >> 드리프트: ask.json(S1) 재캡처 검토" + breaking=1 + fi ;; + 401|403) echo " SKIP — 인증 필요(JWT). DS ask 체크는 토큰 주입 시에만(S1 도메인)." ;; + *) echo " SKIP — 도달 불가/비200 (http=${code:-curlfail})." ;; +esac + +echo +if [ "$breaking" -ne 0 ]; then + echo "RESULT: DRIFT 발견 — 위 fixture 재캡처 PR 필요(S2-0d 의 유일 합법 fixture 변경 경로)." + exit 1 +elif [ "$checked" -eq 0 ]; then + echo "RESULT: 체크한 엔드포인트 0건(전부 도달 불가) — 드리프트 판정 불가. (green 아님 → exit 2)" + exit 2 +else + echo "RESULT: OK — 체크 ${checked}건 모두 shape 일치, 드리프트 없음." + exit 0 +fi diff --git a/clients/ds-app/contract/fixtures/ask.json b/clients/ds-app/contract/fixtures/ask.json new file mode 100644 index 0000000..a650751 --- /dev/null +++ b/clients/ds-app/contract/fixtures/ask.json @@ -0,0 +1,47 @@ +{ + "results": [ + { + "id": 4912, + "title": "ASME Section VIII Div 1 — Impact Test 요건", + "ai_domain": "Engineering", + "ai_summary": "압력용기 재료의 충격시험 면제/요구 조건(UCS-66 등)을 정리.", + "file_format": "pdf", + "score": 0.8714, + "snippet": "...UCS-66 면제 곡선과 MDMT 적용...", + "match_reason": "vector+rerank", + "chunk_id": 88213, + "chunk_index": 3, + "section_title": "2. UCS-66 면제 곡선", + "rerank_score": 0.913, + "freshness_debug": null + } + ], + "ai_answer": "충격시험 면제는 UCS-66 면제 곡선으로 판정합니다 [1]. 재료군(Curve A~D)과 거버닝 두께에 따라 최소설계금속온도(MDMT)에서 면제 여부가 정해지며, 설계 응력비가 낮으면 UCS-66.1에 따라 MDMT를 추가로 낮출 수 있습니다 [1].", + "citations": [ + { + "n": 1, + "chunk_id": 88213, + "doc_id": 4912, + "title": "ASME Section VIII Div 1 — Impact Test 요건", + "section_title": "2. UCS-66 면제 곡선", + "span_text": "재료군(Curve A~D)과 거버닝 두께에 따라 최소설계금속온도(MDMT)에서의 충격시험 면제 여부를 결정한다.", + "full_snippet": "재료군(Curve A~D)과 거버닝 두께에 따라 최소설계금속온도(MDMT)에서의 충격시험 면제 여부를 결정한다. 설계 응력비가 낮으면 UCS-66.1에 따라 MDMT를 추가로 낮출 수 있다. 면제되지 않는 경우 UG-84에 따라 Charpy V-notch 시험을 수행한다.", + "relevance": 0.91, + "rerank_score": 0.913 + } + ], + "synthesis_status": "completed", + "synthesis_ms": 2841.5, + "confidence": "high", + "refused": false, + "no_results_reason": null, + "query": "충격시험은 언제 면제되나", + "total": 1, + "completeness": "full", + "covered_aspects": ["면제 곡선", "MDMT 적용"], + "missing_aspects": null, + "confirmed_items": null, + "backend_requested": "mac-mini-default", + "backend_used": "gemma-macmini", + "debug": null +} diff --git a/clients/ds-app/contract/fixtures/auth_login.json b/clients/ds-app/contract/fixtures/auth_login.json new file mode 100644 index 0000000..cea80a3 --- /dev/null +++ b/clients/ds-app/contract/fixtures/auth_login.json @@ -0,0 +1,4 @@ +{ + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJoeXVuZ2kiLCJ0eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzQ5MDAwMDAwfQ.FIXTURE_SIGNATURE_NOT_REAL", + "token_type": "bearer" +} diff --git a/clients/ds-app/contract/fixtures/auth_me.json b/clients/ds-app/contract/fixtures/auth_me.json new file mode 100644 index 0000000..c92ea4f --- /dev/null +++ b/clients/ds-app/contract/fixtures/auth_me.json @@ -0,0 +1,7 @@ +{ + "id": 1, + "username": "hyungi", + "is_active": true, + "totp_enabled": false, + "last_login_at": "2026-06-04T07:55:12.330Z" +} diff --git a/clients/ds-app/contract/fixtures/digest.json b/clients/ds-app/contract/fixtures/digest.json new file mode 100644 index 0000000..3c78d0e --- /dev/null +++ b/clients/ds-app/contract/fixtures/digest.json @@ -0,0 +1,54 @@ +{ + "digest_date": "2026-06-03", + "window_start": "2026-05-27T00:00:00.000Z", + "window_end": "2026-06-03T00:00:00.000Z", + "decay_lambda": 0.18, + "total_articles": 312, + "total_countries": 4, + "total_topics": 9, + "generation_ms": 18420, + "llm_calls": 11, + "llm_failures": 0, + "status": "completed", + "countries": [ + { + "country": "KR", + "topics": [ + { + "topic_rank": 1, + "topic_label": "산업안전 규제 개정", + "summary": "중대재해처벌법 후속 시행령 개정 논의가 이어지며 제조업 현장 점검이 강화되는 흐름.", + "article_ids": [880123, 880140, 880155], + "articles": [ + { "id": 880123, "title": "고용부, 중대재해 시행령 개정안 입법예고" }, + { "id": 880140, "title": "제조 현장 안전점검 확대" }, + { "id": 880155, "title": "압력설비 검사 주기 단축 검토" } + ], + "article_count": 3, + "importance_score": 0.91, + "raw_weight_sum": 2.74, + "llm_fallback_used": false + } + ] + }, + { + "country": "US", + "topics": [ + { + "topic_rank": 1, + "topic_label": "ASME 코드 업데이트", + "summary": "ASME BPVC 2025 에디션 관련 산업계 적용 사례와 해설 자료가 늘어남.", + "article_ids": [880301, 880322], + "articles": [ + { "id": 880301, "title": "ASME BPVC 2025 adoption notes" }, + { "id": 880322, "title": "Impact test exemption clarifications" } + ], + "article_count": 2, + "importance_score": 0.77, + "raw_weight_sum": 1.62, + "llm_fallback_used": false + } + ] + } + ] +} diff --git a/clients/ds-app/contract/fixtures/document_content.json b/clients/ds-app/contract/fixtures/document_content.json new file mode 100644 index 0000000..b5430c3 --- /dev/null +++ b/clients/ds-app/contract/fixtures/document_content.json @@ -0,0 +1,12 @@ +{ + "id": 4912, + "title": "ASME Section VIII Div 1 — Impact Test 요건", + "domain": "Engineering", + "sub_group": "압력용기", + "document_type": "standard", + "ai_summary": "압력용기 재료의 충격시험 면제/요구 조건(UCS-66 등)을 정리.", + "ai_tags": ["ASME", "Section VIII", "충격시험", "UCS-66"], + "content": "ASME Section VIII Division 1 Impact Test Requirements\nUCS-66 면제 곡선과 MDMT 적용 ... (최대 15000자) ...", + "content_length": 8421, + "truncated": false +} diff --git a/clients/ds-app/contract/fixtures/document_detail.json b/clients/ds-app/contract/fixtures/document_detail.json new file mode 100644 index 0000000..e285003 --- /dev/null +++ b/clients/ds-app/contract/fixtures/document_detail.json @@ -0,0 +1,63 @@ +{ + "id": 4912, + "file_path": "Engineering/ASME/ASME_SecVIII_Div1_Impact_Test.pdf", + "file_format": "pdf", + "file_size": 1338920, + "file_type": "document", + "title": "ASME Section VIII Div 1 — Impact Test 요건", + "ai_domain": "Engineering", + "ai_sub_group": "압력용기", + "ai_tags": ["ASME", "Section VIII", "충격시험", "UCS-66"], + "ai_summary": "압력용기 재료의 충격시험 면제/요구 조건(UCS-66 등)을 정리.", + "document_type": "standard", + "importance": "high", + "ai_confidence": 0.93, + "user_note": "MDMT 판정 시 자주 참조", + "user_tags": ["자주봄"], + "pinned": true, + "ask_includable": true, + "derived_path": "Engineering/ASME/ASME_SecVIII_Div1_Impact_Test.md", + "original_format": "pdf", + "conversion_status": "completed", + "is_read": true, + "review_status": "approved", + "edit_url": null, + "preview_status": "ready", + "source_channel": "upload", + "data_origin": "external", + "doc_purpose": "reference", + "facet_company": "ASME", + "facet_topic": "압력용기", + "facet_year": 2023, + "facet_doctype": "standard", + "category": "library", + "ai_suggestion": null, + "ai_tldr": "충격시험 면제 곡선(UCS-66)과 MDMT 적용.", + "ai_bullets": ["UCS-66 면제 곡선", "UCS-66.1 감액", "UG-84 시험 요건"], + "ai_detail_summary": "본 표준 절은 탄소강·저합금강 압력용기 재료의 노치 인성(충격시험) 요구를 다룬다. UCS-66 면제 곡선은 재료군(A~D)과 두께에 따라 최소설계금속온도(MDMT)에서의 시험 면제 여부를 정한다.", + "ai_inconsistencies": [], + "ai_analysis_tier": "deep", + "extracted_at": "2026-05-22T05:00:11.000Z", + "ai_processed_at": "2026-05-22T05:04:40.000Z", + "embedded_at": "2026-05-22T05:06:02.000Z", + "created_at": "2026-05-22T04:59:50.000Z", + "updated_at": "2026-06-01T09:21:33.000Z", + "read_count": 11, + "last_read_at": "2026-06-03T18:02:10.000Z", + "original_filename": "ASME_SecVIII_Div1_Impact_Test.pdf", + "duplicate_of": null, + "duplicate_count": 1, + "extracted_text": "ASME Section VIII Division 1 Impact Test Requirements\nUCS-66 ... (원문 추출 텍스트, 폴백용) ...", + "md_content": "# ASME Section VIII Div 1 — 충격시험 요건\n\n## 1. 범위\n탄소강 및 저합금강 압력용기 재료의 노치 인성(충격시험) 요구를 규정한다.\n\n## 2. UCS-66 면제 곡선\n재료군(Curve A~D)과 거버닝 두께에 따라 **최소설계금속온도(MDMT)** 에서의 충격시험 면제 여부를 결정한다.\n\n| 곡선 | 대표 재료 | 비고 |\n|---|---|---|\n| A | SA-516 (비정규화) | 가장 보수적 |\n| B | SA-516 정규화 | |\n| C | SA-537 | |\n| D | 인성 우수 재료 | 가장 관대 |\n\n## 3. UCS-66.1 감액\n설계 응력비(stress ratio)가 낮으면 MDMT 를 추가로 낮출 수 있다.\n\n## 4. UG-84 시험 요건\n면제되지 않는 경우 Charpy V-notch 시험으로 흡수에너지/측면팽창 기준을 만족해야 한다.\n", + "md_frontmatter": { + "title": "ASME Section VIII Div 1 — 충격시험 요건", + "domain": "Engineering", + "source": "ASME_SecVIII_Div1_Impact_Test.pdf" + }, + "md_status": "completed", + "md_extraction_quality": { "page_count": 14, "table_count": 3, "ocr_used": false }, + "md_extraction_error": null, + "md_extraction_engine": "marker", + "md_extraction_engine_version": "1.10.2", + "md_generated_at": "2026-05-22T05:03:30.000Z" +} diff --git a/clients/ds-app/contract/fixtures/document_detail_pending_md.json b/clients/ds-app/contract/fixtures/document_detail_pending_md.json new file mode 100644 index 0000000..f664d71 --- /dev/null +++ b/clients/ds-app/contract/fixtures/document_detail_pending_md.json @@ -0,0 +1,59 @@ +{ + "id": 5301, + "file_path": "General/매뉴얼/02_왕복압축기_운전매뉴얼.docx", + "file_format": "docx", + "file_size": 73402, + "file_type": "document", + "title": "02 왕복압축기 운전 매뉴얼", + "ai_domain": "General", + "ai_sub_group": "설비매뉴얼", + "ai_tags": ["왕복압축기", "운전", "매뉴얼"], + "ai_summary": "왕복동식 압축기 기동/정지/점검 절차 매뉴얼.", + "document_type": "manual", + "importance": "normal", + "ai_confidence": 0.81, + "user_note": null, + "user_tags": null, + "pinned": false, + "ask_includable": true, + "derived_path": null, + "original_format": "docx", + "conversion_status": "pending", + "is_read": false, + "review_status": "pending", + "edit_url": null, + "preview_status": "pending", + "source_channel": "upload", + "data_origin": "internal", + "doc_purpose": "reference", + "facet_company": null, + "facet_topic": "설비매뉴얼", + "facet_year": 2024, + "facet_doctype": "manual", + "category": "library", + "ai_suggestion": null, + "ai_tldr": null, + "ai_bullets": null, + "ai_detail_summary": null, + "ai_inconsistencies": null, + "ai_analysis_tier": "triage", + "extracted_at": "2026-06-03T01:20:00.000Z", + "ai_processed_at": "2026-06-03T01:22:14.000Z", + "embedded_at": null, + "created_at": "2026-06-03T01:19:55.000Z", + "updated_at": "2026-06-03T01:22:14.000Z", + "read_count": 0, + "last_read_at": null, + "original_filename": "02_왕복압축기_운전매뉴얼.docx", + "duplicate_of": null, + "duplicate_count": 0, + "extracted_text": "왕복압축기 운전 매뉴얼\n1. 기동 전 점검\n - 윤활유 레벨 확인\n - 흡입/토출 밸브 상태 확인\n2. 기동 절차 ...", + "md_content": null, + "md_frontmatter": null, + "md_status": "pending", + "md_extraction_quality": null, + "md_extraction_error": null, + "md_extraction_engine": null, + "md_extraction_engine_version": null, + "md_generated_at": null +} diff --git a/clients/ds-app/contract/fixtures/documents_duplicates.json b/clients/ds-app/contract/fixtures/documents_duplicates.json new file mode 100644 index 0000000..0095e2c --- /dev/null +++ b/clients/ds-app/contract/fixtures/documents_duplicates.json @@ -0,0 +1,18 @@ +{ + "groups": [ + { + "canonical_id": 4912, + "members": [4912, 4977], + "reason": "content_hash", + "detail": "동일 본문 해시 (md_content normalized SHA-256 일치)" + }, + { + "canonical_id": 5120, + "members": [5120, 5121, 5260], + "reason": "near_duplicate", + "detail": "제목/본문 유사도 0.97 (cross-format: pdf + docx 동일 문서)" + } + ], + "total_groups": 2, + "total_duplicate_docs": 3 +} diff --git a/clients/ds-app/contract/fixtures/documents_list.json b/clients/ds-app/contract/fixtures/documents_list.json new file mode 100644 index 0000000..2b892f5 --- /dev/null +++ b/clients/ds-app/contract/fixtures/documents_list.json @@ -0,0 +1,157 @@ +{ + "items": [ + { + "id": 5187, + "file_path": "Engineering/기계가공/엘보_내경가공_절차서.pdf", + "file_format": "pdf", + "file_size": 482113, + "file_type": "document", + "title": "엘보 내경가공 절차서", + "ai_domain": "Engineering", + "ai_sub_group": "기계가공", + "ai_tags": ["엘보", "내경가공", "절차서", "가공공차"], + "ai_summary": "엘보 내경 가공 시 공차 관리와 가공 순서를 정리한 사내 절차서.", + "document_type": "procedure", + "importance": "normal", + "ai_confidence": 0.86, + "user_note": null, + "user_tags": null, + "pinned": false, + "ask_includable": true, + "derived_path": "Engineering/기계가공/엘보_내경가공_절차서.md", + "original_format": "pdf", + "conversion_status": "completed", + "is_read": true, + "review_status": "approved", + "edit_url": null, + "preview_status": "ready", + "source_channel": "upload", + "data_origin": "internal", + "doc_purpose": "reference", + "facet_company": null, + "facet_topic": "기계가공", + "facet_year": 2025, + "facet_doctype": "procedure", + "category": "library", + "ai_suggestion": null, + "ai_tldr": "엘보 내경 가공 공차·순서 절차.", + "ai_bullets": ["가공 전 소재 검사", "내경 공차 +0.1/-0.0", "최종 치수 검사 기록"], + "ai_detail_summary": null, + "ai_inconsistencies": null, + "ai_analysis_tier": "triage", + "extracted_at": "2026-05-30T02:11:04.000Z", + "ai_processed_at": "2026-05-30T02:13:51.000Z", + "embedded_at": "2026-05-30T02:15:09.000Z", + "created_at": "2026-05-30T02:10:58.000Z", + "updated_at": "2026-05-30T02:15:09.000Z", + "read_count": 3, + "last_read_at": "2026-06-02T13:40:22.000Z", + "original_filename": "엘보_내경가공_절차서.pdf", + "duplicate_of": null, + "duplicate_count": 0 + }, + { + "id": 4912, + "file_path": "Engineering/ASME/ASME_SecVIII_Div1_Impact_Test.pdf", + "file_format": "pdf", + "file_size": 1338920, + "file_type": "document", + "title": "ASME Section VIII Div 1 — Impact Test 요건", + "ai_domain": "Engineering", + "ai_sub_group": "압력용기", + "ai_tags": ["ASME", "Section VIII", "충격시험", "UCS-66"], + "ai_summary": "압력용기 재료의 충격시험 면제/요구 조건(UCS-66 등)을 정리.", + "document_type": "standard", + "importance": "high", + "ai_confidence": 0.93, + "user_note": "MDMT 판정 시 자주 참조", + "user_tags": ["자주봄"], + "pinned": true, + "ask_includable": true, + "derived_path": "Engineering/ASME/ASME_SecVIII_Div1_Impact_Test.md", + "original_format": "pdf", + "conversion_status": "completed", + "is_read": true, + "review_status": "approved", + "edit_url": null, + "preview_status": "ready", + "source_channel": "upload", + "data_origin": "external", + "doc_purpose": "reference", + "facet_company": "ASME", + "facet_topic": "압력용기", + "facet_year": 2023, + "facet_doctype": "standard", + "category": "library", + "ai_suggestion": null, + "ai_tldr": "충격시험 면제 곡선(UCS-66)과 MDMT 적용.", + "ai_bullets": ["UCS-66 면제 곡선", "UCS-66.1 감액", "UG-84 시험 요건"], + "ai_detail_summary": null, + "ai_inconsistencies": null, + "ai_analysis_tier": "deep", + "extracted_at": "2026-05-22T05:00:11.000Z", + "ai_processed_at": "2026-05-22T05:04:40.000Z", + "embedded_at": "2026-05-22T05:06:02.000Z", + "created_at": "2026-05-22T04:59:50.000Z", + "updated_at": "2026-06-01T09:21:33.000Z", + "read_count": 11, + "last_read_at": "2026-06-03T18:02:10.000Z", + "original_filename": "ASME_SecVIII_Div1_Impact_Test.pdf", + "duplicate_of": null, + "duplicate_count": 1 + }, + { + "id": 5301, + "file_path": "General/매뉴얼/02_왕복압축기_운전매뉴얼.docx", + "file_format": "docx", + "file_size": 73402, + "file_type": "document", + "title": "02 왕복압축기 운전 매뉴얼", + "ai_domain": "General", + "ai_sub_group": "설비매뉴얼", + "ai_tags": ["왕복압축기", "운전", "매뉴얼"], + "ai_summary": "왕복동식 압축기 기동/정지/점검 절차 매뉴얼.", + "document_type": "manual", + "importance": "normal", + "ai_confidence": 0.81, + "user_note": null, + "user_tags": null, + "pinned": false, + "ask_includable": true, + "derived_path": null, + "original_format": "docx", + "conversion_status": "pending", + "is_read": false, + "review_status": "pending", + "edit_url": null, + "preview_status": "pending", + "source_channel": "upload", + "data_origin": "internal", + "doc_purpose": "reference", + "facet_company": null, + "facet_topic": "설비매뉴얼", + "facet_year": 2024, + "facet_doctype": "manual", + "category": "library", + "ai_suggestion": null, + "ai_tldr": null, + "ai_bullets": null, + "ai_detail_summary": null, + "ai_inconsistencies": null, + "ai_analysis_tier": "triage", + "extracted_at": "2026-06-03T01:20:00.000Z", + "ai_processed_at": "2026-06-03T01:22:14.000Z", + "embedded_at": null, + "created_at": "2026-06-03T01:19:55.000Z", + "updated_at": "2026-06-03T01:22:14.000Z", + "read_count": 0, + "last_read_at": null, + "original_filename": "02_왕복압축기_운전매뉴얼.docx", + "duplicate_of": null, + "duplicate_count": 0 + } + ], + "total": 783, + "page": 1, + "page_size": 20 +} diff --git a/clients/ds-app/contract/fixtures/documents_stats.json b/clients/ds-app/contract/fixtures/documents_stats.json new file mode 100644 index 0000000..f4e8edc --- /dev/null +++ b/clients/ds-app/contract/fixtures/documents_stats.json @@ -0,0 +1,14 @@ +{ + "total": 1163, + "documents": 783, + "by_domain": { + "Industrial_Safety": 426, + "Engineering": 351, + "General": 189, + "Programming": 60, + "법령": 23, + "Philosophy": 12 + }, + "review_pending": 725, + "pipeline_failed": 19 +} diff --git a/clients/ds-app/contract/fixtures/documents_tree.json b/clients/ds-app/contract/fixtures/documents_tree.json new file mode 100644 index 0000000..d2fe617 --- /dev/null +++ b/clients/ds-app/contract/fixtures/documents_tree.json @@ -0,0 +1,16 @@ +[ + { "name": "Industrial_Safety", "path": "Industrial_Safety", "count": 426, + "children": [ + { "name": "위험성평가", "path": "Industrial_Safety/위험성평가", "count": 118, "children": [] }, + { "name": "KGS", "path": "Industrial_Safety/KGS", "count": 73, "children": [] } + ] }, + { "name": "Engineering", "path": "Engineering", "count": 351, + "children": [ + { "name": "압력용기", "path": "Engineering/압력용기", "count": 96, "children": [] }, + { "name": "기계가공", "path": "Engineering/기계가공", "count": 54, "children": [] } + ] }, + { "name": "General", "path": "General", "count": 189, "children": [] }, + { "name": "Programming", "path": "Programming", "count": 60, "children": [] }, + { "name": "법령", "path": "법령", "count": 23, "children": [] }, + { "name": "Philosophy", "path": "Philosophy", "count": 12, "children": [] } +] diff --git a/clients/ds-app/contract/fixtures/foundationmodels-respond.json b/clients/ds-app/contract/fixtures/foundationmodels-respond.json new file mode 100644 index 0000000..294cd2d --- /dev/null +++ b/clients/ds-app/contract/fixtures/foundationmodels-respond.json @@ -0,0 +1,96 @@ +{ + "_meta": { + "fixture": "foundationmodels-respond", + "purpose": "S2-3a — Apple FoundationModels live capture (OnDeviceProvider 결선 + 테스트 동결 기준)", + "captured_on": "M5 Max MacBook Pro (128GB, Apple Intelligence)", + "captured_date": "2026-06-04", + "sdk": "macOS 26.5 SDK / FoundationModels.framework", + "note": "SDK 가 marshaling 하므로 raw request_body 는 없음. 이 파일은 응답 모양 + 에러 타입 + 취소 동작의 동결 기준." + }, + + "availability": { + "observed": "available", + "is_available_convenience": true, + "read_is_synchronous": true, + "api": "SystemLanguageModel.default.availability", + "enum": { + "available": "case available", + "unavailable_reasons": ["deviceNotEligible", "appleIntelligenceNotEnabled", "modelNotReady"] + }, + "supports_korean": true, + "supported_language_count": 23 + }, + + "happy_path": { + "api": "session.respond(to: String, options: GenerationOptions) async throws -> Response", + "content_accessor": "response.content (Response.content : String)", + "observed_content": "압력용기의 충격시험(Charpy) 면제 판정은, 용기의 압력 등급이 10MPa 이하인 경우, 충격 시험을 면제할 수 있으며, 이는 용기의 안전성을 보장하기 위한 중요한 기준입니다.", + "observed_latency_ms": 1291.3, + "transcript_entries_count": 1, + "is_responding_after": false, + "quality_note": "내용은 부정확(온디바이스 ~3B/2-bit QAT). corpusAsk 부적합·quickSummarize/classify 적합 라우팅 정합 — 사실성은 RemoteDS 코퍼스가 담당." + }, + + "session_init": { + "api": "LanguageModelSession(model: .default, tools: [], instructions: String?)", + "instructions_timing": "init (per-call 아님)", + "instructions_nil_handling": "systemPrompt == nil 이면 instructions 인자 생략 (빈 문자열 금지)", + "prewarm": "session.prewarm() — 동기 반환(관찰 ~1.3ms), 백그라운드 워밍", + "stateless_per_request": "호출마다 새 세션 생성 → instructions(init-time) + rateLimited/concurrentRequests(세션 상태) 둘 다 우회" + }, + + "generation_options": { + "api": "GenerationOptions(sampling: SamplingMode? = nil, temperature: Double? = nil, maximumResponseTokens: Int? = nil)", + "mapping": "AICompletionRequest.maxTokens -> maximumResponseTokens", + "temperature_note": "AICompletionRequest 에 temperature 필드 없음(동결) → 미설정(모델 기본). LocalMLX 와 동일 정책(둘 다 미설정)." + }, + + "generation_error": { + "_source": "Xcode jump-to-def / swiftinterface (LanguageModelSession.GenerationError) — authoritative, version-accurate", + "type": "LanguageModelSession.GenerationError : Error, LocalizedError", + "associated_value": "각 case 는 GenerationError.Context (refusal 은 (Refusal, Context))", + "cases": [ + "exceededContextWindowSize(Context)", + "assetsUnavailable(Context)", + "guardrailViolation(Context)", + "unsupportedGuide(Context)", + "unsupportedLanguageOrLocale(Context)", + "decodingFailure(Context)", + "rateLimited(Context)", + "concurrentRequests(Context)", + "refusal(Refusal, Context)" + ], + "plan_corrections": [ + "plan 가정 'refusal 케이스명 없음' = 틀림 → refusal 은 별도 case 로 존재(guardrailViolation 과 구분).", + "plan 에 없던 concurrentRequests case 존재 — rateLimited 와 함께 stateless 세션에서 뜨면 세션 공유 버그 신호.", + "assetsUnavailable 정확명 확정(모델 자산 미가용)." + ], + "reproduced_live": { + "exceededContextWindowSize": { + "trigger": "의도적 컨텍스트 오버플로(긴 프롬프트)", + "errorDescription": "Exceeded model context window size" + } + }, + "finish_reason_mapping": { + "guardrailViolation": ".refused", + "refusal": ".refused", + "exceededContextWindowSize": ".unavailable", + "rateLimited": ".unavailable + loud log (stateless 인데 발생 = 세션 재사용 버그 신호)", + "concurrentRequests": ".unavailable + loud log (동일 — stateless 위반 신호)", + "unsupportedLanguageOrLocale": ".unavailable (+ supportedLocale 사전체크로 회피)", + "unsupportedGuide": ".unavailable", + "decodingFailure": ".unavailable", + "assetsUnavailable": ".unavailable", + "@unknown default": ".unavailable + loud log" + } + }, + + "cancellation": { + "_finding": "S2-Fe 전제 확정 — COOPERATIVE", + "cancel_requested_at_ms": 500, + "threw": "CancellationError", + "elapsed_ms": 533.6, + "interpretation": "respond() 는 mid-flight Task 취소를 협조적으로 honor(요청 33ms 후 CancellationError throw).", + "implication": "OnDevice complete() 에 surrounding Task.checkCancellation() 은 belt-and-suspenders(실제 중단은 respond() 내부). streamResponse 토큰단위 취소 폴백 불필요(선전환 금지)." + } +} diff --git a/clients/ds-app/contract/fixtures/llm-router-chat.request.json b/clients/ds-app/contract/fixtures/llm-router-chat.request.json new file mode 100644 index 0000000..bc6bb08 --- /dev/null +++ b/clients/ds-app/contract/fixtures/llm-router-chat.request.json @@ -0,0 +1,18 @@ +{ + "_meta": { + "fixture": "llm-router-chat.request", + "status": "CAPTURED_LIVE", + "captured_date": "2026-06-05", + "captured_via": "MacBook → Tailscale 100.76.254.116:8890 (맥미니 llm-router, GUI 로그인 복구 후)", + "endpoint": "POST http://100.76.254.116:8890/v1/chat/completions", + "model_note": "llm-router /v1/models 노출 ID = {gemma-4-26b-a4b-it-8bit, mac-mini-default, qwen-macbook, claude-cloud}. LocalMLXProvider 기본 = 'mac-mini-default'(별칭 → 라우터가 gemma-4-26b 로 resolve). 이전 provisional 의 'gemma-macmini' 는 llm-router 모델 ID 아님(그건 DS /search/ask?backend= 쪽 이름).", + "call_shape_note": "messages=[system,user] 분리 고정(load-bearing). system.content = AICompletionRequest.systemPrompt ?? \"\". max_tokens = AICompletionRequest.maxTokens." + }, + "model": "mac-mini-default", + "messages": [ + { "role": "system", "content": "You are a concise technical assistant." }, + { "role": "user", "content": "충격시험 면제 기준을 한 문장으로 요약해줘." } + ], + "max_tokens": 512, + "stream": false +} diff --git a/clients/ds-app/contract/fixtures/llm-router-chat.response.json b/clients/ds-app/contract/fixtures/llm-router-chat.response.json new file mode 100644 index 0000000..c290105 --- /dev/null +++ b/clients/ds-app/contract/fixtures/llm-router-chat.response.json @@ -0,0 +1,25 @@ +{ + "_meta": { + "fixture": "llm-router-chat.response", + "status": "CAPTURED_LIVE", + "captured_date": "2026-06-05", + "endpoint": "POST http://100.76.254.116:8890/v1/chat/completions", + "note": "raw llm-router 응답(맥미니 Gemma 4 26B, MLX). 요청 model='mac-mini-default' → 응답 model='mlx-community/gemma-4-26b-a4b-it-8bit' 로 resolve. 매핑: choices[0].message.content→text, finish_reason→AIFinishReason, latency=측정값.", + "latency_observed_s": 1.73 + }, + "id": "chatcmpl-1780610323", + "object": "chat.completion", + "created": 1780610323, + "model": "mlx-community/gemma-4-26b-a4b-it-8bit", + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": "충격시험은 제품의 설계, 재질, 구조적 특성상 충격 에너지를 흡수할 수 있거나 파손 위험이 극히 낮음을 기술적으로 입증할 경우 면제될 수 있습니다." + }, + "finish_reason": "stop" + } + ], + "usage": { "prompt_tokens": 42, "completion_tokens": 48, "total_tokens": 90 } +} diff --git a/clients/ds-app/contract/fixtures/memo_detail.json b/clients/ds-app/contract/fixtures/memo_detail.json new file mode 100644 index 0000000..0495a0e --- /dev/null +++ b/clients/ds-app/contract/fixtures/memo_detail.json @@ -0,0 +1,23 @@ +{ + "id": 20238, + "title": "엘보 발주 확인 건", + "content": "엘보 내경가공 발주서 금요일까지 확인.\n- [ ] 도면 rev C 기준 공차 재확인\n- [ ] 발주처에 납기 회신", + "file_format": "txt", + "user_tags": ["업무"], + "ai_tags": ["발주", "엘보"], + "ai_domain": "General", + "ai_sub_group": "업무메모", + "ai_summary": "엘보 발주서 금요일까지 확인, 도면 rev C 공차 재확인.", + "pinned": true, + "archived": false, + "ask_includable": true, + "memo_task_state": { "0": { "checked_at": "2026-06-03T10:02:00.000Z" } }, + "ai_event_kind": "task", + "ai_event_confidence": 0.78, + "source_channel": "memo", + "source_metadata": {}, + "file_type": "note", + "file_path": null, + "created_at": "2026-06-03T09:40:00.000Z", + "updated_at": "2026-06-03T10:02:00.000Z" +} diff --git a/clients/ds-app/contract/fixtures/memos_list.json b/clients/ds-app/contract/fixtures/memos_list.json new file mode 100644 index 0000000..53ea0ea --- /dev/null +++ b/clients/ds-app/contract/fixtures/memos_list.json @@ -0,0 +1,53 @@ +{ + "items": [ + { + "id": 20238, + "title": "엘보 발주 확인 건", + "content": "엘보 내경가공 발주서 금요일까지 확인. 도면 rev C 기준으로 공차 재확인 필요.", + "file_format": "txt", + "user_tags": ["업무"], + "ai_tags": ["발주", "엘보"], + "ai_domain": "General", + "ai_sub_group": "업무메모", + "ai_summary": "엘보 발주서 금요일까지 확인, 도면 rev C 공차 재확인.", + "pinned": true, + "archived": false, + "ask_includable": true, + "memo_task_state": { "0": { "checked_at": "2026-06-03T10:02:00.000Z" } }, + "ai_event_kind": "task", + "ai_event_confidence": 0.78, + "source_channel": "memo", + "source_metadata": {}, + "file_type": "note", + "file_path": null, + "created_at": "2026-06-03T09:40:00.000Z", + "updated_at": "2026-06-03T10:02:00.000Z" + }, + { + "id": 20251, + "title": "음성 메모 — 현장 점검", + "content": "3공장 압축기 베어링 소음. 다음 점검 때 진동 측정 추가하기로.", + "file_format": "m4a", + "user_tags": null, + "ai_tags": ["현장", "압축기", "점검"], + "ai_domain": "Industrial_Safety", + "ai_sub_group": "현장메모", + "ai_summary": "3공장 압축기 베어링 소음, 진동 측정 추가 예정.", + "pinned": false, + "archived": false, + "ask_includable": true, + "memo_task_state": {}, + "ai_event_kind": "note", + "ai_event_confidence": 0.64, + "source_channel": "voice", + "source_metadata": { "duration_s": 23, "device": "iPhone" }, + "file_type": "audio", + "file_path": "memos/voice/2026/06/test-voice-memo.m4a", + "created_at": "2026-06-02T17:11:00.000Z", + "updated_at": "2026-06-02T17:11:40.000Z" + } + ], + "total": 4807, + "page": 1, + "page_size": 20 +} diff --git a/clients/ds-app/contract/fixtures/search.json b/clients/ds-app/contract/fixtures/search.json new file mode 100644 index 0000000..f17e72e --- /dev/null +++ b/clients/ds-app/contract/fixtures/search.json @@ -0,0 +1,38 @@ +{ + "results": [ + { + "id": 4912, + "title": "ASME Section VIII Div 1 — Impact Test 요건", + "ai_domain": "Engineering", + "ai_summary": "압력용기 재료의 충격시험 면제/요구 조건(UCS-66 등)을 정리.", + "file_format": "pdf", + "score": 0.8714, + "snippet": "...UCS-66 면제 곡선과 MDMT 적용. 충격시험 면제 여부는 재료군과 두께로 결정...", + "match_reason": "vector+rerank", + "chunk_id": 88213, + "chunk_index": 3, + "section_title": "2. UCS-66 면제 곡선", + "rerank_score": 0.913, + "freshness_debug": null + }, + { + "id": 5044, + "title": "KGS FU211 §2.5 — 가스설비 충격 관련 요건", + "ai_domain": "Industrial_Safety", + "ai_summary": "KGS FU211 가스 사용시설 기준 중 충격/내압 관련 조항.", + "file_format": "pdf", + "score": 0.7321, + "snippet": "...§2.5 충격에 의한 손상 방지... §2.8 내압 시험...", + "match_reason": "vector", + "chunk_id": 90122, + "chunk_index": 1, + "section_title": "2.5", + "rerank_score": 0.742, + "freshness_debug": null + } + ], + "total": 2, + "query": "충격시험 면제", + "mode": "hybrid", + "debug": null +} diff --git a/clients/ds-app/contract/shape_diff.py b/clients/ds-app/contract/shape_diff.py new file mode 100755 index 0000000..bbfd29a --- /dev/null +++ b/clients/ds-app/contract/shape_diff.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""S2-Ff shape diff — frozen fixture ↔ live response 의 *모양*(키/타입)만 비교. + +스칼라 *값*은 무시한다(LLM 응답 본문·id·토큰수는 호출마다 다름). 비교 대상 = 키 존재 + 타입. +argv[1] = frozen fixture 경로, argv[2] = live 응답 JSON 경로. +exit 1 = breaking 드리프트(frozen 키 삭제 / 타입 변경), exit 0 = clean(동일 or live 신규키만). +양쪽 top-level `_meta`(우리 주석)는 비교에서 제외. +""" +import json +import sys + + +def strip_meta(o): + return {k: v for k, v in o.items() if k != "_meta"} if isinstance(o, dict) else o + + +def tag(v): + if v is None: + return "null" + if isinstance(v, bool): + return "bool" + if isinstance(v, (int, float)): + return "number" + if isinstance(v, str): + return "string" + if isinstance(v, list): + return "array" + if isinstance(v, dict): + return "object" + return "unknown" + + +breaking = [] +info = [] + + +def diff(live, frozen, path): + lt, ft = tag(live), tag(frozen) + # nullability 는 드리프트로 보지 않음(fixture 의 null = nullable 표식). + if lt == "null" or ft == "null": + if lt != ft: + info.append(f"{path or ''}: null↔{ft if lt == 'null' else lt} (nullable, 무시 가능)") + return + if lt != ft: + breaking.append(f"{path or ''}: frozen={ft} live={lt} (타입 변경)") + return + if ft == "object": + for k, fv in frozen.items(): + p = f"{path}.{k}" if path else k + if k in live: + diff(live[k], fv, p) + else: + breaking.append(f"{p}: frozen 에 있으나 live 에 없음 (키 삭제)") + for k in live: + if k not in frozen: + p = f"{path}.{k}" if path else k + info.append(f"{p}: live 신규 키 (비파괴)") + elif ft == "array": + if frozen: + if live: + diff(live[0], frozen[0], f"{path}[0]") + else: + info.append(f"{path}[]: live 빈 배열 — element shape 검증 불가") + + +def main(): + if len(sys.argv) != 3: + print("usage: shape_diff.py ", file=sys.stderr) + sys.exit(2) + frozen = strip_meta(json.load(open(sys.argv[1], encoding="utf-8"))) + live = strip_meta(json.load(open(sys.argv[2], encoding="utf-8"))) + diff(live, frozen, "") + for m in breaking: + print(f" DRIFT {m}") + for m in info: + print(f" info {m}") + if not breaking and not info: + print(" (shape identical)") + sys.exit(1 if breaking else 0) + + +if __name__ == "__main__": + main() diff --git a/clients/ds-app/project.yml b/clients/ds-app/project.yml new file mode 100644 index 0000000..ba780af --- /dev/null +++ b/clients/ds-app/project.yml @@ -0,0 +1,76 @@ +# A-6 — real macOS .app target for the DS app shell. +# +# Why this exists: opening Package.swift directly in Xcode produced an *executable* SwiftPM target +# with no .app bundle / Info.plist, so macOS treated it as an accessory and Cmd+R never opened a +# window (see ds-native-app-program memory). This generates a proper application target whose @main +# lives in App/DSApp.swift and which links the AppFeature library product from the local SwiftPM +# package. project.yml is the source of truth; DSApp.xcodeproj + Support/*.plist|.entitlements are +# generated artifacts (gitignored). Regenerate with: xcodegen generate +name: DSApp +options: + bundleIdPrefix: net.hyungi + deploymentTarget: + macOS: "26.0" + createIntermediateGroups: true + minimumXcodeGenVersion: "2.40.0" + +settings: + base: + SWIFT_VERSION: "6.0" # Swift 6 language mode (matches package .swiftLanguageMode(.v6)) + SWIFT_STRICT_CONCURRENCY: complete + MACOSX_DEPLOYMENT_TARGET: "26.0" + CODE_SIGN_STYLE: Automatic + # Local dev: no signing identity needed to build/run on this Mac. + CODE_SIGNING_ALLOWED: "NO" + CODE_SIGNING_REQUIRED: "NO" + +packages: + # Local SwiftPM package = this repo (Package.swift): AIFabric + DSKit + AppFeature libraries. + DSPackage: + path: . + +targets: + DSApp: + type: application + platform: macOS + deploymentTarget: "26.0" + sources: + - path: App # App/DSApp.swift only — @main window + DI shell + dependencies: + - package: DSPackage + product: AppFeature + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: net.hyungi.dsapp + PRODUCT_NAME: DS + GENERATE_INFOPLIST_FILE: "NO" + MARKETING_VERSION: "0.1" + CURRENT_PROJECT_VERSION: "1" + ENABLE_HARDENED_RUNTIME: "YES" + info: + path: Support/Info.plist + properties: + CFBundleName: DS + CFBundleDisplayName: DS + CFBundleShortVersionString: "0.1" + CFBundleVersion: "1" + CFBundlePackageType: APPL + LSMinimumSystemVersion: "26.0" + LSApplicationCategoryType: public.app-category.productivity + NSHumanReadableCopyright: "" + entitlements: + path: Support/DSApp.entitlements + properties: + com.apple.security.app-sandbox: true + com.apple.security.network.client: true # LiveDSClient HTTP → DS / mac mini :8890 + com.apple.security.files.user-selected.read-write: true # FU-C upload / FU-D download save + +schemes: + DSApp: + build: + targets: + DSApp: all + run: + config: Debug + test: + config: Debug