feat(app): ds-app 네이티브 클라이언트(S2 AIFabric + S3 macOS 앱)를 clients/ds-app 로 통합 — monorepo, 원종=Document Server. 계약(contract/)을 백엔드와 동일 repo 에서 co-evolve, 배포는 build context 분리(./services·./app·./frontend)로 무영향
git-subtree-dir: clients/ds-app git-subtree-mainline:a24e3e6f22git-subtree-split:5206cf3b0c
This commit is contained in:
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)]
|
||||
),
|
||||
]
|
||||
)
|
||||
@@ -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?)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
@@ -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<String>.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<String>.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
|
||||
@@ -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(과금 버킷 분리).
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 "낮음" }
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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> = []) -> [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<AIProviderID> = []) -> 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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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..<columns, id: \.self) { i in
|
||||
inline(i < cells.count ? cells[i] : "")
|
||||
.font(isHeader ? .callout.weight(.semibold) : .callout)
|
||||
.foregroundStyle(isHeader ? Sage.ink : Sage.muted)
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
.padding(.horizontal, 8).padding(.vertical, 6)
|
||||
}
|
||||
}
|
||||
.background(isHeader ? Sage.surface : Sage.card)
|
||||
.overlay(Rectangle().fill(Sage.line).frame(height: 1), alignment: .bottom)
|
||||
}
|
||||
|
||||
private func inline(_ s: String) -> 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) }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import SwiftUI
|
||||
import DSKit
|
||||
|
||||
struct DocumentListView: View {
|
||||
@Environment(AppModel.self) private var model
|
||||
|
||||
var body: some View {
|
||||
let selection = Binding<Int?>(
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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<Int?>(
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
}
|
||||
@@ -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<AppModel.Section?>(
|
||||
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
|
||||
@@ -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)" }
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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<String, Error>?
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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<T: Decodable>(_ 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)
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
}
|
||||
}
|
||||
@@ -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<T: Decodable>(_ 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) }
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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<Int> {
|
||||
guard let o = memoTaskState?.objectValue else { return [] }
|
||||
var s = Set<Int>()
|
||||
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"
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJoeXVuZ2kiLCJ0eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzQ5MDAwMDAwfQ.FIXTURE_SIGNATURE_NOT_REAL",
|
||||
"token_type": "bearer"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"id": 1,
|
||||
"username": "hyungi",
|
||||
"is_active": true,
|
||||
"totp_enabled": false,
|
||||
"last_login_at": "2026-06-04T07:55:12.330Z"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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": [] }
|
||||
]
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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).
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) — 이 파일은 <root>/Tests/AITests/FixtureSupport.swift.
|
||||
static let repoRoot: URL = URL(fileURLWithPath: #filePath)
|
||||
.deletingLastPathComponent() // Tests/AITests
|
||||
.deletingLastPathComponent() // Tests
|
||||
.deletingLastPathComponent() // <root>
|
||||
|
||||
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<T: Decodable>(_ type: T.Type, from name: String, using decoder: JSONDecoder = JSONDecoder()) throws -> T {
|
||||
try decoder.decode(type, from: data(name))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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" }
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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<String, OnDeviceGenerationError>
|
||||
|
||||
init(avail: OnDeviceAvailability = .available,
|
||||
outcome: Result<String, OnDeviceGenerationError> = .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 후)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 로 가시화")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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=<prompt>&backend=<map(explicitProvider)> → 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`) 변경 시만 합의.
|
||||
@@ -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 <access_token>` 헤더.
|
||||
|
||||
### 인증 흐름 (네이티브)
|
||||
- `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=<access>&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=<access>&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]]).
|
||||
@@ -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`).
|
||||
Executable
+80
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJoeXVuZ2kiLCJ0eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzQ5MDAwMDAwfQ.FIXTURE_SIGNATURE_NOT_REAL",
|
||||
"token_type": "bearer"
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"id": 1,
|
||||
"username": "hyungi",
|
||||
"is_active": true,
|
||||
"totp_enabled": false,
|
||||
"last_login_at": "2026-06-04T07:55:12.330Z"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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": [] }
|
||||
]
|
||||
@@ -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<String>",
|
||||
"content_accessor": "response.content (Response<String>.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 토큰단위 취소 폴백 불필요(선전환 금지)."
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Executable
+83
@@ -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 '<root>'}: null↔{ft if lt == 'null' else lt} (nullable, 무시 가능)")
|
||||
return
|
||||
if lt != ft:
|
||||
breaking.append(f"{path or '<root>'}: 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 <frozen_fixture> <live_json>", 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()
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user