Files
hyungi_document_server/Sources/DSKit/Models/Document.swift
T
Hyungi 0becf7829e 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>
2026-06-04 17:16:55 +09:00

202 lines
8.2 KiB
Swift

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"
}
}