Files
hyungi_document_server/clients/ds-app/contract/CONTRACT.md
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

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, 공인) · 대안 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)로 내려옴 → URLSessionHTTPCookieStorage가 자동 보관, POST /api/auth/refresh로 access 재발급 가능(네이티브에서도 동작).
  • 권장: access_token은 Keychain 보관. 만료 시 refresh → 실패하면 재로그인. (장수명 365d 토큰 옵션도 가능하나 v1은 정식 로그인.)
  • 로그아웃 POST /api/auth/logout, 현재 사용자 GET /api/auth/meUserResponse.

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만 노출.

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 }

변경 관리

  • 이 계약을 깨는 변경(필드 제거/타입 변경) = version bump + S1/S2/S3 합의. [S1-ADD]는 옵셔널이라 non-breaking.
  • 통합 결선 시 fixture와 실 응답 call-shape regression test로 대조(feedback_fixture_first_call_shape).