diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..fc04238 --- /dev/null +++ b/Package.swift @@ -0,0 +1,27 @@ +// swift-tools-version: 6.2 +import PackageDescription + +// DS multidevice app — macOS-first scaffold (S3). +// Phase 1 targets: AI (S2-owned, read-only) + DSKit (models + client + fixtures) + DSKitTests. +// Phase 2 will add AppFeature (SwiftUI shell) + DSApp (executable) — see plans/2026-06-04-ds-app-s3-scaffold-plan.html. +let package = Package( + name: "DSApp", + platforms: [.macOS(.v26)], + targets: [ + .target( + name: "AI", + path: "Sources/AI", + swiftSettings: [.swiftLanguageMode(.v6)] + ), + .target( + name: "DSKit", + resources: [.process("Resources")], + swiftSettings: [.swiftLanguageMode(.v6)] + ), + .testTarget( + name: "DSKitTests", + dependencies: ["DSKit", "AI"], + swiftSettings: [.swiftLanguageMode(.v6)] + ), + ] +) diff --git a/Sources/DSKit/DSClient.swift b/Sources/DSKit/DSClient.swift new file mode 100644 index 0000000..6082ad8 --- /dev/null +++ b/Sources/DSKit/DSClient.swift @@ -0,0 +1,43 @@ +import Foundation + +/// The single networking seam the whole app codes against. The app builds entirely on +/// `FixtureDSClient` (zero backend); `LiveDSClient` (FU-A) drops in later unchanged. +/// +/// `ask(backend:)` keeps `backend` as `String?` (raw AI-ROUTING §4 values) — S2 maps AIProviderID +/// to that string. This shape is locked by AI-ROUTING.md §4 (a documented contract), verified at +/// integration by the FU-B call-shape regression. +public protocol DSClient: Sendable { + // Auth + func login(username: String, password: String, totpCode: String?) async throws -> AccessTokenResponse + func me() async throws -> UserResponse + func refresh() async throws -> AccessTokenResponse + func logout() async throws + + // Documents + func documents(_ query: DocumentListQuery) async throws -> DocumentListResponse + func document(id: Int) async throws -> DocumentDetailResponse + func documentContent(id: Int) async throws -> DocumentContentResponse + func documentTree() async throws -> [DomainTreeNode] + func categoryCounts() async throws -> CategoryCounts + func duplicates() async throws -> DuplicatesResponse + func patchDocument(id: Int, _ update: DocumentUpdate) async throws -> DocumentResponse + func putContent(id: Int, content: String) async throws + func deleteDocument(id: Int) async throws + + // Search / Ask + func search(q: String, mode: SearchMode?, page: Int?, debug: Bool?) async throws -> SearchResponse + func ask(q: String, limit: Int?, backend: String?, debug: Bool?) async throws -> AskResponse + + // Memos + func memos(_ query: MemoListQuery) async throws -> MemoListResponse + func memo(id: Int) async throws -> MemoResponse + func createMemo(_ create: MemoCreate) async throws -> MemoResponse + func patchMemo(id: Int, _ update: MemoUpdate) async throws -> MemoResponse + func pinMemo(id: Int, pinned: Bool) async throws -> MemoResponse + func archiveMemo(id: Int, archived: Bool) async throws -> MemoResponse + func toggleMemoTask(id: Int, taskIndex: Int, checked: Bool) async throws -> MemoResponse + func deleteMemo(id: Int) async throws + + // Digest + func digest(date: String?, country: String?) async throws -> DigestResponse +} diff --git a/Sources/DSKit/DSCoding.swift b/Sources/DSKit/DSCoding.swift new file mode 100644 index 0000000..44199ec --- /dev/null +++ b/Sources/DSKit/DSCoding.swift @@ -0,0 +1,18 @@ +import Foundation + +/// Single decoder/encoder factory shared by every fixture load and both DS clients +/// (call-shape single source of truth). NO `.convertFromSnakeCase` (every model has explicit +/// CodingKeys) and NO global date strategy (dates are String-raw + DSDate accessors). +public enum DSDecoder { + public static func make() -> JSONDecoder { + JSONDecoder() + } +} + +public enum DSEncoder { + public static func make() -> JSONEncoder { + let e = JSONEncoder() + e.outputFormatting = [.sortedKeys] + return e + } +} diff --git a/Sources/DSKit/DSConfig.swift b/Sources/DSKit/DSConfig.swift new file mode 100644 index 0000000..d699242 --- /dev/null +++ b/Sources/DSKit/DSConfig.swift @@ -0,0 +1,18 @@ +import Foundation + +/// Injectable base URL. Public TLS by default; Tailscale alternative uses a MagicDNS hostname +/// (NOT a hardcoded 100.x IP, which changes on node re-registration). Scaffold never makes a live +/// call, so the Tailscale host is a placeholder until FU-A. +public enum DSBaseURL: Sendable { + case publicTLS + case tailscale + case custom(URL) + + public var url: URL { + switch self { + case .publicTLS: return URL(string: "https://document.hyungi.net/api")! + case .tailscale: return URL(string: "http://ds-gpu.tailnet-name.ts.net:8000/api")! + case .custom(let u): return u + } + } +} diff --git a/Sources/DSKit/DSDate.swift b/Sources/DSKit/DSDate.swift new file mode 100644 index 0000000..1834452 --- /dev/null +++ b/Sources/DSKit/DSDate.swift @@ -0,0 +1,33 @@ +import Foundation + +/// Date parsing for the contract's mixed shapes. Two ISO-8601 datetime shapes (with / without +/// fractional seconds) coexist with date-only strings in the SAME response (digest), so a single +/// JSONDecoder.dateDecodingStrategy is impossible. Dates are decoded as `String?` raw and parsed here. +/// +/// Concurrency (B-1 review): uses value-type `Date.ISO8601FormatStyle` (Sendable) as static lets — +/// no shared reference formatter, so no `nonisolated(unsafe)`. +/// +/// Date-only (B-1 r2 review): `digest_date` should be DISPLAYED from its raw string (no Date conversion) +/// to avoid KST off-by-one; `parse` here fixes date-only to UTC and is for sort/window math only. +public enum DSDate { + private static let isoFractional = Date.ISO8601FormatStyle(includingFractionalSeconds: true) + private static let isoPlain = Date.ISO8601FormatStyle(includingFractionalSeconds: false) + + public static func parse(_ s: String?) -> Date? { + guard let s, !s.isEmpty else { return nil } + if let d = try? isoFractional.parse(s) { return d } + if let d = try? isoPlain.parse(s) { return d } + return parseDateOnly(s) + } + + /// Date-only "YYYY-MM-DD" fixed to UTC (avoids timezone off-by-one). Value-type Calendar, Sendable-safe. + private static func parseDateOnly(_ s: String) -> Date? { + let p = s.split(separator: "-") + guard p.count == 3, let y = Int(p[0]), let m = Int(p[1]), let d = Int(p[2]) else { return nil } + var comps = DateComponents() + comps.year = y; comps.month = m; comps.day = d + var cal = Calendar(identifier: .gregorian) + cal.timeZone = TimeZone(identifier: "UTC")! + return cal.date(from: comps) + } +} diff --git a/Sources/DSKit/DSError.swift b/Sources/DSKit/DSError.swift new file mode 100644 index 0000000..300e2b0 --- /dev/null +++ b/Sources/DSKit/DSError.swift @@ -0,0 +1,62 @@ +import Foundation + +/// Typed error decoded from the contract's two error body shapes ({detail: String} OR +/// {detail: {error_code, message}}) and keyed by HTTP status. +/// +/// Boundary note: an in-body synthesis failure (AskResponse.synthesis_status == "backend_unavailable") +/// is an HTTP success and is NOT a DSError — only a true HTTP 503 maps to .serviceUnavailable. +public enum DSError: Error, Sendable { + case unauthorized(message: String?) + case notFound(message: String?) + case validation(message: String?, errorCode: String?) + case serviceUnavailable(message: String?) + case server(status: Int, message: String?, errorCode: String?) + case transport(underlying: String) + case decoding(String) + + public var isAuthExpired: Bool { + if case .unauthorized = self { return true } + return false + } + + static func from(status: Int, data: Data) -> DSError { + let body = DSErrorBody.parse(data) + switch status { + case 401: return .unauthorized(message: body?.message) + case 404: return .notFound(message: body?.message) + case 422: return .validation(message: body?.message, errorCode: body?.errorCode) + case 503: return .serviceUnavailable(message: body?.message) + default: return .server(status: status, message: body?.message, errorCode: body?.errorCode) + } + } +} + +struct DSErrorBody { + let message: String? + let errorCode: String? + + static func parse(_ data: Data) -> DSErrorBody? { + guard let obj = try? JSONSerialization.jsonObject(with: data) as? [String: Any] else { return nil } + if let s = obj["detail"] as? String { + return DSErrorBody(message: s, errorCode: nil) + } + if let d = obj["detail"] as? [String: Any] { + return DSErrorBody(message: d["message"] as? String, errorCode: d["error_code"] as? String) + } + return nil + } +} + +extension DSError: LocalizedError { + public var errorDescription: String? { + switch self { + case .unauthorized(let m): return m ?? "인증이 만료되었습니다. 다시 로그인하세요." + case .notFound(let m): return m ?? "찾을 수 없습니다." + case .validation(let m, _): return m ?? "요청이 올바르지 않습니다." + case .serviceUnavailable(let m): return m ?? "서비스를 일시적으로 사용할 수 없습니다." + case .server(let s, let m, _): return m ?? "서버 오류 (\(s))" + case .transport(let u): return "네트워크 오류: \(u)" + case .decoding(let d): return "응답 해석 실패: \(d)" + } + } +} diff --git a/Sources/DSKit/DownloadURL.swift b/Sources/DSKit/DownloadURL.swift new file mode 100644 index 0000000..0c7c0be --- /dev/null +++ b/Sources/DSKit/DownloadURL.swift @@ -0,0 +1,21 @@ +import Foundation + +/// Original-file download URL builder. CRITICAL: this endpoint authenticates via the `?token=` QUERY +/// parameter, NOT an Authorization header (iframe/download compatibility). The token must live only in +/// the query. Callers must redact the token when logging the URL. +public enum DSDownload { + public static func fileURL( + base: DSBaseURL, + documentID id: Int, + accessToken: String, + download: Bool = true + ) -> URL? { + let endpoint = base.url.appendingPathComponent("documents/\(id)/file") + var comps = URLComponents(url: endpoint, resolvingAgainstBaseURL: false) + comps?.queryItems = [ + URLQueryItem(name: "token", value: accessToken), + URLQueryItem(name: "download", value: download ? "true" : "false"), + ] + return comps?.url + } +} diff --git a/Sources/DSKit/FixtureDSClient.swift b/Sources/DSKit/FixtureDSClient.swift new file mode 100644 index 0000000..4cf9f23 --- /dev/null +++ b/Sources/DSKit/FixtureDSClient.swift @@ -0,0 +1,81 @@ +import Foundation + +/// Zero-backend DSClient: loads the 14 bundled fixtures (Bundle.module) with the SAME decoder the +/// live client will use, so "it previews" == "the DTOs decode the real shapes". Powers SwiftUI +/// previews and the contract acceptance test. Write-side methods return a plausible echo. +/// +/// Holds no non-Sendable stored state (uses Bundle.module locally) → Sendable. +public struct FixtureDSClient: DSClient { + public init() {} + + private func load(_ name: String, as type: T.Type) throws -> T { + guard let url = Bundle.module.url(forResource: name, withExtension: "json") else { + throw DSError.notFound(message: "fixture \(name).json") + } + let data = try Data(contentsOf: url) + do { + return try DSDecoder.make().decode(T.self, from: data) + } catch { + throw DSError.decoding("\(name): \(error)") + } + } + + // Auth + public func login(username: String, password: String, totpCode: String?) async throws -> AccessTokenResponse { + try load("auth_login", as: AccessTokenResponse.self) + } + public func me() async throws -> UserResponse { try load("auth_me", as: UserResponse.self) } + public func refresh() async throws -> AccessTokenResponse { try load("auth_login", as: AccessTokenResponse.self) } + public func logout() async throws {} + + // Documents + public func documents(_ query: DocumentListQuery) async throws -> DocumentListResponse { + try load("documents_list", as: DocumentListResponse.self) + } + public func document(id: Int) async throws -> DocumentDetailResponse { + // id 5301 = the pending-MD fixture (extracted_text fallback); otherwise the completed MD-first fixture. + try load(id == 5301 ? "document_detail_pending_md" : "document_detail", as: DocumentDetailResponse.self) + } + public func documentContent(id: Int) async throws -> DocumentContentResponse { + try load("document_content", as: DocumentContentResponse.self) + } + public func documentTree() async throws -> [DomainTreeNode] { + try load("documents_tree", as: [DomainTreeNode].self) + } + public func categoryCounts() async throws -> CategoryCounts { + try load("documents_stats", as: CategoryCounts.self) + } + public func duplicates() async throws -> DuplicatesResponse { + try load("documents_duplicates", as: DuplicatesResponse.self) + } + public func patchDocument(id: Int, _ update: DocumentUpdate) async throws -> DocumentResponse { + try load("document_detail", as: DocumentDetailResponse.self).base + } + public func putContent(id: Int, content: String) async throws {} + public func deleteDocument(id: Int) async throws {} + + // Search / Ask + public func search(q: String, mode: SearchMode?, page: Int?, debug: Bool?) async throws -> SearchResponse { + try load("search", as: SearchResponse.self) + } + public func ask(q: String, limit: Int?, backend: String?, debug: Bool?) async throws -> AskResponse { + try load("ask", as: AskResponse.self) + } + + // Memos + public func memos(_ query: MemoListQuery) async throws -> MemoListResponse { + try load("memos_list", as: MemoListResponse.self) + } + public func memo(id: Int) async throws -> MemoResponse { try load("memo_detail", as: MemoResponse.self) } + public func createMemo(_ create: MemoCreate) async throws -> MemoResponse { try load("memo_detail", as: MemoResponse.self) } + public func patchMemo(id: Int, _ update: MemoUpdate) async throws -> MemoResponse { try load("memo_detail", as: MemoResponse.self) } + public func pinMemo(id: Int, pinned: Bool) async throws -> MemoResponse { try load("memo_detail", as: MemoResponse.self) } + public func archiveMemo(id: Int, archived: Bool) async throws -> MemoResponse { try load("memo_detail", as: MemoResponse.self) } + public func toggleMemoTask(id: Int, taskIndex: Int, checked: Bool) async throws -> MemoResponse { try load("memo_detail", as: MemoResponse.self) } + public func deleteMemo(id: Int) async throws {} + + // Digest + public func digest(date: String?, country: String?) async throws -> DigestResponse { + try load("digest", as: DigestResponse.self) + } +} diff --git a/Sources/DSKit/JSONValue.swift b/Sources/DSKit/JSONValue.swift new file mode 100644 index 0000000..4ea67b7 --- /dev/null +++ b/Sources/DSKit/JSONValue.swift @@ -0,0 +1,71 @@ +import Foundation + +/// A Sendable, fully-Codable representation of arbitrary JSON, for the contract's open-shape +/// `[String: Any]?` fields (ai_suggestion, md_frontmatter, md_extraction_quality, memo_task_state, +/// source_metadata, freshness_debug, debug). Plain Codable cannot decode those; this can. +/// +/// Numeric note (B-1 review): on Foundation's JSONDecoder, a JSON boolean fails `Int` decode +/// (no silent 1/0 coercion), so Bool ordering is harmless. The real disambiguation is Int-before-Double +/// so integral numbers stay `.int`. BUT whole-valued floats like `1.0` can land as `.int` depending on +/// toolchain — so numeric meaning MUST be read through `doubleValue`/`intValue` (which cross-convert), +/// never by pattern-matching the raw case. +public enum JSONValue: Codable, Sendable, Hashable { + case string(String) + case int(Int) + case double(Double) + case bool(Bool) + case object([String: JSONValue]) + case array([JSONValue]) + case null + + public init(from decoder: Decoder) throws { + let c = try decoder.singleValueContainer() + if c.decodeNil() { self = .null; return } + if let b = try? c.decode(Bool.self) { self = .bool(b); return } + if let i = try? c.decode(Int.self) { self = .int(i); return } + if let d = try? c.decode(Double.self) { self = .double(d); return } + if let s = try? c.decode(String.self) { self = .string(s); return } + if let o = try? c.decode([String: JSONValue].self) { self = .object(o); return } + if let a = try? c.decode([JSONValue].self) { self = .array(a); return } + throw DecodingError.dataCorruptedError(in: c, debugDescription: "Unsupported JSON value") + } + + public func encode(to encoder: Encoder) throws { + var c = encoder.singleValueContainer() + switch self { + case .string(let s): try c.encode(s) + case .int(let i): try c.encode(i) + case .double(let d): try c.encode(d) + case .bool(let b): try c.encode(b) + case .object(let o): try c.encode(o) + case .array(let a): try c.encode(a) + case .null: try c.encodeNil() + } + } + + // Accessors cross-convert so numeric reads are robust regardless of int/double storage. + public var stringValue: String? { if case .string(let s) = self { return s }; return nil } + public var intValue: Int? { + switch self { + case .int(let i): return i + case .double(let d): return Int(d) + default: return nil + } + } + public var doubleValue: Double? { + switch self { + case .double(let d): return d + case .int(let i): return Double(i) + default: return nil + } + } + public var boolValue: Bool? { if case .bool(let b) = self { return b }; return nil } + public var objectValue: [String: JSONValue]? { if case .object(let o) = self { return o }; return nil } + public var arrayValue: [JSONValue]? { if case .array(let a) = self { return a }; return nil } + + public subscript(key: String) -> JSONValue? { objectValue?[key] } + public subscript(index: Int) -> JSONValue? { + guard let a = arrayValue, index >= 0, index < a.count else { return nil } + return a[index] + } +} diff --git a/Sources/DSKit/Models/Auth.swift b/Sources/DSKit/Models/Auth.swift new file mode 100644 index 0000000..70e7661 --- /dev/null +++ b/Sources/DSKit/Models/Auth.swift @@ -0,0 +1,28 @@ +import Foundation + +public struct AccessTokenResponse: Codable, Sendable, Equatable { + public let accessToken: String + public let tokenType: String + + enum CodingKeys: String, CodingKey { + case accessToken = "access_token" + case tokenType = "token_type" + } +} + +public struct UserResponse: Codable, Sendable, Equatable, Identifiable { + public let id: Int + public let username: String + public let isActive: Bool + public let totpEnabled: Bool + public let lastLoginAtRaw: String? + + public var lastLoginAt: Date? { DSDate.parse(lastLoginAtRaw) } + + enum CodingKeys: String, CodingKey { + case id, username + case isActive = "is_active" + case totpEnabled = "totp_enabled" + case lastLoginAtRaw = "last_login_at" + } +} diff --git a/Sources/DSKit/Models/Catalog.swift b/Sources/DSKit/Models/Catalog.swift new file mode 100644 index 0000000..272a2d8 --- /dev/null +++ b/Sources/DSKit/Models/Catalog.swift @@ -0,0 +1,54 @@ +import Foundation + +/// Recursive domain tree (documents_tree.json is a top-level ARRAY). `children` is optional so +/// SwiftUI OutlineGroup can treat leaves cleanly; `kids` is the non-optional accessor. +public struct DomainTreeNode: Codable, Sendable, Identifiable { + public let name: String + public let path: String + public let count: Int + public let children: [DomainTreeNode]? + + public var id: String { path } + public var kids: [DomainTreeNode] { children ?? [] } +} + +public struct CategoryCounts: Codable, Sendable { + public let total: Int + public let documents: Int + public let byDomain: [String: Int] + public let reviewPending: Int + public let pipelineFailed: Int + + enum CodingKeys: String, CodingKey { + case total, documents + case byDomain = "by_domain" + case reviewPending = "review_pending" + case pipelineFailed = "pipeline_failed" + } +} + +public struct DuplicateGroup: Codable, Sendable, Identifiable { + public let canonicalId: Int + public let members: [Int] + public let reason: String + public let detail: String? + + public var id: Int { canonicalId } + + enum CodingKeys: String, CodingKey { + case members, reason, detail + case canonicalId = "canonical_id" + } +} + +public struct DuplicatesResponse: Codable, Sendable { + public let groups: [DuplicateGroup] + public let totalGroups: Int + public let totalDuplicateDocs: Int + + enum CodingKeys: String, CodingKey { + case groups + case totalGroups = "total_groups" + case totalDuplicateDocs = "total_duplicate_docs" + } +} diff --git a/Sources/DSKit/Models/Digest.swift b/Sources/DSKit/Models/Digest.swift new file mode 100644 index 0000000..d4d5098 --- /dev/null +++ b/Sources/DSKit/Models/Digest.swift @@ -0,0 +1,88 @@ +import Foundation + +public struct ArticleRef: Codable, Sendable, Identifiable { + public let id: Int + public let title: String? +} + +public struct TopicResponse: Codable, Sendable, Identifiable { + public let topicRank: Int + public let topicLabel: String + public let summary: String + public let articleIds: [Int]? + public let articles: [ArticleRef] + public let articleCount: Int? + public let importanceScore: Double? + public let rawWeightSum: Double? + public let llmFallbackUsed: Bool? + + public var id: Int { topicRank } + + enum CodingKeys: String, CodingKey { + case summary, articles + case topicRank = "topic_rank" + case topicLabel = "topic_label" + case articleIds = "article_ids" + case articleCount = "article_count" + case importanceScore = "importance_score" + case rawWeightSum = "raw_weight_sum" + case llmFallbackUsed = "llm_fallback_used" + } +} + +public struct CountryGroup: Codable, Sendable, Identifiable { + public let country: String + public let topics: [TopicResponse] + public var id: String { country } +} + +public struct DigestResponse: Codable, Sendable { + public let digestDateRaw: String? + public let windowStartRaw: String? + public let windowEndRaw: String? + public let decayLambda: Double? + public let totalArticles: Int? + public let totalCountries: Int? + public let totalTopics: Int? + public let generationMs: Int? + public let llmCalls: Int? + public let llmFailures: Int? + public let status: String? + public let countries: [CountryGroup] + + /// date-only (B-1 r2): DISPLAY the raw string (no Date conversion → no KST off-by-one). + public var digestDateDisplay: String { digestDateRaw ?? "" } + /// Date conversions are for sort/window math only (UTC-fixed via DSDate). + public var windowStart: Date? { DSDate.parse(windowStartRaw) } + public var windowEnd: Date? { DSDate.parse(windowEndRaw) } + + enum CodingKeys: String, CodingKey { + case status, countries + case digestDateRaw = "digest_date" + case windowStartRaw = "window_start" + case windowEndRaw = "window_end" + case decayLambda = "decay_lambda" + case totalArticles = "total_articles" + case totalCountries = "total_countries" + case totalTopics = "total_topics" + case generationMs = "generation_ms" + case llmCalls = "llm_calls" + case llmFailures = "llm_failures" + } +} + +public struct DigestDateSummary: Codable, Sendable { + public let digestDateRaw: String? + public let totalTopics: Int? + public let totalCountries: Int? + public let totalArticles: Int? + public let status: String? + + enum CodingKeys: String, CodingKey { + case status + case digestDateRaw = "digest_date" + case totalTopics = "total_topics" + case totalCountries = "total_countries" + case totalArticles = "total_articles" + } +} diff --git a/Sources/DSKit/Models/Document.swift b/Sources/DSKit/Models/Document.swift new file mode 100644 index 0000000..d2cd79e --- /dev/null +++ b/Sources/DSKit/Models/Document.swift @@ -0,0 +1,201 @@ +import Foundation + +/// Document list row. Non-optional set is intentional (B-2 review): only structural/envelope-grade +/// fields whose absence is a contract violation are non-optional, so a malformed response throws +/// loudly rather than silently nil-ing. `file_format` and `read_count` are OPTIONAL + fallback +/// (a note has no format; a light projection may omit the counter — one production null must not +/// kill the whole list decode). Everything else is optional (all-optional discipline). +public struct DocumentResponse: Codable, Sendable, Identifiable, Equatable { + // Invariants (loud-throw on absence) + public let id: Int + public let fileType: String + public let createdAtRaw: String + public let updatedAtRaw: String + // Optional + fallback + public let fileFormat: String? + public let readCount: Int? + // Everything else optional + public let filePath: String? + public let fileSize: Int? + public let title: String? + public let aiDomain: String? + public let aiSubGroup: String? + public let aiTags: [String]? + public let aiSummary: String? + public let documentType: String? + public let importance: String? + public let aiConfidence: Double? + public let userNote: String? + public let userTags: [String]? + public let pinned: Bool? + public let askIncludable: Bool? + public let derivedPath: String? + public let originalFormat: String? + public let conversionStatus: String? + public let isRead: Bool? + public let reviewStatus: String? + public let editUrl: String? + public let previewStatus: String? + public let sourceChannel: String? + public let dataOrigin: String? + public let docPurpose: String? + public let facetCompany: String? + public let facetTopic: String? + public let facetYear: Int? + public let facetDoctype: String? + public let category: String? + public let aiSuggestion: JSONValue? + public let aiTldr: String? + public let aiBullets: [String]? + public let aiDetailSummary: String? + public let aiInconsistencies: [String]? + public let aiAnalysisTier: String? + public let extractedAtRaw: String? + public let aiProcessedAtRaw: String? + public let embeddedAtRaw: String? + public let lastReadAtRaw: String? + // [S1-ADD] + public let originalFilename: String? + public let duplicateOf: Int? + public let duplicateCount: Int? + + public var createdAt: Date? { DSDate.parse(createdAtRaw) } + public var updatedAt: Date? { DSDate.parse(updatedAtRaw) } + public var lastReadAt: Date? { DSDate.parse(lastReadAtRaw) } + public var reads: Int { readCount ?? 0 } + public var displayFormat: String { + fileFormat ?? filePath.map { ($0 as NSString).pathExtension }.flatMap { $0.isEmpty ? nil : $0 } ?? "?" + } + public var downloadLabel: String { + originalFilename ?? filePath.map { ($0 as NSString).lastPathComponent } ?? "원본" + } + public var hasDownloadableOriginal: Bool { fileType != "note" } + + enum CodingKeys: String, CodingKey { + case id, title, importance, pinned, category + case fileType = "file_type" + case createdAtRaw = "created_at" + case updatedAtRaw = "updated_at" + case fileFormat = "file_format" + case readCount = "read_count" + case filePath = "file_path" + case fileSize = "file_size" + case aiDomain = "ai_domain" + case aiSubGroup = "ai_sub_group" + case aiTags = "ai_tags" + case aiSummary = "ai_summary" + case documentType = "document_type" + case aiConfidence = "ai_confidence" + case userNote = "user_note" + case userTags = "user_tags" + case askIncludable = "ask_includable" + case derivedPath = "derived_path" + case originalFormat = "original_format" + case conversionStatus = "conversion_status" + case isRead = "is_read" + case reviewStatus = "review_status" + case editUrl = "edit_url" + case previewStatus = "preview_status" + case sourceChannel = "source_channel" + case dataOrigin = "data_origin" + case docPurpose = "doc_purpose" + case facetCompany = "facet_company" + case facetTopic = "facet_topic" + case facetYear = "facet_year" + case facetDoctype = "facet_doctype" + case aiSuggestion = "ai_suggestion" + case aiTldr = "ai_tldr" + case aiBullets = "ai_bullets" + case aiDetailSummary = "ai_detail_summary" + case aiInconsistencies = "ai_inconsistencies" + case aiAnalysisTier = "ai_analysis_tier" + case extractedAtRaw = "extracted_at" + case aiProcessedAtRaw = "ai_processed_at" + case embeddedAtRaw = "embedded_at" + case lastReadAtRaw = "last_read_at" + case originalFilename = "original_filename" + case duplicateOf = "duplicate_of" + case duplicateCount = "duplicate_count" + } +} + +/// Single-document detail = the flat DocumentResponse fields + markdown/body fields, decoded from the +/// same flat JSON object via composition (avoids duplicating ~45 field declarations). The pending-md +/// fixture decodes through the SAME struct. +public struct DocumentDetailResponse: Decodable, Sendable, Identifiable { + public let base: DocumentResponse + public let extractedText: String? + public let mdContent: String? + public let mdFrontmatter: JSONValue? + public let mdStatus: String? + public let mdExtractionQuality: JSONValue? + public let mdExtractionError: String? + public let mdExtractionEngine: String? + public let mdExtractionEngineVersion: String? + public let mdGeneratedAtRaw: String? + + public var id: Int { base.id } + public var mdGeneratedAt: Date? { DSDate.parse(mdGeneratedAtRaw) } + /// MD-first render rule: render md_content when status is completed/partial, else fall back. + public var mdIsRenderable: Bool { mdStatus == "completed" || mdStatus == "partial" } + + enum CodingKeys: String, CodingKey { + case extractedText = "extracted_text" + case mdContent = "md_content" + case mdFrontmatter = "md_frontmatter" + case mdStatus = "md_status" + case mdExtractionQuality = "md_extraction_quality" + case mdExtractionError = "md_extraction_error" + case mdExtractionEngine = "md_extraction_engine" + case mdExtractionEngineVersion = "md_extraction_engine_version" + case mdGeneratedAtRaw = "md_generated_at" + } + + public init(from decoder: Decoder) throws { + self.base = try DocumentResponse(from: decoder) + let c = try decoder.container(keyedBy: CodingKeys.self) + self.extractedText = try c.decodeIfPresent(String.self, forKey: .extractedText) + self.mdContent = try c.decodeIfPresent(String.self, forKey: .mdContent) + self.mdFrontmatter = try c.decodeIfPresent(JSONValue.self, forKey: .mdFrontmatter) + self.mdStatus = try c.decodeIfPresent(String.self, forKey: .mdStatus) + self.mdExtractionQuality = try c.decodeIfPresent(JSONValue.self, forKey: .mdExtractionQuality) + self.mdExtractionError = try c.decodeIfPresent(String.self, forKey: .mdExtractionError) + self.mdExtractionEngine = try c.decodeIfPresent(String.self, forKey: .mdExtractionEngine) + self.mdExtractionEngineVersion = try c.decodeIfPresent(String.self, forKey: .mdExtractionEngineVersion) + self.mdGeneratedAtRaw = try c.decodeIfPresent(String.self, forKey: .mdGeneratedAtRaw) + } +} + +public struct DocumentListResponse: Codable, Sendable { + public let items: [DocumentResponse] + public let total: Int + public let page: Int + public let pageSize: Int + + enum CodingKeys: String, CodingKey { + case items, total, page + case pageSize = "page_size" + } +} + +public struct DocumentContentResponse: Codable, Sendable { + public let id: Int + public let title: String? + public let domain: String? + public let subGroup: String? + public let documentType: String? + public let aiSummary: String? + public let aiTags: [String]? + public let content: String? + public let contentLength: Int? + public let truncated: Bool? + + enum CodingKeys: String, CodingKey { + case id, title, domain, content, truncated + case subGroup = "sub_group" + case documentType = "document_type" + case aiSummary = "ai_summary" + case aiTags = "ai_tags" + case contentLength = "content_length" + } +} diff --git a/Sources/DSKit/Models/Memo.swift b/Sources/DSKit/Models/Memo.swift new file mode 100644 index 0000000..a0c7d62 --- /dev/null +++ b/Sources/DSKit/Models/Memo.swift @@ -0,0 +1,71 @@ +import Foundation + +public struct MemoResponse: Codable, Sendable, Identifiable { + public let id: Int + public let title: String? + public let content: String? + public let fileFormat: String? + public let fileType: String? + public let filePath: String? + public let userTags: [String]? + public let aiTags: [String]? + public let aiDomain: String? + public let aiSubGroup: String? + public let aiSummary: String? + public let pinned: Bool? + public let archived: Bool? + public let askIncludable: Bool? + public let memoTaskState: JSONValue? + public let aiEventKind: String? + public let aiEventConfidence: Double? + public let sourceChannel: String? + public let sourceMetadata: JSONValue? + public let createdAtRaw: String? + public let updatedAtRaw: String? + + public var createdAt: Date? { DSDate.parse(createdAtRaw) } + public var updatedAt: Date? { DSDate.parse(updatedAtRaw) } + public var isPinned: Bool { pinned ?? false } + public var isArchived: Bool { archived ?? false } + public var isAudio: Bool { fileType == "audio" } + + /// Checked task indices derived from memo_task_state keys (index-keyed object). + public var checkedTaskIndices: Set { + guard let o = memoTaskState?.objectValue else { return [] } + var s = Set() + for k in o.keys { if let i = Int(k) { s.insert(i) } } + return s + } + + enum CodingKeys: String, CodingKey { + case id, title, content, pinned, archived + case fileFormat = "file_format" + case fileType = "file_type" + case filePath = "file_path" + case userTags = "user_tags" + case aiTags = "ai_tags" + case aiDomain = "ai_domain" + case aiSubGroup = "ai_sub_group" + case aiSummary = "ai_summary" + case askIncludable = "ask_includable" + case memoTaskState = "memo_task_state" + case aiEventKind = "ai_event_kind" + case aiEventConfidence = "ai_event_confidence" + case sourceChannel = "source_channel" + case sourceMetadata = "source_metadata" + case createdAtRaw = "created_at" + case updatedAtRaw = "updated_at" + } +} + +public struct MemoListResponse: Codable, Sendable { + public let items: [MemoResponse] + public let total: Int + public let page: Int + public let pageSize: Int + + enum CodingKeys: String, CodingKey { + case items, total, page + case pageSize = "page_size" + } +} diff --git a/Sources/DSKit/Models/Requests.swift b/Sources/DSKit/Models/Requests.swift new file mode 100644 index 0000000..9798499 --- /dev/null +++ b/Sources/DSKit/Models/Requests.swift @@ -0,0 +1,61 @@ +import Foundation + +public enum SearchMode: String, Sendable, CaseIterable { + case text, vector, hybrid +} + +public struct DocumentListQuery: Sendable { + public var page: Int = 1 + public var pageSize: Int = 20 + public var domain: String? + public var subGroup: String? + public var source: String? + public var format: String? + public var reviewStatus: String? + public var category: String? + public init() {} +} + +public struct MemoListQuery: Sendable { + public var page: Int = 1 + public var pageSize: Int = 20 + public var pinned: Bool? + public var archived: Bool? + public init() {} +} + +public struct DocumentUpdate: Codable, Sendable { + public var title: String? + public var userNote: String? + public var pinned: Bool? + public var reviewStatus: String? + public init(title: String? = nil, userNote: String? = nil, pinned: Bool? = nil, reviewStatus: String? = nil) { + self.title = title; self.userNote = userNote; self.pinned = pinned; self.reviewStatus = reviewStatus + } + enum CodingKeys: String, CodingKey { + case title, pinned + case userNote = "user_note" + case reviewStatus = "review_status" + } +} + +public struct MemoCreate: Codable, Sendable { + public var content: String + public var title: String? + public var askIncludable: Bool? + public var sourceChannel: String? + public init(content: String, title: String? = nil, askIncludable: Bool? = nil, sourceChannel: String? = nil) { + self.content = content; self.title = title; self.askIncludable = askIncludable; self.sourceChannel = sourceChannel + } + enum CodingKeys: String, CodingKey { + case content, title + case askIncludable = "ask_includable" + case sourceChannel = "source_channel" + } +} + +public struct MemoUpdate: Codable, Sendable { + public var content: String + public var title: String? + public init(content: String, title: String? = nil) { self.content = content; self.title = title } +} diff --git a/Sources/DSKit/Models/Search.swift b/Sources/DSKit/Models/Search.swift new file mode 100644 index 0000000..09222ad --- /dev/null +++ b/Sources/DSKit/Models/Search.swift @@ -0,0 +1,103 @@ +import Foundation + +public struct SearchResult: Codable, Sendable, Identifiable { + public let id: Int // doc_id + public let title: String? + public let aiDomain: String? + public let aiSummary: String? + public let fileFormat: String? + public let score: Double? + public let snippet: String? + public let matchReason: String? + public let chunkId: Int? + public let chunkIndex: Int? + public let sectionTitle: String? + public let rerankScore: Double? + public let freshnessDebug: JSONValue? + + enum CodingKeys: String, CodingKey { + case id, title, score, snippet + case aiDomain = "ai_domain" + case aiSummary = "ai_summary" + case fileFormat = "file_format" + case matchReason = "match_reason" + case chunkId = "chunk_id" + case chunkIndex = "chunk_index" + case sectionTitle = "section_title" + case rerankScore = "rerank_score" + case freshnessDebug = "freshness_debug" + } +} + +public struct SearchResponse: Codable, Sendable { + public let results: [SearchResult] + public let total: Int + public let query: String + public let mode: String + public let debug: JSONValue? +} + +/// Ask-response citation. DISTINCT from S2's `AICitation` (Sources/AI) — different field sets; +/// RemoteDSProvider maps this -> AICitation itself. Do not reuse the S2 type here. +public struct Citation: Codable, Sendable, Identifiable { + public let n: Int + public let chunkId: Int? + public let docId: Int + public let title: String? + public let sectionTitle: String? + public let spanText: String + public let fullSnippet: String + public let relevance: Double + public let rerankScore: Double + + public var id: Int { n } + + enum CodingKeys: String, CodingKey { + case n, title, relevance + case chunkId = "chunk_id" + case docId = "doc_id" + case sectionTitle = "section_title" + case spanText = "span_text" + case fullSnippet = "full_snippet" + case rerankScore = "rerank_score" + } +} + +public struct ConfirmedItem: Codable, Sendable { + public let aspect: String + public let text: String + public let citations: [Int] +} + +public struct AskResponse: Codable, Sendable { + public let results: [SearchResult] + public let aiAnswer: String? + public let citations: [Citation] + public let synthesisStatus: String + public let synthesisMs: Double + public let confidence: String? + public let refused: Bool + public let noResultsReason: String? + public let query: String + public let total: Int + public let completeness: String + public let coveredAspects: [String]? + public let missingAspects: [String]? + public let confirmedItems: [ConfirmedItem]? + public let backendRequested: String? + public let backendUsed: String? + public let debug: JSONValue? + + enum CodingKeys: String, CodingKey { + case results, citations, confidence, refused, query, total, completeness, debug + case aiAnswer = "ai_answer" + case synthesisStatus = "synthesis_status" + case synthesisMs = "synthesis_ms" + case noResultsReason = "no_results_reason" + case coveredAspects = "covered_aspects" + case missingAspects = "missing_aspects" + case confirmedItems = "confirmed_items" + case backendRequested = "backend_requested" + case backendUsed = "backend_used" + } +} diff --git a/Sources/DSKit/Resources/ask.json b/Sources/DSKit/Resources/ask.json new file mode 100644 index 0000000..a650751 --- /dev/null +++ b/Sources/DSKit/Resources/ask.json @@ -0,0 +1,47 @@ +{ + "results": [ + { + "id": 4912, + "title": "ASME Section VIII Div 1 — Impact Test 요건", + "ai_domain": "Engineering", + "ai_summary": "압력용기 재료의 충격시험 면제/요구 조건(UCS-66 등)을 정리.", + "file_format": "pdf", + "score": 0.8714, + "snippet": "...UCS-66 면제 곡선과 MDMT 적용...", + "match_reason": "vector+rerank", + "chunk_id": 88213, + "chunk_index": 3, + "section_title": "2. UCS-66 면제 곡선", + "rerank_score": 0.913, + "freshness_debug": null + } + ], + "ai_answer": "충격시험 면제는 UCS-66 면제 곡선으로 판정합니다 [1]. 재료군(Curve A~D)과 거버닝 두께에 따라 최소설계금속온도(MDMT)에서 면제 여부가 정해지며, 설계 응력비가 낮으면 UCS-66.1에 따라 MDMT를 추가로 낮출 수 있습니다 [1].", + "citations": [ + { + "n": 1, + "chunk_id": 88213, + "doc_id": 4912, + "title": "ASME Section VIII Div 1 — Impact Test 요건", + "section_title": "2. UCS-66 면제 곡선", + "span_text": "재료군(Curve A~D)과 거버닝 두께에 따라 최소설계금속온도(MDMT)에서의 충격시험 면제 여부를 결정한다.", + "full_snippet": "재료군(Curve A~D)과 거버닝 두께에 따라 최소설계금속온도(MDMT)에서의 충격시험 면제 여부를 결정한다. 설계 응력비가 낮으면 UCS-66.1에 따라 MDMT를 추가로 낮출 수 있다. 면제되지 않는 경우 UG-84에 따라 Charpy V-notch 시험을 수행한다.", + "relevance": 0.91, + "rerank_score": 0.913 + } + ], + "synthesis_status": "completed", + "synthesis_ms": 2841.5, + "confidence": "high", + "refused": false, + "no_results_reason": null, + "query": "충격시험은 언제 면제되나", + "total": 1, + "completeness": "full", + "covered_aspects": ["면제 곡선", "MDMT 적용"], + "missing_aspects": null, + "confirmed_items": null, + "backend_requested": "mac-mini-default", + "backend_used": "gemma-macmini", + "debug": null +} diff --git a/Sources/DSKit/Resources/auth_login.json b/Sources/DSKit/Resources/auth_login.json new file mode 100644 index 0000000..cea80a3 --- /dev/null +++ b/Sources/DSKit/Resources/auth_login.json @@ -0,0 +1,4 @@ +{ + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJoeXVuZ2kiLCJ0eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzQ5MDAwMDAwfQ.FIXTURE_SIGNATURE_NOT_REAL", + "token_type": "bearer" +} diff --git a/Sources/DSKit/Resources/auth_me.json b/Sources/DSKit/Resources/auth_me.json new file mode 100644 index 0000000..c92ea4f --- /dev/null +++ b/Sources/DSKit/Resources/auth_me.json @@ -0,0 +1,7 @@ +{ + "id": 1, + "username": "hyungi", + "is_active": true, + "totp_enabled": false, + "last_login_at": "2026-06-04T07:55:12.330Z" +} diff --git a/Sources/DSKit/Resources/digest.json b/Sources/DSKit/Resources/digest.json new file mode 100644 index 0000000..3c78d0e --- /dev/null +++ b/Sources/DSKit/Resources/digest.json @@ -0,0 +1,54 @@ +{ + "digest_date": "2026-06-03", + "window_start": "2026-05-27T00:00:00.000Z", + "window_end": "2026-06-03T00:00:00.000Z", + "decay_lambda": 0.18, + "total_articles": 312, + "total_countries": 4, + "total_topics": 9, + "generation_ms": 18420, + "llm_calls": 11, + "llm_failures": 0, + "status": "completed", + "countries": [ + { + "country": "KR", + "topics": [ + { + "topic_rank": 1, + "topic_label": "산업안전 규제 개정", + "summary": "중대재해처벌법 후속 시행령 개정 논의가 이어지며 제조업 현장 점검이 강화되는 흐름.", + "article_ids": [880123, 880140, 880155], + "articles": [ + { "id": 880123, "title": "고용부, 중대재해 시행령 개정안 입법예고" }, + { "id": 880140, "title": "제조 현장 안전점검 확대" }, + { "id": 880155, "title": "압력설비 검사 주기 단축 검토" } + ], + "article_count": 3, + "importance_score": 0.91, + "raw_weight_sum": 2.74, + "llm_fallback_used": false + } + ] + }, + { + "country": "US", + "topics": [ + { + "topic_rank": 1, + "topic_label": "ASME 코드 업데이트", + "summary": "ASME BPVC 2025 에디션 관련 산업계 적용 사례와 해설 자료가 늘어남.", + "article_ids": [880301, 880322], + "articles": [ + { "id": 880301, "title": "ASME BPVC 2025 adoption notes" }, + { "id": 880322, "title": "Impact test exemption clarifications" } + ], + "article_count": 2, + "importance_score": 0.77, + "raw_weight_sum": 1.62, + "llm_fallback_used": false + } + ] + } + ] +} diff --git a/Sources/DSKit/Resources/document_content.json b/Sources/DSKit/Resources/document_content.json new file mode 100644 index 0000000..b5430c3 --- /dev/null +++ b/Sources/DSKit/Resources/document_content.json @@ -0,0 +1,12 @@ +{ + "id": 4912, + "title": "ASME Section VIII Div 1 — Impact Test 요건", + "domain": "Engineering", + "sub_group": "압력용기", + "document_type": "standard", + "ai_summary": "압력용기 재료의 충격시험 면제/요구 조건(UCS-66 등)을 정리.", + "ai_tags": ["ASME", "Section VIII", "충격시험", "UCS-66"], + "content": "ASME Section VIII Division 1 Impact Test Requirements\nUCS-66 면제 곡선과 MDMT 적용 ... (최대 15000자) ...", + "content_length": 8421, + "truncated": false +} diff --git a/Sources/DSKit/Resources/document_detail.json b/Sources/DSKit/Resources/document_detail.json new file mode 100644 index 0000000..e285003 --- /dev/null +++ b/Sources/DSKit/Resources/document_detail.json @@ -0,0 +1,63 @@ +{ + "id": 4912, + "file_path": "Engineering/ASME/ASME_SecVIII_Div1_Impact_Test.pdf", + "file_format": "pdf", + "file_size": 1338920, + "file_type": "document", + "title": "ASME Section VIII Div 1 — Impact Test 요건", + "ai_domain": "Engineering", + "ai_sub_group": "압력용기", + "ai_tags": ["ASME", "Section VIII", "충격시험", "UCS-66"], + "ai_summary": "압력용기 재료의 충격시험 면제/요구 조건(UCS-66 등)을 정리.", + "document_type": "standard", + "importance": "high", + "ai_confidence": 0.93, + "user_note": "MDMT 판정 시 자주 참조", + "user_tags": ["자주봄"], + "pinned": true, + "ask_includable": true, + "derived_path": "Engineering/ASME/ASME_SecVIII_Div1_Impact_Test.md", + "original_format": "pdf", + "conversion_status": "completed", + "is_read": true, + "review_status": "approved", + "edit_url": null, + "preview_status": "ready", + "source_channel": "upload", + "data_origin": "external", + "doc_purpose": "reference", + "facet_company": "ASME", + "facet_topic": "압력용기", + "facet_year": 2023, + "facet_doctype": "standard", + "category": "library", + "ai_suggestion": null, + "ai_tldr": "충격시험 면제 곡선(UCS-66)과 MDMT 적용.", + "ai_bullets": ["UCS-66 면제 곡선", "UCS-66.1 감액", "UG-84 시험 요건"], + "ai_detail_summary": "본 표준 절은 탄소강·저합금강 압력용기 재료의 노치 인성(충격시험) 요구를 다룬다. UCS-66 면제 곡선은 재료군(A~D)과 두께에 따라 최소설계금속온도(MDMT)에서의 시험 면제 여부를 정한다.", + "ai_inconsistencies": [], + "ai_analysis_tier": "deep", + "extracted_at": "2026-05-22T05:00:11.000Z", + "ai_processed_at": "2026-05-22T05:04:40.000Z", + "embedded_at": "2026-05-22T05:06:02.000Z", + "created_at": "2026-05-22T04:59:50.000Z", + "updated_at": "2026-06-01T09:21:33.000Z", + "read_count": 11, + "last_read_at": "2026-06-03T18:02:10.000Z", + "original_filename": "ASME_SecVIII_Div1_Impact_Test.pdf", + "duplicate_of": null, + "duplicate_count": 1, + "extracted_text": "ASME Section VIII Division 1 Impact Test Requirements\nUCS-66 ... (원문 추출 텍스트, 폴백용) ...", + "md_content": "# ASME Section VIII Div 1 — 충격시험 요건\n\n## 1. 범위\n탄소강 및 저합금강 압력용기 재료의 노치 인성(충격시험) 요구를 규정한다.\n\n## 2. UCS-66 면제 곡선\n재료군(Curve A~D)과 거버닝 두께에 따라 **최소설계금속온도(MDMT)** 에서의 충격시험 면제 여부를 결정한다.\n\n| 곡선 | 대표 재료 | 비고 |\n|---|---|---|\n| A | SA-516 (비정규화) | 가장 보수적 |\n| B | SA-516 정규화 | |\n| C | SA-537 | |\n| D | 인성 우수 재료 | 가장 관대 |\n\n## 3. UCS-66.1 감액\n설계 응력비(stress ratio)가 낮으면 MDMT 를 추가로 낮출 수 있다.\n\n## 4. UG-84 시험 요건\n면제되지 않는 경우 Charpy V-notch 시험으로 흡수에너지/측면팽창 기준을 만족해야 한다.\n", + "md_frontmatter": { + "title": "ASME Section VIII Div 1 — 충격시험 요건", + "domain": "Engineering", + "source": "ASME_SecVIII_Div1_Impact_Test.pdf" + }, + "md_status": "completed", + "md_extraction_quality": { "page_count": 14, "table_count": 3, "ocr_used": false }, + "md_extraction_error": null, + "md_extraction_engine": "marker", + "md_extraction_engine_version": "1.10.2", + "md_generated_at": "2026-05-22T05:03:30.000Z" +} diff --git a/Sources/DSKit/Resources/document_detail_pending_md.json b/Sources/DSKit/Resources/document_detail_pending_md.json new file mode 100644 index 0000000..f664d71 --- /dev/null +++ b/Sources/DSKit/Resources/document_detail_pending_md.json @@ -0,0 +1,59 @@ +{ + "id": 5301, + "file_path": "General/매뉴얼/02_왕복압축기_운전매뉴얼.docx", + "file_format": "docx", + "file_size": 73402, + "file_type": "document", + "title": "02 왕복압축기 운전 매뉴얼", + "ai_domain": "General", + "ai_sub_group": "설비매뉴얼", + "ai_tags": ["왕복압축기", "운전", "매뉴얼"], + "ai_summary": "왕복동식 압축기 기동/정지/점검 절차 매뉴얼.", + "document_type": "manual", + "importance": "normal", + "ai_confidence": 0.81, + "user_note": null, + "user_tags": null, + "pinned": false, + "ask_includable": true, + "derived_path": null, + "original_format": "docx", + "conversion_status": "pending", + "is_read": false, + "review_status": "pending", + "edit_url": null, + "preview_status": "pending", + "source_channel": "upload", + "data_origin": "internal", + "doc_purpose": "reference", + "facet_company": null, + "facet_topic": "설비매뉴얼", + "facet_year": 2024, + "facet_doctype": "manual", + "category": "library", + "ai_suggestion": null, + "ai_tldr": null, + "ai_bullets": null, + "ai_detail_summary": null, + "ai_inconsistencies": null, + "ai_analysis_tier": "triage", + "extracted_at": "2026-06-03T01:20:00.000Z", + "ai_processed_at": "2026-06-03T01:22:14.000Z", + "embedded_at": null, + "created_at": "2026-06-03T01:19:55.000Z", + "updated_at": "2026-06-03T01:22:14.000Z", + "read_count": 0, + "last_read_at": null, + "original_filename": "02_왕복압축기_운전매뉴얼.docx", + "duplicate_of": null, + "duplicate_count": 0, + "extracted_text": "왕복압축기 운전 매뉴얼\n1. 기동 전 점검\n - 윤활유 레벨 확인\n - 흡입/토출 밸브 상태 확인\n2. 기동 절차 ...", + "md_content": null, + "md_frontmatter": null, + "md_status": "pending", + "md_extraction_quality": null, + "md_extraction_error": null, + "md_extraction_engine": null, + "md_extraction_engine_version": null, + "md_generated_at": null +} diff --git a/Sources/DSKit/Resources/documents_duplicates.json b/Sources/DSKit/Resources/documents_duplicates.json new file mode 100644 index 0000000..0095e2c --- /dev/null +++ b/Sources/DSKit/Resources/documents_duplicates.json @@ -0,0 +1,18 @@ +{ + "groups": [ + { + "canonical_id": 4912, + "members": [4912, 4977], + "reason": "content_hash", + "detail": "동일 본문 해시 (md_content normalized SHA-256 일치)" + }, + { + "canonical_id": 5120, + "members": [5120, 5121, 5260], + "reason": "near_duplicate", + "detail": "제목/본문 유사도 0.97 (cross-format: pdf + docx 동일 문서)" + } + ], + "total_groups": 2, + "total_duplicate_docs": 3 +} diff --git a/Sources/DSKit/Resources/documents_list.json b/Sources/DSKit/Resources/documents_list.json new file mode 100644 index 0000000..2b892f5 --- /dev/null +++ b/Sources/DSKit/Resources/documents_list.json @@ -0,0 +1,157 @@ +{ + "items": [ + { + "id": 5187, + "file_path": "Engineering/기계가공/엘보_내경가공_절차서.pdf", + "file_format": "pdf", + "file_size": 482113, + "file_type": "document", + "title": "엘보 내경가공 절차서", + "ai_domain": "Engineering", + "ai_sub_group": "기계가공", + "ai_tags": ["엘보", "내경가공", "절차서", "가공공차"], + "ai_summary": "엘보 내경 가공 시 공차 관리와 가공 순서를 정리한 사내 절차서.", + "document_type": "procedure", + "importance": "normal", + "ai_confidence": 0.86, + "user_note": null, + "user_tags": null, + "pinned": false, + "ask_includable": true, + "derived_path": "Engineering/기계가공/엘보_내경가공_절차서.md", + "original_format": "pdf", + "conversion_status": "completed", + "is_read": true, + "review_status": "approved", + "edit_url": null, + "preview_status": "ready", + "source_channel": "upload", + "data_origin": "internal", + "doc_purpose": "reference", + "facet_company": null, + "facet_topic": "기계가공", + "facet_year": 2025, + "facet_doctype": "procedure", + "category": "library", + "ai_suggestion": null, + "ai_tldr": "엘보 내경 가공 공차·순서 절차.", + "ai_bullets": ["가공 전 소재 검사", "내경 공차 +0.1/-0.0", "최종 치수 검사 기록"], + "ai_detail_summary": null, + "ai_inconsistencies": null, + "ai_analysis_tier": "triage", + "extracted_at": "2026-05-30T02:11:04.000Z", + "ai_processed_at": "2026-05-30T02:13:51.000Z", + "embedded_at": "2026-05-30T02:15:09.000Z", + "created_at": "2026-05-30T02:10:58.000Z", + "updated_at": "2026-05-30T02:15:09.000Z", + "read_count": 3, + "last_read_at": "2026-06-02T13:40:22.000Z", + "original_filename": "엘보_내경가공_절차서.pdf", + "duplicate_of": null, + "duplicate_count": 0 + }, + { + "id": 4912, + "file_path": "Engineering/ASME/ASME_SecVIII_Div1_Impact_Test.pdf", + "file_format": "pdf", + "file_size": 1338920, + "file_type": "document", + "title": "ASME Section VIII Div 1 — Impact Test 요건", + "ai_domain": "Engineering", + "ai_sub_group": "압력용기", + "ai_tags": ["ASME", "Section VIII", "충격시험", "UCS-66"], + "ai_summary": "압력용기 재료의 충격시험 면제/요구 조건(UCS-66 등)을 정리.", + "document_type": "standard", + "importance": "high", + "ai_confidence": 0.93, + "user_note": "MDMT 판정 시 자주 참조", + "user_tags": ["자주봄"], + "pinned": true, + "ask_includable": true, + "derived_path": "Engineering/ASME/ASME_SecVIII_Div1_Impact_Test.md", + "original_format": "pdf", + "conversion_status": "completed", + "is_read": true, + "review_status": "approved", + "edit_url": null, + "preview_status": "ready", + "source_channel": "upload", + "data_origin": "external", + "doc_purpose": "reference", + "facet_company": "ASME", + "facet_topic": "압력용기", + "facet_year": 2023, + "facet_doctype": "standard", + "category": "library", + "ai_suggestion": null, + "ai_tldr": "충격시험 면제 곡선(UCS-66)과 MDMT 적용.", + "ai_bullets": ["UCS-66 면제 곡선", "UCS-66.1 감액", "UG-84 시험 요건"], + "ai_detail_summary": null, + "ai_inconsistencies": null, + "ai_analysis_tier": "deep", + "extracted_at": "2026-05-22T05:00:11.000Z", + "ai_processed_at": "2026-05-22T05:04:40.000Z", + "embedded_at": "2026-05-22T05:06:02.000Z", + "created_at": "2026-05-22T04:59:50.000Z", + "updated_at": "2026-06-01T09:21:33.000Z", + "read_count": 11, + "last_read_at": "2026-06-03T18:02:10.000Z", + "original_filename": "ASME_SecVIII_Div1_Impact_Test.pdf", + "duplicate_of": null, + "duplicate_count": 1 + }, + { + "id": 5301, + "file_path": "General/매뉴얼/02_왕복압축기_운전매뉴얼.docx", + "file_format": "docx", + "file_size": 73402, + "file_type": "document", + "title": "02 왕복압축기 운전 매뉴얼", + "ai_domain": "General", + "ai_sub_group": "설비매뉴얼", + "ai_tags": ["왕복압축기", "운전", "매뉴얼"], + "ai_summary": "왕복동식 압축기 기동/정지/점검 절차 매뉴얼.", + "document_type": "manual", + "importance": "normal", + "ai_confidence": 0.81, + "user_note": null, + "user_tags": null, + "pinned": false, + "ask_includable": true, + "derived_path": null, + "original_format": "docx", + "conversion_status": "pending", + "is_read": false, + "review_status": "pending", + "edit_url": null, + "preview_status": "pending", + "source_channel": "upload", + "data_origin": "internal", + "doc_purpose": "reference", + "facet_company": null, + "facet_topic": "설비매뉴얼", + "facet_year": 2024, + "facet_doctype": "manual", + "category": "library", + "ai_suggestion": null, + "ai_tldr": null, + "ai_bullets": null, + "ai_detail_summary": null, + "ai_inconsistencies": null, + "ai_analysis_tier": "triage", + "extracted_at": "2026-06-03T01:20:00.000Z", + "ai_processed_at": "2026-06-03T01:22:14.000Z", + "embedded_at": null, + "created_at": "2026-06-03T01:19:55.000Z", + "updated_at": "2026-06-03T01:22:14.000Z", + "read_count": 0, + "last_read_at": null, + "original_filename": "02_왕복압축기_운전매뉴얼.docx", + "duplicate_of": null, + "duplicate_count": 0 + } + ], + "total": 783, + "page": 1, + "page_size": 20 +} diff --git a/Sources/DSKit/Resources/documents_stats.json b/Sources/DSKit/Resources/documents_stats.json new file mode 100644 index 0000000..f4e8edc --- /dev/null +++ b/Sources/DSKit/Resources/documents_stats.json @@ -0,0 +1,14 @@ +{ + "total": 1163, + "documents": 783, + "by_domain": { + "Industrial_Safety": 426, + "Engineering": 351, + "General": 189, + "Programming": 60, + "법령": 23, + "Philosophy": 12 + }, + "review_pending": 725, + "pipeline_failed": 19 +} diff --git a/Sources/DSKit/Resources/documents_tree.json b/Sources/DSKit/Resources/documents_tree.json new file mode 100644 index 0000000..d2fe617 --- /dev/null +++ b/Sources/DSKit/Resources/documents_tree.json @@ -0,0 +1,16 @@ +[ + { "name": "Industrial_Safety", "path": "Industrial_Safety", "count": 426, + "children": [ + { "name": "위험성평가", "path": "Industrial_Safety/위험성평가", "count": 118, "children": [] }, + { "name": "KGS", "path": "Industrial_Safety/KGS", "count": 73, "children": [] } + ] }, + { "name": "Engineering", "path": "Engineering", "count": 351, + "children": [ + { "name": "압력용기", "path": "Engineering/압력용기", "count": 96, "children": [] }, + { "name": "기계가공", "path": "Engineering/기계가공", "count": 54, "children": [] } + ] }, + { "name": "General", "path": "General", "count": 189, "children": [] }, + { "name": "Programming", "path": "Programming", "count": 60, "children": [] }, + { "name": "법령", "path": "법령", "count": 23, "children": [] }, + { "name": "Philosophy", "path": "Philosophy", "count": 12, "children": [] } +] diff --git a/Sources/DSKit/Resources/memo_detail.json b/Sources/DSKit/Resources/memo_detail.json new file mode 100644 index 0000000..0495a0e --- /dev/null +++ b/Sources/DSKit/Resources/memo_detail.json @@ -0,0 +1,23 @@ +{ + "id": 20238, + "title": "엘보 발주 확인 건", + "content": "엘보 내경가공 발주서 금요일까지 확인.\n- [ ] 도면 rev C 기준 공차 재확인\n- [ ] 발주처에 납기 회신", + "file_format": "txt", + "user_tags": ["업무"], + "ai_tags": ["발주", "엘보"], + "ai_domain": "General", + "ai_sub_group": "업무메모", + "ai_summary": "엘보 발주서 금요일까지 확인, 도면 rev C 공차 재확인.", + "pinned": true, + "archived": false, + "ask_includable": true, + "memo_task_state": { "0": { "checked_at": "2026-06-03T10:02:00.000Z" } }, + "ai_event_kind": "task", + "ai_event_confidence": 0.78, + "source_channel": "memo", + "source_metadata": {}, + "file_type": "note", + "file_path": null, + "created_at": "2026-06-03T09:40:00.000Z", + "updated_at": "2026-06-03T10:02:00.000Z" +} diff --git a/Sources/DSKit/Resources/memos_list.json b/Sources/DSKit/Resources/memos_list.json new file mode 100644 index 0000000..53ea0ea --- /dev/null +++ b/Sources/DSKit/Resources/memos_list.json @@ -0,0 +1,53 @@ +{ + "items": [ + { + "id": 20238, + "title": "엘보 발주 확인 건", + "content": "엘보 내경가공 발주서 금요일까지 확인. 도면 rev C 기준으로 공차 재확인 필요.", + "file_format": "txt", + "user_tags": ["업무"], + "ai_tags": ["발주", "엘보"], + "ai_domain": "General", + "ai_sub_group": "업무메모", + "ai_summary": "엘보 발주서 금요일까지 확인, 도면 rev C 공차 재확인.", + "pinned": true, + "archived": false, + "ask_includable": true, + "memo_task_state": { "0": { "checked_at": "2026-06-03T10:02:00.000Z" } }, + "ai_event_kind": "task", + "ai_event_confidence": 0.78, + "source_channel": "memo", + "source_metadata": {}, + "file_type": "note", + "file_path": null, + "created_at": "2026-06-03T09:40:00.000Z", + "updated_at": "2026-06-03T10:02:00.000Z" + }, + { + "id": 20251, + "title": "음성 메모 — 현장 점검", + "content": "3공장 압축기 베어링 소음. 다음 점검 때 진동 측정 추가하기로.", + "file_format": "m4a", + "user_tags": null, + "ai_tags": ["현장", "압축기", "점검"], + "ai_domain": "Industrial_Safety", + "ai_sub_group": "현장메모", + "ai_summary": "3공장 압축기 베어링 소음, 진동 측정 추가 예정.", + "pinned": false, + "archived": false, + "ask_includable": true, + "memo_task_state": {}, + "ai_event_kind": "note", + "ai_event_confidence": 0.64, + "source_channel": "voice", + "source_metadata": { "duration_s": 23, "device": "iPhone" }, + "file_type": "audio", + "file_path": "memos/voice/2026/06/test-voice-memo.m4a", + "created_at": "2026-06-02T17:11:00.000Z", + "updated_at": "2026-06-02T17:11:40.000Z" + } + ], + "total": 4807, + "page": 1, + "page_size": 20 +} diff --git a/Sources/DSKit/Resources/search.json b/Sources/DSKit/Resources/search.json new file mode 100644 index 0000000..f17e72e --- /dev/null +++ b/Sources/DSKit/Resources/search.json @@ -0,0 +1,38 @@ +{ + "results": [ + { + "id": 4912, + "title": "ASME Section VIII Div 1 — Impact Test 요건", + "ai_domain": "Engineering", + "ai_summary": "압력용기 재료의 충격시험 면제/요구 조건(UCS-66 등)을 정리.", + "file_format": "pdf", + "score": 0.8714, + "snippet": "...UCS-66 면제 곡선과 MDMT 적용. 충격시험 면제 여부는 재료군과 두께로 결정...", + "match_reason": "vector+rerank", + "chunk_id": 88213, + "chunk_index": 3, + "section_title": "2. UCS-66 면제 곡선", + "rerank_score": 0.913, + "freshness_debug": null + }, + { + "id": 5044, + "title": "KGS FU211 §2.5 — 가스설비 충격 관련 요건", + "ai_domain": "Industrial_Safety", + "ai_summary": "KGS FU211 가스 사용시설 기준 중 충격/내압 관련 조항.", + "file_format": "pdf", + "score": 0.7321, + "snippet": "...§2.5 충격에 의한 손상 방지... §2.8 내압 시험...", + "match_reason": "vector", + "chunk_id": 90122, + "chunk_index": 1, + "section_title": "2.5", + "rerank_score": 0.742, + "freshness_debug": null + } + ], + "total": 2, + "query": "충격시험 면제", + "mode": "hybrid", + "debug": null +} diff --git a/Tests/DSKitTests/FixtureDecodeTests.swift b/Tests/DSKitTests/FixtureDecodeTests.swift new file mode 100644 index 0000000..7bf0cee --- /dev/null +++ b/Tests/DSKitTests/FixtureDecodeTests.swift @@ -0,0 +1,137 @@ +import XCTest +@testable import DSKit + +/// The contract's "1차 수용 테스트": every fixture must decode through the app's models via the +/// shared decoder, with representative VALUE assertions (no-throw alone would miss a silently-missing +/// CodingKey). Driven through FixtureDSClient so it exercises client + decoder + models together. +final class FixtureDecodeTests: XCTestCase { + let client = FixtureDSClient() + + func testAuthLogin() async throws { + let r = try await client.login(username: "x", password: "y", totpCode: nil) + XCTAssertEqual(r.tokenType, "bearer") + XCTAssertFalse(r.accessToken.isEmpty) + } + + func testAuthMe() async throws { + let u = try await client.me() + XCTAssertEqual(u.id, 1) + XCTAssertEqual(u.username, "hyungi") + XCTAssertNotNil(u.lastLoginAt) // fractional-seconds ISO parse + } + + func testDocumentsList() async throws { + let r = try await client.documents(DocumentListQuery()) + XCTAssertEqual(r.total, 783) + XCTAssertEqual(r.items.count, 3) + XCTAssertEqual(r.items[2].conversionStatus, "pending") + XCTAssertEqual(r.items[0].aiTags?.count, 4) + XCTAssertEqual(r.items[1].duplicateCount, 1) + XCTAssertEqual(r.items[0].fileFormat, "pdf") + XCTAssertEqual(r.items[0].reads, 3) // read_count fallback accessor + } + + func testDocumentDetailCompleted() async throws { + let d = try await client.document(id: 4912) + XCTAssertEqual(d.mdStatus, "completed") + XCTAssertNotNil(d.mdContent) + XCTAssertTrue(d.mdIsRenderable) + XCTAssertEqual(d.mdExtractionQuality?["page_count"]?.intValue, 14) + XCTAssertEqual(d.base.aiInconsistencies?.isEmpty, true) + XCTAssertEqual(d.base.title, "ASME Section VIII Div 1 — Impact Test 요건") + } + + func testDocumentDetailPending() async throws { + let d = try await client.document(id: 5301) + XCTAssertEqual(d.mdStatus, "pending") + XCTAssertNil(d.mdContent) + XCTAssertFalse(d.mdIsRenderable) + XCTAssertNotNil(d.extractedText) + XCTAssertNil(d.mdFrontmatter) + } + + func testDocumentContent() async throws { + let c = try await client.documentContent(id: 4912) + XCTAssertEqual(c.contentLength, 8421) + XCTAssertEqual(c.truncated, false) + } + + func testDocumentTree() async throws { + let t = try await client.documentTree() + XCTAssertEqual(t.count, 6) + XCTAssertEqual(t[0].kids.count, 2) + XCTAssertEqual(t[0].kids[0].name, "위험성평가") + } + + func testStats() async throws { + let s = try await client.categoryCounts() + XCTAssertEqual(s.documents, 783) + XCTAssertEqual(s.byDomain["법령"], 23) // non-ASCII dict key + } + + func testDuplicates() async throws { + let d = try await client.duplicates() + XCTAssertEqual(d.totalGroups, 2) + XCTAssertEqual(d.groups[1].members.count, 3) + XCTAssertEqual(d.groups[0].reason, "content_hash") + } + + func testSearch() async throws { + let s = try await client.search(q: "충격시험", mode: .hybrid, page: 1, debug: false) + XCTAssertEqual(s.results.count, 2) + XCTAssertEqual(s.results[0].rerankScore, 0.913) + XCTAssertEqual(s.mode, "hybrid") + } + + func testAsk() async throws { + let a = try await client.ask(q: "충격시험은 언제 면제되나", limit: nil, backend: nil, debug: false) + XCTAssertEqual(a.citations.count, 1) + XCTAssertEqual(a.confidence, "high") + XCTAssertNil(a.confirmedItems) + XCTAssertEqual(a.coveredAspects?.count, 2) + XCTAssertEqual(a.backendUsed, "gemma-macmini") + } + + func testMemosList() async throws { + let m = try await client.memos(MemoListQuery()) + XCTAssertEqual(m.total, 4807) + XCTAssertEqual(m.items[0].checkedTaskIndices, [0]) + XCTAssertEqual(m.items[1].fileType, "audio") + XCTAssertTrue(m.items[1].isAudio) + } + + func testMemoDetail() async throws { + let m = try await client.memo(id: 20238) + XCTAssertTrue(m.isPinned) + XCTAssertNotNil(m.memoTaskState?["0"]?["checked_at"]?.stringValue) + } + + func testDigest() async throws { + let d = try await client.digest(date: nil, country: nil) + XCTAssertEqual(d.digestDateDisplay, "2026-06-03") // date-only raw display + XCTAssertNotNil(d.windowStart) // fractional ISO parse + XCTAssertEqual(d.countries.count, 2) + XCTAssertEqual(d.countries[0].topics[0].articles.count, 3) + } + + /// JSONValue numeric robustness (B-1 review): whole-valued floats and integers must read correctly + /// through the cross-converting accessors regardless of int/double storage. + func testJSONValueNumberTrap() throws { + let data = Data(#"{"a": 1.0, "b": 0.97, "c": 23, "d": true}"#.utf8) + let v = try DSDecoder.make().decode(JSONValue.self, from: data) + XCTAssertEqual(v["a"]?.doubleValue, 1.0) + XCTAssertEqual(v["b"]?.doubleValue, 0.97) + XCTAssertEqual(v["c"]?.intValue, 23) + XCTAssertEqual(v["d"]?.boolValue, true) + } + + /// Light round-trip encode (call-shape regression guard): decode -> encode -> decode, compare values. + func testAskRoundTrip() async throws { + let a = try await client.ask(q: "q", limit: nil, backend: nil, debug: false) + let data = try DSEncoder.make().encode(a) + let a2 = try DSDecoder.make().decode(AskResponse.self, from: data) + XCTAssertEqual(a2.citations.count, a.citations.count) + XCTAssertEqual(a2.backendUsed, a.backendUsed) + XCTAssertEqual(a2.confidence, a.confidence) + } +} diff --git a/Tests/DSKitTests/RouterFallbackTests.swift b/Tests/DSKitTests/RouterFallbackTests.swift new file mode 100644 index 0000000..cf36852 --- /dev/null +++ b/Tests/DSKitTests/RouterFallbackTests.swift @@ -0,0 +1,42 @@ +import XCTest +import AI + +/// 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) + } +}