f527c63232
- FU-C 멀티파트 업로드(DSClient.uploadDocument + LiveDSClient 401 재시도 공유 + 툴바/상태바) - FU-D 네이티브 다운로드(NSSavePanel + URLSession, ?token= 미노출, 임시파일 정리) - 로그아웃(AppModel.logout 세션 전체 초기화 + 계정 메뉴) - 셸 2-column 재구성: 질문/이드 제거, 홈 코크핏 + 문서 3-pane 컬럼 브라우저 (인스펙터 TL;DR/핵심점/심층/불일치) + 도메인 필터 전체 load-all - 적대 리뷰 반영(stale 401 데모션·다운로드 임시파일 정리·메모 저장 saveMemo 경유·도메인 필터 선택 정합) Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
12 KiB
12 KiB
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, 공인) · 대안 Tailscalehttp://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"). SwiftDate디코딩 시 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 |
— | {counts: {category: n}, library_pending_suggestions} — raw dict 반환(Pydantic 모델 없음), 2026-06-07 라이브 재캡처로 정정(초기 추출이 shape 합성 오류) |
documents_stats.json |
| POST | /documents/ (multipart/form-data) |
file(필수) + doc_purpose?(business|knowledge) library_path? facet_*? |
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 렌더 규칙 (앱 측 계약)
- 본문 뷰 =
md_content우선 (md_status ∈ {completed, partial}일 때). 일관성 = 모든 포맷을 markdown으로 본다. - md 없음(md_status ∈ {pending, processing, failed, skipped, null}) →
extracted_text폴백 + "원본 다운로드" 강조 + "MD 변환 대기" 배지. - 원본 접근 = 항상 다운로드 버튼 →
GET /documents/{id}/file?token=<access>&download=true.- 주의: 이 엔드포인트는 Authorization 헤더가 아니라
?token=쿼리 파라미터로 인증(iframe/다운로드 호환). 앱은 access_token을 쿼리로 붙인다. note(메모)는 물리 파일 없음 → 404. 다운로드 버튼 숨김.
- 주의: 이 엔드포인트는 Authorization 헤더가 아니라
- 앱은 절대 SMB를 보지 않는다. 원본/스토리지 계층(맥미니 4TB ↔ NAS Docker)은 이 URL 뒤에서 S1이 추상화. 앱엔 단일 다운로드 URL만 노출.
3. Search
| Method | Path | 요청 | 응답 | fixture |
|---|---|---|---|---|
| GET | /search/ |
`q, mode?(text | vector | hybrid), page?, debug?` |
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 }
변경 관리
- 이 계약을 깨는 변경(필드 제거/타입 변경) =
versionbump + S1/S2/S3 합의.[S1-ADD]는 옵셔널이라 non-breaking. - 통합 결선 시 fixture와 실 응답 call-shape regression test로 대조(feedback_fixture_first_call_shape).