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: a24e3e6f22
git-subtree-split: 5206cf3b0c
This commit is contained in:
hyungi
2026-06-05 09:52:50 +09:00
95 changed files with 6534 additions and 0 deletions
+28
View File
@@ -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
+24
View File
@@ -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)
}
}
+57
View File
@@ -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)]
),
]
)
+143
View File
@@ -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?)
}
+99
View File
@@ -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, completenotImplemented(.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
}
}
}
+33
View File
@@ -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
}
}
+135
View File
@@ -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)
}
}
+67
View File
@@ -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`) 변경 시만 합의.
+224
View File
@@ -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]]).
+38
View File
@@ -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`).
+80
View File
@@ -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
+47
View File
@@ -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
}
+83
View File
@@ -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()
+76
View File
@@ -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