# 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` | — | `{counts: {category: n}, library_pending_suggestions}` — **raw dict 반환(Pydantic 모델 없음), 2026-06-07 라이브 재캡처로 정정**(초기 추출이 shape 합성 오류) | `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]]).