feat(s3): SwiftPM scaffold + DSKit data layer + 14-fixture acceptance

- Package.swift: AI (S2-owned) + DSKit (models/client/fixtures) + DSKitTests, tools 6.2, .swiftLanguageMode(.v6), .macOS(.v26)
- JSONValue (Sendable AnyCodable), DSDate (value-type ISO8601FormatStyle cascade, date-only UTC), explicit-CodingKeys decoder
- Models: Auth/Document(+Detail flat-compose, MD-first)/Catalog/Search+Ask/Memo/Digest; non-optional limited to id/file_type/created+updated_at/total
- DSClient protocol + FixtureDSClient (Bundle.module, zero backend) + DSError + DSConfig + DownloadURL (?token= query)
- Tests: 14-fixture contract acceptance (value asserts) + JSONValue number trap + Ask round-trip + AI router fallback/explicit-unavailable

swift build + swift test green (19 tests). Sources/AI untouched.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Hyungi
2026-06-04 17:16:55 +09:00
parent 17f8830d37
commit 0becf7829e
32 changed files with 1724 additions and 0 deletions
+27
View File
@@ -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)]
),
]
)
+43
View File
@@ -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
}
+18
View File
@@ -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
}
}
+18
View File
@@ -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)
}
}
+62
View File
@@ -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)"
}
}
}
+21
View File
@@ -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
}
}
+81
View File
@@ -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)
}
}
+71
View File
@@ -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]
}
}
+28
View File
@@ -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"
}
}
+54
View File
@@ -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"
}
}
+88
View File
@@ -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"
}
}
+201
View File
@@ -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"
}
}
+71
View File
@@ -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"
}
}
+61
View File
@@ -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 }
}
+103
View File
@@ -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"
}
}
+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
}
+4
View File
@@ -0,0 +1,4 @@
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJoeXVuZ2kiLCJ0eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzQ5MDAwMDAwfQ.FIXTURE_SIGNATURE_NOT_REAL",
"token_type": "bearer"
}
+7
View File
@@ -0,0 +1,7 @@
{
"id": 1,
"username": "hyungi",
"is_active": true,
"totp_enabled": false,
"last_login_at": "2026-06-04T07:55:12.330Z"
}
+54
View File
@@ -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
}
+157
View File
@@ -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": [] }
]
+23
View File
@@ -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"
}
+53
View File
@@ -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
}
+38
View File
@@ -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
}
+137
View File
@@ -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 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)
}
}