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:
@@ -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 {
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user