commit 17f8830d379b01f978a0d1a17b8b958b6e5aacf1 Author: Hyungi Date: Thu Jun 4 15:27:24 2026 +0900 feat(ds-app): freeze S1 contract + S2 AIProvider interface baseline S1 = contract/CONTRACT.md + 14 fixtures + README + AI-ROUTING. S2 = Sources/AI/{AIProvider,AIRouter,MockAIProvider} + Providers skeletons. Baseline before S3 (device app) scaffold work begins. Co-Authored-By: Claude Opus 4.8 (1M context) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3c2d917 --- /dev/null +++ b/.gitignore @@ -0,0 +1,24 @@ +# macOS +.DS_Store + +# Swift / SwiftPM +.build/ +.swiftpm/ +Package.resolved + +# Xcode +DerivedData/ +build/ +*.xcodeproj/project.xcworkspace/xcuserdata/ +*.xcodeproj/xcuserdata/ +*.xcworkspace/xcuserdata/ +*.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +xcuserdata/ +*.moved-aside +*.hmap +*.ipa +*.dSYM.zip +*.dSYM + +# Misc +*.swp diff --git a/Sources/AI/AIProvider.swift b/Sources/AI/AIProvider.swift new file mode 100644 index 0000000..c99db10 --- /dev/null +++ b/Sources/AI/AIProvider.swift @@ -0,0 +1,143 @@ +// AIProvider.swift — S2 인터페이스 동결 (멀티디바이스 LLM 라우팅 패브릭) +// +// 이 파일이 앱(S3)이 코딩하는 "AI 호출 모양"을 동결한다. +// S3 는 이 프로토콜 + MockAIProvider 로 빌드하고, S2 가 실제 provider +// (OnDevice / LocalMLX / RemoteDS / Specialized)를 그 뒤에서 채운다. +// +// Foundation 외 의존 0 → 표준 출발선. 실제 구현(FoundationModels / MLX / URLSession)은 +// Providers/ 의 각 스켈레톤에서 S2 가 결선. + +import Foundation + +// MARK: - Provider 식별 (디바이스 역할 = 인프라 패브릭과 1:1) + +public enum AIProviderID: String, Codable, Sendable, CaseIterable { + /// 맥북·아이폰 온디바이스 (Apple Foundation Models). 즉답·오프라인·프라이버시. + case onDevice + /// 맥미니 메인 로컬 LLM 허브 (Gemma 4 26B, MLX :8801 / llm-router :8890). 무거운 생성. + case localMLX + /// 원격 DS 코퍼스 RAG (`GET /search/ask`). 전체 지식베이스 질의 — 온디바이스 불가 영역. + case remoteDS + /// GPU 특화 통로 (rerank / embed / vision / OCR). 온디맨드. + case specialized +} + +// MARK: - 태스크 분류 (앱이 AI 에 요청하는 일의 종류 → 라우팅 기준) + +public enum AITask: String, Codable, Sendable { + /// 선택 텍스트/문서 빠른 요약. + case quickSummarize + /// 메모 정리·제목 생성·태그 제안. + case memoAssist + /// 현재 문서/선택에 대한 질문 (로컬 컨텍스트, 코퍼스 불필요). + case askSelection + /// 전체 지식베이스 RAG 질의 (코퍼스 필요 → 원격 DS). + case corpusAsk + /// 문서 분류 (도메인/카테고리 제안). + case classify + /// 이미지/스캔 PDF 이해 (비전). + case vision +} + +public enum AIConfidence: String, Codable, Sendable { + case high, medium, low +} + +public enum AIFinishReason: String, Codable, Sendable { + case completed // 정상 완료 + case refused // 모델이 답변 거부 (근거 부족 등) + case timeout // 생성 지연으로 생략 + case unavailable // provider 사용 불가 (503 계열) + case noEvidence // (corpusAsk) 관련 근거 없음 +} + +// MARK: - 요청 / 응답 + +public struct AICompletionRequest: Sendable { + public var task: AITask + public var prompt: String + /// 선택 텍스트·문서 본문 등 로컬 컨텍스트 (corpusAsk 는 보통 nil — 코퍼스가 컨텍스트). + public var context: String? + public var systemPrompt: String? + public var maxTokens: Int? + /// 사용자가 명시적으로 고른 provider. 지정 시 **자동 fallback 금지**(명시 opt-in). + public var explicitProvider: AIProviderID? + + public init( + task: AITask, + prompt: String, + context: String? = nil, + systemPrompt: String? = nil, + maxTokens: Int? = nil, + explicitProvider: AIProviderID? = nil + ) { + self.task = task + self.prompt = prompt + self.context = context + self.systemPrompt = systemPrompt + self.maxTokens = maxTokens + self.explicitProvider = explicitProvider + } +} + +/// corpusAsk 응답의 근거 — DS `Citation`(CONTRACT.md §4)에서 매핑. +public struct AICitation: Codable, Sendable, Identifiable { + public var id: Int { n } + public var n: Int + public var docId: Int + public var title: String? + public var sectionTitle: String? + public var spanText: String + + public init(n: Int, docId: Int, title: String?, sectionTitle: String?, spanText: String) { + self.n = n + self.docId = docId + self.title = title + self.sectionTitle = sectionTitle + self.spanText = spanText + } +} + +public struct AICompletionResponse: Sendable { + public var text: String + public var providerUsed: AIProviderID + public var finishReason: AIFinishReason + public var citations: [AICitation] + public var confidence: AIConfidence? + public var latencyMs: Double? + /// 라우팅 가시성 — rule-based fallback 으로 1차 선택이 빗나갔을 때 채워짐(silent 금지). + public var routingNote: String? + + public init( + text: String, + providerUsed: AIProviderID, + finishReason: AIFinishReason = .completed, + citations: [AICitation] = [], + confidence: AIConfidence? = nil, + latencyMs: Double? = nil, + routingNote: String? = nil + ) { + self.text = text + self.providerUsed = providerUsed + self.finishReason = finishReason + self.citations = citations + self.confidence = confidence + self.latencyMs = latencyMs + self.routingNote = routingNote + } +} + +// MARK: - 프로토콜 (S2 가 구현, S3 가 소비) + +public protocol AIProvider: Sendable { + var id: AIProviderID { get } + /// 능력 프로브 (온디바이스 Apple Intelligence 가용? 맥미니 도달 가능? 등). + var isAvailable: Bool { get async } + func complete(_ request: AICompletionRequest) async throws -> AICompletionResponse +} + +public enum AIProviderError: Error, Sendable { + case notImplemented(AIProviderID) + case unavailable(AIProviderID) + case backendError(AIProviderID, status: Int, reason: String?) +} diff --git a/Sources/AI/AIRouter.swift b/Sources/AI/AIRouter.swift new file mode 100644 index 0000000..75a5688 --- /dev/null +++ b/Sources/AI/AIRouter.swift @@ -0,0 +1,99 @@ +// AIRouter.swift — 태스크 → provider 라우팅 정책 (3단 fallback: explicit > rule > error) +// +// 정책 = feedback_task_routing_hybrid + feedback_no_silent_fallback_explicit_opt_in: +// 1) 명시 provider 지정 → 그것만, 불가 시 **에러**(자동 fallback 금지). +// 2) 미지정 → 태스크별 선호 체인(rule), 정의된 fallback 은 routingNote 로 가시화. +// 3) 전부 불가 → noProviderAvailable. + +import Foundation + +public struct AIRoutingPolicy: Sendable { + /// 태스크별 provider 선호 순서. 앞쪽 우선, 불가/실패 시 다음으로(가시 fallback). + public var preference: [AITask: [AIProviderID]] + + public init(preference: [AITask: [AIProviderID]]) { + self.preference = preference + } + + public func chain(for task: AITask) -> [AIProviderID] { + preference[task] ?? [.onDevice, .localMLX, .remoteDS] + } + + /// 기본 정책 — 디바이스 역할 표와 일치. + /// 빠른/사적 = 온디바이스 우선, 무거운 생성 = 맥미니, 코퍼스 = 원격 DS, 비전 = GPU 특화. + public static let `default` = AIRoutingPolicy(preference: [ + .quickSummarize: [.onDevice, .localMLX], + .memoAssist: [.onDevice, .localMLX], + .askSelection: [.onDevice, .localMLX, .remoteDS], + .corpusAsk: [.remoteDS], // 코퍼스는 원격만 — 온디바이스로 폴백 불가 + .classify: [.localMLX, .remoteDS, .onDevice], + .vision: [.specialized, .onDevice], + ]) +} + +public enum AIRoutingError: Error, Sendable { + case providerNotConfigured(AIProviderID) + case explicitProviderUnavailable(AIProviderID) // 명시 지정인데 불가 → 자동 fallback 안 함 + case noProviderAvailable(AITask) +} + +public struct AIRouter: Sendable { + public let providers: [AIProviderID: any AIProvider] + public let policy: AIRoutingPolicy + /// 라우팅 로그 훅 (silent skip 방지 — 가시 fallback). 기본은 무시. + public let log: @Sendable (String) -> Void + + public init( + providers: [AIProviderID: any AIProvider], + policy: AIRoutingPolicy = .default, + log: @escaping @Sendable (String) -> Void = { _ in } + ) { + self.providers = providers + self.policy = policy + self.log = log + } + + public func route(_ request: AICompletionRequest) async throws -> AICompletionResponse { + // 1) 명시 opt-in — 자동 fallback 금지. + if let explicit = request.explicitProvider { + guard let provider = providers[explicit] else { + throw AIRoutingError.providerNotConfigured(explicit) + } + guard await provider.isAvailable else { + log("explicit provider \(explicit.rawValue) unavailable — no fallback (opt-in)") + throw AIRoutingError.explicitProviderUnavailable(explicit) + } + return try await provider.complete(request) + } + + // 2) rule-based 선호 체인 — 정의된 fallback 은 routingNote 로 가시화. + let chain = policy.chain(for: request.task) + var attempted: [AIProviderID] = [] + var lastError: Error? + + for id in chain { + guard let provider = providers[id] else { continue } + guard await provider.isAvailable else { + log("provider \(id.rawValue) unavailable for \(request.task.rawValue) — trying next") + attempted.append(id) + continue + } + do { + var response = try await provider.complete(request) + if !attempted.isEmpty { + response.routingNote = "fallback from \(attempted.map(\.rawValue).joined(separator: ",")) → \(id.rawValue)" + } + return response + } catch { + log("provider \(id.rawValue) failed for \(request.task.rawValue): \(error) — trying next") + attempted.append(id) + lastError = error + continue + } + } + + // 3) 전부 불가. + if let lastError { throw lastError } + throw AIRoutingError.noProviderAvailable(request.task) + } +} diff --git a/Sources/AI/MockAIProvider.swift b/Sources/AI/MockAIProvider.swift new file mode 100644 index 0000000..69459a7 --- /dev/null +++ b/Sources/AI/MockAIProvider.swift @@ -0,0 +1,67 @@ +// MockAIProvider.swift — S3 가 실 LLM 없이 빌드/프리뷰하기 위한 mock. +// +// 어떤 AIProviderID 로도 가장해 태스크별 canned 응답을 반환한다. +// S3 SwiftUI 프리뷰 / UI 테스트가 이걸 주입해 AI 흐름을 끝까지 그릴 수 있다. + +import Foundation + +public struct MockAIProvider: AIProvider { + public let id: AIProviderID + public let available: Bool + /// 인위적 지연(ms) — 로딩 UI 확인용. + public let simulatedLatencyMs: Double + + public init(id: AIProviderID = .onDevice, available: Bool = true, simulatedLatencyMs: Double = 120) { + self.id = id + self.available = available + self.simulatedLatencyMs = simulatedLatencyMs + } + + public var isAvailable: Bool { + get async { available } + } + + public func complete(_ request: AICompletionRequest) async throws -> AICompletionResponse { + guard available else { throw AIProviderError.unavailable(id) } + + switch request.task { + case .quickSummarize: + return AICompletionResponse( + text: "요약(mock): 엘보 내경 가공 시 공차 +0.1/-0.0 관리와 최종 치수 검사 기록이 핵심.", + providerUsed: id, confidence: .high, latencyMs: simulatedLatencyMs + ) + case .memoAssist: + return AICompletionResponse( + text: "제목 제안(mock): 엘보 발주 확인 / 태그: 발주, 엘보, 업무", + providerUsed: id, confidence: .medium, latencyMs: simulatedLatencyMs + ) + case .askSelection: + return AICompletionResponse( + text: "선택 구간 답변(mock): UCS-66 면제 곡선은 재료군과 두께로 MDMT 면제를 판정합니다.", + providerUsed: id, confidence: .high, latencyMs: simulatedLatencyMs + ) + case .corpusAsk: + return AICompletionResponse( + text: "충격시험 면제는 UCS-66 면제 곡선으로 판정합니다 [1]. 설계 응력비가 낮으면 UCS-66.1로 MDMT를 더 낮출 수 있습니다 [1].", + providerUsed: .remoteDS, + citations: [ + AICitation(n: 1, docId: 4912, + title: "ASME Section VIII Div 1 — Impact Test 요건", + sectionTitle: "2. UCS-66 면제 곡선", + spanText: "재료군과 두께에 따라 MDMT에서의 충격시험 면제 여부를 결정한다.") + ], + confidence: .high, latencyMs: 2840 + ) + case .classify: + return AICompletionResponse( + text: "분류 제안(mock): Engineering / 압력용기", + providerUsed: id, confidence: .medium, latencyMs: simulatedLatencyMs + ) + case .vision: + return AICompletionResponse( + text: "비전(mock): 스캔 도면에서 표제란·치수선·용접기호가 식별됨.", + providerUsed: .specialized, confidence: .medium, latencyMs: 900 + ) + } + } +} diff --git a/Sources/AI/Providers/LocalMLXProvider.swift b/Sources/AI/Providers/LocalMLXProvider.swift new file mode 100644 index 0000000..244959d --- /dev/null +++ b/Sources/AI/Providers/LocalMLXProvider.swift @@ -0,0 +1,33 @@ +// LocalMLXProvider.swift — S2 구현 스켈레톤 (맥미니 메인 로컬 LLM 허브). +// +// 실제 구현: 맥미니 Gemma 4 26B (MLX) OpenAI 호환 엔드포인트 호출. +// - 엔드포인트: llm-router :8890 (권장) 또는 MLX :8801 (Tailscale 100.76.254.116) +// - isAvailable = 짧은 health 핑 (도달 + 모델 로드) +// - complete = POST /v1/chat/completions (messages: system/user 분리, call-shape 고정) +// 인터페이스 동결 단계에서는 스텁. + +import Foundation + +public struct LocalMLXProvider: AIProvider { + public let id: AIProviderID = .localMLX + + /// 맥미니 허브 베이스 URL (S2 가 설정/Keychain 에서 주입). + public let baseURL: URL + + public init(baseURL: URL) { + self.baseURL = baseURL + } + + public var isAvailable: Bool { + get async { + // S2: GET /v1/models 또는 경량 health 핑으로 교체. + false + } + } + + public func complete(_ request: AICompletionRequest) async throws -> AICompletionResponse { + // S2: OpenAI 호환 chat/completions 호출 → AICompletionResponse(providerUsed: .localMLX). + // messages 구조(system/user 분리)는 production 호출과 단일 source-of-truth. + throw AIProviderError.notImplemented(id) + } +} diff --git a/Sources/AI/Providers/OnDeviceProvider.swift b/Sources/AI/Providers/OnDeviceProvider.swift new file mode 100644 index 0000000..bf0a4d5 --- /dev/null +++ b/Sources/AI/Providers/OnDeviceProvider.swift @@ -0,0 +1,26 @@ +// OnDeviceProvider.swift — S2 구현 스켈레톤 (맥북·아이폰 온디바이스). +// +// 실제 구현: `import FoundationModels` 후 SystemLanguageModel / LanguageModelSession 사용. +// - isAvailable = SystemLanguageModel.default.availability == .available +// - complete = LanguageModelSession 으로 prompt 응답 (citations 없음 — 로컬 생성) +// 인터페이스 동결 단계에서는 Foundation-only 스텁(notImplemented). + +import Foundation + +public struct OnDeviceProvider: AIProvider { + public let id: AIProviderID = .onDevice + + public init() {} + + public var isAvailable: Bool { + get async { + // S2: FoundationModels 가용성 프로브로 교체. + false + } + } + + public func complete(_ request: AICompletionRequest) async throws -> AICompletionResponse { + // S2: LanguageModelSession(.default) 호출 → AICompletionResponse(providerUsed: .onDevice). + throw AIProviderError.notImplemented(id) + } +} diff --git a/Sources/AI/Providers/RemoteDSProvider.swift b/Sources/AI/Providers/RemoteDSProvider.swift new file mode 100644 index 0000000..dcda7d5 --- /dev/null +++ b/Sources/AI/Providers/RemoteDSProvider.swift @@ -0,0 +1,44 @@ +// RemoteDSProvider.swift — S2 구현 스켈레톤 (원격 DS 코퍼스 RAG). +// +// 이 provider 가 S1 계약과 만나는 다리: +// complete(corpusAsk) → GET /search/ask?q=&backend= (CONTRACT.md §4, AskResponse) +// AskResponse.citations → [AICitation] 매핑 +// AskResponse.synthesis_status → AIFinishReason +// AskResponse.backend_used → routingNote (어느 LLM 이 응답했는지) +// backend 인자: nil(=mac-mini-default) 또는 explicitProvider 매핑(localMLX→gemma-macmini 등). +// 인터페이스 동결 단계에서는 스텁(S3 의 DS API client 주입 후 S2 가 결선). + +import Foundation + +public struct RemoteDSProvider: AIProvider { + public let id: AIProviderID = .remoteDS + + public init() {} + + public var isAvailable: Bool { + get async { true } // 원격 코퍼스는 항상 후보 (실제 도달 실패는 complete 에서 backendError). + } + + public func complete(_ request: AICompletionRequest) async throws -> AICompletionResponse { + // corpusAsk 외 태스크는 이 provider 의 책임 아님(라우터가 거름). + guard request.task == .corpusAsk else { + throw AIProviderError.notImplemented(id) + } + // S2: DS API client.ask(q:) 호출 → AskResponse 디코딩 → 아래 매핑. + // let r = try await dsClient.ask(q: request.prompt, backend: mappedBackend(request.explicitProvider)) + // return Self.map(r) + throw AIProviderError.notImplemented(id) + } + + /// AskResponse(JSON) → AICompletionResponse 매핑 규칙(고정). S2 가 이 형태로 결선. + /// 시그니처만 동결 — 실제 호출은 S3 DS client 와 결합. + static func finishReason(fromSynthesisStatus status: String) -> AIFinishReason { + switch status { + case "completed": return .completed + case "timeout": return .timeout + case "no_evidence", "skipped": return .noEvidence + case "backend_unavailable": return .unavailable + default: return .refused + } + } +} diff --git a/contract/AI-ROUTING.md b/contract/AI-ROUTING.md new file mode 100644 index 0000000..60eb551 --- /dev/null +++ b/contract/AI-ROUTING.md @@ -0,0 +1,67 @@ +# DS App AI 라우팅 계약 (S2 인터페이스 동결) — v0.1 + +S1(데이터 fixture)이 앱의 **데이터 모양**을 동결했듯, 이 문서 + `Sources/AI/`는 앱의 **AI 호출 모양**을 동결한다. +S3 는 `AIProvider` 프로토콜 + `MockAIProvider` 로 AI 흐름을 끝까지 그리고, S2 가 실 provider 를 뒤에서 채운다. + +- **Frozen**: 2026-06-04 · **typecheck PASS** (swift 6 strict concurrency) · **router 스모크 PASS** +- **위치**: `Sources/AI/{AIProvider,AIRouter,MockAIProvider}.swift` + `Sources/AI/Providers/*.swift` +- **파일 경계**: 이 `AI/` 디렉토리 = **S2 소유**(앱 나머지 = S3). 같은 Xcode repo 디렉토리 단위 분담 → 충돌 0. + +## 1. Provider 티어 (= 디바이스 역할 표와 1:1) + +| `AIProviderID` | 노드 | 용도 | 구현 경로 | +|---|---|---|---| +| `.onDevice` | 맥북·아이폰 | 즉답·오프라인·프라이버시 | Apple **FoundationModels** (`SystemLanguageModel`/`LanguageModelSession`) | +| `.localMLX` | 맥미니 허브 | 무거운 로컬 생성 | Gemma 4 26B — llm-router `:8890` / MLX `:8801` (OpenAI 호환) | +| `.remoteDS` | GPU(원격) | **코퍼스 RAG** | `GET /search/ask?backend=` (CONTRACT.md §4) | +| `.specialized` | GPU | rerank·embed·**vision**·OCR | 특화 모델 통로(온디맨드) | + +## 2. 태스크 → 티어 정책 (`AIRoutingPolicy.default`) + +| `AITask` | 선호 체인 | 근거 | +|---|---|---| +| `quickSummarize` | onDevice → localMLX | 빠르고 사적 | +| `memoAssist` | onDevice → localMLX | 짧은 보조 | +| `askSelection` | onDevice → localMLX → remoteDS | 로컬 컨텍스트, 부족 시 승급 | +| `corpusAsk` | **remoteDS only** | 코퍼스 필요 — 온디바이스로 폴백 불가 | +| `classify` | localMLX → remoteDS → onDevice | 분류 품질 우선 | +| `vision` | specialized → onDevice | GPU VLM 우선 | + +## 3. 3단 fallback 규칙 (feedback_task_routing_hybrid + no_silent_fallback) + +1. **explicit > rule > error.** +2. **명시 opt-in**(`request.explicitProvider`) → 그 provider 만. 불가 시 **에러**(`explicitProviderUnavailable`) — 자동 다른 티어 호출 **금지**. +3. **미지정** → 태스크 선호 체인 순회. 불가/실패는 다음으로 넘기되 **`routingNote`로 가시화**(silent skip 금지) + `log` 훅. +4. 전부 불가 → `noProviderAvailable`. + +스모크 검증: +``` +quickSummarize → onDevice corpusAsk → remoteDS (citations=1) +vision → specialized classify → localMLX +explicit onDevice 불가 → 에러(자동 fallback X) +rule fallback: onDevice 불가 → localMLX note="fallback from onDevice → localMLX" +``` + +## 4. S1 계약과의 다리 (`RemoteDSProvider`) + +`corpusAsk` 만 `.remoteDS` 책임. 매핑(고정): +``` +GET /search/ask?q=&backend= → AskResponse (CONTRACT.md §4) +AskResponse.ai_answer → AICompletionResponse.text +AskResponse.citations[] → AICitation[] (n, doc_id, title, section_title, span_text) +AskResponse.synthesis_status → AIFinishReason (completed/timeout/no_evidence/backend_unavailable/…) +AskResponse.confidence → AIConfidence +AskResponse.backend_used → routingNote (어느 LLM 이 응답했는지) +``` +`backend` 매핑: `nil`→`mac-mini-default` · `.localMLX`→`gemma-macmini` · (M5 Max Qwen 경로)→`qwen-macbook` · cloud→`claude-cloud`(503, 별 PR). + +## 5. S2 가 다음에 채울 것 (인터페이스는 고정) +- `OnDeviceProvider`: `import FoundationModels` 가용성 프로브 + `LanguageModelSession` 호출. +- `LocalMLXProvider`: 맥미니 OpenAI 호환 호출(messages system/user 분리 call-shape 고정). +- `RemoteDSProvider`: S3 의 DS API client 주입 → 위 §4 매핑 결선. +- `SpecializedProvider`: GPU 비전/특화 통로(필요 태스크만). + +## 동시 출발선 (S1 + S2 둘 다 동결 완료) +- **S3** = `MockAIProvider` 주입 + S1 `fixtures/` 디코딩 → 실 백엔드·실 LLM 대기 0 으로 앱 전체 빌드. +- **S1** = `[S1-ADD]` 구현 + 응답 shape 유지. +- **S2** = 위 §5 provider 결선. 인터페이스(`AIProvider`/`AIRouter`) 변경 시만 합의. diff --git a/contract/CONTRACT.md b/contract/CONTRACT.md new file mode 100644 index 0000000..8d00920 --- /dev/null +++ b/contract/CONTRACT.md @@ -0,0 +1,224 @@ +# DS App ↔ Backend API 계약 (S1 인터페이스 동결) + +> **목적**: 멀티디바이스 DS 앱(S3)이 빌드/프리뷰 시 의존하는 **응답 shape를 동결**한다. +> 백엔드 구현(S1)·LLM 라우팅(S2)과 무관하게 이 계약 + `fixtures/*.json`만 보고 앱을 만든다. +> **이 계약이 곧 S1·S2·S3 동시 출발선이다.** 변경은 버전 bump + 합의로만. + +- **Contract version**: `v0.1` (frozen 2026-06-04) +- **Base URL**: `https://document.hyungi.net/api` (TLS, 공인) · 대안 Tailscale `http://100.110.63.63:8000/api` +- **출처**: 실제 GPU 백엔드 Pydantic 응답 모델에서 추출(지어내지 않음). 파일 = `app/api/{documents,search,memos,digest,auth}.py`. +- **표기**: `[EXISTING]` = 현재 백엔드가 이미 반환. `[S1-ADD]` = 신규 요구(MD-first 전포맷·중복검사·다운로드 편의)로 **S1이 추가**할 필드/엔드포인트 — 앱은 옵셔널로 디코딩(`?`), 없으면 폴백. + +--- + +## 0. 공통 규약 + +- **datetime**: ISO-8601 문자열 (`"2026-06-03T08:12:44.120Z"`). Swift `Date` 디코딩 시 ISO8601 + fractional seconds. +- **date**: `"2026-06-03"` (date-only). +- **null**: 필드 부재 가능 → 앱 모델 전부 옵셔널(`String?`). 위 모델의 `| None` = 옵셔널. +- **페이지네이션**: `{ items, total, page, page_size }`. 요청 `?page=1&page_size=20`. +- **에러 shape**: `{ "detail": "<메시지>" }` 또는 `{ "detail": { "error_code": "...", "message": "..." } }`. HTTP status로 분기(401/404/422/503). +- **인증**: 모든 `/api/*`(auth 제외)는 `Authorization: Bearer ` 헤더. + +### 인증 흐름 (네이티브) +- `POST /api/auth/login {username, password, totp_code?}` → `AccessTokenResponse {access_token, token_type}`. +- refresh는 **HttpOnly 쿠키**(`path=/api/auth`)로 내려옴 → `URLSession`의 `HTTPCookieStorage`가 자동 보관, `POST /api/auth/refresh`로 access 재발급 가능(네이티브에서도 동작). +- **권장**: access_token은 **Keychain** 보관. 만료 시 refresh → 실패하면 재로그인. (장수명 365d 토큰 옵션도 가능하나 v1은 정식 로그인.) +- 로그아웃 `POST /api/auth/logout`, 현재 사용자 `GET /api/auth/me` → `UserResponse`. + +--- + +## 1. Auth + +| Method | Path | 요청 | 응답 | fixture | +|---|---|---|---|---| +| POST | `/auth/login` | `{username, password, totp_code?}` | `AccessTokenResponse` | `auth_login.json` | +| POST | `/auth/refresh` | (쿠키) | `AccessTokenResponse` | `auth_login.json` | +| GET | `/auth/me` | — | `UserResponse` | `auth_me.json` | +| POST | `/auth/logout` | — | `{}` | — | + +``` +AccessTokenResponse { access_token: String, token_type: String("bearer") } [EXISTING] +UserResponse { id: Int, username: String, is_active: Bool, totp_enabled: Bool, last_login_at: Date? } [EXISTING] +``` + +--- + +## 2. Documents (MD-first 뷰 핵심) + +| Method | Path | 요청 | 응답 | fixture | +|---|---|---|---|---| +| GET | `/documents/` | `page, page_size, domain?, sub_group?, source?, format?, review_status?, category?, has_suggestion?, proposed_category?` | `DocumentListResponse` | `documents_list.json` | +| GET | `/documents/{id}` | — | `DocumentDetailResponse` (**md_content 동봉**) | `document_detail.json` | +| GET | `/documents/{id}/file` | `?token=&download=true` | **바이너리 원본** (PDF/이미지/오디오/원본) | — | +| GET | `/documents/{id}/content` | — | 경량 텍스트(`content` 15k cap) | `document_content.json` | +| GET | `/documents/tree` | — | 도메인 트리(사이드바) | `documents_tree.json` | +| GET | `/documents/stats/category-counts` | — | 카테고리 카운트 | `documents_stats.json` | +| POST | `/documents/` (multipart) | 파일 업로드 | `DocumentResponse` (201) | `document_detail.json` | +| PATCH | `/documents/{id}` | `DocumentUpdate` | `DocumentResponse` | — | +| PUT | `/documents/{id}/content` | `{content}` (md 편집 저장) | `{}` | — | +| POST | `/documents/{id}/accept-suggestion` | `{expected_source_updated_at}` | `DocumentResponse` | — | +| DELETE | `/documents/{id}/suggestion` | — | 204 | — | +| DELETE | `/documents/{id}` | — | 204 | — | + +### DocumentResponse (리스트 행 — 경량, md 본문 없음) `[EXISTING]` +``` +id: Int +file_path: String? file_format: String file_size: Int? file_type: String +title: String? +ai_domain: String? ai_sub_group: String? ai_tags: [String]? ai_summary: String? +document_type: String? importance: String? ai_confidence: Double? +user_note: String? user_tags: [String]? pinned: Bool? ask_includable: Bool? +derived_path: String? original_format: String? conversion_status: String? +is_read: Bool? review_status: String? edit_url: String? preview_status: String? +source_channel: String? data_origin: String? doc_purpose: String? +facet_company: String? facet_topic: String? facet_year: Int? facet_doctype: String? +category: String? ai_suggestion: [String:Any]? +ai_tldr: String? ai_bullets: [String]? ai_detail_summary: String? +ai_inconsistencies: [String]? ai_analysis_tier: String? // 'triage' | 'deep' | null +extracted_at: Date? ai_processed_at: Date? embedded_at: Date? +created_at: Date updated_at: Date +read_count: Int(=0) last_read_at: Date? +``` + +### DocumentDetailResponse (단건 — 위 전부 + 본문/canonical markdown) `[EXISTING]` +``` +…DocumentResponse 전 필드… +extracted_text: String? +md_content: String? // ← MD-first 뷰의 1차 렌더 소스 (canonical markdown) +md_frontmatter: [String:Any]? +md_status: String? // pending|processing|completed|partial|failed|skipped (enum은 S1 동결) +md_extraction_quality: [String:Any]? +md_extraction_error: String? +md_extraction_engine: String? md_extraction_engine_version: String? +md_generated_at: Date? +``` + +### `[S1-ADD]` (신규 요구 반영 — 앱은 옵셔널 디코딩, 없으면 폴백) +``` +DocumentResponse / Detail 에 추가 예정: + original_filename: String? // 다운로드 버튼 라벨용 (없으면 file_path basename) + duplicate_of: Int? // 중복검사 개선 — canonical doc id (자기 자신이 canonical이면 null) + duplicate_count: Int(=0) // 이 문서와 동일 판정된 사본 수 +신규 엔드포인트: + GET /documents/duplicates // 중복 그룹 목록 { groups: [{ canonical_id, members:[id], reason }] } +``` + +### MD-first 렌더 규칙 (앱 측 계약) +1. 본문 뷰 = **`md_content` 우선** (md_status ∈ {completed, partial}일 때). 일관성 = 모든 포맷을 markdown으로 본다. +2. md 없음(md_status ∈ {pending, processing, failed, skipped, null}) → `extracted_text` 폴백 + "원본 다운로드" 강조 + "MD 변환 대기" 배지. +3. **원본 접근 = 항상 다운로드 버튼** → `GET /documents/{id}/file?token=&download=true`. + - 주의: 이 엔드포인트는 **Authorization 헤더가 아니라 `?token=` 쿼리 파라미터**로 인증(iframe/다운로드 호환). 앱은 access_token을 쿼리로 붙인다. + - `note`(메모)는 물리 파일 없음 → 404. 다운로드 버튼 숨김. +4. 앱은 **절대 SMB를 보지 않는다.** 원본/스토리지 계층(맥미니 4TB ↔ NAS Docker)은 이 URL 뒤에서 S1이 추상화. 앱엔 단일 다운로드 URL만 노출. + +--- + +## 3. Search + +| Method | Path | 요청 | 응답 | fixture | +|---|---|---|---|---| +| GET | `/search/` | `q, mode?(text|vector|hybrid), page?, debug?` | `SearchResponse` | `search.json` | + +``` +SearchResponse { results: [SearchResult], total: Int, query: String, mode: String, debug: SearchDebug? } [EXISTING] +SearchResult { + id: Int // doc_id + title: String? ai_domain: String? ai_summary: String? file_format: String + score: Double snippet: String? match_reason: String? + chunk_id: Int? chunk_index: Int? section_title: String? + rerank_score: Double? freshness_debug: [String:Any]? +} +``` + +--- + +## 4. Ask (RAG — 원격 DS, S2 LLM 라우팅과 연결) + +| Method | Path | 요청 | 응답 | fixture | +|---|---|---|---|---| +| GET | `/search/ask` | `q, limit?, backend?, debug?` | `AskResponse` | `ask.json` | +| POST | `/search/ask/react` | `{...}` | `AskReactResponse` | — | + +``` +AskResponse { [EXISTING] + results: [SearchResult] + ai_answer: String? + citations: [Citation] + synthesis_status: "completed"|"timeout"|"skipped"|"no_evidence"|"parse_failed"|"llm_error"|"backend_unavailable" + synthesis_ms: Double + confidence: "high"|"medium"|"low" | null + refused: Bool no_results_reason: String? query: String total: Int + completeness: "full"|"partial"|"insufficient" // 기본 "full" + covered_aspects: [String]? missing_aspects: [String]? + confirmed_items: [ConfirmedItem]? + backend_requested: String? backend_used: String? // S2 라우팅 메타 + debug: AskDebug? +} +Citation { n: Int, chunk_id: Int?, doc_id: Int, title: String?, section_title: String?, + span_text: String, full_snippet: String, relevance: Double, rerank_score: Double } +ConfirmedItem { aspect: String, text: String, citations: [Int] } +``` + +- **`backend` 쿼리** = S2 인터페이스 접점: `qwen-macbook | gemma-macmini | mac-mini-default | claude-cloud | auto`. 미지정 = `mac-mini-default`(맥미니 26B). +- **앱 라우팅 규칙(S2 계약)**: 빠른 요약/선택문 ask/메모 보조 = **온디바이스(Apple FM)** 로컬 처리(이 엔드포인트 미호출). **전체 코퍼스 RAG** = 이 `/search/ask` 호출(backend로 맥미니/특화 선택). `[S1-ADD]` 없음 — backend 인자는 이미 존재. + +--- + +## 5. Memos (캡처/쓰기) + +| Method | Path | 요청 | 응답 | fixture | +|---|---|---|---|---| +| GET | `/memos/` | `page, page_size, pinned?, archived?` | `MemoListResponse` | `memos_list.json` | +| GET | `/memos/{id}` | — | `MemoResponse` | `memo_detail.json` | +| POST | `/memos/` | `MemoCreate {content, title?, ask_includable?, source_channel?, source_metadata?}` | `MemoResponse` (201) | `memo_detail.json` | +| PATCH | `/memos/{id}` | `MemoUpdate {content, title?}` | `MemoResponse` | — | +| PATCH | `/memos/{id}/pin` · `/archive` · `/ask-includable` | `{...}` | `MemoResponse` | — | +| PATCH | `/memos/{id}/tasks/{task_index}` | `{checked}` | `MemoResponse` | — | +| POST | `/memos/{id}/promote-to-event` | — | 201 | — | +| DELETE | `/memos/{id}` | — | 204 | — | + +``` +MemoResponse { [EXISTING] + id: Int title: String? content: String? // = extracted_text + file_format: String file_type: String? // "audio"(음성) | "note"(텍스트) + file_path: String? // 음성 메모 오디오 경로(있으면 재생 가능) + user_tags: [String]? ai_tags: [String]? + ai_domain: String? ai_sub_group: String? ai_summary: String? + pinned: Bool archived: Bool ask_includable: Bool + memo_task_state: [String:Any] // {"0": {"checked_at": "ISO"}} + ai_event_kind: String? ai_event_confidence: Double? + source_channel: String? source_metadata: [String:Any] + created_at: Date updated_at: Date +} +``` + +--- + +## 6. Digest (뉴스) + +| Method | Path | 요청 | 응답 | fixture | +|---|---|---|---|---| +| GET | `/digest` | `?date=YYYY-MM-DD&country?` | `DigestResponse` | `digest.json` | +| GET | `/digest/dates` | — | `[DigestDateSummary]` | — | + +``` +DigestResponse { [EXISTING] + digest_date: Date(date-only) window_start: Date window_end: Date + decay_lambda: Double total_articles: Int total_countries: Int total_topics: Int + generation_ms: Int? llm_calls: Int llm_failures: Int status: String + countries: [CountryGroup] +} +CountryGroup { country: String, topics: [TopicResponse] } +TopicResponse { topic_rank: Int, topic_label: String, summary: String, + article_ids: [Int], articles: [ArticleRef], article_count: Int, + importance_score: Double, raw_weight_sum: Double, llm_fallback_used: Bool } +ArticleRef { id: Int, title: String? } +DigestDateSummary { digest_date: Date, total_topics: Int, total_countries: Int, total_articles: Int, status: String } +``` + +--- + +## 변경 관리 +- 이 계약을 깨는 변경(필드 제거/타입 변경) = `version` bump + S1/S2/S3 합의. `[S1-ADD]`는 옵셔널이라 non-breaking. +- 통합 결선 시 fixture와 실 응답 **call-shape regression test**로 대조([[feedback_fixture_first_call_shape]]). diff --git a/contract/README.md b/contract/README.md new file mode 100644 index 0000000..cca56a2 --- /dev/null +++ b/contract/README.md @@ -0,0 +1,38 @@ +# DS App Contract (S1 인터페이스 동결) — v0.1 + +S1·S2·S3 **동시 출발선**. 앱(S3)은 실 백엔드/실 LLM 없이 **이 디렉토리만 보고** 빌드한다. + +``` +contract/ + CONTRACT.md ← 엔드포인트 + 요청/응답 shape 동결 스펙 (읽기 시작점) + fixtures/ ← 응답 JSON 박제 (앱 프리뷰/디코딩 테스트가 로드) + auth_login.json POST /auth/login + auth_me.json GET /auth/me + documents_list.json GET /documents/ (DocumentListResponse) + document_detail.json GET /documents/{id} (md_status=completed — MD-first 렌더) + document_detail_pending_md.json GET /documents/{id} (md_status=pending — extracted_text 폴백 케이스) + document_content.json GET /documents/{id}/content + documents_tree.json GET /documents/tree + documents_stats.json GET /documents/stats/category-counts + documents_duplicates.json GET /documents/duplicates [S1-ADD] 중복검사 + search.json GET /search/ + ask.json GET /search/ask + memos_list.json GET /memos/ + memo_detail.json GET /memos/{id} + digest.json GET /digest +``` + +## 동결 원칙 +- 모든 shape는 **실제 GPU 백엔드 Pydantic 모델에서 추출**(지어내지 않음). `[S1-ADD]` 필드만 신규. +- 앱 모델(Swift Codable)은 위 fixtures를 그대로 디코딩할 수 있어야 한다 = 1차 수용 테스트. +- 통합 시 fixture ↔ 실 응답 **call-shape regression**으로 대조. + +## `[S1-ADD]` (S1이 추가할 신규 — 앱은 옵셔널 디코딩) +- `original_filename`, `duplicate_of`, `duplicate_count` (DocumentResponse/Detail) +- `GET /documents/duplicates` (중복검사 개선 트랙) +- Word/Excel/이미지/오디오 → `md_content` 채우기 (MD-first 전포맷 — marker는 현재 PDF 전용) + +## 다음 +- S3: 이 fixtures를 디코딩하는 Swift `Codable` 모델 + API client(프로토콜) → macOS 앱. +- S1: `[S1-ADD]` 구현 + 위 shape 유지(깨면 version bump). +- S2: `/search/ask?backend=` + 온디바이스 provider 추상화(`AIProvider`). diff --git a/contract/fixtures/ask.json b/contract/fixtures/ask.json new file mode 100644 index 0000000..a650751 --- /dev/null +++ b/contract/fixtures/ask.json @@ -0,0 +1,47 @@ +{ + "results": [ + { + "id": 4912, + "title": "ASME Section VIII Div 1 — Impact Test 요건", + "ai_domain": "Engineering", + "ai_summary": "압력용기 재료의 충격시험 면제/요구 조건(UCS-66 등)을 정리.", + "file_format": "pdf", + "score": 0.8714, + "snippet": "...UCS-66 면제 곡선과 MDMT 적용...", + "match_reason": "vector+rerank", + "chunk_id": 88213, + "chunk_index": 3, + "section_title": "2. UCS-66 면제 곡선", + "rerank_score": 0.913, + "freshness_debug": null + } + ], + "ai_answer": "충격시험 면제는 UCS-66 면제 곡선으로 판정합니다 [1]. 재료군(Curve A~D)과 거버닝 두께에 따라 최소설계금속온도(MDMT)에서 면제 여부가 정해지며, 설계 응력비가 낮으면 UCS-66.1에 따라 MDMT를 추가로 낮출 수 있습니다 [1].", + "citations": [ + { + "n": 1, + "chunk_id": 88213, + "doc_id": 4912, + "title": "ASME Section VIII Div 1 — Impact Test 요건", + "section_title": "2. UCS-66 면제 곡선", + "span_text": "재료군(Curve A~D)과 거버닝 두께에 따라 최소설계금속온도(MDMT)에서의 충격시험 면제 여부를 결정한다.", + "full_snippet": "재료군(Curve A~D)과 거버닝 두께에 따라 최소설계금속온도(MDMT)에서의 충격시험 면제 여부를 결정한다. 설계 응력비가 낮으면 UCS-66.1에 따라 MDMT를 추가로 낮출 수 있다. 면제되지 않는 경우 UG-84에 따라 Charpy V-notch 시험을 수행한다.", + "relevance": 0.91, + "rerank_score": 0.913 + } + ], + "synthesis_status": "completed", + "synthesis_ms": 2841.5, + "confidence": "high", + "refused": false, + "no_results_reason": null, + "query": "충격시험은 언제 면제되나", + "total": 1, + "completeness": "full", + "covered_aspects": ["면제 곡선", "MDMT 적용"], + "missing_aspects": null, + "confirmed_items": null, + "backend_requested": "mac-mini-default", + "backend_used": "gemma-macmini", + "debug": null +} diff --git a/contract/fixtures/auth_login.json b/contract/fixtures/auth_login.json new file mode 100644 index 0000000..cea80a3 --- /dev/null +++ b/contract/fixtures/auth_login.json @@ -0,0 +1,4 @@ +{ + "access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJoeXVuZ2kiLCJ0eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzQ5MDAwMDAwfQ.FIXTURE_SIGNATURE_NOT_REAL", + "token_type": "bearer" +} diff --git a/contract/fixtures/auth_me.json b/contract/fixtures/auth_me.json new file mode 100644 index 0000000..c92ea4f --- /dev/null +++ b/contract/fixtures/auth_me.json @@ -0,0 +1,7 @@ +{ + "id": 1, + "username": "hyungi", + "is_active": true, + "totp_enabled": false, + "last_login_at": "2026-06-04T07:55:12.330Z" +} diff --git a/contract/fixtures/digest.json b/contract/fixtures/digest.json new file mode 100644 index 0000000..3c78d0e --- /dev/null +++ b/contract/fixtures/digest.json @@ -0,0 +1,54 @@ +{ + "digest_date": "2026-06-03", + "window_start": "2026-05-27T00:00:00.000Z", + "window_end": "2026-06-03T00:00:00.000Z", + "decay_lambda": 0.18, + "total_articles": 312, + "total_countries": 4, + "total_topics": 9, + "generation_ms": 18420, + "llm_calls": 11, + "llm_failures": 0, + "status": "completed", + "countries": [ + { + "country": "KR", + "topics": [ + { + "topic_rank": 1, + "topic_label": "산업안전 규제 개정", + "summary": "중대재해처벌법 후속 시행령 개정 논의가 이어지며 제조업 현장 점검이 강화되는 흐름.", + "article_ids": [880123, 880140, 880155], + "articles": [ + { "id": 880123, "title": "고용부, 중대재해 시행령 개정안 입법예고" }, + { "id": 880140, "title": "제조 현장 안전점검 확대" }, + { "id": 880155, "title": "압력설비 검사 주기 단축 검토" } + ], + "article_count": 3, + "importance_score": 0.91, + "raw_weight_sum": 2.74, + "llm_fallback_used": false + } + ] + }, + { + "country": "US", + "topics": [ + { + "topic_rank": 1, + "topic_label": "ASME 코드 업데이트", + "summary": "ASME BPVC 2025 에디션 관련 산업계 적용 사례와 해설 자료가 늘어남.", + "article_ids": [880301, 880322], + "articles": [ + { "id": 880301, "title": "ASME BPVC 2025 adoption notes" }, + { "id": 880322, "title": "Impact test exemption clarifications" } + ], + "article_count": 2, + "importance_score": 0.77, + "raw_weight_sum": 1.62, + "llm_fallback_used": false + } + ] + } + ] +} diff --git a/contract/fixtures/document_content.json b/contract/fixtures/document_content.json new file mode 100644 index 0000000..b5430c3 --- /dev/null +++ b/contract/fixtures/document_content.json @@ -0,0 +1,12 @@ +{ + "id": 4912, + "title": "ASME Section VIII Div 1 — Impact Test 요건", + "domain": "Engineering", + "sub_group": "압력용기", + "document_type": "standard", + "ai_summary": "압력용기 재료의 충격시험 면제/요구 조건(UCS-66 등)을 정리.", + "ai_tags": ["ASME", "Section VIII", "충격시험", "UCS-66"], + "content": "ASME Section VIII Division 1 Impact Test Requirements\nUCS-66 면제 곡선과 MDMT 적용 ... (최대 15000자) ...", + "content_length": 8421, + "truncated": false +} diff --git a/contract/fixtures/document_detail.json b/contract/fixtures/document_detail.json new file mode 100644 index 0000000..e285003 --- /dev/null +++ b/contract/fixtures/document_detail.json @@ -0,0 +1,63 @@ +{ + "id": 4912, + "file_path": "Engineering/ASME/ASME_SecVIII_Div1_Impact_Test.pdf", + "file_format": "pdf", + "file_size": 1338920, + "file_type": "document", + "title": "ASME Section VIII Div 1 — Impact Test 요건", + "ai_domain": "Engineering", + "ai_sub_group": "압력용기", + "ai_tags": ["ASME", "Section VIII", "충격시험", "UCS-66"], + "ai_summary": "압력용기 재료의 충격시험 면제/요구 조건(UCS-66 등)을 정리.", + "document_type": "standard", + "importance": "high", + "ai_confidence": 0.93, + "user_note": "MDMT 판정 시 자주 참조", + "user_tags": ["자주봄"], + "pinned": true, + "ask_includable": true, + "derived_path": "Engineering/ASME/ASME_SecVIII_Div1_Impact_Test.md", + "original_format": "pdf", + "conversion_status": "completed", + "is_read": true, + "review_status": "approved", + "edit_url": null, + "preview_status": "ready", + "source_channel": "upload", + "data_origin": "external", + "doc_purpose": "reference", + "facet_company": "ASME", + "facet_topic": "압력용기", + "facet_year": 2023, + "facet_doctype": "standard", + "category": "library", + "ai_suggestion": null, + "ai_tldr": "충격시험 면제 곡선(UCS-66)과 MDMT 적용.", + "ai_bullets": ["UCS-66 면제 곡선", "UCS-66.1 감액", "UG-84 시험 요건"], + "ai_detail_summary": "본 표준 절은 탄소강·저합금강 압력용기 재료의 노치 인성(충격시험) 요구를 다룬다. UCS-66 면제 곡선은 재료군(A~D)과 두께에 따라 최소설계금속온도(MDMT)에서의 시험 면제 여부를 정한다.", + "ai_inconsistencies": [], + "ai_analysis_tier": "deep", + "extracted_at": "2026-05-22T05:00:11.000Z", + "ai_processed_at": "2026-05-22T05:04:40.000Z", + "embedded_at": "2026-05-22T05:06:02.000Z", + "created_at": "2026-05-22T04:59:50.000Z", + "updated_at": "2026-06-01T09:21:33.000Z", + "read_count": 11, + "last_read_at": "2026-06-03T18:02:10.000Z", + "original_filename": "ASME_SecVIII_Div1_Impact_Test.pdf", + "duplicate_of": null, + "duplicate_count": 1, + "extracted_text": "ASME Section VIII Division 1 Impact Test Requirements\nUCS-66 ... (원문 추출 텍스트, 폴백용) ...", + "md_content": "# ASME Section VIII Div 1 — 충격시험 요건\n\n## 1. 범위\n탄소강 및 저합금강 압력용기 재료의 노치 인성(충격시험) 요구를 규정한다.\n\n## 2. UCS-66 면제 곡선\n재료군(Curve A~D)과 거버닝 두께에 따라 **최소설계금속온도(MDMT)** 에서의 충격시험 면제 여부를 결정한다.\n\n| 곡선 | 대표 재료 | 비고 |\n|---|---|---|\n| A | SA-516 (비정규화) | 가장 보수적 |\n| B | SA-516 정규화 | |\n| C | SA-537 | |\n| D | 인성 우수 재료 | 가장 관대 |\n\n## 3. UCS-66.1 감액\n설계 응력비(stress ratio)가 낮으면 MDMT 를 추가로 낮출 수 있다.\n\n## 4. UG-84 시험 요건\n면제되지 않는 경우 Charpy V-notch 시험으로 흡수에너지/측면팽창 기준을 만족해야 한다.\n", + "md_frontmatter": { + "title": "ASME Section VIII Div 1 — 충격시험 요건", + "domain": "Engineering", + "source": "ASME_SecVIII_Div1_Impact_Test.pdf" + }, + "md_status": "completed", + "md_extraction_quality": { "page_count": 14, "table_count": 3, "ocr_used": false }, + "md_extraction_error": null, + "md_extraction_engine": "marker", + "md_extraction_engine_version": "1.10.2", + "md_generated_at": "2026-05-22T05:03:30.000Z" +} diff --git a/contract/fixtures/document_detail_pending_md.json b/contract/fixtures/document_detail_pending_md.json new file mode 100644 index 0000000..f664d71 --- /dev/null +++ b/contract/fixtures/document_detail_pending_md.json @@ -0,0 +1,59 @@ +{ + "id": 5301, + "file_path": "General/매뉴얼/02_왕복압축기_운전매뉴얼.docx", + "file_format": "docx", + "file_size": 73402, + "file_type": "document", + "title": "02 왕복압축기 운전 매뉴얼", + "ai_domain": "General", + "ai_sub_group": "설비매뉴얼", + "ai_tags": ["왕복압축기", "운전", "매뉴얼"], + "ai_summary": "왕복동식 압축기 기동/정지/점검 절차 매뉴얼.", + "document_type": "manual", + "importance": "normal", + "ai_confidence": 0.81, + "user_note": null, + "user_tags": null, + "pinned": false, + "ask_includable": true, + "derived_path": null, + "original_format": "docx", + "conversion_status": "pending", + "is_read": false, + "review_status": "pending", + "edit_url": null, + "preview_status": "pending", + "source_channel": "upload", + "data_origin": "internal", + "doc_purpose": "reference", + "facet_company": null, + "facet_topic": "설비매뉴얼", + "facet_year": 2024, + "facet_doctype": "manual", + "category": "library", + "ai_suggestion": null, + "ai_tldr": null, + "ai_bullets": null, + "ai_detail_summary": null, + "ai_inconsistencies": null, + "ai_analysis_tier": "triage", + "extracted_at": "2026-06-03T01:20:00.000Z", + "ai_processed_at": "2026-06-03T01:22:14.000Z", + "embedded_at": null, + "created_at": "2026-06-03T01:19:55.000Z", + "updated_at": "2026-06-03T01:22:14.000Z", + "read_count": 0, + "last_read_at": null, + "original_filename": "02_왕복압축기_운전매뉴얼.docx", + "duplicate_of": null, + "duplicate_count": 0, + "extracted_text": "왕복압축기 운전 매뉴얼\n1. 기동 전 점검\n - 윤활유 레벨 확인\n - 흡입/토출 밸브 상태 확인\n2. 기동 절차 ...", + "md_content": null, + "md_frontmatter": null, + "md_status": "pending", + "md_extraction_quality": null, + "md_extraction_error": null, + "md_extraction_engine": null, + "md_extraction_engine_version": null, + "md_generated_at": null +} diff --git a/contract/fixtures/documents_duplicates.json b/contract/fixtures/documents_duplicates.json new file mode 100644 index 0000000..0095e2c --- /dev/null +++ b/contract/fixtures/documents_duplicates.json @@ -0,0 +1,18 @@ +{ + "groups": [ + { + "canonical_id": 4912, + "members": [4912, 4977], + "reason": "content_hash", + "detail": "동일 본문 해시 (md_content normalized SHA-256 일치)" + }, + { + "canonical_id": 5120, + "members": [5120, 5121, 5260], + "reason": "near_duplicate", + "detail": "제목/본문 유사도 0.97 (cross-format: pdf + docx 동일 문서)" + } + ], + "total_groups": 2, + "total_duplicate_docs": 3 +} diff --git a/contract/fixtures/documents_list.json b/contract/fixtures/documents_list.json new file mode 100644 index 0000000..2b892f5 --- /dev/null +++ b/contract/fixtures/documents_list.json @@ -0,0 +1,157 @@ +{ + "items": [ + { + "id": 5187, + "file_path": "Engineering/기계가공/엘보_내경가공_절차서.pdf", + "file_format": "pdf", + "file_size": 482113, + "file_type": "document", + "title": "엘보 내경가공 절차서", + "ai_domain": "Engineering", + "ai_sub_group": "기계가공", + "ai_tags": ["엘보", "내경가공", "절차서", "가공공차"], + "ai_summary": "엘보 내경 가공 시 공차 관리와 가공 순서를 정리한 사내 절차서.", + "document_type": "procedure", + "importance": "normal", + "ai_confidence": 0.86, + "user_note": null, + "user_tags": null, + "pinned": false, + "ask_includable": true, + "derived_path": "Engineering/기계가공/엘보_내경가공_절차서.md", + "original_format": "pdf", + "conversion_status": "completed", + "is_read": true, + "review_status": "approved", + "edit_url": null, + "preview_status": "ready", + "source_channel": "upload", + "data_origin": "internal", + "doc_purpose": "reference", + "facet_company": null, + "facet_topic": "기계가공", + "facet_year": 2025, + "facet_doctype": "procedure", + "category": "library", + "ai_suggestion": null, + "ai_tldr": "엘보 내경 가공 공차·순서 절차.", + "ai_bullets": ["가공 전 소재 검사", "내경 공차 +0.1/-0.0", "최종 치수 검사 기록"], + "ai_detail_summary": null, + "ai_inconsistencies": null, + "ai_analysis_tier": "triage", + "extracted_at": "2026-05-30T02:11:04.000Z", + "ai_processed_at": "2026-05-30T02:13:51.000Z", + "embedded_at": "2026-05-30T02:15:09.000Z", + "created_at": "2026-05-30T02:10:58.000Z", + "updated_at": "2026-05-30T02:15:09.000Z", + "read_count": 3, + "last_read_at": "2026-06-02T13:40:22.000Z", + "original_filename": "엘보_내경가공_절차서.pdf", + "duplicate_of": null, + "duplicate_count": 0 + }, + { + "id": 4912, + "file_path": "Engineering/ASME/ASME_SecVIII_Div1_Impact_Test.pdf", + "file_format": "pdf", + "file_size": 1338920, + "file_type": "document", + "title": "ASME Section VIII Div 1 — Impact Test 요건", + "ai_domain": "Engineering", + "ai_sub_group": "압력용기", + "ai_tags": ["ASME", "Section VIII", "충격시험", "UCS-66"], + "ai_summary": "압력용기 재료의 충격시험 면제/요구 조건(UCS-66 등)을 정리.", + "document_type": "standard", + "importance": "high", + "ai_confidence": 0.93, + "user_note": "MDMT 판정 시 자주 참조", + "user_tags": ["자주봄"], + "pinned": true, + "ask_includable": true, + "derived_path": "Engineering/ASME/ASME_SecVIII_Div1_Impact_Test.md", + "original_format": "pdf", + "conversion_status": "completed", + "is_read": true, + "review_status": "approved", + "edit_url": null, + "preview_status": "ready", + "source_channel": "upload", + "data_origin": "external", + "doc_purpose": "reference", + "facet_company": "ASME", + "facet_topic": "압력용기", + "facet_year": 2023, + "facet_doctype": "standard", + "category": "library", + "ai_suggestion": null, + "ai_tldr": "충격시험 면제 곡선(UCS-66)과 MDMT 적용.", + "ai_bullets": ["UCS-66 면제 곡선", "UCS-66.1 감액", "UG-84 시험 요건"], + "ai_detail_summary": null, + "ai_inconsistencies": null, + "ai_analysis_tier": "deep", + "extracted_at": "2026-05-22T05:00:11.000Z", + "ai_processed_at": "2026-05-22T05:04:40.000Z", + "embedded_at": "2026-05-22T05:06:02.000Z", + "created_at": "2026-05-22T04:59:50.000Z", + "updated_at": "2026-06-01T09:21:33.000Z", + "read_count": 11, + "last_read_at": "2026-06-03T18:02:10.000Z", + "original_filename": "ASME_SecVIII_Div1_Impact_Test.pdf", + "duplicate_of": null, + "duplicate_count": 1 + }, + { + "id": 5301, + "file_path": "General/매뉴얼/02_왕복압축기_운전매뉴얼.docx", + "file_format": "docx", + "file_size": 73402, + "file_type": "document", + "title": "02 왕복압축기 운전 매뉴얼", + "ai_domain": "General", + "ai_sub_group": "설비매뉴얼", + "ai_tags": ["왕복압축기", "운전", "매뉴얼"], + "ai_summary": "왕복동식 압축기 기동/정지/점검 절차 매뉴얼.", + "document_type": "manual", + "importance": "normal", + "ai_confidence": 0.81, + "user_note": null, + "user_tags": null, + "pinned": false, + "ask_includable": true, + "derived_path": null, + "original_format": "docx", + "conversion_status": "pending", + "is_read": false, + "review_status": "pending", + "edit_url": null, + "preview_status": "pending", + "source_channel": "upload", + "data_origin": "internal", + "doc_purpose": "reference", + "facet_company": null, + "facet_topic": "설비매뉴얼", + "facet_year": 2024, + "facet_doctype": "manual", + "category": "library", + "ai_suggestion": null, + "ai_tldr": null, + "ai_bullets": null, + "ai_detail_summary": null, + "ai_inconsistencies": null, + "ai_analysis_tier": "triage", + "extracted_at": "2026-06-03T01:20:00.000Z", + "ai_processed_at": "2026-06-03T01:22:14.000Z", + "embedded_at": null, + "created_at": "2026-06-03T01:19:55.000Z", + "updated_at": "2026-06-03T01:22:14.000Z", + "read_count": 0, + "last_read_at": null, + "original_filename": "02_왕복압축기_운전매뉴얼.docx", + "duplicate_of": null, + "duplicate_count": 0 + } + ], + "total": 783, + "page": 1, + "page_size": 20 +} diff --git a/contract/fixtures/documents_stats.json b/contract/fixtures/documents_stats.json new file mode 100644 index 0000000..f4e8edc --- /dev/null +++ b/contract/fixtures/documents_stats.json @@ -0,0 +1,14 @@ +{ + "total": 1163, + "documents": 783, + "by_domain": { + "Industrial_Safety": 426, + "Engineering": 351, + "General": 189, + "Programming": 60, + "법령": 23, + "Philosophy": 12 + }, + "review_pending": 725, + "pipeline_failed": 19 +} diff --git a/contract/fixtures/documents_tree.json b/contract/fixtures/documents_tree.json new file mode 100644 index 0000000..d2fe617 --- /dev/null +++ b/contract/fixtures/documents_tree.json @@ -0,0 +1,16 @@ +[ + { "name": "Industrial_Safety", "path": "Industrial_Safety", "count": 426, + "children": [ + { "name": "위험성평가", "path": "Industrial_Safety/위험성평가", "count": 118, "children": [] }, + { "name": "KGS", "path": "Industrial_Safety/KGS", "count": 73, "children": [] } + ] }, + { "name": "Engineering", "path": "Engineering", "count": 351, + "children": [ + { "name": "압력용기", "path": "Engineering/압력용기", "count": 96, "children": [] }, + { "name": "기계가공", "path": "Engineering/기계가공", "count": 54, "children": [] } + ] }, + { "name": "General", "path": "General", "count": 189, "children": [] }, + { "name": "Programming", "path": "Programming", "count": 60, "children": [] }, + { "name": "법령", "path": "법령", "count": 23, "children": [] }, + { "name": "Philosophy", "path": "Philosophy", "count": 12, "children": [] } +] diff --git a/contract/fixtures/memo_detail.json b/contract/fixtures/memo_detail.json new file mode 100644 index 0000000..0495a0e --- /dev/null +++ b/contract/fixtures/memo_detail.json @@ -0,0 +1,23 @@ +{ + "id": 20238, + "title": "엘보 발주 확인 건", + "content": "엘보 내경가공 발주서 금요일까지 확인.\n- [ ] 도면 rev C 기준 공차 재확인\n- [ ] 발주처에 납기 회신", + "file_format": "txt", + "user_tags": ["업무"], + "ai_tags": ["발주", "엘보"], + "ai_domain": "General", + "ai_sub_group": "업무메모", + "ai_summary": "엘보 발주서 금요일까지 확인, 도면 rev C 공차 재확인.", + "pinned": true, + "archived": false, + "ask_includable": true, + "memo_task_state": { "0": { "checked_at": "2026-06-03T10:02:00.000Z" } }, + "ai_event_kind": "task", + "ai_event_confidence": 0.78, + "source_channel": "memo", + "source_metadata": {}, + "file_type": "note", + "file_path": null, + "created_at": "2026-06-03T09:40:00.000Z", + "updated_at": "2026-06-03T10:02:00.000Z" +} diff --git a/contract/fixtures/memos_list.json b/contract/fixtures/memos_list.json new file mode 100644 index 0000000..53ea0ea --- /dev/null +++ b/contract/fixtures/memos_list.json @@ -0,0 +1,53 @@ +{ + "items": [ + { + "id": 20238, + "title": "엘보 발주 확인 건", + "content": "엘보 내경가공 발주서 금요일까지 확인. 도면 rev C 기준으로 공차 재확인 필요.", + "file_format": "txt", + "user_tags": ["업무"], + "ai_tags": ["발주", "엘보"], + "ai_domain": "General", + "ai_sub_group": "업무메모", + "ai_summary": "엘보 발주서 금요일까지 확인, 도면 rev C 공차 재확인.", + "pinned": true, + "archived": false, + "ask_includable": true, + "memo_task_state": { "0": { "checked_at": "2026-06-03T10:02:00.000Z" } }, + "ai_event_kind": "task", + "ai_event_confidence": 0.78, + "source_channel": "memo", + "source_metadata": {}, + "file_type": "note", + "file_path": null, + "created_at": "2026-06-03T09:40:00.000Z", + "updated_at": "2026-06-03T10:02:00.000Z" + }, + { + "id": 20251, + "title": "음성 메모 — 현장 점검", + "content": "3공장 압축기 베어링 소음. 다음 점검 때 진동 측정 추가하기로.", + "file_format": "m4a", + "user_tags": null, + "ai_tags": ["현장", "압축기", "점검"], + "ai_domain": "Industrial_Safety", + "ai_sub_group": "현장메모", + "ai_summary": "3공장 압축기 베어링 소음, 진동 측정 추가 예정.", + "pinned": false, + "archived": false, + "ask_includable": true, + "memo_task_state": {}, + "ai_event_kind": "note", + "ai_event_confidence": 0.64, + "source_channel": "voice", + "source_metadata": { "duration_s": 23, "device": "iPhone" }, + "file_type": "audio", + "file_path": "memos/voice/2026/06/test-voice-memo.m4a", + "created_at": "2026-06-02T17:11:00.000Z", + "updated_at": "2026-06-02T17:11:40.000Z" + } + ], + "total": 4807, + "page": 1, + "page_size": 20 +} diff --git a/contract/fixtures/search.json b/contract/fixtures/search.json new file mode 100644 index 0000000..f17e72e --- /dev/null +++ b/contract/fixtures/search.json @@ -0,0 +1,38 @@ +{ + "results": [ + { + "id": 4912, + "title": "ASME Section VIII Div 1 — Impact Test 요건", + "ai_domain": "Engineering", + "ai_summary": "압력용기 재료의 충격시험 면제/요구 조건(UCS-66 등)을 정리.", + "file_format": "pdf", + "score": 0.8714, + "snippet": "...UCS-66 면제 곡선과 MDMT 적용. 충격시험 면제 여부는 재료군과 두께로 결정...", + "match_reason": "vector+rerank", + "chunk_id": 88213, + "chunk_index": 3, + "section_title": "2. UCS-66 면제 곡선", + "rerank_score": 0.913, + "freshness_debug": null + }, + { + "id": 5044, + "title": "KGS FU211 §2.5 — 가스설비 충격 관련 요건", + "ai_domain": "Industrial_Safety", + "ai_summary": "KGS FU211 가스 사용시설 기준 중 충격/내압 관련 조항.", + "file_format": "pdf", + "score": 0.7321, + "snippet": "...§2.5 충격에 의한 손상 방지... §2.8 내압 시험...", + "match_reason": "vector", + "chunk_id": 90122, + "chunk_index": 1, + "section_title": "2.5", + "rerank_score": 0.742, + "freshness_debug": null + } + ], + "total": 2, + "query": "충격시험 면제", + "mode": "hybrid", + "debug": null +}