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) <noreply@anthropic.com>
This commit is contained in:
Hyungi
2026-06-04 15:27:24 +09:00
commit 17f8830d37
24 changed files with 1330 additions and 0 deletions
+24
View File
@@ -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
+143
View File
@@ -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?)
}
+99
View File
@@ -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)
}
}
+67
View File
@@ -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
)
}
}
}
@@ -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)
}
}
@@ -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)
}
}
@@ -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 (localMLXgemma-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
}
}
}
+67
View File
@@ -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=<prompt>&backend=<map(explicitProvider)> → 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`) 변경 시만 합의.
+224
View File
@@ -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 <access_token>` 헤더.
### 인증 흐름 (네이티브)
- `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=<access>&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=<access>&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]]).
+38
View File
@@ -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`).
+47
View File
@@ -0,0 +1,47 @@
{
"results": [
{
"id": 4912,
"title": "ASME Section VIII Div 1 — Impact Test 요건",
"ai_domain": "Engineering",
"ai_summary": "압력용기 재료의 충격시험 면제/요구 조건(UCS-66 등)을 정리.",
"file_format": "pdf",
"score": 0.8714,
"snippet": "...UCS-66 면제 곡선과 MDMT 적용...",
"match_reason": "vector+rerank",
"chunk_id": 88213,
"chunk_index": 3,
"section_title": "2. UCS-66 면제 곡선",
"rerank_score": 0.913,
"freshness_debug": null
}
],
"ai_answer": "충격시험 면제는 UCS-66 면제 곡선으로 판정합니다 [1]. 재료군(Curve A~D)과 거버닝 두께에 따라 최소설계금속온도(MDMT)에서 면제 여부가 정해지며, 설계 응력비가 낮으면 UCS-66.1에 따라 MDMT를 추가로 낮출 수 있습니다 [1].",
"citations": [
{
"n": 1,
"chunk_id": 88213,
"doc_id": 4912,
"title": "ASME Section VIII Div 1 — Impact Test 요건",
"section_title": "2. UCS-66 면제 곡선",
"span_text": "재료군(Curve A~D)과 거버닝 두께에 따라 최소설계금속온도(MDMT)에서의 충격시험 면제 여부를 결정한다.",
"full_snippet": "재료군(Curve A~D)과 거버닝 두께에 따라 최소설계금속온도(MDMT)에서의 충격시험 면제 여부를 결정한다. 설계 응력비가 낮으면 UCS-66.1에 따라 MDMT를 추가로 낮출 수 있다. 면제되지 않는 경우 UG-84에 따라 Charpy V-notch 시험을 수행한다.",
"relevance": 0.91,
"rerank_score": 0.913
}
],
"synthesis_status": "completed",
"synthesis_ms": 2841.5,
"confidence": "high",
"refused": false,
"no_results_reason": null,
"query": "충격시험은 언제 면제되나",
"total": 1,
"completeness": "full",
"covered_aspects": ["면제 곡선", "MDMT 적용"],
"missing_aspects": null,
"confirmed_items": null,
"backend_requested": "mac-mini-default",
"backend_used": "gemma-macmini",
"debug": null
}
+4
View File
@@ -0,0 +1,4 @@
{
"access_token": "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJoeXVuZ2kiLCJ0eXBlIjoiYWNjZXNzIiwiZXhwIjoxNzQ5MDAwMDAwfQ.FIXTURE_SIGNATURE_NOT_REAL",
"token_type": "bearer"
}
+7
View File
@@ -0,0 +1,7 @@
{
"id": 1,
"username": "hyungi",
"is_active": true,
"totp_enabled": false,
"last_login_at": "2026-06-04T07:55:12.330Z"
}
+54
View File
@@ -0,0 +1,54 @@
{
"digest_date": "2026-06-03",
"window_start": "2026-05-27T00:00:00.000Z",
"window_end": "2026-06-03T00:00:00.000Z",
"decay_lambda": 0.18,
"total_articles": 312,
"total_countries": 4,
"total_topics": 9,
"generation_ms": 18420,
"llm_calls": 11,
"llm_failures": 0,
"status": "completed",
"countries": [
{
"country": "KR",
"topics": [
{
"topic_rank": 1,
"topic_label": "산업안전 규제 개정",
"summary": "중대재해처벌법 후속 시행령 개정 논의가 이어지며 제조업 현장 점검이 강화되는 흐름.",
"article_ids": [880123, 880140, 880155],
"articles": [
{ "id": 880123, "title": "고용부, 중대재해 시행령 개정안 입법예고" },
{ "id": 880140, "title": "제조 현장 안전점검 확대" },
{ "id": 880155, "title": "압력설비 검사 주기 단축 검토" }
],
"article_count": 3,
"importance_score": 0.91,
"raw_weight_sum": 2.74,
"llm_fallback_used": false
}
]
},
{
"country": "US",
"topics": [
{
"topic_rank": 1,
"topic_label": "ASME 코드 업데이트",
"summary": "ASME BPVC 2025 에디션 관련 산업계 적용 사례와 해설 자료가 늘어남.",
"article_ids": [880301, 880322],
"articles": [
{ "id": 880301, "title": "ASME BPVC 2025 adoption notes" },
{ "id": 880322, "title": "Impact test exemption clarifications" }
],
"article_count": 2,
"importance_score": 0.77,
"raw_weight_sum": 1.62,
"llm_fallback_used": false
}
]
}
]
}
+12
View File
@@ -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
}
+63
View File
@@ -0,0 +1,63 @@
{
"id": 4912,
"file_path": "Engineering/ASME/ASME_SecVIII_Div1_Impact_Test.pdf",
"file_format": "pdf",
"file_size": 1338920,
"file_type": "document",
"title": "ASME Section VIII Div 1 — Impact Test 요건",
"ai_domain": "Engineering",
"ai_sub_group": "압력용기",
"ai_tags": ["ASME", "Section VIII", "충격시험", "UCS-66"],
"ai_summary": "압력용기 재료의 충격시험 면제/요구 조건(UCS-66 등)을 정리.",
"document_type": "standard",
"importance": "high",
"ai_confidence": 0.93,
"user_note": "MDMT 판정 시 자주 참조",
"user_tags": ["자주봄"],
"pinned": true,
"ask_includable": true,
"derived_path": "Engineering/ASME/ASME_SecVIII_Div1_Impact_Test.md",
"original_format": "pdf",
"conversion_status": "completed",
"is_read": true,
"review_status": "approved",
"edit_url": null,
"preview_status": "ready",
"source_channel": "upload",
"data_origin": "external",
"doc_purpose": "reference",
"facet_company": "ASME",
"facet_topic": "압력용기",
"facet_year": 2023,
"facet_doctype": "standard",
"category": "library",
"ai_suggestion": null,
"ai_tldr": "충격시험 면제 곡선(UCS-66)과 MDMT 적용.",
"ai_bullets": ["UCS-66 면제 곡선", "UCS-66.1 감액", "UG-84 시험 요건"],
"ai_detail_summary": "본 표준 절은 탄소강·저합금강 압력용기 재료의 노치 인성(충격시험) 요구를 다룬다. UCS-66 면제 곡선은 재료군(A~D)과 두께에 따라 최소설계금속온도(MDMT)에서의 시험 면제 여부를 정한다.",
"ai_inconsistencies": [],
"ai_analysis_tier": "deep",
"extracted_at": "2026-05-22T05:00:11.000Z",
"ai_processed_at": "2026-05-22T05:04:40.000Z",
"embedded_at": "2026-05-22T05:06:02.000Z",
"created_at": "2026-05-22T04:59:50.000Z",
"updated_at": "2026-06-01T09:21:33.000Z",
"read_count": 11,
"last_read_at": "2026-06-03T18:02:10.000Z",
"original_filename": "ASME_SecVIII_Div1_Impact_Test.pdf",
"duplicate_of": null,
"duplicate_count": 1,
"extracted_text": "ASME Section VIII Division 1 Impact Test Requirements\nUCS-66 ... (원문 추출 텍스트, 폴백용) ...",
"md_content": "# ASME Section VIII Div 1 — 충격시험 요건\n\n## 1. 범위\n탄소강 및 저합금강 압력용기 재료의 노치 인성(충격시험) 요구를 규정한다.\n\n## 2. UCS-66 면제 곡선\n재료군(Curve A~D)과 거버닝 두께에 따라 **최소설계금속온도(MDMT)** 에서의 충격시험 면제 여부를 결정한다.\n\n| 곡선 | 대표 재료 | 비고 |\n|---|---|---|\n| A | SA-516 (비정규화) | 가장 보수적 |\n| B | SA-516 정규화 | |\n| C | SA-537 | |\n| D | 인성 우수 재료 | 가장 관대 |\n\n## 3. UCS-66.1 감액\n설계 응력비(stress ratio)가 낮으면 MDMT 를 추가로 낮출 수 있다.\n\n## 4. UG-84 시험 요건\n면제되지 않는 경우 Charpy V-notch 시험으로 흡수에너지/측면팽창 기준을 만족해야 한다.\n",
"md_frontmatter": {
"title": "ASME Section VIII Div 1 — 충격시험 요건",
"domain": "Engineering",
"source": "ASME_SecVIII_Div1_Impact_Test.pdf"
},
"md_status": "completed",
"md_extraction_quality": { "page_count": 14, "table_count": 3, "ocr_used": false },
"md_extraction_error": null,
"md_extraction_engine": "marker",
"md_extraction_engine_version": "1.10.2",
"md_generated_at": "2026-05-22T05:03:30.000Z"
}
@@ -0,0 +1,59 @@
{
"id": 5301,
"file_path": "General/매뉴얼/02_왕복압축기_운전매뉴얼.docx",
"file_format": "docx",
"file_size": 73402,
"file_type": "document",
"title": "02 왕복압축기 운전 매뉴얼",
"ai_domain": "General",
"ai_sub_group": "설비매뉴얼",
"ai_tags": ["왕복압축기", "운전", "매뉴얼"],
"ai_summary": "왕복동식 압축기 기동/정지/점검 절차 매뉴얼.",
"document_type": "manual",
"importance": "normal",
"ai_confidence": 0.81,
"user_note": null,
"user_tags": null,
"pinned": false,
"ask_includable": true,
"derived_path": null,
"original_format": "docx",
"conversion_status": "pending",
"is_read": false,
"review_status": "pending",
"edit_url": null,
"preview_status": "pending",
"source_channel": "upload",
"data_origin": "internal",
"doc_purpose": "reference",
"facet_company": null,
"facet_topic": "설비매뉴얼",
"facet_year": 2024,
"facet_doctype": "manual",
"category": "library",
"ai_suggestion": null,
"ai_tldr": null,
"ai_bullets": null,
"ai_detail_summary": null,
"ai_inconsistencies": null,
"ai_analysis_tier": "triage",
"extracted_at": "2026-06-03T01:20:00.000Z",
"ai_processed_at": "2026-06-03T01:22:14.000Z",
"embedded_at": null,
"created_at": "2026-06-03T01:19:55.000Z",
"updated_at": "2026-06-03T01:22:14.000Z",
"read_count": 0,
"last_read_at": null,
"original_filename": "02_왕복압축기_운전매뉴얼.docx",
"duplicate_of": null,
"duplicate_count": 0,
"extracted_text": "왕복압축기 운전 매뉴얼\n1. 기동 전 점검\n - 윤활유 레벨 확인\n - 흡입/토출 밸브 상태 확인\n2. 기동 절차 ...",
"md_content": null,
"md_frontmatter": null,
"md_status": "pending",
"md_extraction_quality": null,
"md_extraction_error": null,
"md_extraction_engine": null,
"md_extraction_engine_version": null,
"md_generated_at": null
}
@@ -0,0 +1,18 @@
{
"groups": [
{
"canonical_id": 4912,
"members": [4912, 4977],
"reason": "content_hash",
"detail": "동일 본문 해시 (md_content normalized SHA-256 일치)"
},
{
"canonical_id": 5120,
"members": [5120, 5121, 5260],
"reason": "near_duplicate",
"detail": "제목/본문 유사도 0.97 (cross-format: pdf + docx 동일 문서)"
}
],
"total_groups": 2,
"total_duplicate_docs": 3
}
+157
View File
@@ -0,0 +1,157 @@
{
"items": [
{
"id": 5187,
"file_path": "Engineering/기계가공/엘보_내경가공_절차서.pdf",
"file_format": "pdf",
"file_size": 482113,
"file_type": "document",
"title": "엘보 내경가공 절차서",
"ai_domain": "Engineering",
"ai_sub_group": "기계가공",
"ai_tags": ["엘보", "내경가공", "절차서", "가공공차"],
"ai_summary": "엘보 내경 가공 시 공차 관리와 가공 순서를 정리한 사내 절차서.",
"document_type": "procedure",
"importance": "normal",
"ai_confidence": 0.86,
"user_note": null,
"user_tags": null,
"pinned": false,
"ask_includable": true,
"derived_path": "Engineering/기계가공/엘보_내경가공_절차서.md",
"original_format": "pdf",
"conversion_status": "completed",
"is_read": true,
"review_status": "approved",
"edit_url": null,
"preview_status": "ready",
"source_channel": "upload",
"data_origin": "internal",
"doc_purpose": "reference",
"facet_company": null,
"facet_topic": "기계가공",
"facet_year": 2025,
"facet_doctype": "procedure",
"category": "library",
"ai_suggestion": null,
"ai_tldr": "엘보 내경 가공 공차·순서 절차.",
"ai_bullets": ["가공 전 소재 검사", "내경 공차 +0.1/-0.0", "최종 치수 검사 기록"],
"ai_detail_summary": null,
"ai_inconsistencies": null,
"ai_analysis_tier": "triage",
"extracted_at": "2026-05-30T02:11:04.000Z",
"ai_processed_at": "2026-05-30T02:13:51.000Z",
"embedded_at": "2026-05-30T02:15:09.000Z",
"created_at": "2026-05-30T02:10:58.000Z",
"updated_at": "2026-05-30T02:15:09.000Z",
"read_count": 3,
"last_read_at": "2026-06-02T13:40:22.000Z",
"original_filename": "엘보_내경가공_절차서.pdf",
"duplicate_of": null,
"duplicate_count": 0
},
{
"id": 4912,
"file_path": "Engineering/ASME/ASME_SecVIII_Div1_Impact_Test.pdf",
"file_format": "pdf",
"file_size": 1338920,
"file_type": "document",
"title": "ASME Section VIII Div 1 — Impact Test 요건",
"ai_domain": "Engineering",
"ai_sub_group": "압력용기",
"ai_tags": ["ASME", "Section VIII", "충격시험", "UCS-66"],
"ai_summary": "압력용기 재료의 충격시험 면제/요구 조건(UCS-66 등)을 정리.",
"document_type": "standard",
"importance": "high",
"ai_confidence": 0.93,
"user_note": "MDMT 판정 시 자주 참조",
"user_tags": ["자주봄"],
"pinned": true,
"ask_includable": true,
"derived_path": "Engineering/ASME/ASME_SecVIII_Div1_Impact_Test.md",
"original_format": "pdf",
"conversion_status": "completed",
"is_read": true,
"review_status": "approved",
"edit_url": null,
"preview_status": "ready",
"source_channel": "upload",
"data_origin": "external",
"doc_purpose": "reference",
"facet_company": "ASME",
"facet_topic": "압력용기",
"facet_year": 2023,
"facet_doctype": "standard",
"category": "library",
"ai_suggestion": null,
"ai_tldr": "충격시험 면제 곡선(UCS-66)과 MDMT 적용.",
"ai_bullets": ["UCS-66 면제 곡선", "UCS-66.1 감액", "UG-84 시험 요건"],
"ai_detail_summary": null,
"ai_inconsistencies": null,
"ai_analysis_tier": "deep",
"extracted_at": "2026-05-22T05:00:11.000Z",
"ai_processed_at": "2026-05-22T05:04:40.000Z",
"embedded_at": "2026-05-22T05:06:02.000Z",
"created_at": "2026-05-22T04:59:50.000Z",
"updated_at": "2026-06-01T09:21:33.000Z",
"read_count": 11,
"last_read_at": "2026-06-03T18:02:10.000Z",
"original_filename": "ASME_SecVIII_Div1_Impact_Test.pdf",
"duplicate_of": null,
"duplicate_count": 1
},
{
"id": 5301,
"file_path": "General/매뉴얼/02_왕복압축기_운전매뉴얼.docx",
"file_format": "docx",
"file_size": 73402,
"file_type": "document",
"title": "02 왕복압축기 운전 매뉴얼",
"ai_domain": "General",
"ai_sub_group": "설비매뉴얼",
"ai_tags": ["왕복압축기", "운전", "매뉴얼"],
"ai_summary": "왕복동식 압축기 기동/정지/점검 절차 매뉴얼.",
"document_type": "manual",
"importance": "normal",
"ai_confidence": 0.81,
"user_note": null,
"user_tags": null,
"pinned": false,
"ask_includable": true,
"derived_path": null,
"original_format": "docx",
"conversion_status": "pending",
"is_read": false,
"review_status": "pending",
"edit_url": null,
"preview_status": "pending",
"source_channel": "upload",
"data_origin": "internal",
"doc_purpose": "reference",
"facet_company": null,
"facet_topic": "설비매뉴얼",
"facet_year": 2024,
"facet_doctype": "manual",
"category": "library",
"ai_suggestion": null,
"ai_tldr": null,
"ai_bullets": null,
"ai_detail_summary": null,
"ai_inconsistencies": null,
"ai_analysis_tier": "triage",
"extracted_at": "2026-06-03T01:20:00.000Z",
"ai_processed_at": "2026-06-03T01:22:14.000Z",
"embedded_at": null,
"created_at": "2026-06-03T01:19:55.000Z",
"updated_at": "2026-06-03T01:22:14.000Z",
"read_count": 0,
"last_read_at": null,
"original_filename": "02_왕복압축기_운전매뉴얼.docx",
"duplicate_of": null,
"duplicate_count": 0
}
],
"total": 783,
"page": 1,
"page_size": 20
}
+14
View File
@@ -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
}
+16
View File
@@ -0,0 +1,16 @@
[
{ "name": "Industrial_Safety", "path": "Industrial_Safety", "count": 426,
"children": [
{ "name": "위험성평가", "path": "Industrial_Safety/위험성평가", "count": 118, "children": [] },
{ "name": "KGS", "path": "Industrial_Safety/KGS", "count": 73, "children": [] }
] },
{ "name": "Engineering", "path": "Engineering", "count": 351,
"children": [
{ "name": "압력용기", "path": "Engineering/압력용기", "count": 96, "children": [] },
{ "name": "기계가공", "path": "Engineering/기계가공", "count": 54, "children": [] }
] },
{ "name": "General", "path": "General", "count": 189, "children": [] },
{ "name": "Programming", "path": "Programming", "count": 60, "children": [] },
{ "name": "법령", "path": "법령", "count": 23, "children": [] },
{ "name": "Philosophy", "path": "Philosophy", "count": 12, "children": [] }
]
+23
View File
@@ -0,0 +1,23 @@
{
"id": 20238,
"title": "엘보 발주 확인 건",
"content": "엘보 내경가공 발주서 금요일까지 확인.\n- [ ] 도면 rev C 기준 공차 재확인\n- [ ] 발주처에 납기 회신",
"file_format": "txt",
"user_tags": ["업무"],
"ai_tags": ["발주", "엘보"],
"ai_domain": "General",
"ai_sub_group": "업무메모",
"ai_summary": "엘보 발주서 금요일까지 확인, 도면 rev C 공차 재확인.",
"pinned": true,
"archived": false,
"ask_includable": true,
"memo_task_state": { "0": { "checked_at": "2026-06-03T10:02:00.000Z" } },
"ai_event_kind": "task",
"ai_event_confidence": 0.78,
"source_channel": "memo",
"source_metadata": {},
"file_type": "note",
"file_path": null,
"created_at": "2026-06-03T09:40:00.000Z",
"updated_at": "2026-06-03T10:02:00.000Z"
}
+53
View File
@@ -0,0 +1,53 @@
{
"items": [
{
"id": 20238,
"title": "엘보 발주 확인 건",
"content": "엘보 내경가공 발주서 금요일까지 확인. 도면 rev C 기준으로 공차 재확인 필요.",
"file_format": "txt",
"user_tags": ["업무"],
"ai_tags": ["발주", "엘보"],
"ai_domain": "General",
"ai_sub_group": "업무메모",
"ai_summary": "엘보 발주서 금요일까지 확인, 도면 rev C 공차 재확인.",
"pinned": true,
"archived": false,
"ask_includable": true,
"memo_task_state": { "0": { "checked_at": "2026-06-03T10:02:00.000Z" } },
"ai_event_kind": "task",
"ai_event_confidence": 0.78,
"source_channel": "memo",
"source_metadata": {},
"file_type": "note",
"file_path": null,
"created_at": "2026-06-03T09:40:00.000Z",
"updated_at": "2026-06-03T10:02:00.000Z"
},
{
"id": 20251,
"title": "음성 메모 — 현장 점검",
"content": "3공장 압축기 베어링 소음. 다음 점검 때 진동 측정 추가하기로.",
"file_format": "m4a",
"user_tags": null,
"ai_tags": ["현장", "압축기", "점검"],
"ai_domain": "Industrial_Safety",
"ai_sub_group": "현장메모",
"ai_summary": "3공장 압축기 베어링 소음, 진동 측정 추가 예정.",
"pinned": false,
"archived": false,
"ask_includable": true,
"memo_task_state": {},
"ai_event_kind": "note",
"ai_event_confidence": 0.64,
"source_channel": "voice",
"source_metadata": { "duration_s": 23, "device": "iPhone" },
"file_type": "audio",
"file_path": "memos/voice/2026/06/test-voice-memo.m4a",
"created_at": "2026-06-02T17:11:00.000Z",
"updated_at": "2026-06-02T17:11:40.000Z"
}
],
"total": 4807,
"page": 1,
"page_size": 20
}
+38
View File
@@ -0,0 +1,38 @@
{
"results": [
{
"id": 4912,
"title": "ASME Section VIII Div 1 — Impact Test 요건",
"ai_domain": "Engineering",
"ai_summary": "압력용기 재료의 충격시험 면제/요구 조건(UCS-66 등)을 정리.",
"file_format": "pdf",
"score": 0.8714,
"snippet": "...UCS-66 면제 곡선과 MDMT 적용. 충격시험 면제 여부는 재료군과 두께로 결정...",
"match_reason": "vector+rerank",
"chunk_id": 88213,
"chunk_index": 3,
"section_title": "2. UCS-66 면제 곡선",
"rerank_score": 0.913,
"freshness_debug": null
},
{
"id": 5044,
"title": "KGS FU211 §2.5 — 가스설비 충격 관련 요건",
"ai_domain": "Industrial_Safety",
"ai_summary": "KGS FU211 가스 사용시설 기준 중 충격/내압 관련 조항.",
"file_format": "pdf",
"score": 0.7321,
"snippet": "...§2.5 충격에 의한 손상 방지... §2.8 내압 시험...",
"match_reason": "vector",
"chunk_id": 90122,
"chunk_index": 1,
"section_title": "2.5",
"rerank_score": 0.742,
"freshness_debug": null
}
],
"total": 2,
"query": "충격시험 면제",
"mode": "hybrid",
"debug": null
}