# 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` | — | `{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=<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]]).
