diff --git a/clients/ds-app/Sources/AppFeature/Pages/DashboardView.swift b/clients/ds-app/Sources/AppFeature/Pages/DashboardView.swift index 93901e9..e4925ca 100644 --- a/clients/ds-app/Sources/AppFeature/Pages/DashboardView.swift +++ b/clients/ds-app/Sources/AppFeature/Pages/DashboardView.swift @@ -11,15 +11,14 @@ struct DashboardView: View { if let s = model.stats { LazyVGrid(columns: [GridItem(.adaptive(minimum: 150), spacing: 12)], spacing: 12) { StatCard(title: "전체", value: s.total, color: Sage.brand) - StatCard(title: "문서", value: s.documents, color: Sage.brand) - StatCard(title: "검토 대기", value: s.reviewPending, color: Sage.amber) - StatCard(title: "파이프라인 실패", value: s.pipelineFailed, color: Sage.danger) + StatCard(title: "문서", value: s.counts["document"] ?? 0, color: Sage.brand) + StatCard(title: "승인 대기", value: s.libraryPendingSuggestions, color: Sage.amber) } VStack(alignment: .leading, spacing: 10) { - Text("도메인 분포").font(.headline).foregroundStyle(Sage.ink) - ForEach(s.byDomain.sorted { $0.value > $1.value }, id: \.key) { key, value in - DomainBar(name: key, count: value, max: s.byDomain.values.max() ?? 1) + Text("카테고리 분포").font(.headline).foregroundStyle(Sage.ink) + ForEach(s.counts.sorted { $0.value > $1.value }, id: \.key) { key, value in + DomainBar(name: Self.categoryLabel(key), count: value, max: s.counts.values.max() ?? 1) .contentShape(Rectangle()) .onTapGesture { model.section = .documents } } @@ -35,4 +34,18 @@ struct DashboardView: View { } .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 + } + } } diff --git a/clients/ds-app/Sources/DSKit/Models/Catalog.swift b/clients/ds-app/Sources/DSKit/Models/Catalog.swift index 272a2d8..8d1ab72 100644 --- a/clients/ds-app/Sources/DSKit/Models/Catalog.swift +++ b/clients/ds-app/Sources/DSKit/Models/Catalog.swift @@ -12,19 +12,21 @@ public struct DomainTreeNode: Codable, Sendable, Identifiable { 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 let total: Int - public let documents: Int - public let byDomain: [String: Int] - public let reviewPending: Int - public let pipelineFailed: Int + /// category(enum)별 건수 — 예: document/library/news/law/memo/audio. + public let counts: [String: Int] + public let libraryPendingSuggestions: Int enum CodingKeys: String, CodingKey { - case total, documents - case byDomain = "by_domain" - case reviewPending = "review_pending" - case pipelineFailed = "pipeline_failed" + case counts + case libraryPendingSuggestions = "library_pending_suggestions" } + + /// 전체 건수 (counts 합) — 파생 접근자. + public var total: Int { counts.values.reduce(0, +) } } public struct DuplicateGroup: Codable, Sendable, Identifiable { diff --git a/clients/ds-app/Sources/DSKit/Resources/documents_stats.json b/clients/ds-app/Sources/DSKit/Resources/documents_stats.json index f4e8edc..adc91eb 100644 --- a/clients/ds-app/Sources/DSKit/Resources/documents_stats.json +++ b/clients/ds-app/Sources/DSKit/Resources/documents_stats.json @@ -1,14 +1,11 @@ { - "total": 1163, - "documents": 783, - "by_domain": { - "Industrial_Safety": 426, - "Engineering": 351, - "General": 189, - "Programming": 60, - "법령": 23, - "Philosophy": 12 + "counts": { + "library": 391, + "law": 229, + "document": 381, + "news": 6182, + "memo": 4, + "audio": 2 }, - "review_pending": 725, - "pipeline_failed": 19 + "library_pending_suggestions": 0 } diff --git a/clients/ds-app/Tests/DSKitTests/FixtureDecodeTests.swift b/clients/ds-app/Tests/DSKitTests/FixtureDecodeTests.swift index 7bf0cee..9f8338f 100644 --- a/clients/ds-app/Tests/DSKitTests/FixtureDecodeTests.swift +++ b/clients/ds-app/Tests/DSKitTests/FixtureDecodeTests.swift @@ -65,8 +65,10 @@ final class FixtureDecodeTests: XCTestCase { func testStats() async throws { let s = try await client.categoryCounts() - XCTAssertEqual(s.documents, 783) - XCTAssertEqual(s.byDomain["법령"], 23) // non-ASCII dict key + XCTAssertEqual(s.counts["news"], 6182) + XCTAssertEqual(s.counts["library"], 391) + XCTAssertEqual(s.libraryPendingSuggestions, 0) + XCTAssertEqual(s.total, 391 + 229 + 381 + 6182 + 4 + 2) // 파생 접근자 = counts 합 } func testDuplicates() async throws { diff --git a/clients/ds-app/contract/CONTRACT.md b/clients/ds-app/contract/CONTRACT.md index 8d00920..d1d87ab 100644 --- a/clients/ds-app/contract/CONTRACT.md +++ b/clients/ds-app/contract/CONTRACT.md @@ -53,7 +53,7 @@ UserResponse { id: Int, username: String, is_active: Bool, totp_enabled: Bool, l | 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` | — | 카테고리 카운트 | `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` | | PATCH | `/documents/{id}` | `DocumentUpdate` | `DocumentResponse` | — | | PUT | `/documents/{id}/content` | `{content}` (md 편집 저장) | `{}` | — | diff --git a/clients/ds-app/contract/fixtures/documents_stats.json b/clients/ds-app/contract/fixtures/documents_stats.json index f4e8edc..adc91eb 100644 --- a/clients/ds-app/contract/fixtures/documents_stats.json +++ b/clients/ds-app/contract/fixtures/documents_stats.json @@ -1,14 +1,11 @@ { - "total": 1163, - "documents": 783, - "by_domain": { - "Industrial_Safety": 426, - "Engineering": 351, - "General": 189, - "Programming": 60, - "법령": 23, - "Philosophy": 12 + "counts": { + "library": 391, + "law": 229, + "document": 381, + "news": 6182, + "memo": 4, + "audio": 2 }, - "review_pending": 725, - "pipeline_failed": 19 + "library_pending_suggestions": 0 }