From 7cc38e8a4a005854a2b650eb2645567c944ad80e Mon Sep 17 00:00:00 2001 From: hyungi Date: Sun, 7 Jun 2026 14:47:43 +0900 Subject: [PATCH] =?UTF-8?q?fix(ds-app):=20category-counts=20=EA=B3=84?= =?UTF-8?q?=EC=95=BD=20=EC=A0=95=EC=A0=95=20=E2=80=94=20=ED=95=A9=EC=84=B1?= =?UTF-8?q?=EB=90=9C=20shape=20=EC=9D=84=20=EB=9D=BC=EC=9D=B4=EB=B8=8C=20?= =?UTF-8?q?=EC=8B=A4=EC=B8=A1=EC=9C=BC=EB=A1=9C=20=EC=9E=AC=EC=BA=A1?= =?UTF-8?q?=EC=B2=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 라이브 결선 첫 실로그인에서 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) --- .../AppFeature/Pages/DashboardView.swift | 25 ++++++++++++++----- .../ds-app/Sources/DSKit/Models/Catalog.swift | 20 ++++++++------- .../DSKit/Resources/documents_stats.json | 19 ++++++-------- .../Tests/DSKitTests/FixtureDecodeTests.swift | 6 +++-- clients/ds-app/contract/CONTRACT.md | 2 +- .../contract/fixtures/documents_stats.json | 19 ++++++-------- 6 files changed, 51 insertions(+), 40 deletions(-) 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 }