Files
hyungi 7cc38e8a4a fix(ds-app): category-counts 계약 정정 — 합성된 shape 을 라이브 실측으로 재캡처
라이브 결선 첫 실로그인에서 decode 실패(Key 'total' not found) 진단:
서버 /documents/stats/category-counts 는 Pydantic response model 없는
raw dict 반환({counts:{category:n}, library_pending_suggestions}) — 초기
계약 추출('실 Pydantic 에서 추출')이 이 엔드포인트에선 shape 을 합성
(total/by_domain/review_pending/pipeline_failed = 실재하지 않음).

- CategoryCounts 모델 = 실측 shape + total 파생 접근자(counts 합)
- fixture 2사본(contract/fixtures + DSKit Resources) = CAPTURED_LIVE 재캡처
- DashboardView 스켈레톤 정합(카테고리 분포 + 한국어 라벨, 본격 재설계는 FU-E)
- CONTRACT.md 해당 행 정정 주석

전 엔드포인트 라이브 shape 전수 대조(토큰 생성 후 11종 curl + shape_diff):
stats 외 진짜 drift 0 — documents/tree·search·memos·digest·auth_me·detail·
content 일치. original_filename/duplicate_* 부재 = S1 미배포(optional 이라
무해, 배포 시 해소) / md_frontmatter·memo_task_state = JSONValue 오픈 shape
데이터 차이(무해) / duplicates 422 = S1 라우트 미배포(예상).

검증: swift test 82/82 + shape_diff (shape identical) + xcodebuild PASS.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 00:55:59 +00:00

225 lines
12 KiB
Markdown

# 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]]).