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