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>
This commit is contained in:
hyungi
2026-06-07 14:47:43 +09:00
parent f1dc2e1a8d
commit 7cc38e8a4a
6 changed files with 51 additions and 40 deletions
@@ -11,15 +11,14 @@ struct DashboardView: View {
if let s = model.stats { if let s = model.stats {
LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: 12)], spacing: 12) { LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: 12)], spacing: 12) {
StatCard(title: "전체", value: s.total, color: Sage.brand) StatCard(title: "전체", value: s.total, color: Sage.brand)
StatCard(title: "문서", value: s.documents, color: Sage.brand) StatCard(title: "문서", value: s.counts["document"] ?? 0, color: Sage.brand)
StatCard(title: "검토 대기", value: s.reviewPending, color: Sage.amber) StatCard(title: "승인 대기", value: s.libraryPendingSuggestions, color: Sage.amber)
StatCard(title: "파이프라인 실패", value: s.pipelineFailed, color: Sage.danger)
} }
VStack(alignment: .leading, spacing: 10) { VStack(alignment: .leading, spacing: 10) {
Text("도메인 분포").font(.headline).foregroundStyle(Sage.ink) Text("카테고리 분포").font(.headline).foregroundStyle(Sage.ink)
ForEach(s.byDomain.sorted { $0.value > $1.value }, id: \.key) { key, value in ForEach(s.counts.sorted { $0.value > $1.value }, id: \.key) { key, value in
DomainBar(name: key, count: value, max: s.byDomain.values.max() ?? 1) DomainBar(name: Self.categoryLabel(key), count: value, max: s.counts.values.max() ?? 1)
.contentShape(Rectangle()) .contentShape(Rectangle())
.onTapGesture { model.section = .documents } .onTapGesture { model.section = .documents }
} }
@@ -35,4 +34,18 @@ struct DashboardView: View {
} }
.background(Sage.surface) .background(Sage.surface)
} }
/// category enum ( raw ).
static func categoryLabel(_ key: String) -> String {
switch key {
case "document": return "문서"
case "library": return "자료실"
case "news": return "뉴스"
case "law": return "법령"
case "memo": return "메모"
case "audio": return "오디오"
case "video": return "비디오"
default: return key
}
}
} }
@@ -12,19 +12,21 @@ public struct DomainTreeNode: Codable, Sendable, Identifiable {
public var kids: [DomainTreeNode] { children ?? [] } public var kids: [DomainTreeNode] { children ?? [] }
} }
/// GET /documents/stats/category-counts Pydantic response model raw dict
/// . shape (total/by_domain/...) decode
/// 2026-06-07 (fixture documents_stats.json = CAPTURED_LIVE).
public struct CategoryCounts: Codable, Sendable { public struct CategoryCounts: Codable, Sendable {
public let total: Int /// category(enum) : document/library/news/law/memo/audio.
public let documents: Int public let counts: [String: Int]
public let byDomain: [String: Int] public let libraryPendingSuggestions: Int
public let reviewPending: Int
public let pipelineFailed: Int
enum CodingKeys: String, CodingKey { enum CodingKeys: String, CodingKey {
case total, documents case counts
case byDomain = "by_domain" case libraryPendingSuggestions = "library_pending_suggestions"
case reviewPending = "review_pending"
case pipelineFailed = "pipeline_failed"
} }
/// (counts ) .
public var total: Int { counts.values.reduce(0, +) }
} }
public struct DuplicateGroup: Codable, Sendable, Identifiable { public struct DuplicateGroup: Codable, Sendable, Identifiable {
@@ -1,14 +1,11 @@
{ {
"total": 1163, "counts": {
"documents": 783, "library": 391,
"by_domain": { "law": 229,
"Industrial_Safety": 426, "document": 381,
"Engineering": 351, "news": 6182,
"General": 189, "memo": 4,
"Programming": 60, "audio": 2
"법령": 23,
"Philosophy": 12
}, },
"review_pending": 725, "library_pending_suggestions": 0
"pipeline_failed": 19
} }
@@ -65,8 +65,10 @@ final class FixtureDecodeTests: XCTestCase {
func testStats() async throws { func testStats() async throws {
let s = try await client.categoryCounts() let s = try await client.categoryCounts()
XCTAssertEqual(s.documents, 783) XCTAssertEqual(s.counts["news"], 6182)
XCTAssertEqual(s.byDomain["법령"], 23) // non-ASCII dict key XCTAssertEqual(s.counts["library"], 391)
XCTAssertEqual(s.libraryPendingSuggestions, 0)
XCTAssertEqual(s.total, 391 + 229 + 381 + 6182 + 4 + 2) // = counts
} }
func testDuplicates() async throws { func testDuplicates() async throws {
+1 -1
View File
@@ -53,7 +53,7 @@ UserResponse { id: Int, username: String, is_active: Bool, totp_enabled: Bool, l
| GET | `/documents/{id}/file` | `?token=<access>&download=true` | **바이너리 원본** (PDF/이미지/오디오/원본) | — | | GET | `/documents/{id}/file` | `?token=<access>&download=true` | **바이너리 원본** (PDF/이미지/오디오/원본) | — |
| GET | `/documents/{id}/content` | — | 경량 텍스트(`content` 15k cap) | `document_content.json` | | GET | `/documents/{id}/content` | — | 경량 텍스트(`content` 15k cap) | `document_content.json` |
| GET | `/documents/tree` | — | 도메인 트리(사이드바) | `documents_tree.json` | | GET | `/documents/tree` | — | 도메인 트리(사이드바) | `documents_tree.json` |
| GET | `/documents/stats/category-counts` | — | 카테고리 카운트 | `documents_stats.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` | | POST | `/documents/` (multipart) | 파일 업로드 | `DocumentResponse` (201) | `document_detail.json` |
| PATCH | `/documents/{id}` | `DocumentUpdate` | `DocumentResponse` | — | | PATCH | `/documents/{id}` | `DocumentUpdate` | `DocumentResponse` | — |
| PUT | `/documents/{id}/content` | `{content}` (md 편집 저장) | `{}` | — | | PUT | `/documents/{id}/content` | `{content}` (md 편집 저장) | `{}` | — |
@@ -1,14 +1,11 @@
{ {
"total": 1163, "counts": {
"documents": 783, "library": 391,
"by_domain": { "law": 229,
"Industrial_Safety": 426, "document": 381,
"Engineering": 351, "news": 6182,
"General": 189, "memo": 4,
"Programming": 60, "audio": 2
"법령": 23,
"Philosophy": 12
}, },
"review_pending": 725, "library_pending_suggestions": 0
"pipeline_failed": 19
} }